add verification-settings-cursor documentation for remote control handling on Xiaomi Mi Box 4

This commit is contained in:
2026-10-09 22:09:26 +03:00
parent 5971da3fc9
commit bddd125306
8 changed files with 111 additions and 33 deletions

View File

@@ -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);

View File

@@ -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<Stop[]>([]);
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<HTMLDialogElement>(null);
const body = useRef<HTMLDivElement>(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<HTMLButtonElement>('.settings-heading button')?.focus({ preventScroll: true });
dialog.current?.querySelector<HTMLButtonElement>('.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 = <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" 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>
<form onSubmit={event => { event.preventDefault(); onSave(draft); }} onChangeCapture={() => setValidation('')} onInvalidCapture={event => {
event.preventDefault();
const invalid = event.currentTarget.querySelector<HTMLElement>(':invalid');
const section = invalid?.closest<HTMLElement>('[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 }));
}
}}>
<div className="settings-heading"><div><div className="eyebrow">RIDEBUS BOARD</div><h2 id="settings-title">Настройки табло</h2></div><div className="settings-heading-actions"><button type="button" className="icon-button" onClick={onFullscreen} aria-label="Полноэкранный режим"><Fullscreen /></button><button type="button" className="icon-button" onClick={onClose} aria-label="Закрыть настройки"><Close /></button></div></div>
<nav className="settings-sections" aria-label="Разделы настроек">{sections.map((name, index) => <button key={name} type="button" aria-current={page === index ? 'page' : undefined} aria-controls={`settings-section-${index}`} onClick={() => setPage(index)}>{name}</button>)}</nav>
<div ref={body} className="settings-body" data-settings-scroll onScroll={updateScroll}>
{validation && <p className="form-hint settings-validation" role="alert">{validation}</p>}
<section hidden={page !== 0} data-settings-section="0" id="settings-section-0"><h3>Остановка</h3>
<div className="settings-note">{config.cityName} · данные RideBus API</div>
<label>Остановка и площадка<select value={draft.stopId} onChange={event => { update('stopId', Number(event.target.value)); update('routeFilter', ''); }}>
{!stops.length && <option value={draft.stopId}>{data?.stop.name || config.stopName}</option>}
@@ -42,28 +78,27 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
</select></label>
<label className="switch-row"><span>Демонстрационные данные<small>Для проверки оформления без API</small></span><input type="checkbox" checked={draft.demo} onChange={event => update('demo', event.target.checked)} /></label>
</section>
<section><h3>Оформление</h3>
<section hidden={page !== 1} data-settings-section="1" id="settings-section-1"><h3>Оформление</h3>
<fieldset className="theme-picker appearance-picker"><legend className="sr-only">Тема табло</legend>{([{ id: 'dark', name: 'Тёмная' }, { id: 'light', name: 'Светлая' }] as const).map(theme => <label className="theme-option" key={theme.id}><input type="radio" name="appearance" checked={draft.theme === theme.id} onChange={() => update('theme', theme.id)} /><span>{theme.name}</span></label>)}</fieldset>
<h4>Цветовая схема</h4><fieldset className="theme-picker"><legend className="sr-only">Акцент для {draft.theme === 'light' ? 'белого' : 'чёрного'} фона</legend>
{schemesForTheme(draft.theme).map(scheme => <label className="theme-option" key={scheme.id}><input type="radio" name="color-scheme" value={scheme.id} checked={(draft.theme === 'light' ? draft.lightColorScheme : draft.colorScheme) === scheme.id} onChange={() => update(draft.theme === 'light' ? 'lightColorScheme' : 'colorScheme', scheme.id)} /><span className="theme-swatch" style={{ background: scheme.color }} /><span>{scheme.name}</span></label>)}
</fieldset><p className="form-hint">У каждой темы своя контрастная палитра. Выбранные цвета сохраняются отдельно.</p>
</section>
<section><h3>Экраны и переходы</h3>
<section hidden={page !== 2} data-settings-section="2" id="settings-section-2"><h3>Экраны и переходы</h3>
<div className="form-grid"><label>Главный экран, сек<input type="number" min="5" max="120" required value={draft.mainSeconds} onChange={event => update('mainSeconds', Number(event.target.value))} /></label>
<label>Информация, сек<input type="number" min="5" max="60" required value={draft.infoSeconds} onChange={event => update('infoSeconds', Number(event.target.value))} /></label></div>
<label>Следующие рейсы<select value={draft.rowCount} onChange={event => update('rowCount', Number(event.target.value))}>{[2,3,4,5,6].map(number => <option key={number} value={number}>{number}</option>)}</select></label>
<label className="switch-row"><span>Удерживать главный экран<small>Когда до рейса осталось не больше 2 минут</small></span><input type="checkbox" checked={draft.holdNear} onChange={event => update('holdNear', event.target.checked)} /></label>
</section>
<section><h3>Рекламный экран <span className="tiny-tag">ПРОТОТИП</span></h3>
<section hidden={page !== 3} data-settings-section="3" id="settings-section-3"><h3>Рекламный экран <span className="tiny-tag">ПРОТОТИП</span></h3>
<label className="switch-row"><span>Добавить в цикл<small>По умолчанию отключён</small></span><input type="checkbox" checked={draft.adsEnabled} onChange={event => update('adsEnabled', event.target.checked)} /></label>
{draft.adsEnabled && <><label>Заголовок<input maxLength={80} required value={draft.adTitle} onChange={event => update('adTitle', event.target.value)} /></label>
<label>Текст<textarea maxLength={200} required value={draft.adText} onChange={event => update('adText', event.target.value)} /></label>
<label>Длительность, сек<input type="number" min="5" max="30" required value={draft.adSeconds} onChange={event => update('adSeconds', Number(event.target.value))} /></label></>}
</section>
<button type="button" className="text-button" onClick={onFullscreen}><Fullscreen size={18} /> Полноэкранный режим <ArrowUpRight size={16} /></button>
<p className="form-hint">S — настройки · F — полный экран · → — следующий экран · пробел — пауза</p>
</div>
<div className="settings-actions"><button type="button" className="text-button" onClick={() => setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, adsEnabled: false })}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
<div className="settings-scroll-controls" aria-label="Прокрутка настроек"><p className="settings-navigation-hint" role="status">{navigation.editing ? 'Стрелки — изменить · OK — готово' : navigation.remote ? 'Стрелки — переход · OK — выбрать' : 'Наведите курсор и нажмите OK'}</p><button type="button" className="settings-scroll-button" disabled={!scroll.up} onClick={() => { if (body.current) body.current.scrollTop -= body.current.clientHeight * .7; }}><ArrowUp size={20} /> Выше</button><button type="button" className="settings-scroll-button" disabled={!scroll.down} onClick={() => { if (body.current) body.current.scrollTop += body.current.clientHeight * .7; }}><ArrowDown size={20} /> Ниже</button></div>
<div className="settings-actions"><button type="button" className="text-button" onClick={() => { setValidation(''); setDraft({ ...draft, mainSeconds: 25, infoSeconds: 8, rowCount: 4, holdNear: true, adsEnabled: false }); }}><Reset size={17} /> Сбросить цикл</button><button type="submit" className="primary-button"><Check size={18} /> Сохранить</button></div>
</form>
</dialog>;
}

View File

@@ -292,17 +292,23 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
@keyframes destination-up-in { from { transform: translateY(105%); } to { transform: translateY(0); } }
@keyframes destination-up-out { from { transform: translateY(0); } to { transform: translateY(-105%); } }
.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 { width: min(640px, calc(100vw - 28px)); height: min(660px, calc(100vh - 32px)); height: min(660px, calc(100dvh - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 0; color: var(--foreground); background: var(--surface); border: 1px solid var(--outline); border-radius: 20px; overflow: hidden; 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 form { height: 100%; min-height: 0; display: flex; flex-direction: column; }
.settings-navigation-hint { flex: 1; margin: 0; color: var(--secondary); font-size: 12px; line-height: 1.4; }
.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 { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 20px 28px; border-bottom: 1px solid var(--line); }
.settings-heading-actions { display: flex; gap: 8px; }
.settings-heading-actions .icon-button { width: 44px; height: 44px; }
.settings-heading h2 { margin: 8px 0 0; font-size: 27px; font-weight: 600; letter-spacing: -.04em; }
.settings-heading .eyebrow { font-size: 11px; }
.settings-body { padding: 0 28px 20px; }
.settings-body section { border-bottom: 1px solid var(--line); padding: 21px 0 14px; }
.settings-sections { flex-shrink: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; padding: 14px 28px; border-bottom: 1px solid var(--line); }
.settings-sections button { min-height: 46px; padding: 8px 4px; border: 1px solid var(--outline); border-radius: 10px; background: var(--input); color: var(--secondary); font-size: 14px; }
.settings-sections button[aria-current='page'] { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.settings-body { flex: 1 1 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scroll-padding: 14px; padding: 0 28px 16px; scrollbar-width: thin; scrollbar-color: var(--outline) var(--surface); }
.settings-body section { padding: 21px 0 0; }
.settings-body .settings-validation { color: var(--warning); margin: 16px 0 0; }
.settings-body h3 { margin: 0 0 17px; font-size: 18px; font-weight: 650; }
.settings-body h4 { margin: 20px 0 14px; font-size: 14px; font-weight: 600; }
.settings-note { color: var(--secondary); font-size: 13px; margin-bottom: 18px; }
@@ -324,8 +330,21 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.text-button { display: inline-flex; align-items: center; gap: 9px; border: 0; background: none; color: var(--secondary); font-size: 14px; padding: 10px 0; }
.settings-body > .text-button { margin-top: 15px; }
.form-hint { font-size: 12px; color: var(--muted); line-height: 1.6; }
.settings-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 20px 28px; border-top: 1px solid var(--line); }
.settings-scroll-controls { flex-shrink: 0; display: flex; align-items: center; gap: 8px; padding: 10px 28px; border-top: 1px solid var(--line); }
.settings-scroll-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-width: 92px; min-height: 44px; padding: 10px 12px; border: 1px solid var(--outline); border-radius: 10px; background: var(--input); color: var(--foreground); font-size: 14px; }
.settings-scroll-button:disabled { opacity: .35; cursor: default; }
.settings-actions { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 28px; border-top: 1px solid var(--line); }
.primary-button { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 9px; padding: 13px 22px; font-size: 15px; font-weight: 650; }
@media (max-width: 480px), (max-height: 540px) {
.settings-heading { padding: 12px 18px; }
.settings-heading h2 { font-size: 23px; }
.settings-sections { padding: 10px 18px; gap: 6px; }
.settings-sections button { font-size: 12px; }
.settings-body { padding-inline: 18px; }
.settings-scroll-controls, .settings-actions { padding-inline: 18px; }
.settings-navigation-hint { font-size: 11px; }
.settings-scroll-button { min-width: 78px; padding-inline: 8px; }
}
@container (max-height: 380px) {
.weather-current strong { font-size: 56px; }

View File

@@ -12,11 +12,12 @@ function controls(root: HTMLElement) {
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;
const scroller = root.querySelector<HTMLElement>('[data-settings-scroll]');
if (!scroller?.contains(element)) return;
const bounds = scroller.getBoundingClientRect(), area = focusArea(element).getBoundingClientRect();
const top = bounds.top + 14, bottom = bounds.bottom - 14;
if (area.top < top) scroller.scrollTop += area.top - top;
else if (area.bottom > bottom) scroller.scrollTop += area.bottom - bottom;
}
export function useSettingsNavigation(dialog: RefObject<HTMLDialogElement | null>, onClose: () => void) {
@@ -73,11 +74,13 @@ export function useSettingsNavigation(dialog: RefObject<HTMLDialogElement | null
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 scroller = root.querySelector<HTMLElement>('[data-settings-scroll]');
if (!scroller) return;
const before = scroller.scrollTop;
scroller.scrollTop += scroller.clientHeight * .75 * (key === 'PageDown' ? 1 : -1);
if (scroller.scrollTop === before) return;
const bounds = scroller.getBoundingClientRect(), targetY = bounds.top + scroller.clientHeight / 2;
const visible = items.filter(item => { const area = focusArea(item).getBoundingClientRect(); return scroller.contains(item) && area.top >= bounds.top + 14 && 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);
}