Skip to content

Commit

Permalink
fix(material/datepicker): change calendar cells to buttons (#24171)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
zarend authored and mmalerba committed Jan 25, 2022
1 parent 1ce3e5e commit 2b67397
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 48 deletions.
81 changes: 46 additions & 35 deletions src/material/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,40 +26,51 @@
[style.paddingBottom]="_cellPadding">
{{_firstRowOffset >= labelMinRequiredCells ? label : ''}}
</td>
<td *ngFor="let item of row; let colIndex = index"
role="gridcell"
class="mat-calendar-body-cell"
[ngClass]="item.cssClasses"
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
[attr.data-mat-row]="rowIndex"
[attr.data-mat-col]="colIndex"
[class.mat-calendar-body-disabled]="!item.enabled"
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
[class.mat-calendar-body-comparison-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
[attr.aria-label]="item.ariaLabel"
[attr.aria-disabled]="!item.enabled || null"
[attr.aria-selected]="_isSelected(item.compareValue)"
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
(click)="_cellClicked(item, $event)"
[style.width]="_cellWidth"
[style.paddingTop]="_cellPadding"
[style.paddingBottom]="_cellPadding">
<div class="mat-calendar-body-cell-content mat-focus-indicator"
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
[class.mat-calendar-body-today]="todayValue === item.compareValue">
{{item.displayValue}}
</div>
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
<!--
Each gridcell in the calendar contains a button, which signals to assistive technology that the
cell is interactable, as well as the selection state via `aria-pressed`. See #23476 for
background.
-->
<td
*ngFor="let item of row; let colIndex = index"
role="gridcell"
class="mat-calendar-body-cell-container"
[style.width]="_cellWidth"
[style.paddingTop]="_cellPadding"
[style.paddingBottom]="_cellPadding"
[attr.data-mat-row]="rowIndex"
[attr.data-mat-col]="colIndex"
>
<button
type="button"
class="mat-calendar-body-cell"
[ngClass]="item.cssClasses"
[tabindex]="_isActiveCell(rowIndex, colIndex) ? 0 : -1"
[class.mat-calendar-body-disabled]="!item.enabled"
[class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)"
[class.mat-calendar-body-range-start]="_isRangeStart(item.compareValue)"
[class.mat-calendar-body-range-end]="_isRangeEnd(item.compareValue)"
[class.mat-calendar-body-in-range]="_isInRange(item.compareValue)"
[class.mat-calendar-body-comparison-bridge-start]="_isComparisonBridgeStart(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-bridge-end]="_isComparisonBridgeEnd(item.compareValue, rowIndex, colIndex)"
[class.mat-calendar-body-comparison-start]="_isComparisonStart(item.compareValue)"
[class.mat-calendar-body-comparison-end]="_isComparisonEnd(item.compareValue)"
[class.mat-calendar-body-in-comparison-range]="_isInComparisonRange(item.compareValue)"
[class.mat-calendar-body-preview-start]="_isPreviewStart(item.compareValue)"
[class.mat-calendar-body-preview-end]="_isPreviewEnd(item.compareValue)"
[class.mat-calendar-body-in-preview]="_isInPreview(item.compareValue)"
[attr.aria-label]="item.ariaLabel"
[attr.aria-disabled]="!item.enabled || null"
[attr.aria-pressed]="_isSelected(item.compareValue)"
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
(click)="_cellClicked(item, $event)">
<div class="mat-calendar-body-cell-content mat-focus-indicator"
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
[class.mat-calendar-body-today]="todayValue === item.compareValue">
{{item.displayValue}}
</div>
<div class="mat-calendar-body-cell-preview" aria-hidden="true"></div>
</button>
</td>
</tr>
16 changes: 14 additions & 2 deletions src/material/datepicker/calendar-body.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@use 'sass:math';
@use '../core/style/button-common';
@use '../../cdk/a11y';

$calendar-body-label-padding-start: 5% !default;
Expand Down Expand Up @@ -31,13 +32,24 @@ $calendar-range-end-body-cell-size:
padding-right: $calendar-body-label-side-padding;
}

.mat-calendar-body-cell {
.mat-calendar-body-cell-container {
position: relative;
height: 0;
line-height: 0;
}

.mat-calendar-body-cell {
@include button-common.reset();
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: none;
text-align: center;
outline: none;
cursor: pointer;
font-family: inherit;
margin: 0;
}

// We use ::before to apply a background to the body cell, because we need to apply a border
Expand Down
16 changes: 7 additions & 9 deletions src/material/datepicker/calendar-body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,13 @@ describe('MatCalendarBody', () => {
expect(selectedCell.innerHTML.trim()).toBe('4');
});

it('should set aria-selected correctly', () => {
const selectedCells = cellEls.filter(c => c.getAttribute('aria-selected') === 'true');
const deselectedCells = cellEls.filter(c => c.getAttribute('aria-selected') === 'false');

expect(selectedCells.length)
.withContext('Expected one cell to be marked as selected.')
.toBe(1);
expect(deselectedCells.length)
.withContext('Expected remaining cells to be marked as deselected.')
it('should set aria-pressed correctly', () => {
const pressedCells = cellEls.filter(c => c.getAttribute('aria-pressed') === 'true');
const depressedCells = cellEls.filter(c => c.getAttribute('aria-pressed') === 'false');

expect(pressedCells.length).withContext('Expected one cell to be marked as pressed.').toBe(1);
expect(depressedCells.length)
.withContext('Expected remaining cells to be marked as not pressed.')
.toBe(cellEls.length - 1);
});

Expand Down
2 changes: 1 addition & 1 deletion src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
// Only reset the preview end value when leaving cells. This looks better, because
// we have a gap between the cells and the rows and we don't want to remove the
// range just for it to show up again when the user moves a few pixels to the side.
if (event.target && isTableCell(event.target as HTMLElement)) {
if (event.target && this._getCellFromElement(event.target as HTMLElement)) {
this._ngZone.run(() => this.previewChange.emit({value: null, event}));
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/material/datepicker/testing/calendar-cell-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class MatCalendarCellHarness extends ComponentHarness {
/** Whether the cell is selected. */
async isSelected(): Promise<boolean> {
const host = await this.host();
return (await host.getAttribute('aria-selected')) === 'true';
return (await host.getAttribute('aria-pressed')) === 'true';
}

/** Whether the cell is disabled. */
Expand Down

0 comments on commit 2b67397

Please sign in to comment.