diff --git a/replay-ui/src/hooks/useKeyboardNav.ts b/replay-ui/src/hooks/useKeyboardNav.ts index d7a2017..2db6c0a 100644 --- a/replay-ui/src/hooks/useKeyboardNav.ts +++ b/replay-ui/src/hooks/useKeyboardNav.ts @@ -1,73 +1,24 @@ import { useEffect } from "react"; +import { dispatchKey, type KeyboardNavOptions } from "../lib/keyboard-nav"; -export interface UseKeyboardNavOptions { - onPrev: () => void; - onNext: () => void; - onJumpStart: () => void; - onJumpEnd: () => void; - onJumpPrev10: () => void; - onJumpNext10: () => void; - onJumpNextViolation: () => void; -} - -const NAVIGATION_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); -const ARROW_OWNING_ROLES = new Set(["tab", "tablist", "option", "listbox", "menuitem", "menu"]); - -function targetOwnsArrowKeys(target: HTMLElement | null): boolean { - let node: HTMLElement | null = target; - while (node) { - const role = node.getAttribute?.("role"); - if (role && ARROW_OWNING_ROLES.has(role)) return true; - node = node.parentElement; - } - return false; -} +export type UseKeyboardNavOptions = KeyboardNavOptions; export function useKeyboardNav(options: UseKeyboardNavOptions) { useEffect(() => { const handler = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - if (target && (NAVIGATION_TAGS.has(target.tagName) || target.isContentEditable)) { - return; - } - const isArrow = event.key.startsWith("Arrow"); - if (isArrow && targetOwnsArrowKeys(target)) { - return; - } - if (event.metaKey || event.ctrlKey || event.altKey) { - return; - } - switch (event.key) { - case "ArrowLeft": - case "k": - if (event.shiftKey) { - options.onJumpPrev10(); - } else { - options.onPrev(); - } - event.preventDefault(); - return; - case "ArrowRight": - case "j": - if (event.shiftKey) { - options.onJumpNext10(); - } else { - options.onNext(); - } - event.preventDefault(); - return; - case "g": - options.onJumpStart(); - event.preventDefault(); - return; - case "G": - options.onJumpEnd(); - event.preventDefault(); - return; - case ".": - options.onJumpNextViolation(); - event.preventDefault(); - return; + const handled = dispatchKey( + { + key: event.key, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + target: event.target as HTMLElement | null, + }, + options, + ); + if (handled) { + event.preventDefault(); } }; window.addEventListener("keydown", handler); diff --git a/replay-ui/src/lib/keyboard-nav.ts b/replay-ui/src/lib/keyboard-nav.ts new file mode 100644 index 0000000..bf21bc5 --- /dev/null +++ b/replay-ui/src/lib/keyboard-nav.ts @@ -0,0 +1,76 @@ +export interface KeyboardNavOptions { + onPrev: () => void; + onNext: () => void; + onJumpStart: () => void; + onJumpEnd: () => void; + onJumpPrev10: () => void; + onJumpNext10: () => void; + onJumpNextViolation: () => void; +} + +export interface KeyEvent { + key: string; + shiftKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + target?: HTMLElement | null; +} + +const NAVIGATION_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); +const ARROW_OWNING_ROLES = new Set([ + "tab", + "tablist", + "option", + "listbox", + "menuitem", + "menu", +]); + +export function targetOwnsArrowKeys(target: HTMLElement | null): boolean { + let node: HTMLElement | null = target; + while (node) { + const role = node.getAttribute?.("role"); + if (role && ARROW_OWNING_ROLES.has(role)) return true; + node = node.parentElement; + } + return false; +} + +// Returns true when the key was consumed (caller should preventDefault). +export function dispatchKey( + event: KeyEvent, + options: KeyboardNavOptions, +): boolean { + const target = event.target ?? null; + if (target && (NAVIGATION_TAGS.has(target.tagName) || target.isContentEditable)) { + return false; + } + if (event.key.startsWith("Arrow") && targetOwnsArrowKeys(target)) { + return false; + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return false; + } + switch (event.key) { + case "ArrowLeft": + case "k": + (event.shiftKey ? options.onJumpPrev10 : options.onPrev)(); + return true; + case "ArrowRight": + case "j": + (event.shiftKey ? options.onJumpNext10 : options.onNext)(); + return true; + case "g": + options.onJumpStart(); + return true; + case "G": + options.onJumpEnd(); + return true; + case ".": + options.onJumpNextViolation(); + return true; + default: + return false; + } +}