feat(frontend): j/k row nav + ? cheatsheet on Acks and Remittances
This commit is contained in:
@@ -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<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(() =>
|
||||
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 <input>: 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();
|
||||
});
|
||||
});
|
||||
@@ -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 `<input>`, `<textarea>`, `<select>`, or
|
||||
* `contenteditable` element has focus — typing "j" into a search box
|
||||
* must not jump rows. `preventDefault` is called for each matched
|
||||
* key to suppress the browser's default (arrow keys scrolling the
|
||||
* page, Escape closing modals/pausing video, etc.).
|
||||
*
|
||||
* Recommended pairing: pass `enabled: !helpOpen` so j/k nav yields
|
||||
* to the cheatsheet while it's open (the cheatsheet's own dismiss
|
||||
* listener still fires for any keypress, including j/k).
|
||||
*/
|
||||
export function useRowKeyboard(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