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
4 changes: 4 additions & 0 deletions packages/web/src/common/utils/form/form.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export type EventFormFocusField =
const queryEventFormElement = <T extends Element>(selector: string): T | null =>
document.querySelector<T>(selector);

/** The docked event form itself, used as a positioning anchor. */
export const getEventFormElement = (): HTMLElement | null =>
queryEventFormElement<HTMLElement>(EVENT_FORM_SELECTOR);

const focusFirstMatch = (selectors: string[]) => {
for (const selector of selectors) {
const element = queryEventFormElement<HTMLElement>(selector);
Expand Down
35 changes: 30 additions & 5 deletions packages/web/src/grid/shortcuts/useGridEventFormFieldSequences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type GridEvent } from "@web/common/types/web.event.types";
import {
type EventFormFocusField,
focusEventFormField,
getEventFormElement,
} from "@web/common/utils/form/form.util";
import { createGridEventDraftFromGridEvent } from "@web/events/grid-event-draft.adapter";
import { findEventInCache } from "@web/events/queries/event.query.cache";
Expand All @@ -12,8 +13,8 @@ import {
useDraftStore,
} from "@web/events/stores/draft.store";
import {
type FocusableGridEventTarget,
findCalendarEventForTarget,
type GridEventShortcutTarget,
} from "@web/grid/shortcuts/focus-adjacent-grid-event";
import { useEditSequenceShortcut } from "@web/shortcuts/useEditSequenceShortcut";

Expand All @@ -28,8 +29,13 @@ const focusFieldAfterPaint = (field: EventFormFocusField) => {
};

/**
* `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.
* `e` (or `Mod+E` while typing) 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.
*
* Returns the anchor resolver for the which-key menu: the focused card while
* the sequence starts from the grid, else the docked form, which is where the
* caret is for the `Mod+E` path.
*/
export function useGridEventFormFieldSequences({
allDayEvents = [],
Expand All @@ -38,7 +44,7 @@ export function useGridEventFormFieldSequences({
}: {
allDayEvents?: GridEvent[];
targeting: {
getFocused: () => GridEventShortcutTarget | null;
getFocused: () => FocusableGridEventTarget | null;
};
timedEvents: GridEvent[];
}) {
Expand All @@ -56,7 +62,15 @@ export function useGridEventFormFieldSequences({

const openFocusedEventFormField = (field: EventFormFocusField) => {
const target = targeting.getFocused();
if (!target) return;

// Started from inside the open form (Mod+E): no card is focused, so jump
// straight to the field rather than trying to reopen anything.
if (!target) {
if (isEventFormOpen()) {
focusEventFormField(field);
}
return;
}

const gridEvent = findCalendarEventForTarget(target, {
allDayEvents,
Expand All @@ -78,7 +92,18 @@ export function useGridEventFormFieldSequences({
focusEventFormField(field);
};

// Plain functions, not useCallback: `targeting` is rebuilt every render by
// the owners, so memoizing on it would be a lie. The hook reads both through
// refs, and the menu only calls the anchor getter during render.
const canArm = () => targeting.getFocused() !== null || isEventFormOpen();

const getMenuAnchor = () =>
targeting.getFocused()?.element ?? getEventFormElement();

useEditSequenceShortcut({
canArm,
onSequence: openFocusedEventFormField,
});

return { getMenuAnchor };
}
57 changes: 0 additions & 57 deletions packages/web/src/grid/shortcuts/useIsGridEventFocused.test.tsx

This file was deleted.

35 changes: 0 additions & 35 deletions packages/web/src/grid/shortcuts/useIsGridEventFocused.ts

This file was deleted.

30 changes: 4 additions & 26 deletions packages/web/src/shortcuts/data/shortcuts.data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,10 @@ describe("shortcuts.data", () => {
});

it("lists u/i focus shortcuts per view", () => {
const findFocus = (view: "day" | "week", eventFocused = false) =>
const findFocus = (view: "day" | "week") =>
getShortcutMenuSections({
view,
isViewingCurrentPeriod: true,
eventFocused,
}).find((section) => section.id === "focus");

expect(stripMetadata(findFocus("day")?.shortcuts ?? [])).toEqual([
Expand All @@ -156,16 +155,6 @@ describe("shortcuts.data", () => {
{ keys: ["u"], label: "Focus calendar event" },
{ keys: ["s"], label: "Toggle event jump keys" },
]);
expect(stripMetadata(findFocus("week", true)?.shortcuts ?? [])).toEqual([
{ keys: ["i"], label: "Focus sidebar" },
{ keys: ["u"], label: "Focus calendar event" },
{ keys: ["s"], label: "Toggle event jump keys" },
]);
expect(stripMetadata(findFocus("day", true)?.shortcuts ?? [])).toEqual([
{ keys: ["i"], label: "Focus sidebar" },
{ keys: ["u"], label: "Focus calendar event" },
{ keys: ["s"], label: "Toggle event jump keys" },
]);
});

it("includes Delete in the day and week Edit sections", () => {
Expand Down Expand Up @@ -214,24 +203,13 @@ describe("shortcuts.data", () => {
}
});

it("lists e-then-field edit sequences only when an event is focused", () => {
it("lists e-then-field edit sequences with nothing focused", () => {
for (const view of ["day", "week"] as const) {
const idleEdit = getShortcutMenuSections({
view,
isViewingCurrentPeriod: true,
eventFocused: false,
}).find((section) => section.id === "edit");
expect(stripMetadata(idleEdit?.shortcuts ?? [])).not.toContainEqual({
keys: ["e", "t"],
label: "Edit title",
});

const focusedEdit = getShortcutMenuSections({
const edit = getShortcutMenuSections({
view,
isViewingCurrentPeriod: true,
eventFocused: true,
}).find((section) => section.id === "edit");
const shortcuts = stripMetadata(focusedEdit?.shortcuts ?? []);
const shortcuts = stripMetadata(edit?.shortcuts ?? []);

expect(shortcuts).toContainEqual({
keys: ["e", "t"],
Expand Down
4 changes: 1 addition & 3 deletions packages/web/src/shortcuts/data/shortcuts.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ interface ShortcutMenuConfig {
view: ShortcutMenuView;
/** Day: viewing today. Week: viewing the current week. Drives the "t" label. */
isViewingCurrentPeriod: boolean;
eventFocused?: boolean;
isFormOpen?: boolean;
}

Expand All @@ -21,12 +20,11 @@ interface ShortcutMenuConfig {
export const getShortcutMenuSections = (
config: ShortcutMenuConfig,
): ShortcutOverlaySection[] => {
const { view, isViewingCurrentPeriod, eventFocused, isFormOpen } = config;
const { view, isViewingCurrentPeriod, isFormOpen } = config;

const filteredShortcuts = filterShortcutsByContext({
view,
isViewingCurrentPeriod,
eventFocused,
isFormOpen,
});

Expand Down
77 changes: 77 additions & 0 deletions packages/web/src/shortcuts/edit-sequence/EditSequenceMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { render, screen } from "@web/__tests__/__mocks__/mock.render";
import { EditSequenceMenu } from "@web/shortcuts/edit-sequence/EditSequenceMenu";
import { editSequenceActions } from "@web/shortcuts/edit-sequence/edit-sequence.store";
import { afterEach, describe, expect, it } from "bun:test";

/**
* Placement is floating-ui's (offset/flip/shift + autoUpdate), so there is no
* positioning math of ours left to unit test; jsdom has no layout to exercise
* it against either. These cover what this component still owns: when it
* renders, and what it says.
*/
describe("EditSequenceMenu", () => {
afterEach(() => {
// No manual DOM reset here: the menu renders through a portal, and
// clearing document.body out from under React breaks its unmount.
editSequenceActions.disarm();
});

const showMenu = () => {
editSequenceActions.arm();
editSequenceActions.showMenu();
};

it("renders nothing until the menu is visible", () => {
render(<EditSequenceMenu getAnchor={() => null} />);

expect(document.querySelector("[data-edit-sequence-menu]")).toBeNull();
});

it("renders once the menu becomes visible", () => {
showMenu();
render(<EditSequenceMenu getAnchor={() => null} />);

expect(document.querySelector("[data-edit-sequence-menu]")).not.toBeNull();
});

it("announces every option to screen readers", () => {
showMenu();
render(<EditSequenceMenu getAnchor={() => null} />);

expect(
screen.getByRole("status", { hidden: true }).textContent,
).toStrictEqual(
"Edit which field? T for title, L for location, D for description, " +
"S for start time, E for end time, R for recurrence, C for calendar. " +
"Escape to cancel." +
"Edit which field?TTitleLLocationDDescriptionSStart timeEEnd time" +
"RRecurrenceCCalendarEsc to cancel",
);
});

it("lists each second key with its field", () => {
showMenu();
render(<EditSequenceMenu getAnchor={() => null} />);

const menu = document.querySelector("[data-edit-sequence-menu]");
for (const label of [
"Title",
"Location",
"Description",
"Start time",
"End time",
"Recurrence",
"Calendar",
]) {
expect(menu?.textContent).toContain(label);
}
});

it("still renders when the anchor is gone, using the viewport fallback", () => {
showMenu();
const detached = document.createElement("div");
render(<EditSequenceMenu getAnchor={() => detached} />);

expect(document.querySelector("[data-edit-sequence-menu]")).not.toBeNull();
});
});
Loading
Loading