From a7eec4e5bb7878f74ea4a354c9853d0078d2c908 Mon Sep 17 00:00:00 2001 From: web-padawan Date: Thu, 3 Sep 2026 10:28:30 +0300 Subject: [PATCH] refactor!: move date cell semantics to a button inside the cell iOS VoiceOver reads the text content of a gridcell and ignores its aria-label, so dates were announced as a bare day number. The accessible name, ARIA state and tabindex now live on a button inside the cell, the shape used by react-aria, Duet, Angular Material and Ionic. NVDA does not enter focus mode for a button the way it does for a grid cell. Left in browse mode it swallowed the arrow keys, so the roving tabindex never moved, and it read out every date when the overlay opened. The month calendar is an application region to force focus mode, which is what react-aria wraps its calendar in and what MOJ Frontend adopted for the same problem. NVDA announces a date twice, once for the cell and once for the focused button, because the cell takes its name from its content. That is inherent to a labelled focusable element inside a gridcell, and react-spectrum ships the same behavior. This covers only the announcement half of #12398. Months that do not contain DOM focus still carry aria-hidden, so month traversal with iOS VoiceOver is unchanged and still needs verification on a device. Co-Authored-By: Claude Fable 5.1 --- .../vaadin-month-calendar-base-styles.js | 12 +- ...aadin-date-picker-overlay-content-mixin.js | 8 +- .../src/vaadin-month-calendar-mixin.js | 27 +- .../date-picker/src/vaadin-month-calendar.js | 28 +- .../test/date-metadata-provider.test.js | 21 +- .../__snapshots__/date-picker.test.snap.js | 27 +- .../__snapshots__/month-calendar.test.snap.js | 2282 ++++++++++++----- packages/date-picker/test/dropdown.test.js | 10 +- packages/date-picker/test/fullscreen.test.js | 8 +- packages/date-picker/test/helpers.js | 35 +- .../date-picker/test/month-calendar.test.js | 13 +- packages/date-picker/test/wai-aria.test.js | 6 +- 12 files changed, 1714 insertions(+), 763 deletions(-) diff --git a/packages/date-picker/src/styles/vaadin-month-calendar-base-styles.js b/packages/date-picker/src/styles/vaadin-month-calendar-base-styles.js index 39ed44d3b96..ecd18453d14 100644 --- a/packages/date-picker/src/styles/vaadin-month-calendar-base-styles.js +++ b/packages/date-picker/src/styles/vaadin-month-calendar-base-styles.js @@ -69,7 +69,8 @@ export const monthCalendarStyles = css` [part~='weekday'], [part~='week-number'], - [part~='date'] { + [part~='date'], + [part~='date-button'] { align-items: center; display: flex; justify-content: center; @@ -81,7 +82,6 @@ export const monthCalendarStyles = css` position: relative; height: var(--vaadin-date-picker-date-height, 2rem); cursor: var(--vaadin-clickable-cursor); - outline: none; } [part~='date']:empty { @@ -97,7 +97,13 @@ export const monthCalendarStyles = css` aspect-ratio: 1; } - :where([part~='date']:focus-visible)::after { + [part~='date-button'] { + flex: 1; + align-self: stretch; + outline: none; + } + + :where([part~='date']:has(:focus-visible))::after { outline: var(--vaadin-focus-ring-width) solid var(--vaadin-focus-ring-color); outline-offset: calc(var(--vaadin-focus-ring-width) * -1); } diff --git a/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js b/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js index f97c2d30293..f5b72be3a6b 100644 --- a/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js +++ b/packages/date-picker/src/vaadin-date-picker-overlay-content-mixin.js @@ -230,6 +230,10 @@ export const DatePickerOverlayContentMixin = (superClass) => return this.calendars.map((calendar) => calendar.focusableDateElement).find(Boolean); } + get focusableDateButton() { + return this.calendars.map((calendar) => calendar.focusableDateButton).find(Boolean); + } + /** @protected */ _initControllers() { this.addController( @@ -897,12 +901,12 @@ export const DatePickerOverlayContentMixin = (superClass) => __tryFocusDate() { const dateToFocus = this.__pendingDateFocus; if (dateToFocus) { - // Check the date element with tabindex="0" + // Check the cell of the date that has the focusable button const dateElement = this.focusableDateElement; if (dateElement && dateEquals(dateElement.date, this.__pendingDateFocus)) { delete this.__pendingDateFocus; - dateElement.focus(); + this.focusableDateButton.focus(); } } } diff --git a/packages/date-picker/src/vaadin-month-calendar-mixin.js b/packages/date-picker/src/vaadin-month-calendar-mixin.js index 6ab5e4b35a4..75a32464073 100644 --- a/packages/date-picker/src/vaadin-month-calendar-mixin.js +++ b/packages/date-picker/src/vaadin-month-calendar-mixin.js @@ -146,15 +146,32 @@ export const MonthCalendarMixin = (superClass) => return ['__focusedDateChanged(focusedDate, _days)', '_showWeekNumbersChanged(showWeekNumbers, i18n)']; } + /** + * The date cell of the focused date. It carries the `date` property and the date part names, + * while the button inside it is the element that takes DOM focus. + */ get focusableDateElement() { return [...this.shadowRoot.querySelectorAll('[part~=date]')].find((datePart) => { return dateEquals(datePart.date, this.focusedDate); }); } + /** + * The button inside the cell of the focused date. Screen readers get the date's name and state + * from this element, so it is also the element that takes DOM focus. + */ + get focusableDateButton() { + const cell = this.focusableDateElement; + return cell ? cell.querySelector('[part~=date-button]') : undefined; + } + /** @protected */ ready() { super.ready(); + + // Use application to enforce focus mode for date cells in NVDA + this.setAttribute('role', 'application'); + addListener(this.$.monthGrid, 'tap', this._handleTap.bind(this)); } @@ -294,11 +311,11 @@ export const MonthCalendarMixin = (superClass) => /** @protected */ _handleTap(e) { - if (!this.ignoreTaps && !this._notTapping && e.target.date && !e.target.hasAttribute('disabled')) { - this.selectedDate = e.target.date; - this.dispatchEvent( - new CustomEvent('date-tap', { detail: { date: e.target.date }, bubbles: true, composed: true }), - ); + // A tap can land on the button or on the cell padding around it. + const cell = e.target.closest('[part~=date]'); + if (!this.ignoreTaps && !this._notTapping && cell?.date && !cell.hasAttribute('disabled')) { + this.selectedDate = cell.date; + this.dispatchEvent(new CustomEvent('date-tap', { detail: { date: cell.date }, bubbles: true, composed: true })); } } diff --git a/packages/date-picker/src/vaadin-month-calendar.js b/packages/date-picker/src/vaadin-month-calendar.js index fce2c1855df..1232352c128 100644 --- a/packages/date-picker/src/vaadin-month-calendar.js +++ b/packages/date-picker/src/vaadin-month-calendar.js @@ -3,7 +3,7 @@ * Copyright (c) 2016 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ -import { html, LitElement } from 'lit'; +import { html, LitElement, nothing } from 'lit'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; import { LumoInjectionMixin } from '@vaadin/vaadin-themable-mixin/lumo-injection-mixin.js'; @@ -80,16 +80,24 @@ class MonthCalendar extends MonthCalendarMixin(ThemableMixin(PolylitMixin(LumoIn )}" .date="${date}" ?disabled="${this.__isDayDisabled(date, this.minDate, this.maxDate, this.isDateDisabled)}" - tabindex="${this.__computeDayTabIndex(date, this.focusedDate)}" aria-selected="${this.__computeDayAriaSelected(date, this.selectedDate)}" - aria-disabled="${this.__computeDayAriaDisabled( - date, - this.minDate, - this.maxDate, - this.isDateDisabled, - )}" - aria-label="${this.__computeDayAriaLabel(date)}" - >${this._getDate(date)}${ + date + ? html`
${this._getDate(date)}
` + : nothing + } `; })} diff --git a/packages/date-picker/test/date-metadata-provider.test.js b/packages/date-picker/test/date-metadata-provider.test.js index e398b80ba81..fd08770f2cb 100644 --- a/packages/date-picker/test/date-metadata-provider.test.js +++ b/packages/date-picker/test/date-metadata-provider.test.js @@ -4,7 +4,16 @@ import sinon from 'sinon'; import '../src/vaadin-date-picker.js'; import { DateMetadataController } from '../src/vaadin-date-metadata-controller.js'; import { formatISODate, monthIndex, parseDate } from '../src/vaadin-date-picker-helper.js'; -import { getCalendars, getDateCell, getDateCells, getMonthCalendar, isoDate, isoDateInMonth, open } from './helpers.js'; +import { + getCalendars, + getDateButton, + getDateCell, + getDateCells, + getMonthCalendar, + isoDate, + isoDateInMonth, + open, +} from './helpers.js'; describe('dateMetadataProvider integration', () => { let datePicker, overlayContent, today, year, month; @@ -84,8 +93,8 @@ describe('dateMetadataProvider integration', () => { }); it('should set aria-disabled on a provider-disabled date', () => { - expect(cell15.getAttribute('aria-disabled')).to.equal('true'); - expect(cell16.getAttribute('aria-disabled')).to.equal('false'); + expect(getDateButton(cell15).getAttribute('aria-disabled')).to.equal('true'); + expect(getDateButton(cell16).getAttribute('aria-disabled')).to.equal('false'); }); it('should not report loading for an answer that needs no waiting', () => { @@ -121,7 +130,7 @@ describe('dateMetadataProvider integration', () => { it('should keep the dates of a pending month selectable', () => { expect(isDisabled(cell15)).to.be.false; - expect(cell15.getAttribute('aria-disabled')).to.equal('false'); + expect(getDateButton(cell15).getAttribute('aria-disabled')).to.equal('false'); }); it('should commit a date picked from a month being fetched', async () => { @@ -144,10 +153,10 @@ describe('dateMetadataProvider integration', () => { expect(overlayContent.hasAttribute('aria-busy')).to.be.false; expect(isDisabled(cell15)).to.be.true; - expect(cell15.getAttribute('aria-disabled')).to.equal('true'); + expect(getDateButton(cell15).getAttribute('aria-disabled')).to.equal('true'); expect(isDisabled(cell16)).to.be.false; - expect(cell16.getAttribute('aria-disabled')).to.equal('false'); + expect(getDateButton(cell16).getAttribute('aria-disabled')).to.equal('false'); expect(cell15.part.contains('loading')).to.be.false; expect(cell16.part.contains('loading')).to.be.false; diff --git a/packages/date-picker/test/dom/__snapshots__/date-picker.test.snap.js b/packages/date-picker/test/dom/__snapshots__/date-picker.test.snap.js index 776f441da61..3fe2ef67148 100644 --- a/packages/date-picker/test/dom/__snapshots__/date-picker.test.snap.js +++ b/packages/date-picker/test/dom/__snapshots__/date-picker.test.snap.js @@ -389,27 +389,42 @@ snapshots["vaadin-date-picker host opened default"] =
-
-
-
- +
-
-
diff --git a/packages/date-picker/test/dom/__snapshots__/month-calendar.test.snap.js b/packages/date-picker/test/dom/__snapshots__/month-calendar.test.snap.js index d85b3ff243e..baede4dacf0 100644 --- a/packages/date-picker/test/dom/__snapshots__/month-calendar.test.snap.js +++ b/packages/date-picker/test/dom/__snapshots__/month-calendar.test.snap.js @@ -2,7 +2,10 @@ export const snapshots = {}; snapshots["vaadin-month-calendar host default"] = -`