Выпадающие списки в стиле меню действий

Все выпадающие списки (фильтры «Устройства», «Бэкапы», «Журнал», поле
«Группа» в окне устройства) оформлены как меню «⋯» (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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-28 12:18:32 +03:00
1 parent ba8ac7be71
commit 6b5c521461
6 files changed
+188 -8

No files matched your search

@@ -0,0 +1,68 @@
# План: 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.
@@ -0,0 +1,23 @@
# Итоги: 020 — выпадающие списки в стиле меню действий
## Сделано
- Все `select.field` (10 списков: фильтры «Устройства» ×3, «Бэкапы» ×3, «Журнал» ×3, поле «Группа» в окне устройства) выглядят как меню «⋯»:
кнопка с текстом выбранного пункта и шевроном, панель `.menu-panel` с пунктами `.mi`, выбранный — «✓», включённый фильтр подсвечен.
- `app/ui/static/app.js::enhanceSelects`: прогрессивное улучшение — нативный `select` остаётся скрытым в разметке (значение уходит с формой,
без JavaScript — стандартный список). Выбор пункта ставит `select.value` и отправляет `change` — существующие `onchange`, `hx-trigger`
и «+ Новая группа…» работают без изменений. Применяется при загрузке и к фрагментам HTMX (окно устройства), без дублей (`data-enhanced`).
- Позиционирование — существующий `placeMenu` (не обрезается таблицей/окном, вверх у нижнего края), панель не уже кнопки, длинные списки прокручиваются (320px).
- Клавиатура: Tab, Enter/Space, ↑/↓ по пунктам, Escape закрывает список и возвращает фокус на кнопку.
- `style.css`: `.select-btn`, `.select-menu` — только существующие токены; шеврон через `currentColor` (следует теме).
- `_device_form.html`: поле «Группа» в `div.lbl` с `aria-labelledby` вместо `label` — клик по подписи не уходит в скрытый `select`.
## Найдено на ревью и исправлено
- Escape при открытом списке внутри окна устройства закрывал и само окно (действие браузера по умолчанию для `<dialog>`) — введённые данные терялись.
Исправлено: `preventDefault` на keydown и на `cancel` окна, если в момент Escape было открыто меню.
## Проверено
- `pytest`: 24 из 24 (серверная часть не менялась); `node --check app.js` — без ошибок.
- Стенд (порт 8001) отдаёт новые `app.js` и `style.css`.
## Оговорки
- Автотестов JS в проекте нет; поведение в браузере (темы, клавиатура, окно устройства) проверяется вручную — на момент коммита пользователем не подтверждено.