forked from angular/components
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(material/datepicker): add aria-descriptions to calendar for start…
…/end dates For date ranges, add aria-descriptions to the cell of the current start date and also for end date. Popuplate aria descriptions with the expected value of the ARIA accessible name of the `matStartDate` and `matEndDate` inputs. Introduces `_computeAriaAccessibleName` function to implement ARIA acc-name-1.2 specificiation. Fixes angular#23442 and angular#23445
- Loading branch information
Showing
15 changed files
with
477 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
import {_computeAriaAccessibleName} from './aria-accessible-name'; | ||
|
||
describe('_computeAriaAccessibleName', () => { | ||
let rootElement: HTMLSpanElement; | ||
|
||
beforeEach(() => { | ||
rootElement = document.createElement('span'); | ||
document.body.appendChild(rootElement); | ||
}); | ||
|
||
afterEach(() => { | ||
document.body.removeChild(rootElement); | ||
}); | ||
|
||
it('uses aria-labelledby over aria-label', () => { | ||
rootElement.innerHTML = ` | ||
<label id='test-label'>Aria Labelledby</label> | ||
<input id='test-el' aria-labelledby='test-label' aria-label='Aria Label'/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Aria Labelledby'); | ||
}); | ||
|
||
it('uses aria-label over for/id', () => { | ||
rootElement.innerHTML = ` | ||
<label for='test-el'>For</label> | ||
<input id='test-el' aria-label='Aria Label'/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Aria Label'); | ||
}); | ||
|
||
it('uses a label with for/id over a title attribute', () => { | ||
rootElement.innerHTML = ` | ||
<label for='test-el'>For</label> | ||
<input id='test-el' title='Title'/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('For'); | ||
}); | ||
|
||
it('returns title when argument has a specifieid title', () => { | ||
rootElement.innerHTML = `<input id="test-el" title='Title'/>`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Title'); | ||
}); | ||
|
||
// match browser behavior of giving placeholder attribute preference over title attribute | ||
it('uses placeholder over title', () => { | ||
rootElement.innerHTML = `<input id="test-el" title='Title' placeholder='Placeholder'/>`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Placeholder'); | ||
}); | ||
|
||
it('uses aria-label over title and placeholder', () => { | ||
rootElement.innerHTML = `<input id="test-el" title='Title' placeholder='Placeholder' | ||
aria-label="Aria Label"/>`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Aria Label'); | ||
}); | ||
|
||
it('includes both textnode and element children of label with for/id', () => { | ||
rootElement.innerHTML = ` | ||
<label for="test-el"> | ||
Hello | ||
<span> | ||
Wo | ||
<span><span>r</span></span> | ||
<span> ld </span> | ||
</span> | ||
! | ||
</label> | ||
<input id='test-el'/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Hello Wo r ld !'); | ||
}); | ||
|
||
it('return computed name of hidden label which has for/id', () => { | ||
rootElement.innerHTML = ` | ||
<label for="test-el" aria-hidden="true" style="display: none;">For</label> | ||
<input id='test-el'/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('For'); | ||
}); | ||
|
||
it('returns computed names of existing elements when 2 of 3 targets of aria-labelledby exist', () => { | ||
rootElement.innerHTML = ` | ||
<label id="label-1-of-2" aria-hidden="true" style="display: none;">Label1</label> | ||
<label id="label-2-of-2" aria-hidden="true" style="display: none;">Label2</label> | ||
<input id="test-el" aria-labelledby="label-1-of-2 label-2-of-2 non-existant-label"/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Label1 Label2'); | ||
}); | ||
|
||
it('returns repeated label when there are duplicate ids in aria-labelledby', () => { | ||
rootElement.innerHTML = ` | ||
<label id="label-1-of-1" aria-hidden="true" style="display: none;">Label1</label> | ||
<input id="test-el" aria-labelledby="label-1-of-1 label-1-of-1"/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Label1 Label1'); | ||
}); | ||
|
||
it('returns empty string when passed `<input id="test-el"/>`', () => { | ||
rootElement.innerHTML = `<input id="test-el"/>`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe(''); | ||
}); | ||
|
||
it('ignores the aria-labelledby of an aria-labelledby', () => { | ||
rootElement.innerHTML = ` | ||
<label id="label" aria-labelledby="transitive-label">Label</label> | ||
<label id="transitive-label" aria-labelled-by="transitive-label">Transitive Label</div> | ||
<input id="test-el" aria-labelledby="label"/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
const label = rootElement.querySelector('#label')!; | ||
expect(_computeAriaAccessibleName(label as any)).toBe('Transitive Label'); | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Label'); | ||
}); | ||
|
||
it('ignores the aria-labelledby on a label with for/id', () => { | ||
rootElement.innerHTML = ` | ||
<label for="transitive2-label" aria-labelledby="transitive2-div"></label> | ||
<div id="transitive2-div">Div</div> | ||
<input id="test-el" aria-labelled-by="transitive2-label"/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe(''); | ||
}); | ||
|
||
it('returns empty string when argument input is aria-labelledby itself', () => { | ||
rootElement.innerHTML = ` | ||
<input id="test-el" aria-labelled-by="test-el"/> | ||
`; | ||
|
||
const input = rootElement.querySelector('#test-el')!; | ||
const computedName = _computeAriaAccessibleName(input as HTMLInputElement); | ||
expect(typeof computedName) | ||
.withContext('should return value of type string') | ||
.toBe('string'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.io/license | ||
*/ | ||
|
||
/** | ||
* Computes what we *expect* the ARIA accessible name would be for argument element. There is not an | ||
* API available to ask what the ARIA accessible name of an element is, so we determine what we | ||
* expect it to be. | ||
* | ||
* ARIA specification [Accessible Name and Description Computation 1.2]( | ||
* https://www.w3.org/TR/accname-1.2/) defines how to calculate an accessible name. This function | ||
* partially implements accname-1.2. Implements a subset of accname-1.2 to be used for the | ||
* Datepicker's use case of the `matStartDate` and `matEndDate` inputs. This is not a general | ||
* use implementation, and it is inteded to *only* be used for the Datepicker. | ||
* | ||
* Argument element is the "Root node" is the accname-1.2 specification. | ||
* | ||
* Limitations: | ||
* - Only covers the needs of `matStartDate` and `matEndDate`. Does not support other use cases. | ||
* - See NOTES's in implementation for specific details on what parts of the accname-1.2 | ||
* specification are not implemented. | ||
* | ||
* To summarize this algorithm, it trys the following methods in order and returns result of first | ||
* method that works. | ||
* | ||
* 1. `aria-labelledby` attribute | ||
* ``` | ||
* <!-- example using aria-labelledby--> | ||
* <label id='label'>Start Date</label> | ||
* <input aria-labelledby='label'/> | ||
* ``` | ||
* 2. `aria-label` attribute (e.g. `<input aria-label="Departure"/>`) | ||
* 3. Label with `for`/`id` | ||
* ``` | ||
* <!-- example using for/id --> | ||
* <label for="current-node">Label</label> | ||
* <input id="current-node"/> | ||
* ``` | ||
* 4. `placeholder` attribute (e.g. `<input placeholder="06/03/1990"/>`) | ||
* 5. `title` attribute (e.g. `<input title="Check-In"/>`) | ||
* 6. text content | ||
* ``` | ||
* <!-- example using text content --> | ||
* <label for="current-node"><span>Departure</span> Date</label> | ||
* <input id="current-node"/> | ||
* ``` | ||
* | ||
* @param element {HTMLInputElement} native <input/> element of `matStartDate` or `matEndDate` component. | ||
* | ||
* @return expected ARIA accessible name of argument <input/> | ||
*/ | ||
export function _computeAriaAccessibleName(element: HTMLInputElement): string { | ||
return _computeAriaAccessibleNameInternal(element, true); | ||
} | ||
|
||
/** | ||
* Calculate the expected ARIA accessible name for given DOM Node. Given DOM Node may be either the "Root node" or "Current node" from accname-1.2 specification. | ||
* | ||
* @return the accessible name of argument DOM Node | ||
* | ||
* @param currentNode node to determine accessible name of | ||
* @param isDirectlyReferenced true if `currentNode` is the root node to calculate ARIA accessible | ||
* name of. False if it is a result of recursion. | ||
*/ | ||
function _computeAriaAccessibleNameInternal( | ||
currentNode: Node, | ||
isDirectlyReferenced: boolean, | ||
): string { | ||
// NOTE: Does not implement Step 1. of accname-1.2: '''If `currentNode`'s role prohibits naming, | ||
// return the empty string ("")'''. | ||
|
||
// NOTE: does not implement Step 2.A. of accname-1.2: '''if current node is hidden and not | ||
// directly referenced by aria-labelledby... return the empty string.''' | ||
|
||
// acc-name-1.2 Step 2.B. aria-labelledby | ||
if (currentNode instanceof Element && isDirectlyReferenced) { | ||
const labelledbyIds: string[] = | ||
currentNode.getAttribute?.('aria-labelledby')?.split(/\s+/g) || []; | ||
const validIdRefs: HTMLElement[] = labelledbyIds.reduce((validIds, id) => { | ||
const elem = document.getElementById(id); | ||
if (elem) { | ||
validIds.push(elem); | ||
} | ||
return validIds; | ||
}, [] as HTMLElement[]); | ||
|
||
if (validIdRefs.length) { | ||
return validIdRefs | ||
.map(idRef => { | ||
return _computeAriaAccessibleNameInternal(idRef, false); | ||
}) | ||
.join(' '); | ||
} | ||
} | ||
|
||
// acc-name-1.2 Step 2.C. aria-label | ||
if (currentNode instanceof Element) { | ||
const ariaLabel = currentNode.getAttribute('aria-label')?.trim(); | ||
|
||
if (ariaLabel) { | ||
return ariaLabel; | ||
} | ||
} | ||
|
||
// acc-name-1.2 Step 2.D. attribute or element that defines a text alternative | ||
// Only implements acc-name-1.2 for `<label>` and `<input/>` element. | ||
// NOTE: does not implement for all other elements that have an attribute or element that defines | ||
// a text alternative. | ||
if (currentNode instanceof HTMLInputElement) { | ||
// use label with a for attribute referencing the current node | ||
const fors = document.querySelectorAll(`[for="${currentNode.id}"]`); | ||
if (fors.length) { | ||
return Array.from(fors) | ||
.map(x => _computeAriaAccessibleNameInternal(x, false)) | ||
.join(' '); | ||
} | ||
|
||
// use the input's placeholder if available | ||
const placeholder = currentNode.getAttribute('placeholder')?.trim(); | ||
if (placeholder) return placeholder; | ||
|
||
// use the input's title if available | ||
const title = currentNode.getAttribute('title')?.trim(); | ||
if (title) return title; | ||
} | ||
|
||
// NOTE: does not implement acc-name-1.2 Step 2.E.: '''if the current node is a control embedded | ||
// within the label... then include the embedded control as part of the text alternative in the | ||
// following manner...'''. Step 2E applies to embedded controls such as textbox, listbox, range, | ||
// etc. | ||
|
||
// NOTE: does not implement acc-name-1.2 step 2.F.: check that '''role allows name from | ||
// content''', which applies to `currentNode` and its children. | ||
// NOTE: does not implement acc-name-1.2 Step 2.F.ii.: '''Check for CSS generated textual | ||
// content''' (e.g. :before and :after). | ||
|
||
// NOTE: does not implement acc-name-1.2 Step 2.I.: '''if the current node has a Tooltip | ||
// attribute, return its value''' | ||
|
||
// Return text content with whitespace collapsed into a single space character. Accomplish | ||
// acc-name-1.2 steps 2F, 2G, and 2H. | ||
return (currentNode.textContent || '').replace(/\s+/g, ' ').trim(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.