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
35 changes: 22 additions & 13 deletions docs/acceptance/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Use this guide to validate:
- navigating between weeks in Week view (J, K, T)
- opening and using the command palette (Cmd+K)
- creating events with keyboard shortcuts (C, A in both Day and Week view)
- toggling the sidebar ([)
- editing events with the same keys in Day and Week (Delete, Shift+arrows, draft arrows)
- toggling the sidebar (])
- undoing with the keyboard (Cmd+Z / Ctrl+Z)
- confirming that shortcuts do not fire while typing in inputs

Expand Down Expand Up @@ -41,16 +42,20 @@ Helpful notes:
| `D` | Global | Navigate to Day view |
| `W` | Global | Navigate to Week view |
| `Cmd+K` / `Ctrl+K` | Global | Open command palette |
| `[` | Global | Toggle sidebar |
| `]` | Global | Toggle sidebar |
| `Cmd+Z` / `Ctrl+Z` | Global | Undo last event action |
| `Cmd+Shift+Z` / `Ctrl+Shift+Z` | Global | Redo last undone event action |
| `J` | Day view | Previous day |
| `K` | Day view | Next day |
| `T` | Day view | Go to today |
| `I` | Day view | Focus sidebar |
| `U` | Day view | Focus calendar |
| `U` | Day view | Focus first calendar event |
| `C` | Day view | Create timed event |
| `A` | Day view | Create all-day event |
| `Delete` | Day view | Delete focused/hovered event |
| `Arrow keys` | Day view | Move open draft event |
| `Shift+ArrowLeft` | Day view | Move focused event to previous day |
| `Shift+ArrowRight` | Day view | Move focused event to next day |
| `Shift+ArrowUp` / `Shift+ArrowDown` | Day view | Move focused timed event 15 min earlier/later |
| `J` | Week view | Previous week |
| `K` | Week view | Next week |
Expand All @@ -60,6 +65,7 @@ Helpful notes:
| `I` | Week view | Focus sidebar |
| `U` | Week view | Focus first calendar event |
| `Delete` | Week view | Delete focused/hovered event |
| `Arrow keys` | Week view | Move open draft event |
| `Shift+ArrowLeft` | Week view | Move focused event to previous day |
| `Shift+ArrowRight` | Week view | Move focused event to next day |
| `Shift+ArrowUp` / `Shift+ArrowDown` | Week view | Move focused timed event 15 min earlier/later |
Expand Down Expand Up @@ -211,41 +217,42 @@ Pressing `C` in Day view opens a new timed event form, the same behavior as `C`

---

## Scenario 8: Toggle The Sidebar ([)
## Scenario 8: Toggle The Sidebar (])

### UX

Pressing `[` toggles the sidebar open or closed from any view.
Pressing `]` toggles the sidebar open or closed from any view.

### Steps

1. Navigate to `/week`.
2. Press `[` to close the sidebar (if open).
3. Press `[` again to reopen it.
2. Press `]` to close the sidebar (if open).
3. Press `]` again to reopen it.
4. Navigate to `/day` and repeat.

### Expected Results

- `[` toggles the sidebar in both Week view and Day view.
- `]` toggles the sidebar in both Week view and Day view.
- The calendar grid expands to fill the space when the sidebar is closed.

---

## Scenario 9: Delete A Focused Event With The Keyboard (Delete, Week View)
## Scenario 9: Delete A Focused Event With The Keyboard (Delete)

### UX

Pressing Delete while an event is focused or hovered in the Week grid deletes it — equivalent to a mouse-driven delete action.
Pressing Delete while an event is focused or hovered in the Day or Week grid deletes it — equivalent to a mouse-driven delete action.

### Steps

1. Navigate to `/week`.
2. Focus or hover an event in the grid.
3. Press Delete.
4. Navigate to `/day` and repeat with a focused or hovered event.

### Expected Results

- The event is removed from the grid.
- The event is removed from the grid in both views.
- An undo toast appears.

---
Expand Down Expand Up @@ -300,7 +307,9 @@ If time is limited, run these checks before shipping shortcut-related changes:
3. `T` returns to today from any offset in both Day and Week view.
4. Cmd+K opens the command palette; Escape closes it without action.
5. `C` opens a timed event form and `A` an all-day event form, in both Day and Week view.
6. `[` toggles the sidebar in both Week and Day view.
7. Delete removes a focused/hovered event in Week view and shows an undo toast.
6. `]` toggles the sidebar in both Week and Day view.
7. Delete removes a focused/hovered event in Day and Week view and shows an undo toast.
8. Cmd+Z / Ctrl+Z undoes the last event action; Cmd+Shift+Z / Ctrl+Shift+Z redoes it.
9. No shortcuts fire inside a focused text input except Cmd+K.
10. Shift+ArrowLeft/Right move a focused event by one day in both Day and Week view.
11. Arrow keys reposition an open draft in both Day and Week view.
3 changes: 2 additions & 1 deletion docs/development/feature-file-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ Use this document to find the first files to inspect for common Compass changes.

- Day view route and content: `packages/web/src/views/Day/view`
- Day view header (includes sidebar toggle control): `packages/web/src/views/Day/components/Header/Header.tsx`
- Day keyboard shortcuts (includes `[` toggle): `packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts`
- Day keyboard shortcuts: `packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts`
- Sidebar toggle (`]`): `packages/web/src/views/Week/hooks/shortcuts/useGlobalShortcuts.ts`
- Day view hooks: `packages/web/src/views/Day/hooks`
- Week view: `packages/web/src/views/Week`
- Responsive layout controller (auto-collapse on breakpoint crossings): `packages/web/src/components/AuthenticatedLayout/useResponsiveLayout.ts`
Expand Down
2 changes: 1 addition & 1 deletion docs/development/testing-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ Assertions to prefer:
- when asserting presence in JSDOM for desktop-only markup (`hidden xl:flex`), use role queries that allow hidden elements where needed
- verify both pathways for toggle behavior:
- user interaction (header toggle button)
- keyboard interaction (`[` shortcut via view shortcut hooks)
- keyboard interaction (`]` shortcut via global shell shortcuts)

### Seeding Event Data And Client State

Expand Down
2 changes: 1 addition & 1 deletion docs/frontend/responsive-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Breakpoints:
- `SIDEBAR_AUTO_COLLAPSE_BREAKPOINT` = 1280px (Tailwind `xl`)

The hook only reacts to `matchMedia` `change` events — a breakpoint crossing,
not a continuous width readout. This means a manual toggle (`[` for the
not a continuous width readout. This means a manual toggle (`]` for the
sidebar) sticks until the next crossing; it does not get silently re-opened
or re-closed by unrelated re-renders.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import dayjs from "@core/util/date/dayjs";
import {
createGridEventDraft,
timedGridSchedule,
} from "@web/events/grid-event-draft.adapter";
import { repositionDraftByKeyboard } from "./reposition-draft-by-keyboard.util";
import { describe, expect, it } from "bun:test";

describe("repositionDraftByKeyboard", () => {
it("moves a createShortcut timed draft by 15 minutes with ArrowDown", () => {
const draft = createGridEventDraft(
timedGridSchedule(
new Date("2026-05-20T09:00:00.000"),
new Date("2026-05-20T10:00:00.000"),
),
);

const next = repositionDraftByKeyboard({
activity: "createShortcut",
draft,
key: "ArrowDown",
});

expect(dayjs(next?.values.schedule.start).format()).toBe(
dayjs("2026-05-20T09:15:00.000").format(),
);
});

it("returns null when the next start is outside the allowed range", () => {
const draft = createGridEventDraft(
timedGridSchedule(
new Date("2026-05-20T09:00:00.000"),
new Date("2026-05-20T10:00:00.000"),
),
);

const next = repositionDraftByKeyboard({
activity: "createShortcut",
draft,
key: "ArrowRight",
isStartAllowed: () => false,
});

expect(next).toBeNull();
});

it("returns null for activities that are not keyboard-repositionable", () => {
const draft = createGridEventDraft(
timedGridSchedule(
new Date("2026-05-20T09:00:00.000"),
new Date("2026-05-20T10:00:00.000"),
),
);

expect(
repositionDraftByKeyboard({
activity: "dnd",
draft,
key: "ArrowDown",
}),
).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import dayjs from "@core/util/date/dayjs";
import {
getArrowKeyMovement,
isTimedEventInsideOneDay,
} from "@web/common/utils/event/event-nudge.util";
import {
type GridEventDraft,
type GridScheduleDraft,
} from "@web/events/event-draft.types";
import { replaceGridDraftSchedule } from "@web/events/grid-event-draft.adapter";
import { type Activity_DraftEvent } from "@web/events/stores/draft.store";

const canRepositionDraftByKeyboard = (
activity: Activity_DraftEvent | null | undefined,
) =>
activity === "createShortcut" ||
activity === "gridClick" ||
activity === "keyboardEdit";

/**
* Moves a draft by one arrow step. Returns the updated draft when the move
* applies, otherwise null. Callers own persistence and any day-follow
* navigation (Day) or visible-range clamping (Week).
*/
export const repositionDraftByKeyboard = ({
activity,
draft,
key,
isStartAllowed,
}: {
activity: Activity_DraftEvent | null | undefined;
draft: GridEventDraft | null | undefined;
key: string;
/** Optional bound for the draft's next start (e.g. Week visible range). */
isStartAllowed?: (nextStart: Date) => boolean;
}): GridEventDraft | null => {
if (!canRepositionDraftByKeyboard(activity) || !draft) return null;

const isAllDay = draft.values.schedule.kind === "allDay";
const movement = getArrowKeyMovement(key, isAllDay);
if (!movement) return null;

const start = dayjs(draft.values.schedule.start);
const end = dayjs(draft.values.schedule.end);
const nextStart = start
.add(movement.days, "day")
.add(movement.minutes, "minutes");
const nextEnd = end
.add(movement.days, "day")
.add(movement.minutes, "minutes");

if (isStartAllowed && !isStartAllowed(nextStart.toDate())) return null;

if (!isAllDay && !isTimedEventInsideOneDay(nextStart, nextEnd)) {
return null;
}

const schedule: GridScheduleDraft = isAllDay
? { kind: "allDay", start: nextStart.toDate(), end: nextEnd.toDate() }
: {
...draft.values.schedule,
start: nextStart.toDate(),
end: nextEnd.toDate(),
};

return replaceGridDraftSchedule(draft, schedule);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ID_SIDEBAR } from "@web/common/constants/web.constants";
* Focuses the month picker's tab-stoppable day (react-datepicker keeps
* exactly one day at tabindex=0) so arrow keys navigate dates right away,
* falling back to the first interactive element in the sidebar.
* Used by the Day and Week views' "u" shortcut. Returns whether a focus
* Used by the Day and Week views' "i" shortcut. Returns whether a focus
* target was found.
*/
export const focusFirstSidebarItem = (): boolean => {
Expand Down
74 changes: 35 additions & 39 deletions packages/web/src/shortcuts/data/shortcuts.data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,56 +124,52 @@ describe("shortcuts.data", () => {

expect(findFocus("day")?.shortcuts).toEqual([
{ keys: ["i"], label: "Focus sidebar" },
{ keys: ["u"], label: "Focus calendar" },
{ keys: ["u"], label: "Focus calendar event" },
]);
expect(findFocus("week")?.shortcuts).toEqual([
{ keys: ["i"], label: "Focus sidebar" },
{ keys: ["u"], label: "Focus calendar event" },
]);
});

it("includes Delete in the week Edit section", () => {
const edit = getShortcutMenuSections({
view: "week",
isViewingCurrentPeriod: true,
}).find((section) => section.id === "edit");

expect(edit?.shortcuts).toContainEqual({
keys: ["Delete"],
label: "Delete calendar event",
});
});

it("lists Shift+Arrow reschedule shortcuts in the week Edit section", () => {
const edit = getShortcutMenuSections({
view: "week",
isViewingCurrentPeriod: true,
}).find((section) => section.id === "edit");
it("includes Delete in the day and week Edit sections", () => {
for (const view of ["day", "week"] as const) {
const edit = getShortcutMenuSections({
view,
isViewingCurrentPeriod: true,
}).find((section) => section.id === "edit");

expect(edit?.shortcuts).toContainEqual({
keys: ["Shift", "ArrowLeft"],
label: "Move event to previous day (or sidebar)",
});
expect(edit?.shortcuts).toContainEqual({
keys: ["Shift", "ArrowRight"],
label: "Move event to next day",
});
expect(edit?.shortcuts).toContainEqual({
keys: ["Shift", "ArrowUp"],
label: "Move event 15 min earlier",
});
expect(edit?.shortcuts).toContainEqual({
keys: ["Delete"],
label: "Delete calendar event",
});
}
});

it("lists event nudge shortcuts in the day Edit section", () => {
const edit = getShortcutMenuSections({
view: "day",
isViewingCurrentPeriod: true,
}).find((section) => section.id === "edit");
it("lists Shift+Arrow reschedule shortcuts in the day and week Edit sections", () => {
for (const view of ["day", "week"] as const) {
const edit = getShortcutMenuSections({
view,
isViewingCurrentPeriod: true,
}).find((section) => section.id === "edit");

expect(edit?.shortcuts).toContainEqual({
keys: ["Shift", "ArrowDown"],
label: "Move event 15 min later",
});
expect(edit?.shortcuts).toContainEqual({
keys: ["Shift", "ArrowLeft"],
label: "Move event to previous day",
});
expect(edit?.shortcuts).toContainEqual({
keys: ["Shift", "ArrowRight"],
label: "Move event to next day",
});
expect(edit?.shortcuts).toContainEqual({
keys: ["Shift", "ArrowUp"],
label: "Move event 15 min earlier",
});
expect(edit?.shortcuts).toContainEqual({
keys: ["Arrow keys"],
label: "Move draft event",
});
}
});

it("labels the Other section with a sidebar toggle", () => {
Expand Down
Loading