// @vitest-environment happy-dom // Mirror the IS_REACT_ACT_ENVIRONMENT setup from the other hook tests so // React doesn't log act() warnings about the createRoot render/unmount. (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, beforeEach } from "vitest"; import { useDrawerKeyboard } from "./useDrawerKeyboard"; /** * Minimal renderHook shim — same pattern as `useReconciliation.test.ts` * and `useClaimDetail.test.ts`. The project doesn't ship * `@testing-library/react`, so we wire a Probe component into a real * `createRoot` and let it call our hook. * * Unlike the other hook tests, `useDrawerKeyboard` returns `void` (its * side effect is the window listener), so the shim doesn't need to * surface a `result` — we just need `unmount` to verify cleanup. */ function renderHook(setup: () => void): { unmount: () => void } { const container = document.createElement("div"); document.body.appendChild(container); function Probe() { setup(); return null; } const root: Root = createRoot(container); act(() => { root.render(React.createElement(Probe)); }); return { unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** * Dispatch a synthetic keydown. By default the target is `window` * (which is what a real keypress bubbles to). Pass an explicit target * for the "input has focus" test, since a real keystroke would have * the focused element as its `target`. `bubbles: true` + `cancelable: * true` so the event behaves like a real keydown — the handler calls * `preventDefault` on matched keys. */ function pressKey( key: string, target: EventTarget = window, init: KeyboardEventInit = {} ): void { const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init, }); target.dispatchEvent(event); } describe("useDrawerKeyboard", () => { // `vi.fn()` with no args in vitest 4 is typed `Mock`, which carries a constructor signature and won't // assign to `() => void`. Constraining the generic to `vi.fn<() => void>()` // gives a plain function-typed mock that flows directly into the hook's // callback params. let onNext: () => void; let onPrev: () => void; let onClose: () => void; let onToggleHelp: () => void; beforeEach(() => { onNext = vi.fn<() => void>(); onPrev = vi.fn<() => void>(); onClose = vi.fn<() => void>(); onToggleHelp = vi.fn<() => void>(); }); it("fires onNext for j and ArrowDown", () => { const { unmount } = renderHook(() => useDrawerKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp }) ); pressKey("j"); expect(onNext).toHaveBeenCalledTimes(1); expect(onPrev).not.toHaveBeenCalled(); pressKey("ArrowDown"); expect(onNext).toHaveBeenCalledTimes(2); expect(onPrev).not.toHaveBeenCalled(); unmount(); }); it("fires onPrev for k and ArrowUp", () => { const { unmount } = renderHook(() => useDrawerKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp }) ); pressKey("k"); expect(onPrev).toHaveBeenCalledTimes(1); expect(onNext).not.toHaveBeenCalled(); pressKey("ArrowUp"); expect(onPrev).toHaveBeenCalledTimes(2); expect(onNext).not.toHaveBeenCalled(); unmount(); }); it("fires onClose when Escape is pressed", () => { const { unmount } = renderHook(() => useDrawerKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp }) ); pressKey("Escape"); expect(onClose).toHaveBeenCalledTimes(1); expect(onNext).not.toHaveBeenCalled(); expect(onPrev).not.toHaveBeenCalled(); expect(onToggleHelp).not.toHaveBeenCalled(); unmount(); }); it('fires onToggleHelp when "?" is pressed', () => { const { unmount } = renderHook(() => useDrawerKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp }) ); pressKey("?"); expect(onToggleHelp).toHaveBeenCalledTimes(1); expect(onNext).not.toHaveBeenCalled(); expect(onPrev).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); unmount(); }); it("ignores keys when an input has focus", () => { const { unmount } = renderHook(() => useDrawerKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp }) ); // Mount a real input and focus it. A real keystroke while the input // is focused would have `event.target === input` and bubble to // window — we simulate that by dispatching the keydown on the input // itself with `bubbles: true`. const input = document.createElement("input"); document.body.appendChild(input); input.focus(); expect(document.activeElement).toBe(input); pressKey("j", input); pressKey("Escape", input); pressKey("?", input); expect(onNext).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(onToggleHelp).not.toHaveBeenCalled(); input.remove(); unmount(); }); it("does nothing when enabled is false", () => { const { unmount } = renderHook(() => useDrawerKeyboard({ enabled: false, onNext, onPrev, onClose, onToggleHelp }) ); pressKey("j"); pressKey("Escape"); pressKey("?"); expect(onNext).not.toHaveBeenCalled(); expect(onPrev).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(onToggleHelp).not.toHaveBeenCalled(); unmount(); }); it("cleans up the keydown listener on unmount", () => { const { unmount } = renderHook(() => useDrawerKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp }) ); pressKey("j"); expect(onNext).toHaveBeenCalledTimes(1); unmount(); // After unmount the effect's cleanup has removed the listener, so a // follow-up keydown should be a no-op. We dispatch a few different // keys to be thorough. pressKey("j"); pressKey("Escape"); pressKey("?"); expect(onNext).toHaveBeenCalledTimes(1); expect(onClose).not.toHaveBeenCalled(); expect(onToggleHelp).not.toHaveBeenCalled(); }); });