Files
AgentCompany/src/renderer/lib/keyboard.ts
T
ZacharyZhang-NY b0b0cfa35b refactor(keyboard): extract shared nextIndexFor, drop duplicate handleKeyDown body (P2-28)
createRovingTabIndex and useRovingTabIndex now share one key-to-index
resolver; keeps both exports for test + React usage without drift risk.
2026-04-16 23:02:17 -04:00

86 lines
2.8 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
interface RovingTabIndexResult {
containerRef: { current: HTMLDivElement | null };
focusedIndex: number;
setFocusedIndex: (index: number) => void;
handleKeyDown: (event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => void;
getTabIndex: (index: number) => 0 | -1;
}
function nextIndexFor(key: string, current: number, itemCount: number): number | null {
if (key === "ArrowDown" || key === "j") return (current + 1) % itemCount;
if (key === "ArrowUp" || key === "k") return (current - 1 + itemCount) % itemCount;
if (key === "Home") return 0;
if (key === "End") return itemCount - 1;
return null;
}
/**
* Core roving-tabindex logic, independent of React. Used directly by tests;
* the React version below reuses the same nextIndexFor resolver.
*/
export function createRovingTabIndex(itemCount: number): RovingTabIndexResult {
let focused = 0;
const containerRef = { current: null as HTMLDivElement | null };
const setFocusedIndex = (index: number) => {
focused = index;
};
const handleKeyDown = (event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => {
if (itemCount === 0) return;
const next = nextIndexFor(event.key, focused, itemCount);
if (next === null) return;
event.preventDefault();
focused = next;
const target = containerRef.current?.querySelector(`[data-index="${next}"]`) as HTMLElement | null;
target?.focus();
};
const getTabIndex = (index: number): 0 | -1 => (index === focused ? 0 : -1);
return {
containerRef,
get focusedIndex() {
return focused;
},
setFocusedIndex,
handleKeyDown,
getTabIndex,
};
}
/** React hook wrapper around roving tabindex. Uses real React state for reactivity in components. */
export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
const [focusedIndex, setFocusedIndexState] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const focusedRef = useRef(0);
const setFocusedIndex = useCallback((index: number) => {
focusedRef.current = index;
setFocusedIndexState(index);
}, []);
const handleKeyDown = useCallback((event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => {
if (itemCount === 0) return;
const next = nextIndexFor(event.key, focusedRef.current, itemCount);
if (next === null) return;
event.preventDefault();
focusedRef.current = next;
setFocusedIndexState(next);
const target = containerRef.current?.querySelector(`[data-index="${next}"]`) as HTMLElement | null;
target?.focus();
}, [itemCount]);
const getTabIndex = useCallback((index: number): 0 | -1 => (index === focusedRef.current ? 0 : -1), []);
return {
containerRef,
focusedIndex,
setFocusedIndex,
handleKeyDown,
getTabIndex,
};
}