Skip to content

fix(material/datepicker): calendar aria-descriptions start/end date #25457

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/material/datepicker/aria-accessible-name.spec.ts
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');
});
});
179 changes: 179 additions & 0 deletions src/material/datepicker/aria-accessible-name.ts
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 &lt;input/&gt; element of `matStartDate` or
* `matEndDate` component. Corresponds to the 'Root Element' from accname-1.2
*
* @return expected ARIA accessible name of argument &lt;input/&gt;
*/
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();
}
8 changes: 8 additions & 0 deletions src/material/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
[attr.aria-disabled]="!item.enabled || null"
[attr.aria-pressed]="_isSelected(item.compareValue)"
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
[attr.aria-describedby]="_getDescribedby(item.compareValue)"
(click)="_cellClicked(item, $event)"
(focus)="_emitActiveDateChange(item, $event)">
<div class="mat-calendar-body-cell-content mat-focus-indicator"
Expand All @@ -75,3 +76,10 @@
</button>
</td>
</tr>

<label [id]="_startDateLabelId" class="mat-calendar-body-hidden-label">
{{startDateAccessibleName}}
</label>
<label [id]="_endDateLabelId" class="mat-calendar-body-hidden-label">
{{endDateAccessibleName}}
</label>
5 changes: 5 additions & 0 deletions src/material/datepicker/calendar-body.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ $calendar-range-end-body-cell-size:
padding-right: $calendar-body-label-side-padding;
}

// Label that is not rendered and removed from the accessibility tree.
.mat-calendar-body-hidden-label {
display: none;
}

.mat-calendar-body-cell-container {
position: relative;
height: 0;
Expand Down
Loading