// @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(); }); it("test_dialog_is_aria_labelledby_visible_heading", async () => { // The DialogContent must carry an aria-labelledby pointing at // the visible

so screen readers announce "Keyboard, dialog" // instead of leaving the modal unlabeled (Radix logs a warning // otherwise). The id referenced must also resolve to a real // element with that id inside the cheatsheet. 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(); // The Radix DialogContent exposes its labelledby on the panel // element itself. We accept either the dialog content id (which // is the data-testid) or its first role-bearing descendant — // depending on which element Radix attaches aria-labelledby to. const labelledBy = sheet?.getAttribute("aria-labelledby") ?? sheet ?.querySelector("[aria-labelledby]") ?.getAttribute("aria-labelledby") ?? null; expect(labelledBy).not.toBeNull(); expect(labelledBy).toBe("keyboard-cheatsheet-title"); // And the id must point at a real element whose text content is // the visible "Keyboard" heading — closing the loop between the // a11y wiring and the visible UI. const titleEl = body.querySelector("#keyboard-cheatsheet-title"); expect(titleEl).not.toBeNull(); expect(titleEl?.textContent).toBe("Keyboard"); unmount(); }); it("test_close_button_has_specific_aria_label_and_describedby", async () => { // The cheatsheet renders an explicit close button (the shared // DialogContent X in the corner has a generic "Close dialog" // label that's out of scope here). The cheatsheet-specific one // must: // - be findable by its cheatsheet-specific aria-label, and // - carry aria-describedby pointing back at the visible heading // so screen-reader users hear "Close keyboard cheatsheet, // Keyboard, button". const onClose = vi.fn(); const { unmount } = renderIntoContainer( ); const body = await settle((b) => b.querySelector('[data-testid="keyboard-cheatsheet"]') !== null ); const closeBtn = body.querySelector( '[aria-label="Close keyboard cheatsheet"]' ) as HTMLButtonElement | null; expect(closeBtn).not.toBeNull(); // The describedby wires the close button back to the title so // the screen-reader announcement has context. expect(closeBtn?.getAttribute("aria-describedby")).toBe( "keyboard-cheatsheet-title" ); // Clicking the close button must invoke the consumer's onClose. expect(onClose).not.toHaveBeenCalled(); act(() => { closeBtn?.click(); }); expect(onClose).toHaveBeenCalledTimes(1); unmount(); }); });