import { useLayoutEffect, useState } from 'react'; import type { FocusEvent, KeyboardEvent, RefObject } from 'react'; import { arrowDirection, directionalFocusIndex } from './lib/remote.mjs'; const field = (element: HTMLElement) => element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement && !['checkbox', 'radio', 'button', 'submit'].includes(element.type); const focusArea = (element: HTMLElement) => element.closest('label') || element; function controls(root: HTMLElement) { return Array.from(root.querySelectorAll('button, input:not([type="hidden"]), select, textarea, a[href]')) .filter(element => !element.matches(':disabled') && element.getClientRects().length > 0); } function reveal(root: HTMLElement, element: HTMLElement) { root.querySelector('[data-tv-focus]')?.removeAttribute('data-tv-focus'); focusArea(element).setAttribute('data-tv-focus', ''); const bounds = root.getBoundingClientRect(), area = focusArea(element).getBoundingClientRect(); const hint = root.querySelector('.settings-navigation-hint')?.getBoundingClientRect().height || 0; const top = bounds.top + hint + 14, bottom = bounds.bottom - 14; if (area.top < top) root.scrollTop += area.top - top; else if (area.bottom > bottom) root.scrollTop += area.bottom - bottom; } export function useSettingsNavigation(dialog: RefObject, onClose: () => void) { const [remote, setRemote] = useState(false); const [editing, setEditing] = useState(null); useLayoutEffect(() => { if (dialog.current && document.activeElement instanceof HTMLElement && dialog.current.contains(document.activeElement)) reveal(dialog.current, document.activeElement); }, [remote, editing, dialog]); const focus = (element: HTMLElement) => { element.focus({ preventScroll: true }); if (dialog.current) reveal(dialog.current, element); }; function onKeyDownCapture(event: KeyboardEvent) { if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing) return; const root = event.currentTarget, active = document.activeElement; if (!(active instanceof HTMLElement)) return; const key = event.key; if (key === 'Tab') { setRemote(false); setEditing(null); return; } if (['Escape', 'BrowserBack', 'GoBack'].includes(key)) { event.preventDefault(); event.stopPropagation(); if (editing) setEditing(null); else onClose(); return; } const direction = arrowDirection(key, event.keyCode); if (direction) { if (editing === active) { const step = direction === 'up' || direction === 'left' ? -1 : 1; if (active instanceof HTMLSelectElement) { event.preventDefault(); event.stopPropagation(); const options = Array.from(active.options).filter(option => !option.disabled); const index = options.findIndex(option => option.index === active.selectedIndex); const next = options[Math.max(0, Math.min(options.length - 1, index + step))]; if (next && next.index !== active.selectedIndex) { active.selectedIndex = next.index; active.dispatchEvent(new Event('change', { bubbles: true })); } } else if (active instanceof HTMLInputElement && active.type === 'number') { event.preventDefault(); event.stopPropagation(); active.stepUp(direction === 'up' || direction === 'right' ? 1 : -1); active.dispatchEvent(new Event('input', { bubbles: true })); } return; } if (!remote && (field(active) || active instanceof HTMLInputElement)) return; event.preventDefault(); event.stopPropagation(); setRemote(true); const items = controls(root), index = items.indexOf(active); const next = directionalFocusIndex(items.map(item => focusArea(item).getBoundingClientRect()), index, direction); if (next >= 0) focus(items[next]); return; } if (['Home', 'End', 'PageDown', 'PageUp'].includes(key) && !editing && (remote || !field(active))) { event.preventDefault(); event.stopPropagation(); setRemote(true); const items = controls(root); if (key === 'Home') { if (items[0]) focus(items[0]); } else if (key === 'End') { if (items.length) focus(items[items.length - 1]); } else { const before = root.scrollTop; root.scrollTop += root.clientHeight * .75 * (key === 'PageDown' ? 1 : -1); if (root.scrollTop === before) return; const bounds = root.getBoundingClientRect(), targetY = bounds.top + root.clientHeight / 2; const visible = items.filter(item => { const area = focusArea(item).getBoundingClientRect(); return area.top >= bounds.top + 54 && area.bottom <= bounds.bottom - 14; }); const target = visible.reduce((best, item) => !best || Math.abs(item.getBoundingClientRect().top - targetY) < Math.abs(best.getBoundingClientRect().top - targetY) ? item : best, null); if (target) focus(target); } return; } if ((key === 'Enter' || key === 'Select' || event.keyCode === 13 || event.keyCode === 23) && (remote || key === 'Select' || event.keyCode === 23)) { event.preventDefault(); event.stopPropagation(); if (event.repeat) return; setRemote(true); if (editing) setEditing(null); else if (field(active)) setEditing(active); else active.click(); } } function onFocusCapture(event: FocusEvent) { if (event.target instanceof HTMLElement) { if (editing && event.target !== editing) setEditing(null); reveal(event.currentTarget, event.target); } } return { remote, editing: Boolean(editing), onKeyDownCapture, onFocusCapture, onPointerDownCapture: () => { setRemote(false); setEditing(null); } }; }