feat(frontend): useDrawerKeyboard hook for j/k/esc/?
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/** Tag names whose descendants can receive free-form text input. */
|
||||||
|
const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the keyboard event originated inside a free-form
|
||||||
|
* text field, in which case we should NOT consume j/k/escape/`?` —
|
||||||
|
* the user is typing, not navigating the drawer.
|
||||||
|
*
|
||||||
|
* - `INPUT` / `TEXTAREA` / `SELECT` are the obvious cases.
|
||||||
|
* - `contenteditable` elements (e.g. rich text editors) are the
|
||||||
|
* non-obvious one: `target.isContentEditable` is the standard check.
|
||||||
|
*/
|
||||||
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (INPUT_TAGS.has(target.tagName)) return true;
|
||||||
|
return target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the spec-driven drawer keyboard shortcuts to the drawer's
|
||||||
|
* callbacks. When `enabled` is true, listens for `keydown` on `window`
|
||||||
|
* and dispatches:
|
||||||
|
*
|
||||||
|
* j / ArrowDown → onNext
|
||||||
|
* k / ArrowUp → onPrev
|
||||||
|
* Escape → onClose
|
||||||
|
* ? → onToggleHelp
|
||||||
|
*
|
||||||
|
* Keys are ignored when an `<input>`, `<textarea>`, `<select>`, or
|
||||||
|
* `contenteditable` element has focus — typing "j" into a search box
|
||||||
|
* must not navigate claims. `preventDefault` is called for each matched
|
||||||
|
* key to suppress the browser's default (arrow keys scrolling the page,
|
||||||
|
* Escape closing modals/pausing video, etc.).
|
||||||
|
*
|
||||||
|
* The effect tears down on unmount or when `enabled` flips to false, and
|
||||||
|
* re-subscribes whenever any callback identity changes (so consumers
|
||||||
|
* don't have to memoize their handlers to keep the listener stable).
|
||||||
|
*/
|
||||||
|
export function useDrawerKeyboard(opts: {
|
||||||
|
enabled: boolean;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleHelp: () => void;
|
||||||
|
}): void {
|
||||||
|
const { enabled, onNext, onPrev, onClose, onToggleHelp } = opts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (isEditableTarget(e.target)) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "j":
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
onNext();
|
||||||
|
break;
|
||||||
|
case "k":
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
onPrev();
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
case "?":
|
||||||
|
e.preventDefault();
|
||||||
|
onToggleHelp();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [enabled, onNext, onPrev, onClose, onToggleHelp]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user