From 5b596d852159e42b1b6e75de27434036b9be913b Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Thu, 16 Dec 2021 19:19:02 +0000 Subject: [PATCH] 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: ``` > ``` 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 angular#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: ``` ``` Fixes #23476, #24086 --- src/material/datepicker/calendar-body.html | 81 +++++++++++-------- src/material/datepicker/calendar-body.scss | 16 +++- src/material/datepicker/calendar-body.spec.ts | 16 ++-- src/material/datepicker/calendar-body.ts | 2 +- .../testing/calendar-cell-harness.ts | 2 +- 5 files changed, 69 insertions(+), 48 deletions(-) diff --git a/src/material/datepicker/calendar-body.html b/src/material/datepicker/calendar-body.html index ffdfef27c1cd..1b0f5f0b1a4a 100644 --- a/src/material/datepicker/calendar-body.html +++ b/src/material/datepicker/calendar-body.html @@ -26,40 +26,51 @@ [style.paddingBottom]="_cellPadding"> {{_firstRowOffset >= labelMinRequiredCells ? label : ''}} - -
- {{item.displayValue}} -
- + + + diff --git a/src/material/datepicker/calendar-body.scss b/src/material/datepicker/calendar-body.scss index 28e5d890e5dd..334585fcd42b 100644 --- a/src/material/datepicker/calendar-body.scss +++ b/src/material/datepicker/calendar-body.scss @@ -1,4 +1,5 @@ @use 'sass:math'; +@use '../core/style/button-common'; @use '../../cdk/a11y'; $calendar-body-label-padding-start: 5% !default; @@ -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 diff --git a/src/material/datepicker/calendar-body.spec.ts b/src/material/datepicker/calendar-body.spec.ts index df10b757c081..52ff40e20359 100644 --- a/src/material/datepicker/calendar-body.spec.ts +++ b/src/material/datepicker/calendar-body.spec.ts @@ -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); }); diff --git a/src/material/datepicker/calendar-body.ts b/src/material/datepicker/calendar-body.ts index 895db4b41472..ef34dc45fbff 100644 --- a/src/material/datepicker/calendar-body.ts +++ b/src/material/datepicker/calendar-body.ts @@ -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})); } } diff --git a/src/material/datepicker/testing/calendar-cell-harness.ts b/src/material/datepicker/testing/calendar-cell-harness.ts index 4ebee4c8ccb4..c0274c0d281d 100644 --- a/src/material/datepicker/testing/calendar-cell-harness.ts +++ b/src/material/datepicker/testing/calendar-cell-harness.ts @@ -69,7 +69,7 @@ export class MatCalendarCellHarness extends ComponentHarness { /** Whether the cell is selected. */ async isSelected(): Promise { const host = await this.host(); - return (await host.getAttribute('aria-selected')) === 'true'; + return (await host.getAttribute('aria-pressed')) === 'true'; } /** Whether the cell is disabled. */