keyboard-driven navigation

Resolves #66
This commit is contained in:
talksik
2026-03-30 12:51:15 -07:00
parent 14285575e4
commit 086cf4f1ee
7 changed files with 188 additions and 40 deletions
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
interface UseStreamKeyboardNavOptions {
streams: Array<{ id: string }>;
viewMode: "list" | "grid";
enabled: boolean;
onNavigate: (streamId: string) => void;
gridColumns?: number;
}
export function useStreamKeyboardNav({
streams,
viewMode,
enabled,
onNavigate,
gridColumns = 3,
}: UseStreamKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
// Reset selection to first item when streams change or view mode switches
useEffect(() => {
setSelectedIndex(streams.length > 0 ? 0 : null);
}, [streams.length, viewMode]);
useEffect(() => {
if (!enabled || streams.length === 0) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
// Number keys 1-9: immediate navigation
const digit = parseInt(e.key, 10);
if (digit >= 1 && digit <= 9) {
const index = digit - 1;
if (index < streams.length) {
e.preventDefault();
onNavigate(streams[index].id);
}
return;
}
// Enter: navigate to selected
if (e.key === "Enter") {
setSelectedIndex((idx) => {
if (idx !== null && idx < streams.length) {
e.preventDefault();
onNavigate(streams[idx].id);
}
return idx;
});
return;
}
// Arrow keys: move selection
let delta: number | null = null;
if (viewMode === "list") {
if (e.key === "ArrowDown") delta = 1;
else if (e.key === "ArrowUp") delta = -1;
} else {
if (e.key === "ArrowDown") delta = gridColumns;
else if (e.key === "ArrowUp") delta = -gridColumns;
else if (e.key === "ArrowRight") delta = 1;
else if (e.key === "ArrowLeft") delta = -1;
}
if (delta !== null) {
e.preventDefault();
setSelectedIndex((prev) => {
if (prev === null) return 0;
const next = prev + delta;
return Math.max(0, Math.min(next, streams.length - 1));
});
}
};
// Use capture phase so arrow keys are intercepted before Radix UI
// components (ToggleGroup, etc.) consume them for their own navigation.
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [enabled, viewMode, gridColumns, streams, onNavigate]);
return { selectedIndex };
}