add verification-settings-cursor documentation for remote control handling on Xiaomi Mi Box 4
This commit is contained in:
@@ -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. Тема и выбранные цвета сохраняются отдельно.
|
||||
|
||||
BIN
artifacts/settings-cursor-scroll-1280x720.jpg
Normal file
BIN
artifacts/settings-cursor-scroll-1280x720.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
artifacts/settings-cursor-scroll-879x593.jpg
Normal file
BIN
artifacts/settings-cursor-scroll-879x593.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
19
artifacts/verification-settings-cursor.md
Normal file
19
artifacts/verification-settings-cursor.md
Normal file
@@ -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 необходимо выполнить после развёртывания и перезапуска браузера с новой сборкой.
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user