Files
cyclone/src/hooks/useDrawerKeyboard.ts
T

82 lines
2.6 KiB
TypeScript

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]);
}