From fe50d60af7f70224d957079faf3b50ecbc1dce58 Mon Sep 17 00:00:00 2001 From: web-padawan Date: Thu, 10 Sep 2026 14:00:32 +0300 Subject: [PATCH] fix: follow date focus moved by assistive technology Nothing synced focusedDate from DOM focus. When a screen reader moved focus to another date, the arrow keys continued from the stale date, the focused part stayed on the wrong cell, and PageUp / PageDown kept the stale day of month. The calendar now reports the focused date with a date-focus event, and the overlay follows it under the same rule as the arrow keys: a disabled date may take focus, a date outside min / max may not. Focus set by the overlay itself is a no-op, since the date already matches. Part of #12398 Co-Authored-By: Claude Fable 5.1 --- ...aadin-date-picker-overlay-content-mixin.js | 23 +++++++ .../src/vaadin-month-calendar-mixin.js | 16 +++++ .../test/keyboard-navigation.test.js | 61 ++++++++++++++++++- 3 files changed, 99 insertions(+), 1 deletion(-) 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 f5b72be3a6b..ec3722c1bcb 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 @@ -318,6 +318,10 @@ export const DatePickerOverlayContentMixin = (superClass) => calendar.addEventListener('selected-date-changed', (e) => { this.selectedDate = e.detail.value; }); + + calendar.addEventListener('date-focus', (e) => { + this.__onDateFocus(e.detail.date); + }); }); this.calendars = calendars; @@ -911,6 +915,25 @@ export const DatePickerOverlayContentMixin = (superClass) => } } + /** + * Follows focus that something other than the keyboard handling moved to a date, + * such as a screen reader swiping to it, so that the arrow keys, the `focused` part + * and the day of month kept for PageUp / PageDown continue from that date. + * @private + */ + __onDateFocus(date) { + // The calendar focuses the date this element already tracks, nothing to follow. + if (dateEquals(date, this.focusedDate)) { + return; + } + // Same rule as the arrow keys: a disabled date may take focus, a date outside min / max may not. + if (!this._dateAllowed(date, undefined, undefined, () => false)) { + return; + } + this.focusedDate = date; + this._focusedMonthDate = date.getDate(); + } + async focusDate(date, keepMonth) { const dateToFocus = date || this.selectedDate || this.initialPosition || new Date(); this.focusedDate = dateToFocus; diff --git a/packages/date-picker/src/vaadin-month-calendar-mixin.js b/packages/date-picker/src/vaadin-month-calendar-mixin.js index 75a32464073..f2310e5e014 100644 --- a/packages/date-picker/src/vaadin-month-calendar-mixin.js +++ b/packages/date-picker/src/vaadin-month-calendar-mixin.js @@ -173,6 +173,22 @@ export const MonthCalendarMixin = (superClass) => this.setAttribute('role', 'application'); addListener(this.$.monthGrid, 'tap', this._handleTap.bind(this)); + this.$.monthGrid.addEventListener('focusin', (e) => this._onDateFocusIn(e)); + } + + /** + * Reports the date whose button received focus, so the overlay can follow + * focus that was moved by something other than its own keyboard handling, + * such as a screen reader swiping to a date. + * @protected + */ + _onDateFocusIn(e) { + const cell = e.target.closest('[part~=date]'); + if (cell?.date) { + this.dispatchEvent( + new CustomEvent('date-focus', { detail: { date: cell.date }, bubbles: true, composed: true }), + ); + } } /** @override */ diff --git a/packages/date-picker/test/keyboard-navigation.test.js b/packages/date-picker/test/keyboard-navigation.test.js index 0ec4843db46..32e3ce3b509 100644 --- a/packages/date-picker/test/keyboard-navigation.test.js +++ b/packages/date-picker/test/keyboard-navigation.test.js @@ -3,7 +3,16 @@ import { sendKeys } from '@vaadin/test-runner-commands'; import { aTimeout, fixtureSync, nextRender } from '@vaadin/testing-helpers'; import sinon from 'sinon'; import '../src/vaadin-date-picker.js'; -import { getDefaultI18n, getFocusedCell, open, untilOverlayRendered, untilOverlayScrolled } from './helpers.js'; +import { + getDateButton, + getDateCell, + getDefaultI18n, + getFocusedCell, + getMonthCalendar, + open, + untilOverlayRendered, + untilOverlayScrolled, +} from './helpers.js'; describe('keyboard navigation', () => { describe('date-picker', () => { @@ -79,6 +88,13 @@ describe('keyboard navigation', () => { expect(content.focusedDate.getTime()).to.equal(focused.getTime()); }); + + it('should show a date focused from outside in the input', async () => { + await open(datePicker); + getDateButton(getDateCell(getMonthCalendar(datePicker, 2001, 0), 15)).focus(); + await nextRender(); + expect(input.value).to.equal('1/15/2001'); + }); }); describe('initial position', () => { @@ -334,6 +350,49 @@ describe('keyboard navigation', () => { expect(overlay.focusedDate).to.eql(new Date(2000, 2, 31)); }); + it('should follow focus moved to another date', async () => { + getDateButton(getDateCell(getMonthCalendar(overlay, 2000, 0), 15)).focus(); + await nextRender(); + expect(overlay.focusedDate).to.eql(new Date(2000, 0, 15)); + const cell = getFocusedCell(overlay); + expect(cell.date).to.eql(new Date(2000, 0, 15)); + expect(cell.getAttribute('part')).to.contain('focused'); + }); + + it('should not notify when the focused date button regains focus', async () => { + const spy = sinon.spy(); + overlay.addEventListener('focused-date-changed', spy); + const button = getDateButton(getDateCell(getMonthCalendar(overlay, 2000, 0), 1)); + button.blur(); + button.focus(); + await nextRender(); + expect(spy.called).to.be.false; + }); + + it('should not follow focus moved to a date outside max', async () => { + overlay.maxDate = new Date(2000, 0, 10); + await nextRender(); + getDateButton(getDateCell(getMonthCalendar(overlay, 2000, 0), 15)).focus(); + await nextRender(); + expect(overlay.focusedDate).to.eql(new Date(2000, 0, 1)); + }); + + it('should follow focus moved to a disabled date inside the range', async () => { + overlay.isDateDisabled = (date) => date.day === 15; + await nextRender(); + getDateButton(getDateCell(getMonthCalendar(overlay, 2000, 0), 15)).focus(); + await nextRender(); + expect(overlay.focusedDate).to.eql(new Date(2000, 0, 15)); + }); + + it('should keep the day of month of a date focused from outside on pagedown', async () => { + getDateButton(getDateCell(getMonthCalendar(overlay, 2000, 0), 15)).focus(); + await nextRender(); + await sendKeys({ press: 'PageDown' }); + await untilOverlayScrolled(overlay); + expect(getFocusedCell(overlay).date).to.eql(new Date(2000, 1, 15)); + }); + it('should focus next year with shift and pagedown', async () => { await sendKeys({ press: 'Shift+PageDown' }); await untilOverlayScrolled(overlay);