From f830735aeccd71774679e956bd51e60381255488 Mon Sep 17 00:00:00 2001 From: gnbm Date: Tue, 7 Jul 2026 22:48:08 +0100 Subject: [PATCH 1/3] Fix keyboard-handling regressions from the accessibility rewrite Restores behavior dropped when input keydown handling was split out of the shared onKeyDown into onInputKeyDown/onTimeKeydown, and fixes an onBlur misfire predating that change: - onInputKeyDown never called triggerEvent("onKeyDown", e), so user-registered onKeyDown callbacks stopped firing for keys pressed in the input. - onInputKeyDown/onTimeKeydown matched only e.key, silently no-oping for callers that only set the legacy e.keyCode. - onTimeKeydown only handled Shift+Tab out of the hour element; the original Tab-cycling across hour/minute/second/AM-PM/plugin elements had been commented out with no replacement. - onBlur hardcoded valueChanged = true for configs without altInput, so blurring an empty, untouched input always fired onChange. --- src/index.ts | 110 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/src/index.ts b/src/index.ts index ff826a9d6..8509a05fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -463,57 +463,81 @@ function FlatpickrInstance( * Method to trigger the picker container when pressing ArrowDown on the input */ function onInputKeyDown(e: KeyboardEvent) { - switch (e.key) { - // Open Calendar and focus on available day - case "ArrowDown": - self.open(); - if (self.config.noCalendar || self.isMobile) { - self.hourElement?.focus(); - } else if (self.loadedPlugins.indexOf("monthSelect") !== -1) { - focusOnAvailableMonth(); - } else { - focusOnDay(getFirstAvailableDay(1), 0); - } - break; + // e.key is the modern way to identify the pressed key, but some callers + // (and older browsers) only set the legacy e.keyCode, so match on either. + const isArrowDown = e.key === "ArrowDown" || e.keyCode === 40; + const isEnter = e.key === "Enter" || e.keyCode === 13; + const isTab = e.key === "Tab" || e.keyCode === 9; + + // Open Calendar and focus on available day + if (isArrowDown) { + self.open(); + if (self.config.noCalendar || self.isMobile) { + self.hourElement?.focus(); + } else if (self.loadedPlugins.indexOf("monthSelect") !== -1) { + focusOnAvailableMonth(); + } else { + focusOnDay(getFirstAvailableDay(1), 0); + } + } else if (isEnter) { // Save the entered Date on the input - case "Enter": - if ( - self.loadedPlugins.indexOf("monthSelect") !== -1 || - (self.config.noCalendar && self.config.enableTime) - ) { - return; - } + if ( + self.loadedPlugins.indexOf("monthSelect") !== -1 || + (self.config.noCalendar && self.config.enableTime) + ) { + triggerEvent("onKeyDown", e); + return; + } - self._input.blur(); - self.close(); - - break; + self._input.blur(); + self.close(); + } else if (isTab) { // Close the calendar - case "Tab": - if (e.shiftKey) { - // Handle Shift+Tab combination - self.close(); - } else { - // Just Tab: Close the calendar - self.close(); - } - break; + self.close(); } + + triggerEvent("onKeyDown", e); } /** * Method to focus on the first available day, when using tab+shift keys on the time input */ function onTimeKeydown(e: KeyboardEvent) { - if (document.activeElement === self.hourElement && e.key === "Tab") { - if (e.shiftKey) { - e.preventDefault(); - if (self.config.noCalendar || self.isMobile) { - self.altInput?.focus(); - } else { - focusOnDay(getFirstAvailableDay(1), 0); - } + // Match e.key (modern) or the legacy e.keyCode, since some callers only set one. + const isTab = e.key === "Tab" || e.keyCode === 9; + if (!isTab) return; + + // Shift+Tab out of the first time element re-enters the calendar + // (or altInput, when there's no calendar to return to). + if (document.activeElement === self.hourElement && e.shiftKey) { + e.preventDefault(); + if (self.config.noCalendar || self.isMobile) { + self.altInput?.focus(); + } else { + focusOnDay(getFirstAvailableDay(1), 0); } + return; + } + + // Otherwise, Tab/Shift+Tab cycles focus across the time elements + // (hour, minute, second, AM/PM, plugin elements), falling back to + // the main input once the cycle runs out. + const eventTarget = getEventTarget(e); + const elems = ([ + self.hourElement, + self.minuteElement, + self.secondElement, + self.amPM, + ] as Node[]) + .concat(self.pluginElements) + .filter((x) => x) as HTMLInputElement[]; + + const i = elems.indexOf(eventTarget as HTMLInputElement); + + if (i !== -1) { + const target = elems[i + (e.shiftKey ? -1 : 1)]; + e.preventDefault(); + (target || self._input).focus(); } } @@ -1779,6 +1803,12 @@ function FlatpickrInstance( valueChanged = self._input.value.trimEnd() !== self.formatDate(new Date(hiddenInputValue), self.config.altFormat); + } else { + // Without altInput there's no separate hidden value to diff against; + // only treat the blur as a change if there's actually a date selected + // or something typed, so blurring an untouched empty input is a no-op. + valueChanged = + self.selectedDates.length > 0 || self._input.value.length > 0; } if ( From a074824266c79cc50dcc832f03d148103e1873cc Mon Sep 17 00:00:00 2001 From: gnbm Date: Tue, 7 Jul 2026 23:15:03 +0100 Subject: [PATCH 2/3] Add regression tests for keyboard-handling and onBlur fixes Locks the three regressions fixed in the previous commit plus the opposite-direction behavior so a future refactor can't silently reintroduce them: - onKeyDown hook fires exactly once for a keydown on the input. - ArrowDown on the input opens the calendar (e.keyCode path). - Enter closes when allowInput is set, via e.key as well as e.keyCode. - Time picker tabs forward through hour/minute/second/AM-PM to the input. - onBlur still fires onChange when a value is entered without altInput (companion to the existing "doesn't misfire" test). Verified each regression-catcher fails against the pre-fix source. --- __tests__/src/index.spec.ts | 104 ++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/__tests__/src/index.spec.ts b/__tests__/src/index.spec.ts index b96da1144..876588544 100644 --- a/__tests__/src/index.spec.ts +++ b/__tests__/src/index.spec.ts @@ -873,6 +873,59 @@ describe("flatpickr", () => { expect(fp.isOpen).toEqual(false); }); + it("onKeyDown closes flatpickr on Enter when allowInput set to true (via e.key)", () => { + // Guards against the input handler matching only e.keyCode: a KeyboardEvent + // dispatched with just `key: "Enter"` (no keyCode) must still close. + createInstance({ + enableTime: true, + allowInput: true, + altInput: true, + }); + + fp.jumpToDate("2016-2-1"); + + fp.open(); + expect(fp.isOpen).toEqual(true); + + simulate( + "keydown", + fp._input, + { + key: "Enter", + }, + KeyboardEvent + ); + expect(fp.isOpen).toEqual(false); + }); + + it("fires the onKeyDown hook exactly once for a keydown on the input", () => { + // Guards against the regression where input keydown handling was moved off + // the shared onKeyDown handler and stopped firing the onKeyDown hook, and + // against accidentally firing it twice. + let fired = 0; + createInstance({ + onKeyDown: () => { + fired++; + }, + }); + + fp._input.focus(); + simulate("keydown", fp._input, { keyCode: 37 }, KeyboardEvent); // "ArrowLeft" + + expect(fired).toEqual(1); + }); + + it("ArrowDown on the input opens the calendar", () => { + // clickOpens is disabled so focus doesn't open the calendar; this isolates + // the ArrowDown handler and guards its e.keyCode path. + createInstance({ clickOpens: false }); + expect(fp.isOpen).toEqual(false); + + simulate("keydown", fp._input, { keyCode: 40 }, KeyboardEvent); // "ArrowDown" + + expect(fp.isOpen).toEqual(true); + }); + it("enabling dates by function", () => { createInstance({ enable: [(d) => d.getDate() === 6, new Date()], @@ -1783,6 +1836,42 @@ describe("flatpickr", () => { expect(document.activeElement).toStrictEqual(fp._input); }); + it("time-picker tabs forward through all time elements", () => { + // Guards against the regression where the time picker's forward Tab + // cycling (hour -> minute -> second -> AM/PM -> input) was removed and + // only Shift+Tab out of the hour element remained. + createInstance({ mode: "time", enableSeconds: true }); + fp.open(); + + expect(fp.hourElement).toBeDefined(); + expect(fp.minuteElement).toBeDefined(); + expect(fp.secondElement).toBeDefined(); + expect(fp.amPM).toBeDefined(); + + if ( + !fp.hourElement || + !fp.minuteElement || + !fp.secondElement || + !fp.amPM + ) + return; + + fp.hourElement.focus(); + expect(document.activeElement).toStrictEqual(fp.hourElement); + + simulate("keydown", fp.hourElement, { keyCode: 9 }, KeyboardEvent); // Tab + expect(document.activeElement).toStrictEqual(fp.minuteElement); + + simulate("keydown", fp.minuteElement, { keyCode: 9 }, KeyboardEvent); // Tab + expect(document.activeElement).toStrictEqual(fp.secondElement); + + simulate("keydown", fp.secondElement, { keyCode: 9 }, KeyboardEvent); // Tab + expect(document.activeElement).toStrictEqual(fp.amPM); + + simulate("keydown", fp.amPM, { keyCode: 9 }, KeyboardEvent); // Tab + expect(document.activeElement).toStrictEqual(fp._input); + }); + it("dropdown should correctly load months with minDate", () => { const fp = createInstance({ defaultDate: new Date(2019, 5, 11), @@ -1918,6 +2007,21 @@ describe("flatpickr", () => { simulate("blur", fp._input); expect(timesFired).toEqual(0); }); + + it("still fires when a value was entered without altInput", () => { + // The counterpart to "doesn't misfire": the guard must not suppress a + // genuine change. A blur after typing a date should still save it. + let timesFired = 0; + const fp = createInstance({ + allowInput: true, + onChange: () => timesFired++, + }); + fp._input.focus(); + fp._input.value = "2016-12-27"; + simulate("blur", fp._input); + expect(timesFired).toEqual(1); + expect(fp.selectedDates.length).toEqual(1); + }); }); }); From 27d99b8ab070bf8361336d7dfb3fd38913730bdc Mon Sep 17 00:00:00 2001 From: gnbm Date: Tue, 7 Jul 2026 23:47:34 +0100 Subject: [PATCH 3/3] Preserve focus-trap accessibility for the time picker The earlier onTimeKeydown fix reinstated manual JS Tab-cycling across the time elements that the accessibility work (35997745) had intentionally removed. That cycling called preventDefault + focus() and, on Tab from the last time element, moved focus to self._input - which lives outside the calendar container, escaping the focus trap and overriding the a11y-managed native tab order. This regressed keyboard navigation for all time-enabled pickers. Revert onTimeKeydown to the accessibility-designed behavior: native tab order (elements are tabindex=0 while open) plus the focus-trap elements keep focus inside the open dialog; only Shift+Tab out of the hour element is handled in JS (back into the calendar days / altInput). Realign the time-picker tests with the focus-trap model instead of the old "tab out to the input" expectation, which only failed under jsdom because jsdom does not simulate native Tab traversal: - keeps keyboard focus trapped within the calendar (trap wrap + tabindex) - leaves forward tab navigation between time elements to the browser (guards against re-introducing the JS cycling that broke the trap) - Shift+Tab from the hour element returns focus to the calendar --- __tests__/src/index.spec.ts | 119 +++++++++++++++++++----------------- src/index.ts | 47 ++++---------- 2 files changed, 76 insertions(+), 90 deletions(-) diff --git a/__tests__/src/index.spec.ts b/__tests__/src/index.spec.ts index 876588544..ba0818952 100644 --- a/__tests__/src/index.spec.ts +++ b/__tests__/src/index.spec.ts @@ -1791,85 +1791,92 @@ describe("flatpickr", () => { expect(fp._input.value).toEqual(""); }); - it("time-picker focuses out onto input", () => { + it("time-picker keeps keyboard focus trapped within the calendar", () => { + // The accessibility model relies on native tab order (all interactive + // elements are tabindex=0 while open) plus focus-trap elements that wrap + // focus at the calendar boundaries, keeping keyboard focus inside the + // open dialog rather than escaping to the input. createInstance({ mode: "time" }); fp.open(); - expect(fp.hourElement).toBeDefined(); - expect(fp.minuteElement).toBeDefined(); - expect(fp.amPM).toBeDefined(); + const topTrap = fp.calendarContainer.querySelector( + ".focus-trap-top-element" + ) as HTMLElement; + const bottomTrap = fp.calendarContainer.querySelector( + ".focus-trap-bottom-element" + ) as HTMLElement; - if (!fp.hourElement || !fp.minuteElement || !fp.amPM) return; + expect(topTrap).not.toBeNull(); + expect(bottomTrap).not.toBeNull(); - fp.minuteElement.focus(); - expect(document.activeElement).toStrictEqual(fp.minuteElement); + // Interactive time elements are reachable via native tab order when open. + expect(fp.hourElement && fp.hourElement.tabIndex).toEqual(0); + expect(fp.minuteElement && fp.minuteElement.tabIndex).toEqual(0); + expect(fp.amPM && fp.amPM.tabIndex).toEqual(0); - simulate( - "keydown", - fp.minuteElement, - { - keyCode: 9, // Tab - }, - KeyboardEvent - ); - expect(document.activeElement).toStrictEqual(fp.amPM); + // Tab past the bottom of the calendar wraps back to the top. + bottomTrap.focus(); + simulate("keydown", bottomTrap, { key: "Tab" }, KeyboardEvent); + expect(document.activeElement).toStrictEqual(topTrap); + // Shift+Tab past the top of the calendar wraps to the bottom. + topTrap.focus(); simulate( "keydown", - fp.amPM, - { - keyCode: 9, // Tab - shiftKey: true, - }, + topTrap, + { key: "Tab", shiftKey: true }, KeyboardEvent ); + expect(document.activeElement).toStrictEqual(bottomTrap); + }); + + it("time-picker leaves forward tab navigation between time elements to the browser", () => { + // Guards against re-introducing the manual JS Tab-cycling that broke the + // focus trap: a forward Tab on a time element must NOT be intercepted and + // must NOT move focus programmatically (the browser's native tab order + // handles this). Only Shift+Tab out of the hour element is handled in JS. + createInstance({ mode: "time" }); + fp.open(); + + expect(fp.minuteElement).toBeDefined(); + if (!fp.minuteElement) return; + + fp.minuteElement.focus(); expect(document.activeElement).toStrictEqual(fp.minuteElement); - simulate( - "keydown", - fp.amPM, - { - keyCode: 9, // Tab - }, - KeyboardEvent - ); - expect(document.activeElement).toStrictEqual(fp._input); + simulate("keydown", fp.minuteElement, { key: "Tab" }, KeyboardEvent); + // Focus is unchanged: the handler did not hijack the forward Tab. + expect(document.activeElement).toStrictEqual(fp.minuteElement); }); - it("time-picker tabs forward through all time elements", () => { - // Guards against the regression where the time picker's forward Tab - // cycling (hour -> minute -> second -> AM/PM -> input) was removed and - // only Shift+Tab out of the hour element remained. - createInstance({ mode: "time", enableSeconds: true }); + it("time-picker Shift+Tab from the hour element returns focus to the calendar", () => { + // The one time-navigation behavior the accessibility model keeps in JS: + // Shift+Tab out of the first time element goes back into the calendar + // days rather than to the previous element in document order. + createInstance({ + enableTime: true, + defaultDate: "2016-12-27 13:00", + }); fp.open(); expect(fp.hourElement).toBeDefined(); - expect(fp.minuteElement).toBeDefined(); - expect(fp.secondElement).toBeDefined(); - expect(fp.amPM).toBeDefined(); - - if ( - !fp.hourElement || - !fp.minuteElement || - !fp.secondElement || - !fp.amPM - ) - return; + if (!fp.hourElement) return; fp.hourElement.focus(); expect(document.activeElement).toStrictEqual(fp.hourElement); - simulate("keydown", fp.hourElement, { keyCode: 9 }, KeyboardEvent); // Tab - expect(document.activeElement).toStrictEqual(fp.minuteElement); - - simulate("keydown", fp.minuteElement, { keyCode: 9 }, KeyboardEvent); // Tab - expect(document.activeElement).toStrictEqual(fp.secondElement); - - simulate("keydown", fp.secondElement, { keyCode: 9 }, KeyboardEvent); // Tab - expect(document.activeElement).toStrictEqual(fp.amPM); + simulate( + "keydown", + fp.hourElement, + { key: "Tab", shiftKey: true }, + KeyboardEvent + ); - simulate("keydown", fp.amPM, { keyCode: 9 }, KeyboardEvent); // Tab - expect(document.activeElement).toStrictEqual(fp._input); + const activeElement = document.activeElement as HTMLElement; + expect(activeElement.classList.contains("flatpickr-day")).toEqual(true); + expect(fp.daysContainer && fp.daysContainer.contains(activeElement)).toEqual( + true + ); }); it("dropdown should correctly load months with minDate", () => { diff --git a/src/index.ts b/src/index.ts index 8509a05fb..996dba2c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -503,41 +503,20 @@ function FlatpickrInstance( * Method to focus on the first available day, when using tab+shift keys on the time input */ function onTimeKeydown(e: KeyboardEvent) { - // Match e.key (modern) or the legacy e.keyCode, since some callers only set one. - const isTab = e.key === "Tab" || e.keyCode === 9; - if (!isTab) return; - - // Shift+Tab out of the first time element re-enters the calendar - // (or altInput, when there's no calendar to return to). - if (document.activeElement === self.hourElement && e.shiftKey) { - e.preventDefault(); - if (self.config.noCalendar || self.isMobile) { - self.altInput?.focus(); - } else { - focusOnDay(getFirstAvailableDay(1), 0); + // Forward Tab navigation between the time elements is handled by the + // browser's native tab order (they are all tabindex=0 while open) and kept + // inside the calendar by the focus-trap elements. We only intervene for + // Shift+Tab out of the first time element, to send focus back into the + // calendar days (or the altInput when there is no calendar). + if (document.activeElement === self.hourElement && e.key === "Tab") { + if (e.shiftKey) { + e.preventDefault(); + if (self.config.noCalendar || self.isMobile) { + self.altInput?.focus(); + } else { + focusOnDay(getFirstAvailableDay(1), 0); + } } - return; - } - - // Otherwise, Tab/Shift+Tab cycles focus across the time elements - // (hour, minute, second, AM/PM, plugin elements), falling back to - // the main input once the cycle runs out. - const eventTarget = getEventTarget(e); - const elems = ([ - self.hourElement, - self.minuteElement, - self.secondElement, - self.amPM, - ] as Node[]) - .concat(self.pluginElements) - .filter((x) => x) as HTMLInputElement[]; - - const i = elems.indexOf(eventTarget as HTMLInputElement); - - if (i !== -1) { - const target = elems[i + (e.shiftKey ? -1 : 1)]; - e.preventDefault(); - (target || self._input).focus(); } }