From 8f419cdebaf6e830e6e234b98824725b329b226d Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 16:57:15 -0600 Subject: [PATCH] feat(frontend): j/k row nav + ? cheatsheet on Acks and Remittances --- src/hooks/useRowKeyboard.test.ts | 226 ++++++++++++++++++ src/hooks/useRowKeyboard.ts | 101 ++++++++ src/pages/Acks.test.tsx | 363 +++++++++++++++++++++++++++- src/pages/Acks.tsx | 247 ++++++++++++------- src/pages/Remittances.test.tsx | 309 +++++++++++++++++++++--- src/pages/Remittances.tsx | 391 ++++++++++++++++++------------- 6 files changed, 1347 insertions(+), 290 deletions(-) create mode 100644 src/hooks/useRowKeyboard.test.ts create mode 100644 src/hooks/useRowKeyboard.ts diff --git a/src/hooks/useRowKeyboard.test.ts b/src/hooks/useRowKeyboard.test.ts new file mode 100644 index 0000000..d4238b0 --- /dev/null +++ b/src/hooks/useRowKeyboard.test.ts @@ -0,0 +1,226 @@ +// @vitest-environment happy-dom +// Mirror the IS_REACT_ACT_ENVIRONMENT setup from `useDrawerKeyboard.test.ts` +// 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 { useRowKeyboard } from "./useRowKeyboard"; + +/** + * Minimal renderHook shim — same shape as the project's other hook tests. + * `useRowKeyboard` returns `void` (its side effect is the window listener), + * so the shim doesn't surface a `result`; we only need `unmount` for + * cleanup verification. + */ +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. `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("useRowKeyboard", () => { + // 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(() => + useRowKeyboard({ 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(() => + useRowKeyboard({ 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(() => + useRowKeyboard({ 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(() => + useRowKeyboard({ 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(() => + useRowKeyboard({ 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("ignores keys when a textarea has focus", () => { + // Same protection as : rich text fields must not be hijacked. + // Mirrors the contenteditable note in the hook's JSDoc. + const { unmount } = renderHook(() => + useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp }) + ); + + const textarea = document.createElement("textarea"); + document.body.appendChild(textarea); + textarea.focus(); + expect(document.activeElement).toBe(textarea); + + pressKey("j", textarea); + pressKey("k", textarea); + expect(onNext).not.toHaveBeenCalled(); + expect(onPrev).not.toHaveBeenCalled(); + + textarea.remove(); + unmount(); + }); + + it("does nothing when enabled is false", () => { + // The `enabled` flag is the recommended way to yield to an open + // overlay (e.g. the cheatsheet). When false, the listener must not + // attach at all — no keydown should reach our handlers. + const { unmount } = renderHook(() => + useRowKeyboard({ 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(() => + useRowKeyboard({ 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(); + }); +}); \ No newline at end of file diff --git a/src/hooks/useRowKeyboard.ts b/src/hooks/useRowKeyboard.ts new file mode 100644 index 0000000..8281af8 --- /dev/null +++ b/src/hooks/useRowKeyboard.ts @@ -0,0 +1,101 @@ +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 table. + * + * - `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. + * + * Lifted from `useDrawerKeyboard` so both hooks agree on the + * "is the user typing?" predicate. Keeping it in lock-step matters: + * if the predicates drift, a future search box might accidentally + * become a navigation surface in one component but not another. + */ +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (INPUT_TAGS.has(target.tagName)) return true; + return target.isContentEditable; +} + +/** + * Wires keyboard shortcuts for a paginated/filterable table whose rows + * are keyboard-navigable (j/k to move, ? for help, Esc to close the + * help overlay). When `enabled` is true, listens for `keydown` on + * `window` and dispatches: + * + * j / ArrowDown → onNext + * k / ArrowUp → onPrev + * Escape → onClose + * ? → onToggleHelp + * + * Semantically a sibling of `useDrawerKeyboard`, but tuned for + * list-row navigation rather than a modal drawer: + * + * - `onNext` / `onPrev` advance/decrement a row index in the + * *list* (the caller decides whether to wrap or clamp; this hook + * doesn't enforce either policy — it just signals "the user wants + * to move"). + * - `onClose` closes whatever overlay is currently open on the + * page — typically just the keyboard-cheatsheet. The hook itself + * doesn't own the overlay; it just forwards the key. + * - `onToggleHelp` flips the cheatsheet open/closed. The hook does + * NOT preempt this on `?` — see KeyboardCheatsheet.tsx for the + * matching "skip `?` in the dismiss listener" rule. + * + * Keys are ignored when an ``, `