Skip to content

Commit c29b1fd

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 01734b3 commit c29b1fd

File tree

11 files changed

+113
-62
lines changed

11 files changed

+113
-62
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
(click)="_cellClicked(item, $event)"
@@ -57,7 +56,18 @@
5756
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
5857
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
5958
[class.mat-calendar-body-today]="todayValue === item.compareValue">
60-
{{item.displayValue}}
59+
<!--
60+
Usually we'd want to set the label as an `aria-label` attribute, but VoiceOver where it
61+
will read out both the `aria-label` and the cell content which is repetitive. We work
62+
around the issue by rendering it as a hidden element and hiding the visual label with
63+
`aria-hidden`. An alternative approach is to keep the `aria-label` and only set
64+
`aria-hidden` on the visual content, but that causes VoiceOver to read out the
65+
word "blank" after each cell.
66+
-->
67+
<span class="cdk-visually-hidden">{{item.ariaLabel}}</span>
68+
<span
69+
class="mat-calendar-body-cell-visual-label"
70+
aria-hidden="true">{{item.displayValue}}</span>
6171
</div>
6272
<div class="mat-calendar-body-cell-preview"></div>
6373
</td>

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,16 @@ describe('MatCalendarBody', () => {
5151

5252
it('highlights today', () => {
5353
const todayCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-today')!;
54+
const todayContent = todayCell.querySelector('.mat-calendar-body-cell-visual-label')!;
5455
expect(todayCell).not.toBeNull();
55-
expect(todayCell.innerHTML.trim()).toBe('3');
56+
expect(todayContent.textContent!.trim()).toBe('3');
5657
});
5758

5859
it('highlights selected', () => {
5960
const selectedCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-selected')!;
61+
const selectedContent = selectedCell.querySelector('.mat-calendar-body-cell-visual-label')!;
6062
expect(selectedCell).not.toBeNull();
61-
expect(selectedCell.innerHTML.trim()).toBe('4');
63+
expect(selectedContent.textContent!.trim()).toBe('4');
6264
});
6365

6466
it('should set aria-selected correctly', () => {
@@ -97,15 +99,24 @@ describe('MatCalendarBody', () => {
9799
});
98100

99101
it('should mark active date', () => {
100-
expect((cellEls[10] as HTMLElement).innerText.trim()).toBe('11');
101-
expect(cellEls[10].classList).toContain('mat-calendar-body-active');
102+
const cell = cellEls[10] as HTMLElement;
103+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
104+
105+
expect(content.innerText.trim()).toBe('11');
106+
expect(cell.classList).toContain('mat-calendar-body-active');
102107
});
103108

104109
it('should set a class on even dates', () => {
105-
expect((cellEls[0] as HTMLElement).innerText.trim()).toBe('1');
106-
expect((cellEls[1] as HTMLElement).innerText.trim()).toBe('2');
107-
expect(cellEls[0].classList).not.toContain('even');
108-
expect(cellEls[1].classList).toContain('even');
110+
const labelClass = '.mat-calendar-body-cell-visual-label';
111+
const firstCell = cellEls[0] as HTMLElement;
112+
const secondCell = cellEls[1] as HTMLElement;
113+
const firstContent = firstCell.querySelector(labelClass) as HTMLElement;
114+
const secondContent = secondCell.querySelector(labelClass) as HTMLElement;
115+
116+
expect(firstContent.innerText.trim()).toBe('1');
117+
expect(secondContent.innerText.trim()).toBe('2');
118+
expect(firstCell.classList).not.toContain('even');
119+
expect(secondCell.classList).toContain('even');
109120
});
110121

111122
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
@@ -68,17 +68,19 @@ describe('MatCalendar', () => {
6868
calendarInstance.updateTodaysDate();
6969
fixture.detectChanges();
7070

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

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

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

8486
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
@@ -530,7 +530,10 @@ describe('MatDateRangeInput', () => {
530530
'.mat-calendar-body-range-start',
531531
'.mat-calendar-body-in-range',
532532
'.mat-calendar-body-range-end'
533-
].join(','))).map(cell => cell.textContent!.trim());
533+
].join(','))).map(cell => {
534+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
535+
return content.textContent!.trim();
536+
});
534537

535538
expect(rangeTexts).toEqual(['2', '3', '4', '5']);
536539
}));
@@ -556,7 +559,10 @@ describe('MatDateRangeInput', () => {
556559
'.mat-calendar-body-comparison-start',
557560
'.mat-calendar-body-in-comparison-range',
558561
'.mat-calendar-body-comparison-end'
559-
].join(','))).map(cell => cell.textContent!.trim());
562+
].join(','))).map(cell => {
563+
const content = cell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
564+
return content.textContent!.trim();
565+
});
560566

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

src/material/datepicker/datepicker.spec.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -676,14 +676,14 @@ describe('MatDatepicker', () => {
676676
testComponent.datepicker.open();
677677
fixture.detectChanges();
678678

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

681681
// When the calendar is in year view, the first cell should be for a month rather than
682682
// for a date.
683683
// When the calendar is in year view, the first cell should be for a month rather than
684-
// for a date.
685-
expect(firstCalendarCell.textContent!.trim())
686-
.withContext('Expected the calendar to be in year-view').toBe('JAN');
684+
// for a date.
685+
expect(firstCalendarCell.textContent!.trim())
686+
.withContext('Expected the calendar to be in year-view').toBe('JAN');
687687
});
688688

689689
it('should fire yearSelected when user selects calendar year in year view',
@@ -728,14 +728,14 @@ expect(firstCalendarCell.textContent!.trim())
728728
testComponent.datepicker.open();
729729
fixture.detectChanges();
730730

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

733733
// When the calendar is in year view, the first cell should be for a month rather than
734734
// for a date.
735735
// When the calendar is in year view, the first cell should be for a month rather than
736-
// for a date.
737-
expect(firstCalendarCell.textContent!.trim())
738-
.withContext('Expected the calendar to be in multi-year-view').toBe('2016');
736+
// for a date.
737+
expect(firstCalendarCell.textContent!.trim())
738+
.withContext('Expected the calendar to be in multi-year-view').toBe('2016');
739739
});
740740

741741
it('should fire yearSelected when user selects calendar year in multiyear view',

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
@@ -64,8 +64,9 @@ describe('MatMultiYearView', () => {
6464
});
6565

6666
it('shows selected year if in same range', () => {
67-
let selectedEl = multiYearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
68-
expect(selectedEl.innerHTML.trim()).toBe('2020');
67+
const selectedContent = multiYearViewNativeElement.querySelector(
68+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
69+
expect(selectedContent.innerHTML.trim()).toBe('2020');
6970
});
7071

7172
it('does not show selected year if in different range', () => {
@@ -81,8 +82,9 @@ describe('MatMultiYearView', () => {
8182
(cellEls[cellEls.length - 1] as HTMLElement).click();
8283
fixture.detectChanges();
8384

84-
let selectedEl = multiYearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
85-
expect(selectedEl.innerHTML.trim()).toBe('2039');
85+
const selectedContent = multiYearViewNativeElement.querySelector(
86+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
87+
expect(selectedContent.textContent!.trim()).toBe('2039');
8688
});
8789

8890
it('should emit the selected year on cell clicked', () => {
@@ -96,9 +98,12 @@ describe('MatMultiYearView', () => {
9698
});
9799

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

104109
describe('a11y', () => {
@@ -284,7 +289,11 @@ describe('MatMultiYearView', () => {
284289
fixture.detectChanges();
285290

286291
const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
287-
expect((cells[0] as HTMLElement).innerText.trim()).toBe('2014');
292+
const firstCell = cells[0] as HTMLElement;
293+
const content =
294+
firstCell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
295+
296+
expect(content.innerText.trim()).toBe('2014');
288297
});
289298
});
290299

@@ -307,7 +316,9 @@ describe('MatMultiYearView', () => {
307316
fixture.detectChanges();
308317

309318
const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
310-
expect((cells[cells.length - 1] as HTMLElement).innerText.trim()).toBe('2020');
319+
const lastCell = cells[cells.length - 1] as HTMLElement;
320+
const content = lastCell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
321+
expect(content.innerText.trim()).toBe('2020');
311322
});
312323
});
313324

@@ -330,7 +341,9 @@ describe('MatMultiYearView', () => {
330341
fixture.detectChanges();
331342

332343
const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
333-
expect((cells[cells.length - 1] as HTMLElement).innerText.trim()).toBe('2020');
344+
const lastCell = cells[cells.length - 1] as HTMLElement;
345+
const content = lastCell.querySelector('.mat-calendar-body-cell-visual-label') as HTMLElement;
346+
expect(content.innerText.trim()).toBe('2020');
334347
});
335348

336349
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.
@@ -53,10 +56,14 @@ export class MatCalendarCellHarness extends ComponentHarness {
5356

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

59-
/** Gets the aria-label of the calendar cell. */
62+
/**
63+
* Gets the aria-label of the calendar cell.
64+
* @deprecated Calendar cells no longer have an `aria-label`. To be removed.
65+
* @breaking-change 14.0.0
66+
*/
6067
async getAriaLabel(): Promise<string> {
6168
// We're guaranteed for the `aria-label` to be defined
6269
// 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
@@ -98,15 +98,6 @@ export function runCalendarHarnessTests(
9898
expect(await targetCell.isSelected()).toBe(true);
9999
});
100100

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

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

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

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

7576
it('does not show selected month if in different year', () => {
@@ -85,8 +86,9 @@ describe('MatYearView', () => {
8586
(cellEls[cellEls.length - 1] as HTMLElement).click();
8687
fixture.detectChanges();
8788

88-
let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
89-
expect(selectedEl.innerHTML.trim()).toBe('DEC');
89+
const selectedContent = yearViewNativeElement.querySelector(
90+
'.mat-calendar-body-selected .mat-calendar-body-cell-visual-label')!;
91+
expect(selectedContent.textContent!.trim()).toBe('DEC');
9092
});
9193

9294
it('should emit the selected month on cell clicked', () => {
@@ -100,9 +102,12 @@ describe('MatYearView', () => {
100102
});
101103

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

108113
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)