diff --git a/README.md b/README.md index 86d1203..efd683e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ npm start - Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят. - Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш. - Полный экран, пауза, ручная смена сцен. Клавиши: `S` — настройки; `F` — полный экран; `→` — следующий экран; `пробел` — пауза. В настройках `Esc` завершает изменение поля, затем закрывает меню без сохранения. -- Настройки доступны с пульта: стрелки переводят фокус между полями и автоматически прокручивают меню. `OK / Enter` активирует кнопку, переключатель или выбор цвета; для списка и числа включает изменение значения стрелками. Повторный `OK / Enter` возвращает к навигации. Отключённые поля пропускаются. Выбранный элемент подсвечен, подсказка остаётся сверху. `Home / End` переходят в начало/конец, `PageUp / PageDown` прокручивают страницу меню. `Tab` возвращает обычное клавиатурное управление; мышь и касание работают как прежде. +- Настройки разделены на четыре раздела: «Остановка», «Оформление», «Экраны», «Реклама». Разделы, закрытие и «Сохранить» всегда видны. Для браузеров с курсором от пульта (например, Яндекс Браузер на Mi Box) доступны постоянные кнопки «Выше / Ниже»: наведите курсор и нажмите `OK`. Они прокручивают содержимое выбранного раздела без колеса мыши и клавиатурных событий; у границы кнопка отключается. Черновик сохраняется при переходах между разделами. +- Если браузер передаёт клавиши страницы, стрелки переводят фокус между полями и прокручивают содержимое раздела. `OK / Enter` активирует кнопку, переключатель или выбор цвета; для списка и числа включает изменение значения стрелками. Повторный `OK / Enter` возвращает к навигации. Отключённые и скрытые поля пропускаются, выбранный элемент подсвечен. `Home / End` достигают крайних кнопок, `PageUp / PageDown` прокручивают раздел. `Tab` возвращает обычное клавиатурное управление; мышь и касание сохраняют стандартное поведение. - Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Высота панели сохраняется, а освободившуюся ширину занимает подпись «Обновлено» справа. - Изменившиеся цифры часов, времени отправления, ожидания и номеров маршрутов прокручиваются вертикально. Названия рейсов при смене страниц плавно сдвигаются тем же движением. Неизменившиеся позиции чисел остаются на месте; увеличение идёт вверх, уменьшение — вниз. При `prefers-reduced-motion` движение выключено. - Для чёрного фона доступны лайм, янтарь, голубой, мята, лаванда и белый; для белого — синий, зелёный, кармин, фиолетовый, бирюзовый и графит. Акцент и логотип меняются вместе. Контраст каждой палитры с её фоном не меньше 7:1. Тема и выбранные цвета сохраняются отдельно. diff --git a/artifacts/settings-cursor-scroll-1280x720.jpg b/artifacts/settings-cursor-scroll-1280x720.jpg new file mode 100644 index 0000000..dff5c06 Binary files /dev/null and b/artifacts/settings-cursor-scroll-1280x720.jpg differ diff --git a/artifacts/settings-cursor-scroll-879x593.jpg b/artifacts/settings-cursor-scroll-879x593.jpg new file mode 100644 index 0000000..34ad342 Binary files /dev/null and b/artifacts/settings-cursor-scroll-879x593.jpg differ diff --git a/artifacts/verification-settings-cursor.md b/artifacts/verification-settings-cursor.md new file mode 100644 index 0000000..be2daa5 --- /dev/null +++ b/artifacts/verification-settings-cursor.md @@ -0,0 +1,19 @@ +# Настройки для пульта с курсором — 9 октября 2026 + +Пользователь подтвердил, что на Xiaomi Mi Box 4 в Яндекс Браузере стрелки двигают курсор. Поэтому обработчик клавиатуры страницы не может быть единственным способом добраться до нижних настроек. Добавлены четыре постоянные кнопки разделов, отдельная прокручиваемая область и постоянные кнопки «Выше / Ниже». Закрытие, полный экран, сброс цикла и сохранение находятся вне прокручиваемого содержимого. + +Проверка выполнена в браузере на production-сборке, а не на физической Mi Box. Последняя проверенная сборка: `/assets/index-DESAuGTH.js`. Основной сценарий использует клики по кнопкам без стрелок и колеса мыши; ввод и раскрытие обычных полей проверены через интерфейс браузера. + +- `npm run build` проходит: TypeScript и Vite. `npm test`: 48/48. API и постоянный формат настроек не изменены. +- 1280×720: раздел «Оформление» прокручивается кнопкой «Ниже» с 0 до 42 px. Вкладки и нижние действия остаются на прежнем месте. «Выше» возвращает к началу. +- 879×593: высота области содержимого ≈242,5 px. Кнопка «Ниже» достигает конца палитры: scrollTop=141 px. «Сохранить» полностью видна: y=515,5–562 при границе диалога y=577. На нижней границе «Ниже» отключена, «Выше» доступна. +- При включении рекламы высота её содержимого увеличивается до 433 px; кнопки прокрутки обновляются автоматически. Два нажатия «Ниже» доходят до нижней границы (170, затем 190 px). Поле длительности полностью видно, сохранение остаётся на месте. +- 960×540: область содержимого высотой 219 px. В разделе «Экраны» «Ниже» достигает scrollTop≈137,5; последний переключатель полностью виден внутри области (y=307,5–349,5 при границе y=382,5). «Сохранить» видна на y=462,5–509. +- Изменения темы и интервала остаются в черновике после переходов между разделами. Закрытие без сохранения отменяет их: при повторном открытии подтверждены тёмная тема, stopId=1, mainSeconds=25 и отключённая реклама. +- Ошибка диапазона в скрытом разделе предотвращает сохранение и автоматически открывает раздел «Экраны», фокусирует поле и показывает «Укажите значение от 5 до 120». «Сбросить цикл» убирает ошибку и восстанавливает 25; сохранение кликом закрывает меню. +- Клавиатурная навигация сохранена: Down из закрытия проходит разделы и достигает списка остановки без изменения stopId. Enter включает редактирование; Down меняет stopId с 1 на 135, фильтр маршрутов отключается. После Enter следующая стрелка пропускает его; Escape отменяет черновик. Повторное открытие подтверждает stopId=1. +- Ошибок и предупреждений в консоли во время проверки нет. В конце меню закрыто, исходные параметры восстановлены, временное изменение viewport сброшено, табло работает в обычном цикле. + +Снимки: `settings-cursor-scroll-1280x720.jpg`, `settings-cursor-scroll-879x593.jpg`. + +Официальная справка Яндекс Браузера для ТВ описывает обновление страницы через «Назад → Обновить»: https://yandex.ru/support/browser-tv/ru/sites. Она не подтверждает доставку событий клавиатуры странице. Фактическую проверку курсорного пульта на Mi Box необходимо выполнить после развёртывания и перезапуска браузера с новой сборкой. diff --git a/src/Icons.tsx b/src/Icons.tsx index 7743928..9686f16 100644 --- a/src/Icons.tsx +++ b/src/Icons.tsx @@ -1,5 +1,5 @@ import { - Icon20ArrowDownOutline, Icon20ArrowRightOutline, Icon20ArrowUpRightOutline, + Icon20ArrowDownOutline, Icon20ArrowUpOutline, Icon20ArrowRightOutline, Icon20ArrowUpRightOutline, Icon20BusOutline, Icon20Check, Icon20ClockOutline, Icon20GlobeCrossOutline, Icon20RefreshOutline, Icon24Cancel, Icon28SettingsOutline, Icon20Pause, Icon20Play, Icon20FullscreenOutline, Icon20InfoCircleOutline, @@ -16,6 +16,7 @@ function adapt(Icon: ComponentType<{ width?: number; height?: number; className? }; } export const ArrowDown = adapt(Icon20ArrowDownOutline); +export const ArrowUp = adapt(Icon20ArrowUpOutline); export const ArrowRight = adapt(Icon20ArrowRightOutline); export const ArrowUpRight = adapt(Icon20ArrowUpRightOutline); export const BusFront = adapt(Icon20BusOutline); diff --git a/src/SettingsPanel.tsx b/src/SettingsPanel.tsx index 94f589d..719f6b0 100644 --- a/src/SettingsPanel.tsx +++ b/src/SettingsPanel.tsx @@ -1,20 +1,44 @@ -import { useEffect, useRef, useState } from 'react'; -import { ArrowUpRight, Check, Fullscreen, Reset, Close } from './Icons'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { ArrowDown, ArrowUp, Check, Fullscreen, Reset, Close } from './Icons'; import { schemesForTheme } from './lib/presentation.mjs'; import { useSettingsNavigation } from './useSettingsNavigation'; import type { BoardData, Config, Settings, Stop } from './types'; +const sections = ['Остановка', 'Оформление', 'Экраны', 'Реклама']; + export function SettingsPanel({ settings, config, data, onSave, onClose, onFullscreen }: { settings: Settings; config: Config; data: BoardData | null; onSave: (value: Settings) => void; onClose: () => void; onFullscreen: () => void; }) { const [draft, setDraft] = useState(settings); const [stops, setStops] = useState([]); const [stopsError, setStopsError] = useState(false); + const [page, setPage] = useState(0); + const [validation, setValidation] = useState(''); + const [scroll, setScroll] = useState({ up: false, down: false }); const dialog = useRef(null); + const body = useRef(null); const navigation = useSettingsNavigation(dialog, onClose); + const updateScroll = useCallback(() => { + if (!body.current) return; + const { scrollTop, scrollHeight, clientHeight } = body.current; + const up = scrollTop > 1, down = scrollTop + clientHeight < scrollHeight - 1; + setScroll(previous => previous.up === up && previous.down === down ? previous : { up, down }); + }, []); + useLayoutEffect(() => { + if (body.current) body.current.scrollTop = 0; + updateScroll(); + }, [page, updateScroll]); + useEffect(() => { + if (!body.current) return; + const observer = new ResizeObserver(updateScroll); + observer.observe(body.current); + body.current.querySelectorAll('section').forEach(section => observer.observe(section)); + updateScroll(); + return () => observer.disconnect(); + }, [updateScroll]); useEffect(() => { dialog.current?.showModal(); - dialog.current?.querySelector('.settings-heading button')?.focus({ preventScroll: true }); + dialog.current?.querySelector('.settings-heading-actions button:last-child')?.focus({ preventScroll: true }); }, []); useEffect(() => { const controller = new AbortController(); @@ -25,11 +49,23 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls const update = (key: K, value: Settings[K]) => setDraft(previous => ({ ...previous, [key]: value })); return -
{ event.preventDefault(); onSave(draft); }}> -
RIDEBUS BOARD

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

-

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

-
-

Остановка

+ { event.preventDefault(); onSave(draft); }} onChangeCapture={() => setValidation('')} onInvalidCapture={event => { + event.preventDefault(); + const invalid = event.currentTarget.querySelector(':invalid'); + const section = invalid?.closest('[data-settings-section]'); + if (invalid && section) { + setValidation(invalid instanceof HTMLInputElement && (invalid.validity.rangeUnderflow || invalid.validity.rangeOverflow) + ? `Укажите значение от ${invalid.min} до ${invalid.max}.` + : 'Заполните выделенное поле.'); + setPage(Number(section.dataset.settingsSection)); + requestAnimationFrame(() => invalid.focus({ preventScroll: true })); + } + }}> +
RIDEBUS BOARD

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

+ +
+ {validation &&

{validation}

} + -

Оформление

+ -

Экраны и переходы

+ -

Рекламный экран ПРОТОТИП

+