Files
KVideo/lib/hooks/useSpatialNavigation.ts
T

152 lines
4.1 KiB
TypeScript

/**
* useSpatialNavigation
* Provides D-pad/arrow key based 2D spatial navigation for TV mode.
* Finds all [data-focusable] elements and navigates between them
* based on directional arrow key presses.
*/
import { useEffect, useCallback } from 'react';
function getRect(el: Element): DOMRect {
return el.getBoundingClientRect();
}
function getCenter(rect: DOMRect): { x: number; y: number } {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
type Direction = 'up' | 'down' | 'left' | 'right';
function findBestCandidate(
current: Element,
candidates: Element[],
direction: Direction
): Element | null {
const currentRect = getRect(current);
const currentCenter = getCenter(currentRect);
let bestElement: Element | null = null;
let bestScore = Infinity;
for (const candidate of candidates) {
if (candidate === current) continue;
const candidateRect = getRect(candidate);
const candidateCenter = getCenter(candidateRect);
const dx = candidateCenter.x - currentCenter.x;
const dy = candidateCenter.y - currentCenter.y;
// Filter by direction
let isInDirection = false;
switch (direction) {
case 'up':
isInDirection = dy < -10;
break;
case 'down':
isInDirection = dy > 10;
break;
case 'left':
isInDirection = dx < -10;
break;
case 'right':
isInDirection = dx > 10;
break;
}
if (!isInDirection) continue;
// Weighted distance: favor elements along the primary axis
let score: number;
if (direction === 'up' || direction === 'down') {
score = Math.abs(dy) + Math.abs(dx) * 3;
} else {
score = Math.abs(dx) + Math.abs(dy) * 3;
}
if (score < bestScore) {
bestScore = score;
bestElement = candidate;
}
}
return bestElement;
}
export function useSpatialNavigation(enabled: boolean) {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!enabled) return;
// Skip if target is input/textarea
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
const directionMap: Record<string, Direction> = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
};
const direction = directionMap[e.key];
if (direction) {
// Check if the focused element is inside a [data-no-spatial] container
const focused = document.activeElement as HTMLElement | null;
if (focused?.closest('[data-no-spatial]')) return;
const focusableElements = Array.from(
document.querySelectorAll('[data-focusable]:not([disabled]):not([aria-hidden="true"])')
).filter(el => {
// Filter out elements inside [data-no-spatial]
if (el.closest('[data-no-spatial]')) return false;
// Filter out hidden elements
const rect = getRect(el);
return rect.width > 0 && rect.height > 0;
});
if (focusableElements.length === 0) return;
const currentFocused = document.activeElement;
const isAlreadyFocused = currentFocused && focusableElements.includes(currentFocused);
if (!isAlreadyFocused) {
// Focus the first element
(focusableElements[0] as HTMLElement).focus();
e.preventDefault();
return;
}
const best = findBestCandidate(currentFocused!, focusableElements, direction);
if (best) {
(best as HTMLElement).focus();
best.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
e.preventDefault();
}
} else if (e.key === 'Enter') {
// Trigger click on focused element
const focused = document.activeElement as HTMLElement;
if (focused && focused.hasAttribute('data-focusable')) {
focused.click();
e.preventDefault();
}
}
}, [enabled]);
useEffect(() => {
if (!enabled) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [enabled, handleKeyDown]);
}