This commit is contained in:
ZacharyZhang-NY committed 2026-04-04 17:01:26 -04:00
1 parent 67c757f481
commit b8dda3c8d0
77 files changed
+1577 -2808

No files matched your search

+61 -6
View File
@@ -1,3 +1,5 @@
import { useCallback, useRef, useState } from "react";
interface RovingTabIndexResult {
containerRef: { current: HTMLDivElement | null };
focusedIndex: number;
@@ -6,11 +8,11 @@ interface RovingTabIndexResult {
getTabIndex: (index: number) => 0 | -1;
}
/** Roving tabindex for keyboard nav in lists. Supports Arrow/Home/End/j/k with wrap-around. */
export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
// Use internal mutable state for testability outside React
// In a React component context, this still works correctly because
// the function is called once per render and returns fresh closures
/**
* Core roving-tabindex logic, independent of React.
* Used directly by tests and wrapped by `useRovingTabIndex` for React components.
*/
export function createRovingTabIndex(itemCount: number): RovingTabIndexResult {
let _focusedIndex = 0;
const containerRef = { current: null as HTMLDivElement | null };
@@ -41,7 +43,6 @@ export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
_focusedIndex = nextIndex;
// Attempt to focus the target element in the DOM (no-op in tests)
const target = containerRef.current?.querySelector(
`[data-index="${nextIndex}"]`,
) as HTMLElement | null;
@@ -62,3 +63,57 @@ export function useRovingTabIndex(itemCount: number): RovingTabIndexResult {
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;
let nextIndex = focusedRef.current;
if (event.key === "ArrowDown" || event.key === "j") {
event.preventDefault();
nextIndex = (focusedRef.current + 1) % itemCount;
} 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();
}, [itemCount]);
const getTabIndex = useCallback((index: number): 0 | -1 => {
return index === focusedRef.current ? 0 : -1;
}, []);
return {
containerRef,
focusedIndex,
setFocusedIndex,
handleKeyDown,
getTabIndex,
};
}