Files
ridebus-board/src/useSettingsNavigation.ts
Zakhar Timoshenko 5971da3fc9 feat: implement remote settings navigation and focus management
- Added `useSettingsNavigation.ts` for handling remote control navigation within settings.
- Introduced `arrowDirection` and `directionalFocusIndex` functions in `remote.mjs` to manage directional input and focus transitions.
- Created a new markdown file `verification-settings-remote.md` documenting the changes and testing outcomes.
- Developed comprehensive tests for arrow key functionality and focus behavior in `remote.test.mjs`.
- Ensured compatibility with standard keys and legacy key codes, maintaining expected navigation behavior across different screen resolutions.
2026-10-09 21:54:07 +03:00

104 lines
5.8 KiB
TypeScript

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<HTMLElement>('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<HTMLDialogElement | null>, onClose: () => void) {
const [remote, setRemote] = useState(false);
const [editing, setEditing] = useState<HTMLElement | null>(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<HTMLDialogElement>) {
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<HTMLElement | null>((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<HTMLDialogElement>) {
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); } };
}