diff --git a/__tests__/src/index.spec.ts b/__tests__/src/index.spec.ts index b96da1144..ba0818952 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()], @@ -1738,49 +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.minuteElement, { key: "Tab" }, KeyboardEvent); + // Focus is unchanged: the handler did not hijack the forward Tab. expect(document.activeElement).toStrictEqual(fp.minuteElement); + }); + + 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(); + if (!fp.hourElement) return; + + fp.hourElement.focus(); + expect(document.activeElement).toStrictEqual(fp.hourElement); simulate( "keydown", - fp.amPM, - { - keyCode: 9, // Tab - }, + fp.hourElement, + { key: "Tab", shiftKey: true }, KeyboardEvent ); - 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", () => { @@ -1918,6 +2014,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); + }); }); }); diff --git a/src/index.ts b/src/index.ts index ff826a9d6..996dba2c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -463,48 +463,51 @@ 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) { + // 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(); @@ -1779,6 +1782,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 (