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
29 changes: 15 additions & 14 deletions docs/acceptance/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Helpful notes:
| `T` | Day view | Go to today |
| `I` | Day view | Focus sidebar |
| `U` | Day view | Focus first calendar event |
| Hold `Shift` | Day view | Show event jump keys on visible events |
| `Shift` | Day view | Toggle event jump keys |
| `C` | Day view | Create timed event |
| `A` | Day view | Create all-day event |
| `Delete` | Day view | Delete focused event |
Expand All @@ -78,7 +78,7 @@ Helpful notes:
| `A` | Week view | Create all-day event |
| `I` | Week view | Focus sidebar |
| `U` | Week view | Focus first calendar event |
| Hold `Shift` | Week view | Show event jump keys on visible events |
| `Shift` | Week view | Toggle event jump keys |
| `Delete` | Week view | Delete focused event |
| `ArrowUp` / `ArrowDown` | Week view | Focus previous/next event |
| `Arrow keys` | Week view | Move open draft event |
Expand Down Expand Up @@ -327,26 +327,27 @@ After deleting or moving an event, pressing Cmd+Z (Mac) or Ctrl+Z (Windows/Linux

---

## Scenario 12: Hold Shift To Jump Focus To A Visible Event
## Scenario 12: Tap Shift To Jump Focus To An Event By Day Prefix

### UX

Holding `Shift` for a short beat (~200ms) assigns home-row jump keys to viewport-visible Week/Day events and paints a keycap chip on each. Pressing an assigned key focuses that event and clears the chips. Releasing `Shift` clears them. Quick chords such as Shift+J or Shift+Arrow do not flash hints. `j`/`k` are never assigned as jump keys.
Tapping `Shift` toggles event-jump mode (activation waits briefly so Shift-Shift can still enter keyboard-only without a jump flash). Week view chips use day prefixes (`SU`/`M`/`T`/`W`/`R`/`F`/`SA`) plus a per-day index (`W4`, `SU1`). Day view uses numeric chips (`1`, `2`, …). Pressing a day letter highlights that column and focuses its first event; a following digit focuses that index. `Esc` or another Shift tap exits. Quick chords such as Shift+J or Shift+Arrow do not toggle the mode.

### Steps

1. Navigate to `/week` with at least three timed events visible on the same day.
2. Hold `Shift` for about a quarter second (do not tap quickly).
3. Press the key shown on the middle event's chip (commonly `S` for the second event in chronological order).
4. Repeat with a fast Shift+J chord and confirm no chips appear.
1. Navigate to `/week` with timed events on at least two different days.
2. Tap `Shift` once (do not hold for a chord).
3. Press the day letter on a chip (for example `W` for Wednesday), then optionally a digit (`2`) or use arrow keys.
4. Press `Esc` (or tap `Shift` again) to exit.
5. Repeat with a fast Shift+J chord and confirm jump mode does not activate.

### Expected Results

- Distinct chips appear on the visible events while Shift is held past the threshold.
- The assigned key focuses that event; chips disappear.
- Releasing Shift without pressing a jump key also clears chips.
- Fast Shift+J / Shift+Arrow do not flash chips.
- While a modal holds the app lock, or focus is in an editable field, holding Shift does nothing.
- Chips appear on events after a Shift tap and stay until Esc / another Shift tap.
- A day letter highlights that column and focuses the first event; digits refine to `Wn`.
- Arrow keys keep jump mode on so letter-then-arrows works.
- Fast Shift+J / Shift+Arrow do not toggle jump mode.
- While a modal holds the app lock, or focus is in an editable field, Shift does not toggle jump mode.

---

Expand Down Expand Up @@ -390,4 +391,4 @@ If time is limited, run these checks before shipping shortcut-related changes:
12. With a focused event and no draft open, ArrowUp/ArrowDown move focus to the previous/next event chronologically.
13. Cmd+D / Ctrl+D duplicates a focused event in Day and Week view.
14. With a focused event, `E` then `T` opens the form with the title focused; bare `E` alone does nothing.
15. Holding Shift shows event jump chips; an assigned key focuses that event; fast Shift+J does not flash chips.
15. Tapping Shift toggles event jump chips; a day letter + digit focuses that event; fast Shift+J does not toggle the mode.
4 changes: 2 additions & 2 deletions e2e/timed/keyboard-only-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ test("SHIFT-SHIFT exits keyboard-only mode and restores clicks", async ({
await expect(page.getByLabel("Title")).toBeVisible();
});

test("hold Shift still flashes jump keys and does not enter keyboard-only mode", async ({
test("hold Shift does not enter keyboard-only or event jump", async ({
page,
}) => {
await prepareCalendarPage(page);
await createTimedEventOnGrid(page, "Hold Not Double");

await page.keyboard.down("Shift");
await page.waitForTimeout(250);
await expect(page.locator("[data-shift-event-hints]")).toHaveCount(1);
await expect(page.locator("[data-shift-event-hints]")).toHaveCount(0);
await expect(keyboardOnlyIndicator(page)).toHaveCount(0);
await page.keyboard.up("Shift");
await expect(page.locator("[data-shift-event-hints]")).toHaveCount(0);
Expand Down
65 changes: 51 additions & 14 deletions e2e/timed/shift-hold-event-hints.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@ import {
prepareCalendarPage,
} from "../utils/event-test-utils";

type CalendarPage = Parameters<typeof prepareCalendarPage>[0];

const DAY_PREFIX_KEYS: Record<string, string[]> = {
SU: ["s", "u"],
M: ["m"],
T: ["t"],
W: ["w"],
R: ["r"],
F: ["f"],
SA: ["s", "a"],
};

const createTimedEventAt = async (
page: Parameters<typeof prepareCalendarPage>[0],
page: CalendarPage,
title: string,
{ xRatio, yRatio }: { xRatio: number; yRatio: number },
) => {
Expand All @@ -18,10 +30,15 @@ const createTimedEventAt = async (
await expectTimedEventVisible(page, title);
};

const shiftHintOverlay = (page: Parameters<typeof prepareCalendarPage>[0]) =>
const shiftHintOverlay = (page: CalendarPage) =>
page.locator("[data-shift-event-hints]");

test("hold Shift shows jump keys and focuses the assigned event", async ({
const tapShift = async (page: CalendarPage) => {
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
};

test("tap Shift shows day-prefix jump keys and focuses the assigned event", async ({
page,
}) => {
await prepareCalendarPage(page);
Expand All @@ -30,7 +47,7 @@ test("hold Shift shows jump keys and focuses the assigned event", async ({
const middleTitle = createEventTitle("Middle Flash");
const lateTitle = createEventTitle("Late Flash");

// Same weekday column; earlier y ≈ earlier start for chronological hints.
// Same timed column; earlier y ≈ earlier start for chronological indices.
await createTimedEventAt(page, earlyTitle, { xRatio: 0.42, yRatio: 0.25 });
await createTimedEventAt(page, middleTitle, { xRatio: 0.42, yRatio: 0.4 });
await createTimedEventAt(page, lateTitle, { xRatio: 0.42, yRatio: 0.55 });
Expand All @@ -39,24 +56,44 @@ test("hold Shift shows jump keys and focuses the assigned event", async ({
.locator("#mainGrid")
.getByRole("button", { name: middleTitle });

await page.keyboard.down("Shift");
await page.waitForTimeout(250);
await page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
});
await tapShift(page);

const overlay = shiftHintOverlay(page);
await expect(overlay.getByText("A", { exact: true })).toHaveCount(1);
await expect(overlay.getByText("S", { exact: true })).toHaveCount(1);
await expect(overlay.getByText("D", { exact: true })).toHaveCount(1);
await expect(overlay.locator(":scope > span")).toHaveCount(3);

const labels = await overlay.evaluate((root) =>
[...root.querySelectorAll(":scope > span")].map(
(node) => node.textContent?.trim() ?? "",
),
);
const dayPrefix = labels[0]?.replace(/\d+$/, "") ?? "";
expect(DAY_PREFIX_KEYS[dayPrefix]).toBeTruthy();
expect([...labels].sort()).toEqual(
[`${dayPrefix}1`, `${dayPrefix}2`, `${dayPrefix}3`].sort(),
);

await page.keyboard.down("s");
await page.keyboard.up("s");
for (const key of DAY_PREFIX_KEYS[dayPrefix] ?? []) {
await page.keyboard.down(key);
await page.keyboard.up(key);
}
await page.keyboard.down("2");
await page.keyboard.up("2");

await expect(middleButton).toBeFocused();
await expect(shiftHintOverlay(page)).toHaveCount(0);
// Mode stays on after a digit focus so another index can be typed.
await expect(shiftHintOverlay(page)).toHaveCount(1);

await page.keyboard.up("Shift");
await page.keyboard.down("Escape");
await page.keyboard.up("Escape");
await expect(shiftHintOverlay(page)).toHaveCount(0);
});

test("fast Shift+J does not flash event jump keys", async ({ page }) => {
test("fast Shift+J does not toggle event jump keys", async ({ page }) => {
await prepareCalendarPage(page);

const title = createEventTitle("Chord Quiet");
Expand Down
13 changes: 13 additions & 0 deletions packages/web/src/components/Sidebar/SidebarStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
selectKeyboardOnlyActive,
useKeyboardOnlyStore,
} from "@web/shortcuts/keyboard-only/keyboard-only.store";
import { EventJumpIndicator } from "@web/shortcuts/shift-hint/EventJumpIndicator";
import {
selectEventJumpActive,
selectEventJumpAnnouncement,
useEventJumpStore,
} from "@web/shortcuts/shift-hint/event-jump.store";

/**
* Pinned status bar at the bottom of the sidebar, just above the actions bar.
Expand All @@ -24,6 +30,9 @@ import {
*/
export const SidebarStatusBar: FC = () => {
const isKeyboardOnly = useKeyboardOnlyStore(selectKeyboardOnlyActive);
const isEventJump = useEventJumpStore(selectEventJumpActive);
const eventJumpAnnouncement = useEventJumpStore(selectEventJumpAnnouncement);
const showEventJump = isEventJump || Boolean(eventJumpAnnouncement);
const isSaving = useHasPendingEventMutations();
// The unscoped hook's `connection` is the primary connection (the one
// whose own state matches the aggregate) - without it, an account's
Expand Down Expand Up @@ -51,6 +60,10 @@ export const SidebarStatusBar: FC = () => {
<div className="flex h-full min-w-0 flex-1 items-center">
<KeyboardOnlyIndicator />
</div>
) : showEventJump ? (
<div className="flex h-full min-w-0 flex-1 items-center">
<EventJumpIndicator />
</div>
) : (
<button
aria-label={
Expand Down
98 changes: 57 additions & 41 deletions packages/web/src/grid/components/AllDayGridRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type ReactNode,
type RefCallback,
} from "react";
import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants";
import {
ID_ALLDAY_COLUMNS,
ID_GRID_ALLDAY_ROW,
Expand All @@ -18,6 +19,10 @@ import {
GRID_TIME_STEP,
} from "@web/grid/grid.constants";
import { type GridVisibleDate } from "@web/grid/types/grid.types";
import {
selectEventJumpActiveDayKeys,
useEventJumpStore,
} from "@web/shortcuts/shift-hint/event-jump.store";

interface AllDayRowProps {
allDayColumnsRef: RefCallback<HTMLDivElement>;
Expand Down Expand Up @@ -56,45 +61,56 @@ export const AllDayGridRow: FC<AllDayRowProps> = ({
rowsCount = 0,
rowId = ID_GRID_ALLDAY_ROW,
visibleDates,
}) => (
<section
className="relative flex w-full shrink-0 items-start bg-background"
aria-label="All-day events"
id={rowId}
ref={allDayRowRef}
onMouseDown={onMouseDown}
style={{
height: `calc(${getAllDayRowHeight(gridOffsetTopPx)} * 2 + ${rowsCount * 2 || 1} * ${getAllDayRowHeight(gridOffsetTopPx)})`,
minHeight: `${getAllDayRowMinHeightPx(rowsCount)}px`,
}}
>
<div
className="absolute top-0 left-[var(--calendar-grid-margin-left)] grid h-full w-[calc(100%_-_var(--calendar-grid-margin-left))] grid-cols-[repeat(var(--calendar-column-count),minmax(var(--calendar-column-min-width),1fr))] before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-0.5 before:bg-border before:content-['']"
id={columnsId}
ref={allDayColumnsRef}
style={
{
"--calendar-column-count": visibleDates.length,
"--calendar-column-min-width": `${EVENT_WIDTH_MINIMUM}px`,
"--calendar-grid-margin-left": `${GRID_MARGIN_LEFT}px`,
} as CSSVariables
}
}) => {
const activeDayKeys = useEventJumpStore(selectEventJumpActiveDayKeys);

return (
<section
className="relative flex w-full shrink-0 items-start bg-background"
aria-label="All-day events"
id={rowId}
ref={allDayRowRef}
onMouseDown={onMouseDown}
style={{
height: `calc(${getAllDayRowHeight(gridOffsetTopPx)} * 2 + ${rowsCount * 2 || 1} * ${getAllDayRowHeight(gridOffsetTopPx)})`,
minHeight: `${getAllDayRowMinHeightPx(rowsCount)}px`,
}}
>
<table className="contents">
<thead className="contents">
<tr className="contents">
{visibleDates.map(({ date, key, surfaceLabel }) => (
<th
className="relative box-border block h-full min-w-[var(--calendar-column-min-width)] border-border border-l"
aria-label={surfaceLabel ?? date.format("dddd, MMMM D, YYYY")}
key={key}
scope="col"
/>
))}
</tr>
</thead>
</table>
</div>
{eventsLayer}
</section>
);
<div
className="absolute top-0 left-[var(--calendar-grid-margin-left)] grid h-full w-[calc(100%_-_var(--calendar-grid-margin-left))] grid-cols-[repeat(var(--calendar-column-count),minmax(var(--calendar-column-min-width),1fr))] before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-0.5 before:bg-border before:content-['']"
id={columnsId}
ref={allDayColumnsRef}
style={
{
"--calendar-column-count": visibleDates.length,
"--calendar-column-min-width": `${EVENT_WIDTH_MINIMUM}px`,
"--calendar-grid-margin-left": `${GRID_MARGIN_LEFT}px`,
} as CSSVariables
}
>
<table className="contents">
<thead className="contents">
<tr className="contents">
{visibleDates.map(({ date, key, surfaceLabel }) => {
const dayKey = date.format(YEAR_MONTH_DAY_FORMAT);
const isJumpDay = activeDayKeys.includes(dayKey);
return (
<th
className="relative box-border block h-full min-w-[var(--calendar-column-min-width)] border-border border-l transition-colors duration-150 data-[jump-day=true]:bg-accent/10 motion-reduce:transition-none"
data-jump-day={isJumpDay ? "true" : undefined}
aria-label={
surfaceLabel ?? date.format("dddd, MMMM D, YYYY")
}
key={key}
scope="col"
/>
);
})}
</tr>
</thead>
</table>
</div>
{eventsLayer}
</section>
);
};
31 changes: 22 additions & 9 deletions packages/web/src/grid/components/TimedGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type RefCallback,
useMemo,
} from "react";
import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants";
import { type Dayjs } from "@core/util/date/dayjs";
import {
DATA_TIMED_GRID_ROW,
Expand All @@ -27,6 +28,10 @@ import {
TIMED_VISIBLE_HOURS,
} from "@web/grid/grid.constants";
import { type GridVisibleDate } from "@web/grid/types/grid.types";
import {
selectEventJumpActiveDayKeys,
useEventJumpStore,
} from "@web/shortcuts/shift-hint/event-jump.store";

interface TimedGridProps {
columnsId?: string;
Expand All @@ -49,6 +54,7 @@ export const TimedGrid: FC<TimedGridProps> = ({
today,
visibleDates,
}) => {
const activeDayKeys = useEventJumpStore(selectEventJumpActiveDayKeys);
const todayColumnIndexes = visibleDates.flatMap(({ date }, index) =>
date.isSame(today, "day") ? [index] : [],
);
Expand Down Expand Up @@ -87,15 +93,22 @@ export const TimedGrid: FC<TimedGridProps> = ({
<table className="contents">
<thead className="contents">
<tr className="contents">
{visibleDates.map(({ date, key, surfaceLabel }) => (
<th
className="relative box-border block h-full min-w-[var(--calendar-column-min-width)] border-border border-l data-[past=true]:bg-surface"
data-past={date.isBefore(today, "day")}
aria-label={surfaceLabel ?? date.format("dddd, MMMM D, YYYY")}
key={key}
scope="col"
/>
))}
{visibleDates.map(({ date, key, surfaceLabel }) => {
const dayKey = date.format(YEAR_MONTH_DAY_FORMAT);
const isJumpDay = activeDayKeys.includes(dayKey);
return (
<th
className="relative box-border block h-full min-w-[var(--calendar-column-min-width)] border-border border-l transition-colors duration-150 data-[past=true]:data-[jump-day=true]:bg-accent/10 data-[jump-day=true]:bg-accent/10 data-[past=true]:bg-surface motion-reduce:transition-none"
data-jump-day={isJumpDay ? "true" : undefined}
data-past={date.isBefore(today, "day")}
aria-label={
surfaceLabel ?? date.format("dddd, MMMM D, YYYY")
}
key={key}
scope="col"
/>
);
})}
</tr>
</thead>
</table>
Expand Down
Loading