feat(frontend): KeyboardCheatsheet overlay

This commit is contained in:
Tyler
2026-06-20 12:18:08 -06:00
parent 67d5c13939
commit d44aa1de3f
3 changed files with 395 additions and 0 deletions
@@ -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<HTMLElement> {
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(
<KeyboardCheatsheet open={false} onClose={onClose} />
);
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(
<KeyboardCheatsheet open={true} onClose={onClose} />
);
// 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(
<KeyboardCheatsheet open={true} onClose={onClose} />
);
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(
<KeyboardCheatsheet open={true} onClose={onClose} />
);
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(
<KeyboardCheatsheet open={true} onClose={onClose} />
);
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(
<KeyboardCheatsheet open={true} onClose={onClose} />
);
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(
<KeyboardCheatsheet open={true} onClose={onClose} />
);
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();
});
});
@@ -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
* `<kbd>` 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 (
<Dialog
open={open}
onOpenChange={(o) => {
// 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();
}}
>
<DialogContent
// Centered modal (DialogContent's default positioning is
// `left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`).
// We narrow it to max-w-md and apply the modern palette so
// it sits on the same surface as the rest of SP4.
className="max-w-md bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)] border border-[color:var(--m-border-heavy)]/20"
aria-describedby={undefined}
data-testid="keyboard-cheatsheet"
>
<header className="flex items-center gap-2 mb-3">
<Keyboard
className="h-4 w-4 text-[color:var(--m-ink-secondary)]"
strokeWidth={1.75}
aria-hidden
/>
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
Keyboard
</h2>
</header>
<ul className="flex flex-col gap-2.5">
{SHORTCUTS.map((s) => (
<li
key={s.description}
className="flex items-center justify-between gap-3 text-sm"
>
<div className="flex items-center gap-1.5">
{s.keys.map((k) => (
<kbd key={k} className="kbd">
{k}
</kbd>
))}
</div>
<span className="text-[color:var(--m-ink-secondary)]">
{s.description}
</span>
</li>
))}
</ul>
<p className="mt-4 text-xs text-[color:var(--m-ink-tertiary)]">
Press any other key to dismiss.
</p>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,3 @@
// Barrel export for the KeyboardCheatsheet module (SP4).
export { KeyboardCheatsheet } from "./KeyboardCheatsheet";