Выпадающие списки в стиле меню действий
Все выпадающие списки (фильтры «Устройства», «Бэкапы», «Журнал», поле «Группа» в окне устройства) оформлены как меню «⋯» (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:
1 parent
ba8ac7be71
commit
6b5c521461
6 files changed
+188
-8
No files matched your search
@@ -40,7 +40,7 @@ Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устр
|
||||
## Интерфейс
|
||||
|
||||
Экран строится сверху вниз: приложение (навигация) → страница (заголовок, счётчики, главное действие) → вкладки групп → полоса инструментов таблицы → данные; «Задачи» — отдельная карточка. Пока ничего не выбрано, полоса показывает фильтры; при выборе строк — действия над выбранными.
|
||||
Добавление и изменение устройств, создание и переименование групп — в окнах поверх страницы (запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в `app/ui/static/fonts` (лицензия OFL), внешние ресурсы не загружаются.
|
||||
Добавление и изменение устройств, создание и переименование групп — в окнах поверх страницы (запасные страницы `/devices/new`, `/devices/{id}/edit` работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Выпадающие списки (фильтры устройств/бэкапов/журнала, поле «Группа») оформлены как меню действий «⋯»: список строит JS поверх обычного `<select>`, который остаётся в разметке скрытым — без JavaScript работает стандартный выбор браузера. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в `app/ui/static/fonts` (лицензия OFL), внешние ресурсы не загружаются.
|
||||
|
||||
## Требования к устройствам
|
||||
|
||||
@@ -161,3 +161,4 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
|
||||
- `017-events-ui-rotation` — страница «Журнал» в UI, настройки ротации, очистка журнала с подтверждением паролем.
|
||||
- `018-correctness-consistency` — одиночное удаление бэкапа в UI через общий сервис `delete_many`, единая система миграций (колонки старой схемы — в `migrations.run` до миграции ID), групповая смена канала фоновыми задачами (`set_channel`).
|
||||
- `019-performance-scaling` — кэш списка бакета (`BACKUPS_CACHE_TTL`, «Обновить список»); обработчики без обращений к event loop — обычные функции (пул потоков FastAPI), запись статуса и тяжёлые операции с БД в фоне — через `asyncio.to_thread`; SQLite — WAL и `busy_timeout`; файловая блокировка БД — один процесс на БД.
|
||||
- `020-custom-select-menus` — выпадающие списки (фильтры, «Группа») в стиле меню действий «⋯»: прогрессивное улучшение в JS, нативный `select` остаётся в разметке и работает без JavaScript.
|
||||
+81
-4
@@ -19,19 +19,74 @@
|
||||
function syncFilters() {
|
||||
const form = $("#dev-filters"); if (!form) return;
|
||||
let active = 0;
|
||||
$$("select.field", form).forEach((s) => { const on = s.value !== ""; s.classList.toggle("on", on); active += on; });
|
||||
$$("select.field", form).forEach((s) => {
|
||||
const on = s.value !== ""; s.classList.toggle("on", on); active += on;
|
||||
syncSelectMenu(s); // подсветка включённого фильтра — на кнопку меню (если select улучшен)
|
||||
});
|
||||
if ($("input[name=f_q]", form)?.value.trim()) active += 1;
|
||||
const reset = $("#filters-reset", form);
|
||||
if (reset) { reset.hidden = active === 0; reset.textContent = `Сбросить (${active})`; }
|
||||
}
|
||||
|
||||
// --- выпадающие списки (select.field): прогрессивное улучшение до вида меню «⋯» ---
|
||||
// Нативный select остаётся в разметке (значение уходит с формой, работает без JS) — только скрывается;
|
||||
// рядом строится details.menu с тем же набором опций. Выбор пункта меняет select.value и шлёт "change" —
|
||||
// срабатывают существующие onchange/hx-trigger/data-newgroup, отдельного кода отправки формы не требуется.
|
||||
const optionLabel = (sel) => sel.options[sel.selectedIndex]?.textContent ?? "";
|
||||
// подпись кнопки: aria-label селекта или id подписи рядом (<span id> — см. "Группа" в форме устройства)
|
||||
const selectAria = (sel) => {
|
||||
const label = sel.getAttribute("aria-label");
|
||||
if (label) return { attr: "aria-label", value: label };
|
||||
const span = sel.closest(".lbl")?.querySelector("span[id]");
|
||||
return span ? { attr: "aria-labelledby", value: span.id } : null;
|
||||
};
|
||||
// обновить текст кнопки и отметки выбранного пункта по текущему значению select
|
||||
function syncSelectMenu(sel) {
|
||||
const menu = sel.nextElementSibling;
|
||||
if (!menu?.classList.contains("select-menu")) return;
|
||||
$(".select-btn-label", menu).textContent = optionLabel(sel);
|
||||
$(".select-btn", menu).classList.toggle("on", sel.value !== "");
|
||||
$$(".mi", menu).forEach((mi) => mi.classList.toggle("on", mi.dataset.value === sel.value));
|
||||
}
|
||||
function enhanceSelects(root = document) {
|
||||
$$("select.field:not([data-enhanced])", root).forEach((sel) => {
|
||||
const menu = document.createElement("details");
|
||||
menu.className = "menu select-menu";
|
||||
if (sel.style.width) menu.style.width = sel.style.width; // та же ширина, что была у select
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "field select-btn";
|
||||
summary.setAttribute("aria-haspopup", "listbox");
|
||||
const aria = selectAria(sel); if (aria) summary.setAttribute(aria.attr, aria.value);
|
||||
summary.innerHTML = '<span class="select-btn-label"></span>'
|
||||
+ '<svg class="ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "menu-panel";
|
||||
$$("option", sel).forEach((opt) => {
|
||||
const mi = document.createElement("button");
|
||||
mi.type = "button"; mi.className = "mi"; mi.dataset.value = opt.value; mi.textContent = opt.textContent;
|
||||
// выбор пункта — как в меню «⋯»: значение уходит в select и дальше по обычной цепочке onchange/hx-trigger
|
||||
mi.addEventListener("click", () => {
|
||||
sel.value = opt.value; syncSelectMenu(sel);
|
||||
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
panel.append(mi);
|
||||
});
|
||||
menu.append(summary, panel);
|
||||
sel.insertAdjacentElement("afterend", menu);
|
||||
sel.hidden = true; sel.tabIndex = -1; sel.dataset.enhanced = "1";
|
||||
syncSelectMenu(sel);
|
||||
});
|
||||
}
|
||||
|
||||
// Выпадающие списки позиционируются от кнопки в координатах окна: их не обрезает прокручиваемая
|
||||
// таблица, а внизу экрана список раскрывается вверх.
|
||||
function placeMenu(menu) {
|
||||
const panel = $(".menu-panel", menu), btn = $("summary", menu);
|
||||
if (!panel || !btn) return;
|
||||
panel.style.position = "fixed"; panel.style.top = "0"; panel.style.left = "0"; panel.style.right = "auto";
|
||||
const b = btn.getBoundingClientRect(), w = panel.offsetWidth, h = panel.offsetHeight, gap = 4, edge = 8;
|
||||
const b = btn.getBoundingClientRect();
|
||||
if (menu.classList.contains("select-menu")) panel.style.minWidth = `${b.width}px`; // список не уже кнопки
|
||||
const w = panel.offsetWidth, h = panel.offsetHeight, gap = 4, edge = 8;
|
||||
let left = menu.classList.contains("right") ? b.right - w : b.left;
|
||||
left = Math.max(edge, Math.min(left, window.innerWidth - w - edge));
|
||||
const below = b.bottom + gap, above = b.top - gap - h;
|
||||
@@ -75,10 +130,30 @@
|
||||
const modal = $("#modal");
|
||||
if (modal && (t.closest("[data-close]") || t === modal)) modal.close();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(null); });
|
||||
// Escape внутри открытого <dialog>: браузер и так закрывает диалог (событие cancel) — если в этот момент
|
||||
// открыто наше меню/список, Escape должен закрыть только его, а не окно с введёнными данными.
|
||||
let escClosesMenu = false;
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
const open = $("details.menu[open]");
|
||||
escClosesMenu = !!open;
|
||||
if (open) e.preventDefault(); // отменяет запрос на закрытие диалога (не везде подавляет — см. "cancel" ниже)
|
||||
closeMenus(null);
|
||||
if (open?.classList.contains("select-menu")) $("summary", open)?.focus(); // фокус — на кнопку списка
|
||||
return;
|
||||
}
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
const menu = e.target.closest(".select-menu[open]"); if (!menu) return; // ↑/↓ — только внутри открытого списка
|
||||
e.preventDefault();
|
||||
const items = $$(".mi", menu), i = items.indexOf(document.activeElement);
|
||||
const next = i === -1 ? items[e.key === "ArrowDown" ? 0 : items.length - 1]
|
||||
: items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length];
|
||||
next?.focus();
|
||||
});
|
||||
|
||||
// окно открывается, когда HTMX положил в него содержимое
|
||||
document.addEventListener("htmx:afterSwap", (e) => {
|
||||
enhanceSelects(e.detail.target); // вставленный HTMX фрагмент: свои select.field, без дублей (data-enhanced)
|
||||
const modal = $("#modal");
|
||||
if (e.detail.target.id === "modal-body") {
|
||||
if (modal && !modal.open) modal.showModal();
|
||||
@@ -87,6 +162,8 @@
|
||||
}
|
||||
syncSelection(); syncFilters(); syncEnables();
|
||||
});
|
||||
// запасной путь: preventDefault на keydown подавляет cancel не во всех браузерах (Chrome) — гасим явно
|
||||
$("#modal")?.addEventListener("cancel", (e) => { if (escClosesMenu) e.preventDefault(); });
|
||||
|
||||
// --- автообновление статусов: сервер опрашивает устройства сам, страница подтягивает результат ---
|
||||
const REFRESH_MS = 15000;
|
||||
@@ -122,6 +199,6 @@
|
||||
const syncEnables = () => $$("input[data-enables]").forEach((i) => { const t = $(i.dataset.enables); if (t) t.disabled = i.disabled || !i.value; });
|
||||
document.addEventListener("input", (e) => { if (e.target.matches("input[data-enables]")) syncEnables(); });
|
||||
|
||||
const init = () => { syncSelection(); syncFilters(); syncEnables(); };
|
||||
const init = () => { enhanceSelects(); syncSelection(); syncFilters(); syncEnables(); };
|
||||
document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", init) : init();
|
||||
})();
|
||||
@@ -176,6 +176,16 @@ table.grid.files { min-width: 1000px; }
|
||||
.mi.on::before { content: "✓"; }
|
||||
summary.mi { list-style: none; } summary.mi::-webkit-details-marker { display: none; }
|
||||
.menu-panel hr { height: 1px; margin: 6px 0; border: 0; background: var(--line); }
|
||||
|
||||
/* select.field улучшается в JS (enhanceSelects, app.js) до кнопки в стиле меню действий */
|
||||
.select-btn { display: inline-flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%;
|
||||
padding: 0 12px 0 14px; font-weight: 500; cursor: pointer; list-style: none; }
|
||||
.select-btn::-webkit-details-marker { display: none; }
|
||||
.select-btn-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.select-btn .ico { color: var(--muted); }
|
||||
.select-btn.on { background-color: var(--acc50); border-color: var(--acc); color: var(--accd); }
|
||||
.select-btn.on .ico { color: var(--accd); }
|
||||
.select-menu .menu-panel { min-width: 100%; max-height: 320px; overflow-y: auto; }
|
||||
details.sub .sub-list { padding-left: 10px; }
|
||||
|
||||
/* ---------- Окна ---------- */
|
||||
|
||||
@@ -23,13 +23,14 @@
|
||||
<label class="lbl"><span>Пароль {% if edit %}<small>пусто — не менять</small>{% endif %}</span>
|
||||
<input class="input" name="password" type="password" autocomplete="new-password" {{ "" if edit else "required" }}></label>
|
||||
</div>
|
||||
<label class="lbl">Группа
|
||||
<select class="field" name="group_id" data-newgroup style="width:100%">
|
||||
{# div, а не label: поле-меню строит JS рядом со скрытым select — клик по подписи не должен форвардиться на него #}
|
||||
<div class="lbl"><span id="group-lbl">Группа</span>
|
||||
<select class="field" name="group_id" data-newgroup aria-labelledby="group-lbl" style="width:100%">
|
||||
<option value="">— без группы —</option>
|
||||
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if v.group_id == g.id }}>{{ g.name }}</option>{% endfor %}
|
||||
<option value="__new__" {{ "selected" if v.group_id == "__new__" }}>+ Новая группа…</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="lbl" id="new-group-field" {{ "" if v.group_id == "__new__" else "hidden" }}>Название новой группы
|
||||
<input class="input" name="new_group" value="{{ v.new_group }}" maxlength="64"></label>
|
||||
<label class="check"><input name="use_tls" type="checkbox" {{ "checked" if v.use_tls }}>
|
||||
|
||||
@@ -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 в проекте нет; поведение в браузере (темы, клавиатура, окно устройства) проверяется вручную — на момент коммита пользователем не подтверждено.
|
||||
Reference in new issue
Block a user