Files
lidify/frontend/hooks/useTVNavigation.ts
2025-12-25 18:58:06 -06:00

288 lines
10 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useIsTV, DPAD_KEYS } from "@/lib/tv-utils";
interface UseTVNavigationOptions {
onBack?: () => void;
onSelect?: (element: HTMLElement) => void;
enabled?: boolean;
}
interface UseTVNavigationResult {
containerRef: React.RefObject<HTMLElement>;
focusedSectionIndex: number;
focusedCardIndex: number;
isContentFocused: boolean;
focusFirstCard: () => void;
handleKeyDown: (e: KeyboardEvent) => void;
}
export function useTVNavigation(options: UseTVNavigationOptions = {}): UseTVNavigationResult {
const { onBack, onSelect, enabled = true } = options;
const isTV = useIsTV();
const containerRef = useRef<HTMLElement>(null);
const [focusedSectionIndex, setFocusedSectionIndex] = useState(0);
const [focusedCardIndex, setFocusedCardIndex] = useState(0);
const [isContentFocused, setIsContentFocused] = useState(false);
// Focus memory: remember last focused card index per section
const focusMemory = useRef<Map<number, number>>(new Map());
// Get all sections with data-tv-section attribute
const getSections = useCallback(() => {
if (!containerRef.current) return [];
return Array.from(
containerRef.current.querySelectorAll<HTMLElement>('[data-tv-section]')
);
}, []);
// Get all cards in a section with data-tv-card attribute
const getCardsInSection = useCallback((section: HTMLElement) => {
return Array.from(
section.querySelectorAll<HTMLElement>('[data-tv-card]')
);
}, []);
// Scroll element into view smoothly
const scrollIntoView = useCallback((element: HTMLElement) => {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}, []);
// Focus a specific card
const focusCard = useCallback((card: HTMLElement | null) => {
if (card) {
card.focus();
scrollIntoView(card);
}
}, [scrollIntoView]);
// Focus first card in content area
const focusFirstCard = useCallback(() => {
const sections = getSections();
if (sections.length === 0) {
// Fallback: find any focusable element
const firstFocusable = containerRef.current?.querySelector<HTMLElement>(
'a[href], button, [tabindex="0"]'
);
if (firstFocusable) {
firstFocusable.focus();
setIsContentFocused(true);
}
return;
}
const firstSection = sections[0];
const cards = getCardsInSection(firstSection);
if (cards.length > 0) {
focusCard(cards[0]);
setFocusedSectionIndex(0);
setFocusedCardIndex(0);
setIsContentFocused(true);
}
}, [getSections, getCardsInSection, focusCard]);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!enabled || !isTV) return;
// If not content focused, ignore - let TVLayout handle it
if (!isContentFocused) return;
const sections = getSections();
if (sections.length === 0) {
// No sections found, try fallback navigation
const focusables = containerRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex="0"]'
);
if (!focusables || focusables.length === 0) return;
const currentIdx = Array.from(focusables).findIndex(el => el === document.activeElement);
if (currentIdx === -1) return;
if (e.key === DPAD_KEYS.RIGHT || e.key === 'ArrowRight') {
e.preventDefault();
const next = focusables[Math.min(currentIdx + 1, focusables.length - 1)];
next?.focus();
} else if (e.key === DPAD_KEYS.LEFT || e.key === 'ArrowLeft') {
e.preventDefault();
const prev = focusables[Math.max(currentIdx - 1, 0)];
prev?.focus();
} else if (e.key === DPAD_KEYS.UP || e.key === 'ArrowUp') {
e.preventDefault();
onBack?.();
setIsContentFocused(false);
}
return;
}
const currentSection = sections[focusedSectionIndex];
if (!currentSection) return;
const cards = getCardsInSection(currentSection);
switch (e.key) {
case DPAD_KEYS.RIGHT:
case 'ArrowRight': {
e.preventDefault();
const nextIndex = Math.min(focusedCardIndex + 1, cards.length - 1);
if (nextIndex !== focusedCardIndex) {
focusCard(cards[nextIndex]);
setFocusedCardIndex(nextIndex);
focusMemory.current.set(focusedSectionIndex, nextIndex);
}
break;
}
case DPAD_KEYS.LEFT:
case 'ArrowLeft': {
e.preventDefault();
const prevIndex = Math.max(focusedCardIndex - 1, 0);
if (prevIndex !== focusedCardIndex) {
focusCard(cards[prevIndex]);
setFocusedCardIndex(prevIndex);
focusMemory.current.set(focusedSectionIndex, prevIndex);
}
break;
}
case DPAD_KEYS.DOWN:
case 'ArrowDown': {
e.preventDefault();
if (focusedSectionIndex < sections.length - 1) {
// Save current position
focusMemory.current.set(focusedSectionIndex, focusedCardIndex);
const nextSectionIndex = focusedSectionIndex + 1;
const nextSection = sections[nextSectionIndex];
const nextCards = getCardsInSection(nextSection);
if (nextCards.length > 0) {
// Try to restore saved position, or use current column, or clamp
const savedIndex = focusMemory.current.get(nextSectionIndex);
const targetIndex = savedIndex !== undefined
? Math.min(savedIndex, nextCards.length - 1)
: Math.min(focusedCardIndex, nextCards.length - 1);
focusCard(nextCards[targetIndex]);
setFocusedSectionIndex(nextSectionIndex);
setFocusedCardIndex(targetIndex);
}
}
break;
}
case DPAD_KEYS.UP:
case 'ArrowUp': {
e.preventDefault();
if (focusedSectionIndex > 0) {
// Save current position
focusMemory.current.set(focusedSectionIndex, focusedCardIndex);
const prevSectionIndex = focusedSectionIndex - 1;
const prevSection = sections[prevSectionIndex];
const prevCards = getCardsInSection(prevSection);
if (prevCards.length > 0) {
// Try to restore saved position
const savedIndex = focusMemory.current.get(prevSectionIndex);
const targetIndex = savedIndex !== undefined
? Math.min(savedIndex, prevCards.length - 1)
: Math.min(focusedCardIndex, prevCards.length - 1);
focusCard(prevCards[targetIndex]);
setFocusedSectionIndex(prevSectionIndex);
setFocusedCardIndex(targetIndex);
}
} else {
// At top section, trigger onBack to return to nav
onBack?.();
setIsContentFocused(false);
}
break;
}
case DPAD_KEYS.CENTER:
case 'Enter': {
const focusedElement = document.activeElement as HTMLElement;
if (focusedElement) {
// If it's a link, let the default behavior happen
if (focusedElement.tagName === 'A') {
return; // Allow default navigation
}
// Otherwise trigger onSelect
onSelect?.(focusedElement);
}
break;
}
case DPAD_KEYS.BACK:
case 'Escape': {
e.preventDefault();
onBack?.();
setIsContentFocused(false);
break;
}
}
}, [
enabled, isTV, isContentFocused, focusedSectionIndex, focusedCardIndex,
getSections, getCardsInSection, focusCard, onBack, onSelect
]);
// Track when content receives focus from outside
useEffect(() => {
const container = containerRef.current;
if (!container || !isTV) return;
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (target.hasAttribute('data-tv-card')) {
setIsContentFocused(true);
// Find which section and card index
const sections = getSections();
for (let sIdx = 0; sIdx < sections.length; sIdx++) {
const cards = getCardsInSection(sections[sIdx]);
const cIdx = cards.indexOf(target);
if (cIdx !== -1) {
setFocusedSectionIndex(sIdx);
setFocusedCardIndex(cIdx);
break;
}
}
}
};
const handleFocusOut = (e: FocusEvent) => {
// Check if focus is leaving the container entirely
const relatedTarget = e.relatedTarget as HTMLElement;
if (!container.contains(relatedTarget)) {
setIsContentFocused(false);
}
};
container.addEventListener('focusin', handleFocusIn);
container.addEventListener('focusout', handleFocusOut);
return () => {
container.removeEventListener('focusin', handleFocusIn);
container.removeEventListener('focusout', handleFocusOut);
};
}, [isTV, getSections, getCardsInSection]);
return {
containerRef,
focusedSectionIndex,
focusedCardIndex,
isContentFocused,
focusFirstCard,
handleKeyDown
};
}