Skip to content

Commit e99bd68

Browse files
committed
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 #23442 and #23445
1 parent 00f4abe commit e99bd68

15 files changed

+478
-22
lines changed

src/dev-app/datepicker/datepicker-demo.html

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,8 @@ <h2>Range picker</h2>
208208
[comparisonStart]="comparisonStart"
209209
[comparisonEnd]="comparisonEnd"
210210
[dateFilter]="filterOdd ? dateFilter : undefined!">
211-
<input matStartDate formControlName="start" placeholder="Start date"/>
212-
<input matEndDate formControlName="end" placeholder="End date"/>
211+
<input matStartDate formControlName="start" aria-label="Start date"/>
212+
<input matEndDate formControlName="end" aria-label="End date"/>
213213
</mat-date-range-input>
214214
<mat-datepicker-toggle [for]="range1Picker" matSuffix></mat-datepicker-toggle>
215215
<mat-date-range-picker
@@ -237,8 +237,8 @@ <h2>Range picker</h2>
237237
[comparisonStart]="comparisonStart"
238238
[comparisonEnd]="comparisonEnd"
239239
[dateFilter]="filterOdd ? dateFilter : undefined!">
240-
<input matStartDate formControlName="start" placeholder="Start date"/>
241-
<input matEndDate formControlName="end" placeholder="End date"/>
240+
<input matStartDate formControlName="start" aria-label="Start date"/>
241+
<input matEndDate formControlName="end" aria-label="End date"/>
242242
</mat-date-range-input>
243243
<mat-datepicker-toggle [for]="range2Picker" matSuffix></mat-datepicker-toggle>
244244
<mat-date-range-picker
@@ -267,8 +267,8 @@ <h2>Range picker</h2>
267267
[comparisonStart]="comparisonStart"
268268
[comparisonEnd]="comparisonEnd"
269269
[dateFilter]="filterOdd ? dateFilter : undefined!">
270-
<input matStartDate formControlName="start" placeholder="Start date"/>
271-
<input matEndDate formControlName="end" placeholder="End date"/>
270+
<input matStartDate formControlName="start" aria-label="Start date"/>
271+
<input matEndDate formControlName="end" aria-label="End date"/>
272272
</mat-date-range-input>
273273
<mat-datepicker-toggle [for]="range3Picker" matSuffix></mat-datepicker-toggle>
274274
<mat-date-range-picker
@@ -290,8 +290,8 @@ <h2>Range picker with custom selection strategy</h2>
290290
<mat-form-field>
291291
<mat-label>Enter a date range</mat-label>
292292
<mat-date-range-input [rangePicker]="range4Picker">
293-
<input matStartDate placeholder="Start date"/>
294-
<input matEndDate placeholder="End date"/>
293+
<input matStartDate aria-label="Start date"/>
294+
<input matEndDate aria-label="End date"/>
295295
</mat-date-range-input>
296296
<mat-datepicker-toggle [for]="range4Picker" matSuffix></mat-datepicker-toggle>
297297
<mat-date-range-picker customRangeStrategy #range4Picker>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import {_computeAriaAccessibleName} from './aria-accessible-name';
2+
3+
describe('_computeAriaAccessibleName', () => {
4+
let rootElement: HTMLSpanElement;
5+
6+
beforeEach(() => {
7+
rootElement = document.createElement('span');
8+
document.body.appendChild(rootElement);
9+
});
10+
11+
afterEach(() => {
12+
document.body.removeChild(rootElement);
13+
});
14+
15+
it('uses aria-labelledby over aria-label', () => {
16+
rootElement.innerHTML = `
17+
<label id='test-label'>Aria Labelledby</label>
18+
<input id='test-el' aria-labelledby='test-label' aria-label='Aria Label'/>
19+
`;
20+
21+
const input = rootElement.querySelector('#test-el')!;
22+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Aria Labelledby');
23+
});
24+
25+
it('uses aria-label over for/id', () => {
26+
rootElement.innerHTML = `
27+
<label for='test-el'>For</label>
28+
<input id='test-el' aria-label='Aria Label'/>
29+
`;
30+
31+
const input = rootElement.querySelector('#test-el')!;
32+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Aria Label');
33+
});
34+
35+
it('uses a label with for/id over a title attribute', () => {
36+
rootElement.innerHTML = `
37+
<label for='test-el'>For</label>
38+
<input id='test-el' title='Title'/>
39+
`;
40+
41+
const input = rootElement.querySelector('#test-el')!;
42+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('For');
43+
});
44+
45+
it('returns title when argument has a specifieid title', () => {
46+
rootElement.innerHTML = `<input id="test-el" title='Title'/>`;
47+
48+
const input = rootElement.querySelector('#test-el')!;
49+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Title');
50+
});
51+
52+
// match browser behavior of giving placeholder attribute preference over title attribute
53+
it('uses placeholder over title', () => {
54+
rootElement.innerHTML = `<input id="test-el" title='Title' placeholder='Placeholder'/>`;
55+
56+
const input = rootElement.querySelector('#test-el')!;
57+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Placeholder');
58+
});
59+
60+
it('uses aria-label over title and placeholder', () => {
61+
rootElement.innerHTML = `<input id="test-el" title='Title' placeholder='Placeholder'
62+
aria-label="Aria Label"/>`;
63+
64+
const input = rootElement.querySelector('#test-el')!;
65+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Aria Label');
66+
});
67+
68+
it('includes both textnode and element children of label with for/id', () => {
69+
rootElement.innerHTML = `
70+
<label for="test-el">
71+
Hello
72+
<span>
73+
Wo
74+
<span><span>r</span></span>
75+
<span> ld </span>
76+
</span>
77+
!
78+
</label>
79+
<input id='test-el'/>
80+
`;
81+
82+
const input = rootElement.querySelector('#test-el')!;
83+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Hello Wo r ld !');
84+
});
85+
86+
it('return computed name of hidden label which has for/id', () => {
87+
rootElement.innerHTML = `
88+
<label for="test-el" aria-hidden="true" style="display: none;">For</label>
89+
<input id='test-el'/>
90+
`;
91+
92+
const input = rootElement.querySelector('#test-el')!;
93+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('For');
94+
});
95+
96+
it('returns computed names of existing elements when 2 of 3 targets of aria-labelledby exist', () => {
97+
rootElement.innerHTML = `
98+
<label id="label-1-of-2" aria-hidden="true" style="display: none;">Label1</label>
99+
<label id="label-2-of-2" aria-hidden="true" style="display: none;">Label2</label>
100+
<input id="test-el" aria-labelledby="label-1-of-2 label-2-of-2 non-existant-label"/>
101+
`;
102+
103+
const input = rootElement.querySelector('#test-el')!;
104+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Label1 Label2');
105+
});
106+
107+
it('returns repeated label when there are duplicate ids in aria-labelledby', () => {
108+
rootElement.innerHTML = `
109+
<label id="label-1-of-1" aria-hidden="true" style="display: none;">Label1</label>
110+
<input id="test-el" aria-labelledby="label-1-of-1 label-1-of-1"/>
111+
`;
112+
113+
const input = rootElement.querySelector('#test-el')!;
114+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Label1 Label1');
115+
});
116+
117+
it('returns empty string when passed `<input id="test-el"/>`', () => {
118+
rootElement.innerHTML = `<input id="test-el"/>`;
119+
120+
const input = rootElement.querySelector('#test-el')!;
121+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('');
122+
});
123+
124+
it('ignores the aria-labelledby of an aria-labelledby', () => {
125+
rootElement.innerHTML = `
126+
<label id="label" aria-labelledby="transitive-label">Label</label>
127+
<label id="transitive-label" aria-labelled-by="transitive-label">Transitive Label</div>
128+
<input id="test-el" aria-labelledby="label"/>
129+
`;
130+
131+
const input = rootElement.querySelector('#test-el')!;
132+
const label = rootElement.querySelector('#label')!;
133+
expect(_computeAriaAccessibleName(label as any)).toBe('Transitive Label');
134+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('Label');
135+
});
136+
137+
it('ignores the aria-labelledby on a label with for/id', () => {
138+
rootElement.innerHTML = `
139+
<label for="transitive2-label" aria-labelledby="transitive2-div"></label>
140+
<div id="transitive2-div">Div</div>
141+
<input id="test-el" aria-labelled-by="transitive2-label"/>
142+
`;
143+
144+
const input = rootElement.querySelector('#test-el')!;
145+
expect(_computeAriaAccessibleName(input as HTMLInputElement)).toBe('');
146+
});
147+
148+
it('returns empty string when argument input is aria-labelledby itself', () => {
149+
rootElement.innerHTML = `
150+
<input id="test-el" aria-labelled-by="test-el"/>
151+
`;
152+
153+
const input = rootElement.querySelector('#test-el')!;
154+
const computedName = _computeAriaAccessibleName(input as HTMLInputElement);
155+
expect(typeof computedName)
156+
.withContext('should return value of type string')
157+
.toBe('string');
158+
});
159+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/**
10+
* Computes what we *expect* the ARIA accessible name would be for argument element. There is not an
11+
* API available to ask what the ARIA accessible name of an element is, so we determine what we
12+
* expect it to be.
13+
*
14+
* ARIA specification [Accessible Name and Description Computation 1.2](
15+
* https://www.w3.org/TR/accname-1.2/) defines how to calculate an accessible name. This function
16+
* partially implements accname-1.2. Implements a subset of accname-1.2 to be used for the
17+
* Datepicker's use case of the `matStartDate` and `matEndDate` inputs. This is not a general
18+
* use implementation, and it is inteded to *only* be used for the Datepicker.
19+
*
20+
* Argument element is the "Root node" is the accname-1.2 specification.
21+
*
22+
* Limitations:
23+
* - Only covers the needs of `matStartDate` and `matEndDate`. Does not support other use cases.
24+
* - See FIXME's in code for specific details on what parts of the accname-1.2 specification are
25+
* not implemented.
26+
*
27+
* To summarize this algorithm, it trys the following methods in order and returns result of first
28+
* method that works.
29+
*
30+
* 1. `aria-labelledby` attribute
31+
* ```
32+
* <!-- example using aria-labelledby-->
33+
* <label id='label'>Start Date</label>
34+
* <input aria-labelledby='label'/>
35+
* ```
36+
* 2. `aria-label` attribute (e.g. `<input aria-label="Departure"/>`)
37+
* 3. Label with `for`/`id`
38+
* ```
39+
* <!-- example using for/id -->
40+
* <label for="current-node">Label</label>
41+
* <input id="current-node"/>
42+
* ```
43+
* 4. `placeholder` attribute (e.g. `<input placeholder="06/03/1990"/>`)
44+
* 5. `title` attribute (e.g. `<input title="Check-In"/>`)
45+
* 6. text content
46+
* ```
47+
* <!-- example using text content -->
48+
* <label for="current-node"><span>Departure</span> Date</label>
49+
* <input id="current-node"/>
50+
* ```
51+
*
52+
* @param element {HTMLInputElement} native <input/> element of `matStartDate` or `matEndDate` component.
53+
*
54+
* @return expected ARIA accessible name of argument <input/>
55+
*/
56+
export function _computeAriaAccessibleName(element: HTMLInputElement): string {
57+
return _computeAriaAccessibleNameInternal(element, true);
58+
}
59+
60+
/**
61+
* 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.
62+
*
63+
* @return the accessible name of argument DOM Node
64+
*
65+
* @param currentNode node to determine accessible name of
66+
* @param isDirectlyReferenced true if `currentNode` is the root node to calculate ARIA accessible
67+
* name of. False if it is a result of recursion.
68+
*/
69+
function _computeAriaAccessibleNameInternal(
70+
currentNode: Node,
71+
isDirectlyReferenced: boolean,
72+
): string {
73+
// FIXME: Implement Step 1. of accname-1.2: '''If `currentNode`'s role prohibits naming, return the
74+
// empty string ("")'''.
75+
76+
// FIXME: Implement Step 2.A. of accname-1.2: '''if current node is hidden and not directly referenced by
77+
// aria-labelledby... return the empty string.'''
78+
79+
// acc-name-1.2 Step 2.B. aria-labelledby
80+
if (currentNode instanceof Element && isDirectlyReferenced) {
81+
const labelledbyIds: string[] =
82+
currentNode.getAttribute?.('aria-labelledby')?.split(/\s+/g) || [];
83+
const validIdRefs: HTMLElement[] = labelledbyIds.reduce((validIds, id) => {
84+
const elem = document.getElementById(id);
85+
if (elem) {
86+
validIds.push(elem);
87+
}
88+
return validIds;
89+
}, [] as HTMLElement[]);
90+
91+
if (validIdRefs.length) {
92+
return validIdRefs
93+
.map(idRef => {
94+
return _computeAriaAccessibleNameInternal(idRef, false);
95+
})
96+
.join(' ');
97+
}
98+
}
99+
100+
// acc-name-1.2 Step 2.C. aria-label
101+
if (currentNode instanceof Element) {
102+
const ariaLabel = currentNode.getAttribute('aria-label')?.trim();
103+
104+
if (ariaLabel) {
105+
return ariaLabel;
106+
}
107+
}
108+
109+
// acc-name-1.2 Step 2.D. attribute or element that defines a text alternative
110+
// Only implements acc-name-1.2 for `<label>` and `<input/>` element.
111+
// FIXME: Implement for all elements that have an attribute or element that defines a text
112+
// alternative.
113+
if (currentNode instanceof HTMLInputElement) {
114+
// use label with a for attribute referencing the current node
115+
const fors = document.querySelectorAll(`[for="${currentNode.id}"]`);
116+
if (fors.length) {
117+
return Array.from(fors)
118+
.map(x => _computeAriaAccessibleNameInternal(x, false))
119+
.join(' ');
120+
}
121+
122+
// use the input's placeholder if available
123+
const placeholder = currentNode.getAttribute('placeholder')?.trim();
124+
if (placeholder) return placeholder;
125+
126+
// use the input's title if available
127+
const title = currentNode.getAttribute('title')?.trim();
128+
if (title) return title;
129+
}
130+
131+
// FIXME: implement acc-name-1.2 Step 2.E.: '''if the current node is a control embedded
132+
// within the label... then include the embedded control as part of the text alternative in the
133+
// following manner...'''. Step 2E applies to embedded controls such as textbox, listbox, range,
134+
// etc.
135+
136+
// FIXME: Implement from acc-name-1.2 step 2.F.: check that '''role allows name from content''',
137+
// which applies to `currentNode` and its children.
138+
// of `currentNode`.
139+
// FIXME: Implement acc-name-1.2 Step 2.F.ii.: '''Check for CSS generated textual content''' (e.g.
140+
// :before and :after).
141+
142+
// FIXME: Implement acc-name-1.2 Step 2.I.: '''if the current node has a Tooltip attribute, return
143+
// its value'''
144+
145+
// Return text content with whitespace collapsed into a single space character. Accomplish
146+
// acc-name-1.2 steps 2F, 2G, and 2H.
147+
return (currentNode.textContent || '').replace(/\s+/g, ' ').trim();
148+
}

src/material/datepicker/calendar-body.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
[attr.aria-disabled]="!item.enabled || null"
6464
[attr.aria-pressed]="_isSelected(item.compareValue)"
6565
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
66+
[attr.aria-describedby]="_getDescribedby(item.compareValue)"
6667
(click)="_cellClicked(item, $event)"
6768
(focus)="_emitActiveDateChange(item, $event)">
6869
<div class="mat-calendar-body-cell-content mat-focus-indicator"
@@ -75,3 +76,10 @@
7576
</button>
7677
</td>
7778
</tr>
79+
80+
<label [id]="_startDateLabelId" class="cdk-visually-hidden" aria-hidden="true">
81+
{{startDateLabelledby}}
82+
</label>
83+
<label [id]="_endDateLabelId" class="cdk-visually-hidden" aria-hidden="true">
84+
{{endDateLabelledby}}
85+
</label>

0 commit comments

Comments
 (0)