Files
ros_control/docs/changes/020-custom-select-menus/plan.md
T
ayurishchevandClaude Opus 5.5 6b5c521461 Выпадающие списки в стиле меню действий
Все выпадающие списки (фильтры «Устройства», «Бэкапы», «Журнал», поле
«Группа» в окне устройства) оформлены как меню «⋯» (docs/changes/020):
- app.js строит меню поверх скрытого select: значение уходит с формой,
  без JavaScript работает стандартный список; выбор отправляет change —
  существующие onchange/hx-trigger не менялись;
- позиционирование — существующий placeMenu, выбранный пункт отмечен «✓»,
  клавиатура: Tab, Enter/Space, ↑/↓, Escape;
- Escape при открытом списке в окне закрывает только список.

Тесты: 24 из 24. Автотестов JS нет; ручная проверка UI пользователем на
момент коммита не подтверждена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:18:32 +03:00

7.8 KiB
Raw Blame History

План: 020 — выпадающие списки в стиле меню действий

Context

Фильтры и поле «Группа» используют стандартный <select>: раскрытый список рисует браузер/ОС, он не совпадает с дизайном приложения (шрифт, скругления, тени, тёмная тема). Пользователь просит заменить все выпадающие списки на оформление меню действий строки устройства («⋯»: details.menu + .menu-panel + пункты .mi, выбранный — .mi.on с «✓»).

Где сейчас <select class="field">:

  • dashboard.html — фильтры «Статус», «Обновления», «Канал» (форма #dev-filters, hx-trigger="input delay:300ms, change");
  • backups.html — «Группа», «Устройство», «Тип файла» (onchange="this.form.submit()");
  • events.html — «Тип», «Актор», «Устройство» (onchange="this.form.submit()");
  • _device_form.html — «Группа» в окне/странице устройства (data-newgroup, пункт «+ Новая группа…» показывает поле названия; окно приходит через HTMX).

Решение (технический выбор оркестратора, UX — по запросу пользователя): прогрессивное улучшение в app.js. Нативный <select> остаётся в разметке (значение уходит с формой, работает без JavaScript, текущая логика syncFilters, data-newgroup, onchange/hx-trigger не меняется), скрывается, а рядом строится меню в стиле «⋯».

Изменения

app/ui/static/app.js

  • enhanceSelects(root = document): для каждого select.field без отметки data-enhanced строит <details class="menu select-menu"> + <summary class="field select-btn"> (текст выбранного <option> + шеврон, как у кнопок меню)
    • <div class="menu-panel"> с <button type="button" class="mi" data-value="…"> на каждый <option>; выбранный — .mi.on («✓» уже есть в CSS). Ширина берётся из style.width селекта (у фильтров заданы 150–200px, в форме — 100%); панель не уже кнопки. aria-label селекта (или текст связанной подписи) переносится на summary; aria-haspopup="listbox". Селект получает hidden и data-enhanced, его tabindex = -1.
  • Выбор пункта: select.value = …, select.dispatchEvent(new Event("change", { bubbles: true })) — срабатывают inline onchange, hx-trigger="change" формы #dev-filters и обработчик data-newgroup; обновить текст кнопки и .on; закрыть меню.
  • Синхронизация: класс .on (включённый фильтр) — зеркалить с селекта на summary в syncFilters и при выборе (сейчас .on ставится на select.field — перенести подсветку на кнопку).
  • Вызов: при init и в htmx:afterSwap для вставленного фрагмента (окно устройства, таблица с OOB-фильтрами).
  • Клавиатура: summary фокусируется Tab; Enter/Space открывают; в открытом меню ↑/↓ — между пунктами, Enter — выбрать, Escape — закрыть (существующий обработчик) и вернуть фокус на кнопку.
  • Переиспользовать существующее: placeMenu (позиционирование fixed, раскрытие вверх, не обрезается таблицей/окном), closeMenus, закрытие по клику вне и после выбора пункта, reflowMenu. Новый код позиционирования не писать.
  • Автообновление таблицы (autoRefresh) уже не срабатывает при открытом details — поведение сохраняется.

app/ui/static/style.css

  • .select-btn: вид как у select.field (высота 36, padding: 0 36px 0 14px, font-weight: 500, шеврон справа, cursor: pointer, обрезка длинного текста text-overflow: ellipsis), list-style: none и скрытие маркера details; .select-btn.on — как select.field.on.
  • .select-menu .menu-panel: max-height с прокруткой (длинные списки устройств/типов событий), min-width = ширина кнопки.
  • Только существующие токены (--surf, --line, --acc50, --accd, --shadow-menu …); литеральных цветов нет, тёмная тема — через токены.

Шаблоны

  • По возможности без изменений. Исключение — _device_form.html: <select> внутри <label>; убедиться, что клик по кнопке списка внутри <label> не вызывает побочного действия подписи (двойное переключение details). Если вызывает — заменить <label class="lbl"> у этого поля на <div class="lbl"> с подписью <span id> и aria-labelledby у кнопки.

Тесты (минимально)

Логика на клиенте, автотестов JS в проекте нет. Серверные тесты не меняются; проверка — вручную (раздел «Проверка»).

Документация

README, раздел «Интерфейс»: выпадающие списки в стиле меню действий (без JavaScript — стандартные). Строка 020 в «История изменений». summary.md — оркестратор.

Исполнение

Исполнитель (Sonnet): app.js, style.css, при необходимости _device_form.html, README; пересборка стенда. Тесты не запускает, не коммитит. Изменение 019 в рабочем дереве не закоммичено — файлы 019 не трогать (кроме README: строка 020 добавляется после строки 019).

Проверка

  • Стенд (override 8001) отдаёт новый app.js (curl -s localhost:8001/static/app.js | grep -c enhanceSelects ≥ 1).
  • pytest — без регрессий.
  • Ручная проверка UI — пользователь (светлая и тёмная тема, Ctrl+F5):
    • «Устройства»: фильтры Статус/Обновления/Канал — выбор применяет фильтр, «Сбросить (N)» считает, подсветка включённого фильтра;
    • «Бэкапы», «Журнал»: выбор перезагружает страницу с фильтром, выбранный пункт отмечен «✓»;
    • окно устройства: «Группа» → «+ Новая группа…» показывает поле названия; сохранение привязывает группу;
    • список открывается над таблицей/окном без обрезки, вверх у нижнего края; клавиатура: Tab, Enter, ↑/↓, Escape.