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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowUpRight, Check, Fullscreen, Reset, Close } from './Icons';
|
||||
import { schemesForTheme } from './lib/presentation.mjs';
|
||||
import { useSettingsNavigation } from './useSettingsNavigation';
|
||||
import type { BoardData, Config, Settings, Stop } from './types';
|
||||
|
||||
export function SettingsPanel({ settings, config, data, onSave, onClose, onFullscreen }: {
|
||||
@@ -10,7 +11,11 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
||||
const [stops, setStops] = useState<Stop[]>([]);
|
||||
const [stopsError, setStopsError] = useState(false);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => { dialog.current?.showModal(); }, []);
|
||||
const navigation = useSettingsNavigation(dialog, onClose);
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
dialog.current?.querySelector<HTMLButtonElement>('.settings-heading button')?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch('/api/stops', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); })
|
||||
@@ -18,9 +23,11 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
const update = <K extends keyof Settings>(key: K, value: Settings[K]) => setDraft(previous => ({ ...previous, [key]: value }));
|
||||
return <dialog ref={dialog} className="settings-dialog" onCancel={onClose} aria-labelledby="settings-title">
|
||||
return <dialog ref={dialog} className="settings-dialog" onCancel={onClose} aria-labelledby="settings-title" data-remote-navigation={navigation.remote} data-remote-editing={navigation.editing}
|
||||
onKeyDownCapture={navigation.onKeyDownCapture} onFocusCapture={navigation.onFocusCapture} onPointerDownCapture={navigation.onPointerDownCapture}>
|
||||
<form onSubmit={event => { event.preventDefault(); onSave(draft); }}>
|
||||
<div className="settings-heading"><div><div className="eyebrow">RIDEBUS BOARD</div><h2 id="settings-title">Настройки табло</h2></div><button type="button" className="icon-button" onClick={onClose} aria-label="Закрыть настройки"><Close /></button></div>
|
||||
<p className="settings-navigation-hint" role="status">{navigation.editing ? 'Измените значение стрелками · OK / Enter — готово · Esc — назад' : 'Пульт: стрелки — переход · OK / Enter — выбрать · Esc — закрыть'}</p>
|
||||
<div className="settings-body">
|
||||
<section><h3>Остановка</h3>
|
||||
<div className="settings-note">{config.cityName} · данные RideBus API</div>
|
||||
|
||||
22
src/lib/remote.mjs
Normal file
22
src/lib/remote.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
export function arrowDirection(key, code) {
|
||||
const directions = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
|
||||
return directions[key] || directions[{ 37: 'ArrowLeft', 38: 'ArrowUp', 39: 'ArrowRight', 40: 'ArrowDown' }[code]] || null;
|
||||
}
|
||||
|
||||
// Rectangles are control labels, so a full-width switch and a two-column palette
|
||||
// behave like visible rows rather than tiny radio/checkbox input coordinates.
|
||||
export function directionalFocusIndex(rects, current, direction) {
|
||||
if (!rects.length) return -1;
|
||||
if (current < 0) return direction === 'up' ? rects.length - 1 : 0;
|
||||
const origin = rects[current], vertical = direction === 'up' || direction === 'down';
|
||||
const backwards = direction === 'up' || direction === 'left';
|
||||
const primary = rect => vertical ? (rect.top + rect.bottom) / 2 : (rect.left + rect.right) / 2;
|
||||
const cross = rect => vertical ? (rect.left + rect.right) / 2 : (rect.top + rect.bottom) / 2;
|
||||
const low = rect => vertical ? rect.left : rect.top, high = rect => vertical ? rect.right : rect.bottom;
|
||||
return rects.map((rect, index) => {
|
||||
const distance = (primary(rect) - primary(origin)) * (backwards ? -1 : 1);
|
||||
const overlap = Math.min(high(rect), high(origin)) - Math.max(low(rect), low(origin)) > 0;
|
||||
return { index, distance, overlap, score: distance + Math.abs(cross(rect) - cross(origin)) * .3 };
|
||||
}).filter(item => item.index !== current && item.distance > 1 && (vertical || item.overlap))
|
||||
.sort((a, b) => Number(b.overlap) - Number(a.overlap) || a.score - b.score || a.index - b.index)[0]?.index ?? -1;
|
||||
}
|
||||
@@ -294,6 +294,10 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
|
||||
.settings-dialog { width: min(600px, calc(100vw - 28px)); max-height: calc(100dvh - 32px); padding: 0; color: var(--foreground); background: var(--surface); border: 1px solid var(--outline); border-radius: 20px; overflow: auto; box-shadow: 0 25px 90px #0006; }
|
||||
.settings-dialog::backdrop { background: #0009; backdrop-filter: blur(8px); }
|
||||
.settings-dialog { scroll-padding: 56px 14px 14px; }
|
||||
.settings-navigation-hint { position: sticky; top: 0; z-index: 2; margin: 0; padding: 10px 28px; color: var(--secondary); font-size: 12px; line-height: 1.4; background: var(--surface); border-bottom: 1px solid var(--line); }
|
||||
.settings-dialog[data-remote-navigation='true'] [data-tv-focus] { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 8px; }
|
||||
.settings-dialog[data-remote-editing='true'] :focus { outline: 3px solid var(--accent); outline-offset: 3px; background: color-mix(in srgb, var(--accent) 12%, var(--input)); }
|
||||
.settings-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 25px 28px; border-bottom: 1px solid var(--line); }
|
||||
.settings-heading h2 { margin: 8px 0 0; font-size: 27px; font-weight: 600; letter-spacing: -.04em; }
|
||||
.settings-heading .eyebrow { font-size: 11px; }
|
||||
|
||||
103
src/useSettingsNavigation.ts
Normal file
103
src/useSettingsNavigation.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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); } };
|
||||
}
|
||||
Reference in New Issue
Block a user