From d44aa1de3f243127ced86e98b3db49e703b9fdd4 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 12:18:08 -0600 Subject: [PATCH] feat(frontend): KeyboardCheatsheet overlay --- .../KeyboardCheatsheet.test.tsx | 270 ++++++++++++++++++ .../KeyboardCheatsheet/KeyboardCheatsheet.tsx | 122 ++++++++ src/components/KeyboardCheatsheet/index.ts | 3 + 3 files changed, 395 insertions(+) create mode 100644 src/components/KeyboardCheatsheet/KeyboardCheatsheet.test.tsx create mode 100644 src/components/KeyboardCheatsheet/KeyboardCheatsheet.tsx create mode 100644 src/components/KeyboardCheatsheet/index.ts diff --git a/src/components/KeyboardCheatsheet/KeyboardCheatsheet.test.tsx b/src/components/KeyboardCheatsheet/KeyboardCheatsheet.test.tsx new file mode 100644 index 0000000..0608960 --- /dev/null +++ b/src/components/KeyboardCheatsheet/KeyboardCheatsheet.test.tsx @@ -0,0 +1,270 @@ +// @vitest-environment happy-dom +// Pure presentation component — no React Query, no async work — but we +// still set IS_REACT_ACT_ENVIRONMENT to match the project convention +// (ClaimDrawerHeader.test.tsx et al.). +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { KeyboardCheatsheet } from "./KeyboardCheatsheet"; + +/** + * Render the component into a real DOM container. No QueryClientProvider + * needed — the cheatsheet has no data dependencies. + */ +function renderIntoContainer( + element: React.ReactElement +): { container: HTMLDivElement; unmount: () => void } { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Wait for a predicate over `document.body` to hold. Radix's Dialog + * portals content into `document.body`, not into the React container, + * so we query there. The component is sync-rendered in the happy + * path (no async work), so a single tick of the microtask queue is + * normally enough; we poll defensively. + */ +async function settle( + predicate: (body: HTMLElement) => boolean, + timeoutMs = 1000 +): Promise { + const body = document.body; + const start = Date.now(); + while (!predicate(body)) { + if (Date.now() - start > timeoutMs) { + throw new Error( + `settle: predicate did not hold within ${timeoutMs}ms (body=${body.innerHTML.slice(0, 200)})` + ); + } + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } + return body; +} + +/** + * Find the Radix overlay. Radix's DialogPortal places the overlay as + * a direct sibling of the dialog content inside `document.body`, so + * `previousElementSibling` reliably points at it across the + * happy-dom test environment. The overlay element carries the + * `data-state="open"` attribute and the `fixed inset-0 z-50` + * positioning classes from the project's dialog primitive. + */ +function findOverlay(sheet: HTMLElement): HTMLElement | null { + return sheet.previousElementSibling as HTMLElement | null; +} + +describe("KeyboardCheatsheet", () => { + it("test_does_not_render_when_closed", () => { + // open=false → no DOM at all. The Dialog primitive respects `open` + // and unmounts its portal when the root is closed. + const onClose = vi.fn(); + const { container, unmount } = renderIntoContainer( + + ); + + expect( + container.querySelector('[data-testid="keyboard-cheatsheet"]') + ).toBeNull(); + // Radix also portals to document.body; nothing there either. + expect( + document.body.querySelector('[data-testid="keyboard-cheatsheet"]') + ).toBeNull(); + + unmount(); + }); + + it("test_renders_overlay_when_open", async () => { + const onClose = vi.fn(); + const { unmount } = renderIntoContainer( + + ); + + // Radix portals to document.body — query there. + const body = await settle((b) => + b.querySelector('[data-testid="keyboard-cheatsheet"]') !== null + ); + + const sheet = body.querySelector('[data-testid="keyboard-cheatsheet"]'); + expect(sheet).not.toBeNull(); + + // All five shortcut rows must be present. The plan calls out five + // shortcuts; we assert each by its key chip text. + const text = (sheet?.textContent ?? "").toLowerCase(); + // j / k / ↑ / ↓ / esc / ? — at minimum these strings must appear. + expect(text).toContain("j"); + expect(text).toContain("k"); + expect(text).toContain("↑"); + expect(text).toContain("↓"); + expect(text).toContain("esc"); + expect(text).toContain("?"); + + unmount(); + }); + + it("test_clicking_outside_dismisses", async () => { + const onClose = vi.fn(); + const { unmount } = renderIntoContainer( + + ); + + const body = await settle((b) => + b.querySelector('[data-testid="keyboard-cheatsheet"]') !== null + ); + + const sheet = body.querySelector( + '[data-testid="keyboard-cheatsheet"]' + ) as HTMLElement | null; + expect(sheet).not.toBeNull(); + + // Radix's overlay click-to-dismiss uses React synthetic event + // handlers (`onPointerDown` + `onClick`). In happy-dom these + // attach on the first macrotask tick after the portal mounts — + // a tick AFTER the testid lands in the DOM. Without this extra + // wait, the pointerdown is dispatched before Radix's listener + // is wired up and `onOpenChange(false)` never fires. + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const overlay = findOverlay(sheet!); + expect(overlay).not.toBeNull(); + // Sanity: the overlay is NOT the content panel. Radix's dismiss + // path (onPointerDown + onClick on the overlay element) requires + // clicking outside the content area, so the overlay and content + // must be distinct elements. + expect(overlay).not.toBe(sheet); + expect(overlay!.getAttribute("data-state")).toBe("open"); + + // Radix's overlay dismiss is gated on a pointerdown event + // (records whether the press started outside the content) followed + // by a click on the overlay element. Dispatching both in order + // triggers onOpenChange(false) → our onClose(). + expect(onClose).not.toHaveBeenCalled(); + act(() => { + overlay!.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + pointerType: "mouse", + }) + ); + overlay!.click(); + }); + expect(onClose).toHaveBeenCalled(); + + unmount(); + }); + + it("test_pressing_any_other_key_dismisses", async () => { + const onClose = vi.fn(); + const { unmount } = renderIntoContainer( + + ); + + await settle((b) => + b.querySelector('[data-testid="keyboard-cheatsheet"]') !== null + ); + + // Dispatch a keydown for an arbitrary key — the component's + // window-level listener should fire onClose. + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }) + ); + }); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("test_pressing_question_mark_does_not_dismiss_internally", async () => { + // The toggle key (?) is the parent's responsibility — the cheatsheet + // explicitly does NOT call onClose when ? is pressed while open. + // The parent will set open=false before re-opening on the next + // toggle. We verify the cheatsheet doesn't preempt the parent. + const onClose = vi.fn(); + const { unmount } = renderIntoContainer( + + ); + + await settle((b) => + b.querySelector('[data-testid="keyboard-cheatsheet"]') !== null + ); + + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "?", bubbles: true }) + ); + }); + expect(onClose).not.toHaveBeenCalled(); + + unmount(); + }); + + it("test_lists_all_shortcuts_with_descriptions", async () => { + const onClose = vi.fn(); + const { unmount } = renderIntoContainer( + + ); + + const body = await settle((b) => + b.querySelector('[data-testid="keyboard-cheatsheet"]') !== null + ); + + const sheet = body.querySelector( + '[data-testid="keyboard-cheatsheet"]' + ) as HTMLElement | null; + const text = (sheet?.textContent ?? "").toLowerCase(); + + // Each shortcut has a human-readable description. We don't pin + // the exact wording (spec says "Next claim" / "Previous claim" / + // "Close drawer" / "Toggle help"), but each row must include at + // least one action descriptor word. + // j → next + expect(text).toMatch(/next/); + // k → previous + expect(text).toMatch(/previous/); + // esc → close + expect(text).toMatch(/close/); + // ? → toggle help + expect(text).toMatch(/help/); + + unmount(); + }); + + it("test_uses_kbd_class_for_key_chips", async () => { + const onClose = vi.fn(); + const { unmount } = renderIntoContainer( + + ); + + const body = await settle((b) => + b.querySelector('[data-testid="keyboard-cheatsheet"]') !== null + ); + + // The project defines a `.kbd` class in src/index.css; the + // cheatsheet must use it for its key chips. + const kbdChips = body.querySelectorAll( + '[data-testid="keyboard-cheatsheet"] .kbd' + ); + expect(kbdChips.length).toBeGreaterThan(0); + + unmount(); + }); +}); diff --git a/src/components/KeyboardCheatsheet/KeyboardCheatsheet.tsx b/src/components/KeyboardCheatsheet/KeyboardCheatsheet.tsx new file mode 100644 index 0000000..8288491 --- /dev/null +++ b/src/components/KeyboardCheatsheet/KeyboardCheatsheet.tsx @@ -0,0 +1,122 @@ +import { useEffect } from "react"; +import { Keyboard } from "lucide-react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +type KeyboardCheatsheetProps = { + open: boolean; + onClose: () => void; +}; + +/** + * Shortcut rows. Each row may have multiple key chips (e.g. "j" and + * "↓" both navigate to the next claim) so the `keys` array renders one + * `` per entry. The order here is the order rendered top→bottom + * and is the project's canonical "drawer shortcuts" reference. + */ +const SHORTCUTS = [ + { keys: ["j", "↓"], description: "Next claim" }, + { keys: ["k", "↑"], description: "Previous claim" }, + { keys: ["Esc"], description: "Close drawer" }, + { keys: ["?"], description: "Toggle this help" }, +] as const; + +/** + * Keyboard help overlay (SP4). + * + * Centered modal-style overlay (per spec: "centered modal-style + * overlay") built on the project's `Dialog` primitive — the only + * modal primitive in the codebase. It reuses Radix's focus + * management, ESC-to-close, and overlay-click-to-dismiss for free; + * the dialog's `onOpenChange(false)` is wired straight to the + * consumer's `onClose`. + * + * Dismissal rules (per spec §3.7): + * + * 1. Overlay click → Radix fires `onOpenChange(false)`. + * 2. Escape key → Radix fires `onOpenChange(false)`. + * 3. Any other key → The window-level `keydown` listener below + * fires `onClose` directly. + * + * The `?` key is intentionally NOT a dismiss trigger: it's the + * toggle key the *parent* owns. The parent flips `open` to false + * (and back to true, if it's a toggle) without the cheatsheet + * pre-empting it — otherwise the "press ? to toggle" gesture would + * silently no-op on the close half of the cycle. + * + * `aria-describedby={undefined}` suppresses the Radix warning about + * a missing description; the cheatsheet's content (key list + helper + * text) is its own description and there's no separate description + * element worth pointing at. + */ +export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) { + // Dismiss on any key while open (except `?`, which the parent owns). + // The listener is only attached when `open` is true — a closed + // cheatsheet shouldn't be intercepting keystrokes for the rest of + // the app. + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "?") return; + onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + return ( + { + // Radix fires `false` on overlay click, Escape, and the + // dialog's own close button. Forward all three as a single + // `onClose` to the parent. + if (!o) onClose(); + }} + > + +
+ +

+ Keyboard +

+
+ +
    + {SHORTCUTS.map((s) => ( +
  • +
    + {s.keys.map((k) => ( + + {k} + + ))} +
    + + {s.description} + +
  • + ))} +
+ +

+ Press any other key to dismiss. +

+
+
+ ); +} diff --git a/src/components/KeyboardCheatsheet/index.ts b/src/components/KeyboardCheatsheet/index.ts new file mode 100644 index 0000000..da8f506 --- /dev/null +++ b/src/components/KeyboardCheatsheet/index.ts @@ -0,0 +1,3 @@ +// Barrel export for the KeyboardCheatsheet module (SP4). + +export { KeyboardCheatsheet } from "./KeyboardCheatsheet";