Skip to content

Commit ed3c80b

Browse files
committed
fix(material/datepicker): VoiceOver reading out cell content twice
Adds a workaround to address the issue where VoiceOver reads out the content of the cells twice.
1 parent 03485cd commit ed3c80b

File tree

11 files changed

+108
-91
lines changed

11 files changed

+108
-91
lines changed

src/material/datepicker/calendar-body.html

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
4747
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
4848
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
49-
[attr.aria-label]="item.ariaLabel"
5049
[attr.aria-disabled]="!item.enabled || null"
5150
[attr.aria-selected]="_isSelected(item.compareValue)"
5251
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
@@ -58,7 +57,18 @@
5857
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
5958
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
6059
[class.mat-calendar-body-today]="todayValue === item.compareValue">
61-
{{item.displayValue}}
60+
<!--
61+
Usually we'd want to set the label as an `aria-label` attribute, but VoiceOver where it
62+
will read out both the `aria-label` and the cell content which is repetitive. We work
63+
around the issue by rendering it as a hidden element and hiding the visual label with
64+
`aria-hidden`. An alternative approach is to keep the `aria-label` and only set
65+
`aria-hidden` on the visual content, but that causes VoiceOver to read out the
66+
word "blank" after each cell.
67+
-->
68+
<span class="cdk-visually-hidden">{{item.ariaLabel}}</span>
69+
<span
70+
class="mat-calendar-body-cell-visual-label"
71+
aria-hidden="true">{{item.displayValue}}</span>
6272
</div>
6373
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
6474
</td>

src/material/datepicker/calendar-body.spec.ts

Lines changed: 20 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -47,49 +47,17 @@ describe('MatCalendarBody', () => {
4747
});
4848

4949
it('highlights today', () => {
50-
const todayCells = calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-today')!;
51-
expect(todayCells.length).toBe(1);
52-
53-
const todayCell = todayCells[0];
54-
55-
expect(todayCell).not.toBeNull();
56-
expect(todayCell.textContent!.trim()).toBe('3');
57-
});
58-
59-
it('sets aria-current="date" on today', () => {
60-
const todayCells = calendarBodyNativeElement.querySelectorAll(
61-
'[aria-current="date"] .mat-calendar-body-today',
62-
)!;
63-
expect(todayCells.length).toBe(1);
64-
65-
const todayCell = todayCells[0];
66-
67-
expect(todayCell).not.toBeNull();
68-
expect(todayCell.textContent!.trim()).toBe('3');
69-
});
70-
71-
it('does not highlight today if today is not within the scope', () => {
72-
testComponent.todayValue = 100000;
73-
fixture.detectChanges();
74-
7550
const todayCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-today')!;
76-
expect(todayCell).toBeNull();
77-
});
78-
79-
it('does not set aria-current="date" on any cell if today is not ' + 'the scope', () => {
80-
testComponent.todayValue = 100000;
81-
fixture.detectChanges();
82-
83-
const todayCell = calendarBodyNativeElement.querySelector(
84-
'[aria-current="date"] .mat-calendar-body-today',
85-
)!;
86-
expect(todayCell).toBeNull();
51+
const todayContent = todayCell.querySelector('.mat-calendar-body-cell-visual-label')!;
52+
expect(todayCell).not.toBeNull();
53+
expect(todayContent.textContent!.trim()).toBe('3');
8754
});
8855

8956
it('highlights selected', () => {
9057
const selectedCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-selected')!;
58+
const selectedContent = selectedCell.querySelector('.mat-calendar-body-cell-visual-label')!;
9159
expect(selectedCell).not.toBeNull();
92-
expect(selectedCell.innerHTML.trim()).toBe('4');
60+
expect(selectedContent.textContent!.trim()).toBe('4');
9361
});
9462

9563
it('should set aria-selected correctly', () => {
@@ -132,15 +100,24 @@ describe('MatCalendarBody', () => {
132100
});
133101

134102
it('should mark active date', () => {
135-
expect((cellEls[10] as HTMLElement).innerText.trim()).toBe('11');
136-
expect(cellEls[10].classList).toContain('mat-calendar-body-active');
103+
const cell = cellEls[10] as HTMLElement;
104+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
105+
106+
expect(content.innerText.trim()).toBe('11');
107+
expect(cell.classList).toContain('mat-calendar-body-active');
137108
});
138109

139110
it('should set a class on even dates', () => {
140-
expect((cellEls[0] as HTMLElement).innerText.trim()).toBe('1');
141-
expect((cellEls[1] as HTMLElement).innerText.trim()).toBe('2');
142-
expect(cellEls[0].classList).not.toContain('even');
143-
expect(cellEls[1].classList).toContain('even');
111+
const labelClass = '.mat-calendar-body-cell-visual-label';
112+
const firstCell = cellEls[0] as HTMLElement;
113+
const secondCell = cellEls[1] as HTMLElement;
114+
const firstContent = firstCell.querySelector(labelClass) as HTMLElement;
115+
const secondContent = secondCell.querySelector(labelClass) as HTMLElement;
116+
117+
expect(firstContent.innerText.trim()).toBe('1');
118+
expect(secondContent.innerText.trim()).toBe('2');
119+
expect(firstCell.classList).not.toContain('even');
120+
expect(secondCell.classList).toContain('even');
144121
});
145122

146123
it('should have a focus indicator', () => {

src/material/datepicker/calendar.spec.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,19 @@ describe('MatCalendar', () => {
6767
calendarInstance.updateTodaysDate();
6868
fixture.detectChanges();
6969

70-
let todayCell = calendarElement.querySelector('.mat-calendar-body-today')!;
71-
expect(todayCell).not.toBeNull();
72-
expect(todayCell.innerHTML.trim()).toBe('1');
70+
let todayContent = calendarElement.querySelector(
71+
'.mat-calendar-body-today .mat-calendar-body-cell-visual-label')!;
72+
expect(todayContent).not.toBeNull();
73+
expect(todayContent.innerHTML.trim()).toBe('1');
7374

7475
fakeToday = new Date(2018, 0, 10);
7576
calendarInstance.updateTodaysDate();
7677
fixture.detectChanges();
7778

78-
todayCell = calendarElement.querySelector('.mat-calendar-body-today')!;
79-
expect(todayCell).not.toBeNull();
80-
expect(todayCell.innerHTML.trim()).toBe('10');
79+
todayContent = calendarElement.querySelector(
80+
'.mat-calendar-body-today .mat-calendar-body-cell-visual-label')!;
81+
expect(todayContent).not.toBeNull();
82+
expect(todayContent.innerHTML.trim()).toBe('10');
8183
}));
8284

8385
it('should be in month view with specified month active', () => {

src/material/datepicker/date-range-input.spec.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,10 @@ describe('MatDateRangeInput', () => {
536536
'.mat-calendar-body-range-end',
537537
].join(','),
538538
),
539-
).map(cell => cell.textContent!.trim());
539+
).map(cell => {
540+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
541+
return content.textContent!.trim();
542+
});
540543

541544
expect(rangeTexts).toEqual(['2', '3', '4', '5']);
542545
}));
@@ -569,7 +572,10 @@ describe('MatDateRangeInput', () => {
569572
'.mat-calendar-body-comparison-end',
570573
].join(','),
571574
),
572-
).map(cell => cell.textContent!.trim());
575+
).map(cell => {
576+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
577+
return content.textContent!.trim();
578+
});
573579

574580
expect(rangeTexts).toEqual(['2', '3', '4', '5']);
575581
}));

src/material/datepicker/datepicker.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ describe('MatDatepicker', () => {
681681
testComponent.datepicker.open();
682682
fixture.detectChanges();
683683

684-
const firstCalendarCell = document.querySelector('.mat-calendar-body-cell')!;
684+
const firstCalendarCell = document.querySelector('.mat-calendar-body-cell-visual-label')!;
685685

686686
// When the calendar is in year view, the first cell should be for a month rather than
687687
// for a date.
@@ -732,7 +732,7 @@ describe('MatDatepicker', () => {
732732
testComponent.datepicker.open();
733733
fixture.detectChanges();
734734

735-
const firstCalendarCell = document.querySelector('.mat-calendar-body-cell')!;
735+
const firstCalendarCell = document.querySelector('.mat-calendar-body-cell-visual-label')!;
736736

737737
// When the calendar is in year view, the first cell should be for a month rather than
738738
// for a date.

src/material/datepicker/month-view.spec.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ describe('MatMonthView', () => {
8484
});
8585

8686
it('shows selected date if in same month', () => {
87-
let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-body-selected')!;
88-
expect(selectedEl.innerHTML.trim()).toBe('10');
87+
const selectedContent = monthViewNativeElement.querySelector(
88+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
89+
expect(selectedContent.textContent!.trim()).toBe('10');
8990
});
9091

9192
it('does not show selected date if in different month', () => {
@@ -97,18 +98,22 @@ describe('MatMonthView', () => {
9798
});
9899

99100
it('fires selected change event on cell clicked', () => {
100-
let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
101+
const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
101102
(cellEls[cellEls.length - 1] as HTMLElement).click();
102103
fixture.detectChanges();
103104

104-
let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-body-selected')!;
105-
expect(selectedEl.innerHTML.trim()).toBe('31');
105+
const selectedContent = monthViewNativeElement.querySelector(
106+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
107+
expect(selectedContent.textContent!.trim()).toBe('31');
106108
});
107109

108110
it('should mark active date', () => {
109-
let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
110-
expect((cellEls[4] as HTMLElement).innerText.trim()).toBe('5');
111-
expect(cellEls[4].classList).toContain('mat-calendar-body-active');
111+
const cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
112+
const cell = cellEls[4] as HTMLElement;
113+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
114+
115+
expect(content.innerText.trim()).toBe('5');
116+
expect(cell.classList).toContain('mat-calendar-body-active');
112117
});
113118

114119
describe('a11y', () => {

src/material/datepicker/multi-year-view.spec.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ describe('MatMultiYearView', () => {
6262
});
6363

6464
it('shows selected year if in same range', () => {
65-
let selectedEl = multiYearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
66-
expect(selectedEl.innerHTML.trim()).toBe('2020');
65+
const selectedContent = multiYearViewNativeElement.querySelector(
66+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
67+
expect(selectedContent.innerHTML.trim()).toBe('2020');
6768
});
6869

6970
it('does not show selected year if in different range', () => {
@@ -79,8 +80,9 @@ describe('MatMultiYearView', () => {
7980
(cellEls[cellEls.length - 1] as HTMLElement).click();
8081
fixture.detectChanges();
8182

82-
let selectedEl = multiYearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
83-
expect(selectedEl.innerHTML.trim()).toBe('2039');
83+
const selectedContent = multiYearViewNativeElement.querySelector(
84+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
85+
expect(selectedContent.textContent!.trim()).toBe('2039');
8486
});
8587

8688
it('should emit the selected year on cell clicked', () => {
@@ -94,9 +96,12 @@ describe('MatMultiYearView', () => {
9496
});
9597

9698
it('should mark active date', () => {
97-
let cellEls = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
98-
expect((cellEls[1] as HTMLElement).innerText.trim()).toBe('2017');
99-
expect(cellEls[1].classList).toContain('mat-calendar-body-active');
99+
const cellEls = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
100+
const cell = cellEls[1] as HTMLElement;
101+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
102+
103+
expect(content.innerText.trim()).toBe('2017');
104+
expect(cell.classList).toContain('mat-calendar-body-active');
100105
});
101106

102107
describe('a11y', () => {
@@ -278,7 +283,11 @@ describe('MatMultiYearView', () => {
278283
fixture.detectChanges();
279284

280285
const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
281-
expect((cells[0] as HTMLElement).innerText.trim()).toBe('2014');
286+
const firstCell = cells[0] as HTMLElement;
287+
const content =
288+
firstCell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
289+
290+
expect(content.innerText.trim()).toBe('2014');
282291
});
283292
});
284293

@@ -301,7 +310,9 @@ describe('MatMultiYearView', () => {
301310
fixture.detectChanges();
302311

303312
const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
304-
expect((cells[cells.length - 1] as HTMLElement).innerText.trim()).toBe('2020');
313+
const lastCell = cells[cells.length - 1] as HTMLElement;
314+
const content = lastCell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
315+
expect(content.innerText.trim()).toBe('2020');
305316
});
306317
});
307318

@@ -324,7 +335,9 @@ describe('MatMultiYearView', () => {
324335
fixture.detectChanges();
325336

326337
const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
327-
expect((cells[cells.length - 1] as HTMLElement).innerText.trim()).toBe('2020');
338+
const lastCell = cells[cells.length - 1] as HTMLElement;
339+
const content = lastCell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
340+
expect(content.innerText.trim()).toBe('2020');
328341
});
329342

330343
it('should disable dates before minDate', () => {

src/material/datepicker/testing/calendar-cell-harness.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export class MatCalendarCellHarness extends ComponentHarness {
1616
/** Reference to the inner content element inside the cell. */
1717
private _content = this.locatorFor('.mat-calendar-body-cell-content');
1818

19+
/** Inner element containing the visual label. */
20+
private _visualLabel = this.locatorFor('.mat-calendar-body-cell-visual-label');
21+
1922
/**
2023
* Gets a `HarnessPredicate` that can be used to search for a `MatCalendarCellHarness`
2124
* that meets certain criteria.
@@ -56,10 +59,14 @@ export class MatCalendarCellHarness extends ComponentHarness {
5659

5760
/** Gets the text of the calendar cell. */
5861
async getText(): Promise<string> {
59-
return (await this._content()).text();
62+
return (await this._visualLabel()).text();
6063
}
6164

62-
/** Gets the aria-label of the calendar cell. */
65+
/**
66+
* Gets the aria-label of the calendar cell.
67+
* @deprecated Calendar cells no longer have an `aria-label`. To be removed.
68+
* @breaking-change 14.0.0
69+
*/
6370
async getAriaLabel(): Promise<string> {
6471
// We're guaranteed for the `aria-label` to be defined
6572
// since this is a private element that we control.

src/material/datepicker/testing/calendar-harness-shared.spec.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,6 @@ export function runCalendarHarnessTests(
101101
expect(await targetCell.isSelected()).toBe(true);
102102
});
103103

104-
it('should get the aria-label of a cell', async () => {
105-
const calendar = await loader.getHarness(calendarHarness.with({selector: '#single'}));
106-
const cells = await calendar.getCells();
107-
108-
expect(await cells[0].getAriaLabel()).toBe('August 1, 2020');
109-
expect(await cells[15].getAriaLabel()).toBe('August 16, 2020');
110-
expect(await cells[30].getAriaLabel()).toBe('August 31, 2020');
111-
});
112-
113104
it('should get the disabled state of a cell', async () => {
114105
fixture.componentInstance.minDate = new Date(
115106
calendarDate.getFullYear(),

src/material/datepicker/year-view.spec.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ describe('MatYearView', () => {
6666
});
6767

6868
it('shows selected month if in same year', () => {
69-
let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
70-
expect(selectedEl.innerHTML.trim()).toBe('MAR');
69+
const selectedContent = yearViewNativeElement.querySelector(
70+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
71+
expect(selectedContent.textContent!.trim()).toBe('MAR');
7172
});
7273

7374
it('does not show selected month if in different year', () => {
@@ -83,8 +84,9 @@ describe('MatYearView', () => {
8384
(cellEls[cellEls.length - 1] as HTMLElement).click();
8485
fixture.detectChanges();
8586

86-
let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
87-
expect(selectedEl.innerHTML.trim()).toBe('DEC');
87+
const selectedContent = yearViewNativeElement.querySelector(
88+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
89+
expect(selectedContent.textContent!.trim()).toBe('DEC');
8890
});
8991

9092
it('should emit the selected month on cell clicked', () => {
@@ -98,9 +100,12 @@ describe('MatYearView', () => {
98100
});
99101

100102
it('should mark active date', () => {
101-
let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
102-
expect((cellEls[0] as HTMLElement).innerText.trim()).toBe('JAN');
103-
expect(cellEls[0].classList).toContain('mat-calendar-body-active');
103+
const cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
104+
const cell = cellEls[0] as HTMLElement;
105+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
106+
107+
expect(content.innerText.trim()).toBe('JAN');
108+
expect(cell.classList).toContain('mat-calendar-body-active');
104109
});
105110

106111
it('should allow selection of month with less days than current active date', () => {

tools/public_api_guard/material/datepicker-testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface DateRangeInputHarnessFilters extends BaseHarnessFilters {
5454
export class MatCalendarCellHarness extends ComponentHarness {
5555
blur(): Promise<void>;
5656
focus(): Promise<void>;
57+
// @deprecated
5758
getAriaLabel(): Promise<string>;
5859
getText(): Promise<string>;
5960
// (undocumented)

0 commit comments

Comments
 (0)