Skip to content

Commit 5b4b476

Browse files
committed
fix(material/datepicker): change calendar cells to buttons
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-selected="true" <!-- ... --> > <!-- additional details ommited --> </button> </td> ``` Fixes #23476
1 parent 2d1c70d commit 5b4b476

File tree

3 files changed

+70
-37
lines changed

3 files changed

+70
-37
lines changed

src/material/datepicker/calendar-body.html

+55-34
Original file line numberDiff line numberDiff line change
@@ -26,40 +26,61 @@
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}}
29+
<!--
30+
The anatomy of a normal cell in the callendar body has two main parts.
31+
1. A `button` role element to visually displays each cell and provides interaction
32+
2. A `gridcell` to container the button to make it part of the table
33+
34+
<td role="gridcell class="mat-caldnar-body-cell-wrapper"...>
35+
<div role="button" class="mat-calendar-body-cell" ...>
36+
...details omitted
6237
</div>
63-
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
38+
</td>
39+
40+
We use buttons to ensure that VoiceOver, and possible other screen readers, announces them as
41+
interactable (issue #23476). Each button is contained in a `gridcell`, so that table-specific
42+
navigation features are available to assistive technology.
43+
-->
44+
<td
45+
*ngFor="let item of row; let colIndex = index"
46+
role="gridcell"
47+
class="mat-calendar-body-cell-container"
48+
[style.width]="_cellWidth"
49+
[style.paddingTop]="_cellPadding"
50+
[style.paddingBottom]="_cellPadding"
51+
[attr.data-mat-row]="rowIndex"
52+
[attr.data-mat-col]="colIndex"
53+
>
54+
<button
55+
type="button"
56+
class="mat-calendar-body-cell"
57+
[ngClass]="item.cssClasses"
58+
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
59+
[class.mat-calendar-body-disabled]="!item.enabled"
60+
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
61+
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
62+
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
63+
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
64+
[class.mat-calendar-body-comparison-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
65+
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
66+
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
67+
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
68+
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
69+
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
70+
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
71+
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
72+
[attr.aria-label]="item.ariaLabel"
73+
[attr.aria-disabled]="!item.enabled || null"
74+
[attr.aria-selected]="_isSelected(item.compareValue)"
75+
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
76+
(click)="_cellClicked(item, $event)">
77+
<div class="mat-calendar-body-cell-content mat-focus-indicator"
78+
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
79+
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
80+
[class.mat-calendar-body-today]="todayValue === item.compareValue">
81+
{{item.displayValue}}
82+
</div>
83+
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
84+
</button>
6485
</td>
6586
</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 {
36+
display: table-cell;
3537
position: relative;
38+
padding: 0;
3639
height: 0;
3740
line-height: 0;
41+
}
42+
43+
.mat-calendar-body-cell {
44+
@include button-common.reset();
45+
position: absolute;
46+
top: 0;
47+
left: 0;
48+
width: 100%;
49+
height: 100%;
50+
background: none;
3851
text-align: center;
3952
outline: none;
40-
cursor: pointer;
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.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
}

0 commit comments

Comments
 (0)