refactor(replay-ui): extract keyboard-nav dispatch into pure module

This commit is contained in:
pj committed 2026-06-06 13:07:43 +05:30
1 parent e03da83cca
commit 153f17c756
2 files changed
+90 -63

No files matched your search

+14 -63
View File
@@ -1,73 +1,24 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { dispatchKey, type KeyboardNavOptions } from "../lib/keyboard-nav";
export interface UseKeyboardNavOptions { export type UseKeyboardNavOptions = KeyboardNavOptions;
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 function useKeyboardNav(options: UseKeyboardNavOptions) { export function useKeyboardNav(options: UseKeyboardNavOptions) {
useEffect(() => { useEffect(() => {
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null; const handled = dispatchKey(
if (target && (NAVIGATION_TAGS.has(target.tagName) || target.isContentEditable)) { {
return; key: event.key,
} shiftKey: event.shiftKey,
const isArrow = event.key.startsWith("Arrow"); metaKey: event.metaKey,
if (isArrow && targetOwnsArrowKeys(target)) { ctrlKey: event.ctrlKey,
return; altKey: event.altKey,
} target: event.target as HTMLElement | null,
if (event.metaKey || event.ctrlKey || event.altKey) { },
return; options,
} );
switch (event.key) { if (handled) {
case "ArrowLeft":
case "k":
if (event.shiftKey) {
options.onJumpPrev10();
} else {
options.onPrev();
}
event.preventDefault(); 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;
} }
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
+76
View File
@@ -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;
}
}