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): calendar aria-descriptions start/end date (a…
…ngular#25457) 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
16 changed files
with
523 additions
and
17 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
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(() => { | ||
rootElement.remove(); | ||
}); | ||
|
||
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 specified 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,179 @@ | ||
/** | ||
* @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 | ||
*/ | ||
|
||
// This file contains the `_computeAriaAccessibleName` function, which computes what the *expected* | ||
// ARIA accessible name would be for a given element. Implements a subset of ARIA specification | ||
// [Accessible Name and Description Computation 1.2](https://www.w3.org/TR/accname-1.2/). | ||
// | ||
// Specification accname-1.2 can be summarized by returning the result of the first method | ||
// available. | ||
// | ||
// 1. `aria-labelledby` attribute | ||
// ``` | ||
// <!-- example using aria-labelledby--> | ||
// <label id='label-id'>Start Date</label> | ||
// <input aria-labelledby='label-id'/> | ||
// ``` | ||
// 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"/> | ||
// ``` | ||
|
||
/** | ||
* Computes the *expected* ARIA accessible name for argument element based on [accname-1.2 | ||
* specification](https://www.w3.org/TR/accname-1.2/). Implements a subset of accname-1.2, | ||
* and should only be used for the Datepicker's specific use case. | ||
* | ||
* Intended use: | ||
* This is not a general use implementation. Only implements the parts of accname-1.2 that are | ||
* required for the Datepicker's specific use case. This function is not intended for any other | ||
* use. | ||
* | ||
* 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. | ||
* | ||
* @param element {HTMLInputElement} native <input/> element of `matStartDate` or | ||
* `matEndDate` component. Corresponds to the 'Root Element' from accname-1.2 | ||
* | ||
* @return expected ARIA accessible name of argument <input/> | ||
*/ | ||
export function _computeAriaAccessibleName( | ||
element: HTMLInputElement | HTMLTextAreaElement, | ||
): string { | ||
return _computeAriaAccessibleNameInternal(element, true); | ||
} | ||
|
||
/** | ||
* Determine if argument node is an Element based on `nodeType` property. This function is safe to | ||
* use with server-side rendering. | ||
*/ | ||
function ssrSafeIsElement(node: Node): node is Element { | ||
return node.nodeType === Node.ELEMENT_NODE; | ||
} | ||
|
||
/** | ||
* Determine if argument node is an HTMLInputElement based on `nodeName` property. This funciton is | ||
* safe to use with server-side rendering. | ||
*/ | ||
function ssrSafeIsHTMLInputElement(node: Node): node is HTMLInputElement { | ||
return node.nodeName === 'INPUT'; | ||
} | ||
|
||
/** | ||
* Determine if argument node is an HTMLTextAreaElement based on `nodeName` property. This | ||
* funciton is safe to use with server-side rendering. | ||
*/ | ||
function ssrSafeIsHTMLTextAreaElement(node: Node): node is HTMLTextAreaElement { | ||
return node.nodeName === 'TEXTAREA'; | ||
} | ||
|
||
/** | ||
* Calculate the expected ARIA accessible name for given DOM Node. Given DOM Node may be either the | ||
* "Root node" passed to `_computeAriaAccessibleName` or "Current node" as result of recursion. | ||
* | ||
* @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: this differs from accname-1.2 specification. | ||
// - Does not implement Step 1. of accname-1.2: '''If `currentNode`'s role prohibits naming, | ||
// return the empty string ("")'''. | ||
// - 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 (ssrSafeIsElement(currentNode) && 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 (ssrSafeIsElement(currentNode)) { | ||
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 | ||
// | ||
// NOTE: this differs from accname-1.2 specification. | ||
// Only implements Step 2.D. for `<label>`,`<input/>`, and `<textarea/>` element. Does not | ||
// implement other elements that have an attribute or element that defines a text alternative. | ||
if (ssrSafeIsHTMLInputElement(currentNode) || ssrSafeIsHTMLTextAreaElement(currentNode)) { | ||
// use label with a `for` attribute referencing the current node | ||
if (currentNode.labels?.length) { | ||
return Array.from(currentNode.labels) | ||
.map(x => _computeAriaAccessibleNameInternal(x, false)) | ||
.join(' '); | ||
} | ||
|
||
// use placeholder if available | ||
const placeholder = currentNode.getAttribute('placeholder')?.trim(); | ||
if (placeholder) { | ||
return placeholder; | ||
} | ||
|
||
// use title if available | ||
const title = currentNode.getAttribute('title')?.trim(); | ||
if (title) { | ||
return title; | ||
} | ||
} | ||
|
||
// NOTE: this differs from accname-1.2 specification. | ||
// - 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. | ||
// - does not implement acc-name-1.2 step 2.F.: check that '''role allows name from content''', | ||
// which applies to `currentNode` and its children. | ||
// - does not implement acc-name-1.2 Step 2.F.ii.: '''Check for CSS generated textual content''' | ||
// (e.g. :before and :after). | ||
// - 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
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.