Skip to content
Merged
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
34 changes: 34 additions & 0 deletions packages/web/src/shortcuts/useAppShortcut.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 5 additions & 3 deletions packages/web/src/shortcuts/useAppShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -52,11 +54,11 @@ export function useAppShortcut(
},
{
enabled,
ignoreInputs,
eventType,
preventDefault,
stopPropagation,
conflictBehavior,
...(ignoreInputs !== undefined ? { ignoreInputs } : {}),
...(preventDefault !== undefined ? { preventDefault } : {}),
...(stopPropagation !== undefined ? { stopPropagation } : {}),
},
);
}
Expand Down
76 changes: 73 additions & 3 deletions packages/web/src/views/Forms/EventForm/EventForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -102,7 +102,7 @@ function dispatchModD(target: HTMLElement) {
cancelable: true,
composed: true,
ctrlKey: isControl,
key: "d",
key,
metaKey: !isControl,
}),
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<GridEventDraft | null>(
createEditDraft(),
);

if (!draft) return null;

return (
<EventForm
draft={draft}
isDraft={false}
isExistingEvent={true}
onClose={mock()}
onDelete={mock()}
onDuplicate={mock()}
onSubmit={onSubmit}
setDraft={setDraft}
/>
);
}

renderWithStore(<Harness />);

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(
<EventForm
draft={createEditDraft()}
isDraft={false}
isExistingEvent={true}
onClose={mock()}
onDelete={mock()}
onDuplicate={mock()}
onSubmit={onSubmit}
setDraft={mock()}
/>,
);

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();
Expand Down
5 changes: 1 addition & 4 deletions packages/web/src/views/Forms/EventForm/EventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,6 @@ export const EventForm: React.FC<GridEventFormProps> = memo(

if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onSubmitForm();
}
};

Expand Down Expand Up @@ -656,9 +655,7 @@ export const EventForm: React.FC<GridEventFormProps> = memo(
e.preventDefault();
onSubmitForm();
},
{
enabled: true,
},
EVENT_FORM_PLAIN_HOTKEY_OPTIONS,
);

useEscapeToCloseForm(onClose);
Expand Down