diff --git a/packages/web/src/shortcuts/useAppShortcut.test.ts b/packages/web/src/shortcuts/useAppShortcut.test.ts index 7037fce70..8fbe63e3c 100644 --- a/packages/web/src/shortcuts/useAppShortcut.test.ts +++ b/packages/web/src/shortcuts/useAppShortcut.test.ts @@ -112,6 +112,40 @@ describe("useAppShortcut", () => { document.body.removeChild(input); }); + it("keeps Mod shortcuts active on focused radios after re-render", async () => { + // Omitting ignoreInputs must not clobber TanStack's Mod default + // (ignoreInputs: false) via setOptions({ ignoreInputs: undefined }). + const radio = document.createElement("input"); + radio.type = "radio"; + document.body.appendChild(radio); + radio.focus(); + + const modifierKey = resolveModifier("Mod"); + const isCtrl = modifierKey === "Control"; + + const { rerender } = renderHook(() => + useAppShortcut("Mod+Enter", mockHandler), + ); + + rerender(); + + dispatchKeyEvent( + "Enter", + "keydown", + { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }, + radio, + ); + + await waitFor(() => { + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + document.body.removeChild(radio); + }); + it("blurs the active element before handling the shortcut when requested", async () => { const input = document.createElement("input"); const blurSpy = mock(); diff --git a/packages/web/src/shortcuts/useAppShortcut.ts b/packages/web/src/shortcuts/useAppShortcut.ts index 9eb5b3361..9ff6d26d9 100644 --- a/packages/web/src/shortcuts/useAppShortcut.ts +++ b/packages/web/src/shortcuts/useAppShortcut.ts @@ -37,6 +37,8 @@ export function useAppShortcut( conflictBehavior = "allow", } = options; + // Omit undefined option keys so TanStack's setOptions cannot clobber + // registration defaults (e.g. Mod shortcuts resolve ignoreInputs: false). useHotkey( hotkey, (event) => { @@ -52,11 +54,11 @@ export function useAppShortcut( }, { enabled, - ignoreInputs, eventType, - preventDefault, - stopPropagation, conflictBehavior, + ...(ignoreInputs !== undefined ? { ignoreInputs } : {}), + ...(preventDefault !== undefined ? { preventDefault } : {}), + ...(stopPropagation !== undefined ? { stopPropagation } : {}), }, ); } diff --git a/packages/web/src/views/Forms/EventForm/EventForm.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.test.tsx index cc751aac2..82a1aef8a 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.test.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.test.tsx @@ -92,7 +92,7 @@ mock.module("@web/views/Forms/EventForm/SaveSection", () => ({ const { EventForm } = require("./EventForm") as typeof import("./EventForm"); -function dispatchModD(target: HTMLElement) { +function dispatchModKey(target: HTMLElement, key: string) { const modifierKey = resolveModifier("Mod"); const isControl = modifierKey === "Control"; @@ -102,7 +102,7 @@ function dispatchModD(target: HTMLElement) { cancelable: true, composed: true, ctrlKey: isControl, - key: "d", + key, metaKey: !isControl, }), ); @@ -321,7 +321,7 @@ describe("EventForm", () => { const titleField = screen.getByPlaceholderText("Title"); act(() => titleField.focus()); - dispatchModD(titleField); + dispatchModKey(titleField, "d"); await waitFor(() => { expect(onDuplicate).toHaveBeenCalledTimes(1); @@ -466,6 +466,76 @@ describe("EventForm", () => { expect(onSubmit).toHaveBeenCalledTimes(1); }); + it("submits with Mod+Enter while a color swatch is focused", async () => { + // Color radios are HTMLInputElements; after a color change the form + // re-renders with focus still on the swatch. Mod+Enter must still submit + // (ignoreInputs: false), not only when focus returns to a text field. + const user = userEvent.setup(); + const onSubmit = mock(); + + function Harness() { + const [draft, setDraft] = useState( + createEditDraft(), + ); + + if (!draft) return null; + + return ( + + ); + } + + renderWithStore(); + + const blueSwatch = screen.getByRole("radio", { name: "Blue" }); + await user.click(blueSwatch); + + expect(blueSwatch).toHaveFocus(); + + dispatchModKey(blueSwatch, "Enter"); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + }); + + it("submits exactly once with Mod+Enter from the title field", async () => { + // Local handleIgnoredKeys only preventDefaults Mod+Enter; the global + // useAppShortcut owns submit. Both must not call onSubmit. + const onSubmit = mock(); + + renderWithStore( + , + ); + + const titleField = screen.getByPlaceholderText("Title"); + act(() => titleField.focus()); + + dispatchModKey(titleField, "Enter"); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + }); + it("still deletes an existing event when Delete is pressed on a non-text form target", async () => { const onClose = mock(); const onDelete = mock(); diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index e998bebee..2e3314a26 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -437,7 +437,6 @@ export const EventForm: React.FC = memo( if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); - onSubmitForm(); } }; @@ -656,9 +655,7 @@ export const EventForm: React.FC = memo( e.preventDefault(); onSubmitForm(); }, - { - enabled: true, - }, + EVENT_FORM_PLAIN_HOTKEY_OPTIONS, ); useEscapeToCloseForm(onClose);