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"] = -`