feat(frontend): useDrawerKeyboard hook for j/k/esc/?

This commit is contained in:
Tyler
2026-06-20 10:23:40 -06:00
parent 1dfd4b58c0
commit c737bd742d
2 changed files with 288 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
// @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<Procedure |
// Constructable>`, 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();
});
});