Files
llink/js/desktop/src/hooks/use-list-keyboard-nav.ts
T
Arjun PatelandGitHub 095b9876f9 feat: stream list view and tasks (#279)
* first attempt at stream sidebar, tasks, and events

* fix folder from root

* cleanup folders and events, and condense changes

* cleanup and add toggle for sidebar

* cleanup

* fix nits
2026-06-12 12:26:49 -07:00

106 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
interface UseListKeyboardNavOptions {
items: Array<{ id: string }>;
enabled: boolean;
onOpen: (id: string) => void;
}
/**
* Shared keyboard grammar for browsable lists (network root, folders):
* ↑/↓ move selection, Enter opens, 19 jump, V toggles video/audio.
*/
export function useListKeyboardNav({
items,
enabled,
onOpen,
}: UseListKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(
items.length > 0 ? 0 : null,
);
const [prevItemCount, setPrevItemCount] = useState(items.length);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
// Select the first item once they load and clear when empty — but not on
// every Firestore update, which would scroll the list back to the top.
if (items.length !== prevItemCount) {
setPrevItemCount(items.length);
if (items.length === 0) {
setSelectedIndex(null);
} else if (selectedIndex === null) {
setSelectedIndex(0);
}
}
useEffect(() => {
if (!enabled || items.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 < items.length) {
e.preventDefault();
onOpen(items[index].id);
}
return;
}
// Enter: open selected
if (e.key === 'Enter') {
setSelectedIndex((idx) => {
if (idx !== null && idx < items.length) {
e.preventDefault();
onOpen(items[idx].id);
}
return idx;
});
return;
}
// V: toggle video / audio
if (e.key === 'v' || e.key === 'V') {
e.preventDefault();
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video');
return;
}
// Arrow keys: move selection
let delta: number | null = null;
if (e.key === 'ArrowDown') delta = 1;
else if (e.key === 'ArrowUp') 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, items.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, items, onOpen, recordingMode, setRecordingMode]);
return { selectedIndex };
}