diff --git a/docs/acceptance/shortcuts.md b/docs/acceptance/shortcuts.md index f7d4112f26..c751167733 100644 --- a/docs/acceptance/shortcuts.md +++ b/docs/acceptance/shortcuts.md @@ -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 | @@ -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 | @@ -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. --- @@ -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. diff --git a/e2e/timed/keyboard-only-mode.spec.ts b/e2e/timed/keyboard-only-mode.spec.ts index b86bf15139..1935e9189f 100644 --- a/e2e/timed/keyboard-only-mode.spec.ts +++ b/e2e/timed/keyboard-only-mode.spec.ts @@ -79,7 +79,7 @@ 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); @@ -87,7 +87,7 @@ test("hold Shift still flashes jump keys and does not enter keyboard-only mode", 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); diff --git a/e2e/timed/shift-hold-event-hints.spec.ts b/e2e/timed/shift-hold-event-hints.spec.ts index 3ec354a5b2..6d41f91ce0 100644 --- a/e2e/timed/shift-hold-event-hints.spec.ts +++ b/e2e/timed/shift-hold-event-hints.spec.ts @@ -7,8 +7,20 @@ import { prepareCalendarPage, } from "../utils/event-test-utils"; +type CalendarPage = Parameters[0]; + +const DAY_PREFIX_KEYS: Record = { + SU: ["s", "u"], + M: ["m"], + T: ["t"], + W: ["w"], + R: ["r"], + F: ["f"], + SA: ["s", "a"], +}; + const createTimedEventAt = async ( - page: Parameters[0], + page: CalendarPage, title: string, { xRatio, yRatio }: { xRatio: number; yRatio: number }, ) => { @@ -18,10 +30,15 @@ const createTimedEventAt = async ( await expectTimedEventVisible(page, title); }; -const shiftHintOverlay = (page: Parameters[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); @@ -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 }); @@ -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"); diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx index 63e369953e..eacff20a99 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.tsx @@ -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. @@ -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 @@ -51,6 +60,10 @@ export const SidebarStatusBar: FC = () => {
+ ) : showEventJump ? ( +
+ +
) : (