diff --git a/README.md b/README.md index 04872c8..86d1203 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ npm start - Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла. - Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят. - Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш. -- Полный экран, пауза, ручная смена сцен. Клавиши: `S` — настройки; `F` — полный экран; `→` — следующий экран; `пробел` — пауза. `Esc` закрывает настройки. +- Полный экран, пауза, ручная смена сцен. Клавиши: `S` — настройки; `F` — полный экран; `→` — следующий экран; `пробел` — пауза. В настройках `Esc` завершает изменение поля, затем закрывает меню без сохранения. +- Настройки доступны с пульта: стрелки переводят фокус между полями и автоматически прокручивают меню. `OK / Enter` активирует кнопку, переключатель или выбор цвета; для списка и числа включает изменение значения стрелками. Повторный `OK / Enter` возвращает к навигации. Отключённые поля пропускаются. Выбранный элемент подсвечен, подсказка остаётся сверху. `Home / End` переходят в начало/конец, `PageUp / PageDown` прокручивают страницу меню. `Tab` возвращает обычное клавиатурное управление; мышь и касание работают как прежде. - Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Высота панели сохраняется, а освободившуюся ширину занимает подпись «Обновлено» справа. - Изменившиеся цифры часов, времени отправления, ожидания и номеров маршрутов прокручиваются вертикально. Названия рейсов при смене страниц плавно сдвигаются тем же движением. Неизменившиеся позиции чисел остаются на месте; увеличение идёт вверх, уменьшение — вниз. При `prefers-reduced-motion` движение выключено. - Для чёрного фона доступны лайм, янтарь, голубой, мята, лаванда и белый; для белого — синий, зелёный, кармин, фиолетовый, бирюзовый и графит. Акцент и логотип меняются вместе. Контраст каждой палитры с её фоном не меньше 7:1. Тема и выбранные цвета сохраняются отдельно. diff --git a/artifacts/settings-remote-navigation-1280x720.jpg b/artifacts/settings-remote-navigation-1280x720.jpg new file mode 100644 index 0000000..e2de620 Binary files /dev/null and b/artifacts/settings-remote-navigation-1280x720.jpg differ diff --git a/artifacts/settings-remote-navigation-879x593.jpg b/artifacts/settings-remote-navigation-879x593.jpg new file mode 100644 index 0000000..0ded820 Binary files /dev/null and b/artifacts/settings-remote-navigation-879x593.jpg differ diff --git a/artifacts/verification-settings-remote.md b/artifacts/verification-settings-remote.md new file mode 100644 index 0000000..114e82a --- /dev/null +++ b/artifacts/verification-settings-remote.md @@ -0,0 +1,16 @@ +# Управление настройками с пульта — 9 октября 2026 + +Изменены `SettingsPanel.tsx` и стили; добавлены `useSettingsNavigation.ts`, геометрический выбор фокуса `lib/remote.mjs` и три теста. Проверка выполнена на production-сборке в браузере с клавишами направления и Enter. Физический телевизор и конкретная модель пульта не проверялись. + +- `npm run build`: TypeScript и Vite проходят. `npm test`: 48/48. Новые тесты проверяют обычные/старые коды стрелок, переходы по строкам и колонкам и границы без циклических скачков. +- 1280×720: настройки открываются с фокусом на закрытии. Стрелка вниз проходит остановку, фильтр маршрутов, переключатели, тему, палитру и параметры до нижних кнопок. Значения списков при навигации не меняются. +- При переходе к «Сохранить» прокрутка ≈901,5 px. Кнопка заканчивается на y≈683,2 при нижней границе диалога y=704; она полностью видима и имеет акцентную рамку фокуса. +- 879×593: тот же сценарий только стрелками достигает «Сохранить», прокрутка ≈1028,5 px. Нижняя граница кнопки y≈556,2, диалога y=577. +- `Enter` на остановке включает изменение. Следующая стрелка меняет выбранную остановку и обновляет React-состояние: зависимый фильтр маршрутов отключается. Повторный Enter завершает изменение; навигация пропускает отключённое поле. Escape закрывает меню без сохранения; исходная остановка сохраняется. +- Числовое поле: Enter, ArrowUp меняет 25 на 26; ArrowDown возвращает 25. Значение обновляется и в DOM, и в контролируемом React-поле. Enter завершает изменение; ArrowRight переводит фокус на соседнее поле интервала. +- Enter на рекламном переключателе включает его в черновике. Следующая стрелка достигает появившегося поля заголовка. Escape сначала завершает редактирование, второй Escape закрывает меню без сохранения. +- `End` достигает «Сохранить». `PageUp` прокручивает меню с ≈894,5 до 387 px, `PageDown` — до ≈901,5 px. Enter на сохранении закрывает диалог. Проверенные сохранённые значения остаются исходными: остановка 1, все маршруты, интервалы 25/8, 4 рейса, тёмная тема. +- Tab возвращает обычное управление: фокус на первом select, режим пульта выключен. Мышь/касание также выключают режим пульта, сохраняя стандартное поведение полей. +- После проверки тестовые изменения отменены, меню закрыто; обычный цикл табло продолжается. Ошибок браузера при проверке не обнаружено. + +Снимки: `settings-remote-navigation-1280x720.jpg`, `settings-remote-navigation-879x593.jpg`. diff --git a/src/SettingsPanel.tsx b/src/SettingsPanel.tsx index 96b1064..94f589d 100644 --- a/src/SettingsPanel.tsx +++ b/src/SettingsPanel.tsx @@ -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([]); const [stopsError, setStopsError] = useState(false); const dialog = useRef(null); - useEffect(() => { dialog.current?.showModal(); }, []); + const navigation = useSettingsNavigation(dialog, onClose); + useEffect(() => { + dialog.current?.showModal(); + dialog.current?.querySelector('.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 = (key: K, value: Settings[K]) => setDraft(previous => ({ ...previous, [key]: value })); - return + return
{ event.preventDefault(); onSave(draft); }}>
RIDEBUS BOARD

Настройки табло

+

{navigation.editing ? 'Измените значение стрелками · OK / Enter — готово · Esc — назад' : 'Пульт: стрелки — переход · OK / Enter — выбрать · Esc — закрыть'}

Остановка

{config.cityName} · данные RideBus API
diff --git a/src/lib/remote.mjs b/src/lib/remote.mjs new file mode 100644 index 0000000..a098a5c --- /dev/null +++ b/src/lib/remote.mjs @@ -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; +} diff --git a/src/styles.css b/src/styles.css index 877cec6..4cd1a0b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/useSettingsNavigation.ts b/src/useSettingsNavigation.ts new file mode 100644 index 0000000..de1d4b1 --- /dev/null +++ b/src/useSettingsNavigation.ts @@ -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('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); } }; +} diff --git a/test/remote.test.mjs b/test/remote.test.mjs new file mode 100644 index 0000000..81b79fb --- /dev/null +++ b/test/remote.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { arrowDirection, directionalFocusIndex } from '../src/lib/remote.mjs'; +const rect = (left, top, width = 100, height = 40) => ({ left, top, right: left + width, bottom: top + height }); + +test('remote arrows support standard keys and legacy key codes', () => { + assert.equal(arrowDirection('ArrowDown'), 'down'); + assert.equal(arrowDirection('Unidentified', 38), 'up'); + assert.equal(arrowDirection('Enter', 13), null); +}); +test('vertical focus follows rows and keeps the palette column', () => { + const boxes = [rect(0, 0, 240), rect(0, 70), rect(140, 70), rect(0, 130), rect(140, 130), rect(0, 200, 240)]; + assert.equal(directionalFocusIndex(boxes, 0, 'down'), 1); + assert.equal(directionalFocusIndex(boxes, 1, 'down'), 3); + assert.equal(directionalFocusIndex(boxes, 2, 'down'), 4); + assert.equal(directionalFocusIndex(boxes, 4, 'down'), 5); + assert.equal(directionalFocusIndex(boxes, 4, 'up'), 2); +}); +test('horizontal arrows stay in the same row and boundaries do not wrap', () => { + const boxes = [rect(0, 0), rect(140, 0), rect(0, 90), rect(140, 90)]; + assert.equal(directionalFocusIndex(boxes, 0, 'right'), 1); + assert.equal(directionalFocusIndex(boxes, 1, 'right'), -1); + assert.equal(directionalFocusIndex(boxes, 0, 'left'), -1); + assert.equal(directionalFocusIndex(boxes, 0, 'up'), -1); + assert.equal(directionalFocusIndex(boxes, 3, 'down'), -1); + assert.equal(directionalFocusIndex(boxes, -1, 'down'), 0); + assert.equal(directionalFocusIndex([], -1, 'down'), -1); +});