Skip to content

Commit 47df437

Browse files
fix(web): submit event form with Mod+Enter from color swatch (#2563)
* fix(web): submit event form with Mod+Enter from color swatch Pass ignoreInputs:false for Mod+Enter like other form hotkeys, and omit undefined options in useAppShortcut so TanStack Mod defaults survive re-renders after color selection. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * fix(web): submit Mod+Enter once from text fields Leave Mod+Enter submit to the global hotkey; local handleIgnoredKeys only preventDefaults, matching Mod+D. Adds a title-field once-submit regression and simplifies the Mod dispatch test helpers. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com>
1 parent ec410e4 commit 47df437

4 files changed

Lines changed: 113 additions & 10 deletions

File tree

packages/web/src/shortcuts/useAppShortcut.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,40 @@ describe("useAppShortcut", () => {
112112
document.body.removeChild(input);
113113
});
114114

115+
it("keeps Mod shortcuts active on focused radios after re-render", async () => {
116+
// Omitting ignoreInputs must not clobber TanStack's Mod default
117+
// (ignoreInputs: false) via setOptions({ ignoreInputs: undefined }).
118+
const radio = document.createElement("input");
119+
radio.type = "radio";
120+
document.body.appendChild(radio);
121+
radio.focus();
122+
123+
const modifierKey = resolveModifier("Mod");
124+
const isCtrl = modifierKey === "Control";
125+
126+
const { rerender } = renderHook(() =>
127+
useAppShortcut("Mod+Enter", mockHandler),
128+
);
129+
130+
rerender();
131+
132+
dispatchKeyEvent(
133+
"Enter",
134+
"keydown",
135+
{
136+
ctrlKey: isCtrl,
137+
metaKey: !isCtrl,
138+
},
139+
radio,
140+
);
141+
142+
await waitFor(() => {
143+
expect(mockHandler).toHaveBeenCalledTimes(1);
144+
});
145+
146+
document.body.removeChild(radio);
147+
});
148+
115149
it("blurs the active element before handling the shortcut when requested", async () => {
116150
const input = document.createElement("input");
117151
const blurSpy = mock();

packages/web/src/shortcuts/useAppShortcut.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export function useAppShortcut(
3737
conflictBehavior = "allow",
3838
} = options;
3939

40+
// Omit undefined option keys so TanStack's setOptions cannot clobber
41+
// registration defaults (e.g. Mod shortcuts resolve ignoreInputs: false).
4042
useHotkey(
4143
hotkey,
4244
(event) => {
@@ -52,11 +54,11 @@ export function useAppShortcut(
5254
},
5355
{
5456
enabled,
55-
ignoreInputs,
5657
eventType,
57-
preventDefault,
58-
stopPropagation,
5958
conflictBehavior,
59+
...(ignoreInputs !== undefined ? { ignoreInputs } : {}),
60+
...(preventDefault !== undefined ? { preventDefault } : {}),
61+
...(stopPropagation !== undefined ? { stopPropagation } : {}),
6062
},
6163
);
6264
}

packages/web/src/views/Forms/EventForm/EventForm.test.tsx

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ mock.module("@web/views/Forms/EventForm/SaveSection", () => ({
9292

9393
const { EventForm } = require("./EventForm") as typeof import("./EventForm");
9494

95-
function dispatchModD(target: HTMLElement) {
95+
function dispatchModKey(target: HTMLElement, key: string) {
9696
const modifierKey = resolveModifier("Mod");
9797
const isControl = modifierKey === "Control";
9898

@@ -102,7 +102,7 @@ function dispatchModD(target: HTMLElement) {
102102
cancelable: true,
103103
composed: true,
104104
ctrlKey: isControl,
105-
key: "d",
105+
key,
106106
metaKey: !isControl,
107107
}),
108108
);
@@ -321,7 +321,7 @@ describe("EventForm", () => {
321321
const titleField = screen.getByPlaceholderText("Title");
322322
act(() => titleField.focus());
323323

324-
dispatchModD(titleField);
324+
dispatchModKey(titleField, "d");
325325

326326
await waitFor(() => {
327327
expect(onDuplicate).toHaveBeenCalledTimes(1);
@@ -466,6 +466,76 @@ describe("EventForm", () => {
466466
expect(onSubmit).toHaveBeenCalledTimes(1);
467467
});
468468

469+
it("submits with Mod+Enter while a color swatch is focused", async () => {
470+
// Color radios are HTMLInputElements; after a color change the form
471+
// re-renders with focus still on the swatch. Mod+Enter must still submit
472+
// (ignoreInputs: false), not only when focus returns to a text field.
473+
const user = userEvent.setup();
474+
const onSubmit = mock();
475+
476+
function Harness() {
477+
const [draft, setDraft] = useState<GridEventDraft | null>(
478+
createEditDraft(),
479+
);
480+
481+
if (!draft) return null;
482+
483+
return (
484+
<EventForm
485+
draft={draft}
486+
isDraft={false}
487+
isExistingEvent={true}
488+
onClose={mock()}
489+
onDelete={mock()}
490+
onDuplicate={mock()}
491+
onSubmit={onSubmit}
492+
setDraft={setDraft}
493+
/>
494+
);
495+
}
496+
497+
renderWithStore(<Harness />);
498+
499+
const blueSwatch = screen.getByRole("radio", { name: "Blue" });
500+
await user.click(blueSwatch);
501+
502+
expect(blueSwatch).toHaveFocus();
503+
504+
dispatchModKey(blueSwatch, "Enter");
505+
506+
await waitFor(() => {
507+
expect(onSubmit).toHaveBeenCalledTimes(1);
508+
});
509+
});
510+
511+
it("submits exactly once with Mod+Enter from the title field", async () => {
512+
// Local handleIgnoredKeys only preventDefaults Mod+Enter; the global
513+
// useAppShortcut owns submit. Both must not call onSubmit.
514+
const onSubmit = mock();
515+
516+
renderWithStore(
517+
<EventForm
518+
draft={createEditDraft()}
519+
isDraft={false}
520+
isExistingEvent={true}
521+
onClose={mock()}
522+
onDelete={mock()}
523+
onDuplicate={mock()}
524+
onSubmit={onSubmit}
525+
setDraft={mock()}
526+
/>,
527+
);
528+
529+
const titleField = screen.getByPlaceholderText("Title");
530+
act(() => titleField.focus());
531+
532+
dispatchModKey(titleField, "Enter");
533+
534+
await waitFor(() => {
535+
expect(onSubmit).toHaveBeenCalledTimes(1);
536+
});
537+
});
538+
469539
it("still deletes an existing event when Delete is pressed on a non-text form target", async () => {
470540
const onClose = mock();
471541
const onDelete = mock();

packages/web/src/views/Forms/EventForm/EventForm.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,6 @@ export const EventForm: React.FC<GridEventFormProps> = memo(
437437

438438
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
439439
e.preventDefault();
440-
onSubmitForm();
441440
}
442441
};
443442

@@ -656,9 +655,7 @@ export const EventForm: React.FC<GridEventFormProps> = memo(
656655
e.preventDefault();
657656
onSubmitForm();
658657
},
659-
{
660-
enabled: true,
661-
},
658+
EVENT_FORM_PLAIN_HOTKEY_OPTIONS,
662659
);
663660

664661
useEscapeToCloseForm(onClose);

0 commit comments

Comments
 (0)