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
+91 -64

No files matched your search

+15 -64
View File
@@ -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);
+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;
}
}