diff --git a/packages/web/src/common/utils/form/form.util.test.ts b/packages/web/src/common/utils/form/form.util.test.ts index 938ea2384..089744009 100644 --- a/packages/web/src/common/utils/form/form.util.test.ts +++ b/packages/web/src/common/utils/form/form.util.test.ts @@ -147,6 +147,9 @@ describe("form.util", () => { form.setAttribute("name", ID_EVENT_FORM); const title = document.createElement("input"); title.name = "Event Title"; + const location = document.createElement("input"); + location.id = "event-form-location"; + location.name = "Event Location"; const description = document.createElement("div"); description.id = "event-form-description"; description.setAttribute("contenteditable", "true"); @@ -162,9 +165,17 @@ describe("form.util", () => { recurrence.appendChild(repeat); const calendar = document.createElement("button"); calendar.id = "event-form-calendar"; - form.append(title, description, start, end, recurrence, calendar); + form.append( + title, + location, + description, + start, + end, + recurrence, + calendar, + ); document.body.appendChild(form); - return { title, description, start, end, repeat, calendar }; + return { title, location, description, start, end, repeat, calendar }; }; it("focuses each shipped form field", () => { @@ -173,6 +184,9 @@ describe("form.util", () => { expect(focusEventFormField("title")).toBe(true); expect(document.activeElement).toBe(fields.title); + expect(focusEventFormField("location")).toBe(true); + expect(document.activeElement).toBe(fields.location); + expect(focusEventFormField("description")).toBe(true); expect(document.activeElement).toBe(fields.description); @@ -205,6 +219,26 @@ describe("form.util", () => { expect(document.activeElement).toBe(end); }); + it("falls back to the location input's name when no id is present", () => { + const form = document.createElement("form"); + form.setAttribute("name", ID_EVENT_FORM); + const location = document.createElement("input"); + location.name = "Event Location"; + form.append(location); + document.body.appendChild(form); + + expect(focusEventFormField("location")).toBe(true); + expect(document.activeElement).toBe(location); + }); + + it("returns false when a field is absent from the form", () => { + const form = document.createElement("form"); + form.setAttribute("name", ID_EVENT_FORM); + document.body.appendChild(form); + + expect(focusEventFormField("location")).toBe(false); + }); + it("keeps focusEventFormTitle as a title helper", () => { const { title } = mountForm(); focusEventFormTitle(); diff --git a/packages/web/src/common/utils/form/form.util.ts b/packages/web/src/common/utils/form/form.util.ts index eab60c834..3725369b1 100644 --- a/packages/web/src/common/utils/form/form.util.ts +++ b/packages/web/src/common/utils/form/form.util.ts @@ -4,6 +4,7 @@ const EVENT_FORM_SELECTOR = `form[name="${ID_EVENT_FORM}"]`; export type EventFormFocusField = | "title" + | "location" | "description" | "start" | "end" @@ -34,6 +35,11 @@ export const focusEventFormField = (field: EventFormFocusField): boolean => { return focusFirstMatch([ `${EVENT_FORM_SELECTOR} input[name="Event Title"]`, ]); + case "location": + return focusFirstMatch([ + `${EVENT_FORM_SELECTOR} #event-form-location`, + `${EVENT_FORM_SELECTOR} input[name="Event Location"]`, + ]); case "description": return focusFirstMatch([`#event-form-description`]); case "start": diff --git a/packages/web/src/grid/shortcuts/useGridEventFormFieldSequences.ts b/packages/web/src/grid/shortcuts/useGridEventFormFieldSequences.ts index e3a3971f0..53baaf511 100644 --- a/packages/web/src/grid/shortcuts/useGridEventFormFieldSequences.ts +++ b/packages/web/src/grid/shortcuts/useGridEventFormFieldSequences.ts @@ -28,8 +28,8 @@ const focusFieldAfterPaint = (field: EventFormFocusField) => { }; /** - * `e` then `t`/`d`/`s`/`e`/`r`/`c`: open the focused event's form (if needed) - * and move caret/focus to the matching field. Shared by Day and Week. + * `e` then `t`/`l`/`d`/`s`/`e`/`r`/`c`: open the focused event's form (if + * needed) and move caret/focus to the matching field. Shared by Day and Week. */ export function useGridEventFormFieldSequences({ allDayEvents = [], diff --git a/packages/web/src/shortcuts/shortcuts.registry.test.ts b/packages/web/src/shortcuts/shortcuts.registry.test.ts index f9fa21212..5e7c2468f 100644 --- a/packages/web/src/shortcuts/shortcuts.registry.test.ts +++ b/packages/web/src/shortcuts/shortcuts.registry.test.ts @@ -107,12 +107,43 @@ describe("shortcuts.registry", () => { }).map((shortcut) => shortcut.id); expect(focused).toContain("edit-focus-title"); + expect(focused).toContain("edit-focus-location"); expect(focused).toContain("edit-focus-description"); expect(focused).toContain("edit-focus-start"); expect(focused).toContain("edit-focus-end"); expect(focused).toContain("edit-focus-recurrence"); expect(focused).toContain("edit-focus-calendar"); }); + + it("excludes form-jump shortcuts when the form is closed and includes them when open", () => { + const formJumpIds = [ + "form-jump-title", + "form-jump-location", + "form-jump-description", + "form-jump-start", + "form-jump-end", + "form-jump-recurrence", + "form-jump-calendar", + ]; + + const closed = filterShortcutsByContext({ + view: "day", + isViewingCurrentPeriod: true, + isFormOpen: false, + }).map((shortcut) => shortcut.id); + for (const id of formJumpIds) { + expect(closed).not.toContain(id); + } + + const open = filterShortcutsByContext({ + view: "day", + isViewingCurrentPeriod: true, + isFormOpen: true, + }).map((shortcut) => shortcut.id); + for (const id of formJumpIds) { + expect(open).toContain(id); + } + }); }); describe("getShortcutsBySection", () => { diff --git a/packages/web/src/shortcuts/shortcuts.registry.ts b/packages/web/src/shortcuts/shortcuts.registry.ts index 6aa8007bc..c9abc7523 100644 --- a/packages/web/src/shortcuts/shortcuts.registry.ts +++ b/packages/web/src/shortcuts/shortcuts.registry.ts @@ -143,6 +143,13 @@ export const SHORTCUTS_REGISTRY: Shortcut[] = [ section: "edit", when: { eventFocused: true }, }, + { + id: "edit-focus-location", + keys: ["e", "l"], + label: "Edit location", + section: "edit", + when: { eventFocused: true }, + }, { id: "edit-focus-description", keys: ["e", "d"], @@ -197,6 +204,55 @@ export const SHORTCUTS_REGISTRY: Shortcut[] = [ section: "edit", when: { isFormOpen: true }, }, + { + id: "form-jump-title", + keys: ["Mod+E", "T"], + label: "Jump to title", + section: "edit", + when: { isFormOpen: true }, + }, + { + id: "form-jump-location", + keys: ["Mod+E", "L"], + label: "Jump to location", + section: "edit", + when: { isFormOpen: true }, + }, + { + id: "form-jump-description", + keys: ["Mod+E", "D"], + label: "Jump to description", + section: "edit", + when: { isFormOpen: true }, + }, + { + id: "form-jump-start", + keys: ["Mod+E", "S"], + label: "Jump to start time", + section: "edit", + when: { isFormOpen: true }, + }, + { + id: "form-jump-end", + keys: ["Mod+E", "E"], + label: "Jump to end time", + section: "edit", + when: { isFormOpen: true }, + }, + { + id: "form-jump-recurrence", + keys: ["Mod+E", "R"], + label: "Jump to recurrence", + section: "edit", + when: { isFormOpen: true }, + }, + { + id: "form-jump-calendar", + keys: ["Mod+E", "C"], + label: "Jump to calendar", + section: "edit", + when: { isFormOpen: true }, + }, { id: "edit-focus-prev", keys: ["ArrowUp"], diff --git a/packages/web/src/shortcuts/useEditSequenceShortcut.test.tsx b/packages/web/src/shortcuts/useEditSequenceShortcut.test.tsx index df4060234..2b58bb303 100644 --- a/packages/web/src/shortcuts/useEditSequenceShortcut.test.tsx +++ b/packages/web/src/shortcuts/useEditSequenceShortcut.test.tsx @@ -116,6 +116,7 @@ describe("useEditSequenceShortcut", () => { const cases = [ ["t", "title"], + ["l", "location"], ["d", "description"], ["s", "start"], ["e", "end"], diff --git a/packages/web/src/shortcuts/useEditSequenceShortcut.ts b/packages/web/src/shortcuts/useEditSequenceShortcut.ts index 7854dd6a4..a52a9137a 100644 --- a/packages/web/src/shortcuts/useEditSequenceShortcut.ts +++ b/packages/web/src/shortcuts/useEditSequenceShortcut.ts @@ -8,6 +8,7 @@ import { isAppLocked } from "@web/shortcuts/app-lock"; /** Leader → field map for the `e` edit sequences. */ export const EDIT_SEQUENCE_FIELDS = { t: "title", + l: "location", d: "description", s: "start", e: "end", diff --git a/packages/web/src/views/Forms/EventForm/EventForm.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.test.tsx index 82a1aef8a..33617f4e0 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.test.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.test.tsx @@ -92,7 +92,11 @@ mock.module("@web/views/Forms/EventForm/SaveSection", () => ({ const { EventForm } = require("./EventForm") as typeof import("./EventForm"); -function dispatchModKey(target: HTMLElement, key: string) { +function dispatchModKey( + target: HTMLElement, + key: string, + { shift = false }: { shift?: boolean } = {}, +) { const modifierKey = resolveModifier("Mod"); const isControl = modifierKey === "Control"; @@ -104,10 +108,22 @@ function dispatchModKey(target: HTMLElement, key: string) { ctrlKey: isControl, key, metaKey: !isControl, + shiftKey: shift, }), ); } +function dispatchKey(target: HTMLElement, key: string) { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + composed: true, + key, + }); + target.dispatchEvent(event); + return event; +} + function dispatchArrowDown(target: HTMLElement) { const event = new KeyboardEvent("keydown", { bubbles: true, @@ -329,6 +345,123 @@ describe("EventForm", () => { expect(onDuplicate).toHaveBeenCalledWith(draft); }); + it("jumps focus to the location field with Mod+E then L from the title field", () => { + renderWithStore( + , + ); + + const titleField = screen.getByPlaceholderText("Title"); + act(() => titleField.focus()); + + dispatchModKey(titleField, "e"); + const followEvent = dispatchKey(titleField, "l"); + + expect(followEvent.defaultPrevented).toBe(true); + expect(screen.getByRole("textbox", { name: "Location" })).toHaveFocus(); + }); + + it("jumps focus to the description field with Mod+E then D from the location field", () => { + renderWithStore( + , + ); + + const locationField = screen.getByRole("textbox", { name: "Location" }); + act(() => locationField.focus()); + + dispatchModKey(locationField, "e"); + dispatchKey(locationField, "d"); + + expect(screen.getByRole("textbox", { name: "Description" })).toHaveFocus(); + }); + + it("jumps focus out of the TipTap description editor to the title field with Mod+E then T", () => { + renderWithStore( + , + ); + + const descriptionField = screen.getByRole("textbox", { + name: "Description", + }); + act(() => descriptionField.focus()); + + dispatchModKey(descriptionField, "e"); + dispatchKey(descriptionField, "t"); + + expect(screen.getByPlaceholderText("Title")).toHaveFocus(); + }); + + it("does not crash jumping to the calendar field on an edit draft, where the picker isn't rendered", () => { + renderWithStore( + , + ); + + const titleField = screen.getByPlaceholderText("Title"); + act(() => titleField.focus()); + + dispatchModKey(titleField, "e"); + expect(() => dispatchKey(titleField, "c")).not.toThrow(); + expect(titleField).toHaveFocus(); + }); + + it("does not jump focus when a bare letter is typed without the Mod+E leader", () => { + renderWithStore( + , + ); + + const titleField = screen.getByPlaceholderText("Title"); + act(() => titleField.focus()); + + const event = dispatchKey(titleField, "l"); + + expect(event.defaultPrevented).toBe(false); + expect(titleField).toHaveFocus(); + }); + it("closes a draft event immediately when deleting from the menu", async () => { const user = userEvent.setup(); const onClose = mock(); diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index c6ef8196b..8a695d68b 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -64,6 +64,7 @@ import { } from "@web/views/Forms/EventForm/types"; import { EventFormShell } from "@web/views/Forms/EventFormShell"; import { useEscapeToCloseForm } from "@web/views/Forms/hooks/useEscapeToCloseForm"; +import { useEventFormFieldJumpShortcuts } from "@web/views/Forms/hooks/useEventFormFieldJumpShortcuts"; const EVENT_FORM_PLAIN_HOTKEY_OPTIONS = { enabled: true, @@ -71,6 +72,7 @@ const EVENT_FORM_PLAIN_HOTKEY_OPTIONS = { } as const; const EVENT_FORM_TITLE_ID = "event-form-title"; +const EVENT_FORM_LOCATION_ID = "event-form-location"; const EVENT_FORM_DESCRIPTION_ID = "event-form-description"; const EVENT_FORM_CALENDAR_ID = "event-form-calendar"; const EVENT_FORM_SCHEDULE_ID = "event-form-schedule"; @@ -659,6 +661,8 @@ export const EventForm: React.FC = memo( EVENT_FORM_PLAIN_HOTKEY_OPTIONS, ); + useEventFormFieldJumpShortcuts(); + const { isConfirmOpen, onCancelConfirm, onDiscardConfirm } = useEscapeToCloseForm(onClose); @@ -839,6 +843,7 @@ export const EventForm: React.FC = memo( /> )} + event.metaKey || event.ctrlKey || event.altKey || event.shiftKey; + +/** + * Mod+E arms, then a bare `t`/`l`/`d`/`s`/`e`/`r`/`c` jumps focus directly to + * the matching form field - the same letters and field mapping as the grid's + * `e`-then-letter sequence (EDIT_SEQUENCE_FIELDS), so there's one "e means + * edit a field" mental model. Unlike the grid sequence, this one fires while + * focus is already inside a form input or the TipTap description - that's + * the point, so it does not bail on editable targets. + */ +export function useEventFormFieldJumpShortcuts() { + useEffect(() => { + const isMac = resolveModifier("Mod") === "Meta"; + let armedUntil = 0; + let armTimeoutId: ReturnType | null = null; + const suppressKeyUp = new Set(); + + const disarm = () => { + armedUntil = 0; + if (armTimeoutId !== null) { + clearTimeout(armTimeoutId); + armTimeoutId = null; + } + }; + + const arm = () => { + disarm(); + armedUntil = Date.now() + ARM_WINDOW_MS; + armTimeoutId = setTimeout(() => { + armedUntil = 0; + armTimeoutId = null; + }, ARM_WINDOW_MS); + }; + + const isArmed = () => armedUntil > Date.now(); + + const isLeaderKeydown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== "e") return false; + if (event.shiftKey || event.altKey) return false; + return isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (isAppLocked()) { + disarm(); + return; + } + + if (isLeaderKeydown(event)) { + event.preventDefault(); + event.stopPropagation(); + arm(); + return; + } + + if (!isArmed()) return; + + if (hasModifier(event)) { + disarm(); + return; + } + + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + const field = + key in EDIT_SEQUENCE_FIELDS + ? EDIT_SEQUENCE_FIELDS[key as EditSequenceSecondKey] + : null; + + if (field) { + event.preventDefault(); + event.stopPropagation(); + suppressKeyUp.add(key); + disarm(); + focusEventFormField(field); + return; + } + + // Unknown second key: disarm silently and let the key through. + disarm(); + }; + + const onKeyUp = (event: KeyboardEvent) => { + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + if (!suppressKeyUp.has(key)) return; + + suppressKeyUp.delete(key); + event.preventDefault(); + event.stopPropagation(); + }; + + document.addEventListener("keydown", onKeyDown, true); + document.addEventListener("keyup", onKeyUp, true); + + return () => { + disarm(); + suppressKeyUp.clear(); + document.removeEventListener("keydown", onKeyDown, true); + document.removeEventListener("keyup", onKeyUp, true); + }; + }, []); +}