Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 137 additions & 26 deletions __tests__/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});

Expand Down
73 changes: 41 additions & 32 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
Expand Down