Skip to content

Commit 2b67397

Browse files
zarendmmalerba
authored andcommittedJan 25, 2022
fix(material/datepicker): change calendar cells to buttons (#24171)
Makes changes to the DOM structure of calendar cells for better screen reader experience. Previously, the DOM structure looksed like this: ``` <!-- Existing DOM structure of each calendar body cell --> <td class="mat-calendar-body-cell" role="gridcell" aria-disabled="false" aria-current="date" aria-selected="true" <!-- ... --> > <!-- additional details ommited --> </> ``` Using the `gridcell` role allows screenreaders to use table specific navigation and some screenreaders would announce that the cells are interactible because of the presence of `aria-selected`. However, some screenreaders did not announce the cells as interactable and treated it the same as a cell in a static table (e.g. VoiceOver announces element type incorrectly #23476). This changes the DOM structure to nest buttons inside of a gridcell to make it more explicit that the table cells can be interacted with and are not static content. The gridcell role is still present, so table navigation will continue to work, but the interaction is done with buttons nested inside the `td` elements. The `td` element is only for adding information to the a11y tree and not used for visual purposes. Updated DOM structure: ``` <td role="gridcell" class="mat-calendar-body-cell-container" > <button class="mat-calendar-body-cell" aria-disabled="false" aria-current="date" aria-pressed="true" <!-- ... --> > <!-- additional details ommited --> </button> </td> ``` Fixes #23476, #24086 (cherry picked from commit 43db844)
1 parent 1ce3e5e commit 2b67397

File tree

5 files changed

+69
-48
lines changed

5 files changed

+69
-48
lines changed
 

‎src/material/datepicker/calendar-body.html

+46-35
Original file line numberDiff line numberDiff line change
@@ -26,40 +26,51 @@
2626
[style.paddingBottom]="_cellPadding">
2727
{{_firstRowOffset >= labelMinRequiredCells ? label : ''}}
2828
</td>
29-
<td *ngFor="let item of row; let colIndex = index"
30-
role="gridcell"
31-
class="mat-calendar-body-cell"
32-
[ngClass]="item.cssClasses"
33-
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
34-
[attr.data-mat-row]="rowIndex"
35-
[attr.data-mat-col]="colIndex"
36-
[class.mat-calendar-body-disabled]="!item.enabled"
37-
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
38-
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
39-
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
40-
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
41-
[class.mat-calendar-body-comparison-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
42-
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
43-
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
44-
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
45-
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
46-
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
47-
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
48-
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
49-
[attr.aria-label]="item.ariaLabel"
50-
[attr.aria-disabled]="!item.enabled || null"
51-
[attr.aria-selected]="_isSelected(item.compareValue)"
52-
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
53-
(click)="_cellClicked(item, $event)"
54-
[style.width]="_cellWidth"
55-
[style.paddingTop]="_cellPadding"
56-
[style.paddingBottom]="_cellPadding">
57-
<div class="mat-calendar-body-cell-content mat-focus-indicator"
58-
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
59-
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
60-
[class.mat-calendar-body-today]="todayValue === item.compareValue">
61-
{{item.displayValue}}
62-
</div>
63-
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
29+
<!--
30+
Each gridcell in the calendar contains a button, which signals to assistive technology that the
31+
cell is interactable, as well as the selection state via `aria-pressed`. See #23476 for
32+
background.
33+
-->
34+
<td
35+
*ngFor="let item of row; let colIndex = index"
36+
role="gridcell"
37+
class="mat-calendar-body-cell-container"
38+
[style.width]="_cellWidth"
39+
[style.paddingTop]="_cellPadding"
40+
[style.paddingBottom]="_cellPadding"
41+
[attr.data-mat-row]="rowIndex"
42+
[attr.data-mat-col]="colIndex"
43+
>
44+
<button
45+
type="button"
46+
class="mat-calendar-body-cell"
47+
[ngClass]="item.cssClasses"
48+
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
49+
[class.mat-calendar-body-disabled]="!item.enabled"
50+
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
51+
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
52+
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
53+
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
54+
[class.mat-calendar-body-comparison-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
55+
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
56+
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
57+
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
58+
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
59+
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
60+
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
61+
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
62+
[attr.aria-label]="item.ariaLabel"
63+
[attr.aria-disabled]="!item.enabled || null"
64+
[attr.aria-pressed]="_isSelected(item.compareValue)"
65+
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
66+
(click)="_cellClicked(item, $event)">
67+
<div class="mat-calendar-body-cell-content mat-focus-indicator"
68+
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
69+
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
70+
[class.mat-calendar-body-today]="todayValue === item.compareValue">
71+
{{item.displayValue}}
72+
</div>
73+
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
74+
</button>
6475
</td>
6576
</tr>

‎src/material/datepicker/calendar-body.scss

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@use 'sass:math';
2+
@use '../core/style/button-common';
23
@use '../../cdk/a11y';
34

45
$calendar-body-label-padding-start: 5% !default;
@@ -31,13 +32,24 @@ $calendar-range-end-body-cell-size:
3132
padding-right: $calendar-body-label-side-padding;
3233
}
3334

34-
.mat-calendar-body-cell {
35+
.mat-calendar-body-cell-container {
3536
position: relative;
3637
height: 0;
3738
line-height: 0;
39+
}
40+
41+
.mat-calendar-body-cell {
42+
@include button-common.reset();
43+
position: absolute;
44+
top: 0;
45+
left: 0;
46+
width: 100%;
47+
height: 100%;
48+
background: none;
3849
text-align: center;
3950
outline: none;
40-
cursor: pointer;
51+
font-family: inherit;
52+
margin: 0;
4153
}
4254

4355
// We use ::before to apply a background to the body cell, because we need to apply a border

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

+7-9
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,13 @@ describe('MatCalendarBody', () => {
9292
expect(selectedCell.innerHTML.trim()).toBe('4');
9393
});
9494

95-
it('should set aria-selected correctly', () => {
96-
const selectedCells = cellEls.filter(c => c.getAttribute('aria-selected') === 'true');
97-
const deselectedCells = cellEls.filter(c => c.getAttribute('aria-selected') === 'false');
98-
99-
expect(selectedCells.length)
100-
.withContext('Expected one cell to be marked as selected.')
101-
.toBe(1);
102-
expect(deselectedCells.length)
103-
.withContext('Expected remaining cells to be marked as deselected.')
95+
it('should set aria-pressed correctly', () => {
96+
const pressedCells = cellEls.filter(c => c.getAttribute('aria-pressed') === 'true');
97+
const depressedCells = cellEls.filter(c => c.getAttribute('aria-pressed') === 'false');
98+
99+
expect(pressedCells.length).withContext('Expected one cell to be marked as pressed.').toBe(1);
100+
expect(depressedCells.length)
101+
.withContext('Expected remaining cells to be marked as not pressed.')
104102
.toBe(cellEls.length - 1);
105103
});
106104

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
337337
// Only reset the preview end value when leaving cells. This looks better, because
338338
// we have a gap between the cells and the rows and we don't want to remove the
339339
// range just for it to show up again when the user moves a few pixels to the side.
340-
if (event.target && isTableCell(event.target as HTMLElement)) {
340+
if (event.target && this._getCellFromElement(event.target as HTMLElement)) {
341341
this._ngZone.run(() => this.previewChange.emit({value: null, event}));
342342
}
343343
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class MatCalendarCellHarness extends ComponentHarness {
6969
/** Whether the cell is selected. */
7070
async isSelected(): Promise<boolean> {
7171
const host = await this.host();
72-
return (await host.getAttribute('aria-selected')) === 'true';
72+
return (await host.getAttribute('aria-pressed')) === 'true';
7373
}
7474

7575
/** Whether the cell is disabled. */

0 commit comments

Comments
 (0)
Please sign in to comment.