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);
+ };
+ }, []);
+}