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.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 23:02:17 -04:00
1 parent 821855931d
commit b0b0cfa35b
1 file changed
+26 -60
+26 -60
View File
@@ -8,55 +8,42 @@ interface RovingTabIndexResult {
getTabIndex: (index: number) => 0 | -1; 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. * Core roving-tabindex logic, independent of React. Used directly by tests;
* Used directly by tests and wrapped by `useRovingTabIndex` for React components. * the React version below reuses the same nextIndexFor resolver.
*/ */
export function createRovingTabIndex(itemCount: number): RovingTabIndexResult { export function createRovingTabIndex(itemCount: number): RovingTabIndexResult {
let _focusedIndex = 0; let focused = 0;
const containerRef = { current: null as HTMLDivElement | null }; const containerRef = { current: null as HTMLDivElement | null };
const setFocusedIndex = (index: number) => { const setFocusedIndex = (index: number) => {
_focusedIndex = index; focused = index;
}; };
const handleKeyDown = (event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => { const handleKeyDown = (event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => {
if (itemCount === 0) return; if (itemCount === 0) return;
const next = nextIndexFor(event.key, focused, itemCount);
let nextIndex = _focusedIndex; if (next === null) return;
event.preventDefault();
if (event.key === "ArrowDown" || event.key === "j") { focused = next;
event.preventDefault(); const target = containerRef.current?.querySelector(`[data-index="${next}"]`) as HTMLElement | null;
nextIndex = (_focusedIndex + 1) % itemCount;
} else if (event.key === "ArrowUp" || event.key === "k") {
event.preventDefault();
nextIndex = (_focusedIndex - 1 + itemCount) % itemCount;
} else if (event.key === "Home") {
event.preventDefault();
nextIndex = 0;
} else if (event.key === "End") {
event.preventDefault();
nextIndex = itemCount - 1;
} else {
return;
}
_focusedIndex = nextIndex;
const target = containerRef.current?.querySelector(
`[data-index="${nextIndex}"]`,
) as HTMLElement | null;
target?.focus(); target?.focus();
}; };
const getTabIndex = (index: number): 0 | -1 => { const getTabIndex = (index: number): 0 | -1 => (index === focused ? 0 : -1);
return index === _focusedIndex ? 0 : -1;
};
return { return {
containerRef, containerRef,
get focusedIndex() { get focusedIndex() {
return _focusedIndex; return focused;
}, },
setFocusedIndex, setFocusedIndex,
handleKeyDown, handleKeyDown,
@@ -77,37 +64,16 @@ export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
const handleKeyDown = useCallback((event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => { const handleKeyDown = useCallback((event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => {
if (itemCount === 0) return; if (itemCount === 0) return;
const next = nextIndexFor(event.key, focusedRef.current, itemCount);
let nextIndex = focusedRef.current; if (next === null) return;
event.preventDefault();
if (event.key === "ArrowDown" || event.key === "j") { focusedRef.current = next;
event.preventDefault(); setFocusedIndexState(next);
nextIndex = (focusedRef.current + 1) % itemCount; const target = containerRef.current?.querySelector(`[data-index="${next}"]`) as HTMLElement | null;
} else if (event.key === "ArrowUp" || event.key === "k") {
event.preventDefault();
nextIndex = (focusedRef.current - 1 + itemCount) % itemCount;
} else if (event.key === "Home") {
event.preventDefault();
nextIndex = 0;
} else if (event.key === "End") {
event.preventDefault();
nextIndex = itemCount - 1;
} else {
return;
}
focusedRef.current = nextIndex;
setFocusedIndexState(nextIndex);
const target = containerRef.current?.querySelector(
`[data-index="${nextIndex}"]`,
) as HTMLElement | null;
target?.focus(); target?.focus();
}, [itemCount]); }, [itemCount]);
const getTabIndex = useCallback((index: number): 0 | -1 => { const getTabIndex = useCallback((index: number): 0 | -1 => (index === focusedRef.current ? 0 : -1), []);
return index === focusedRef.current ? 0 : -1;
}, []);
return { return {
containerRef, containerRef,