* 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
106 lines
3.1 KiB
TypeScript
106 lines
3.1 KiB
TypeScript
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, 1–9 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 };
|
||
}
|