Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user