diff --git a/app/static/app.js b/app/static/app.js index f616700..3e1f950 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -336,6 +336,7 @@ function resetPrivateState() { state.catalog = { instances: [], groups: [], instancesLoaded: false, groupsLoaded: false }; state.selected = { instanceId: null, groupId: null, runId: null }; state.groupMemberDraft = new Set(); + resetCustomSelectValues(); state.pendingAction = null; state.pendingDelete = null; state.pending.clear(); @@ -640,102 +641,218 @@ async function fetchGroupCatalog(force = false) { async function ensureCatalogs(force = false) { await Promise.all([fetchInstanceCatalog(force), fetchGroupCatalog(force)]); - populateResourceSelects(); + return populateResourceSelects(); } -function populateSelect(select, items, firstLabel, selectedValue = null) { - if (!select) return; - const previous = selectedValue === null ? select.value : String(selectedValue || ""); - select.innerHTML = `${items.map((item) => ``).join("")}`; - if ($(`option[value="${CSS.escape(previous)}"]`, select)) select.value = previous; -} +const INSTANCE_STATUS_OPTIONS = [ + { value: "enabled", label: "已启用", icon: "check-circle-2", tone: "success" }, + { value: "disabled", label: "已停用", icon: "server-off", tone: "muted" }, +]; -function instanceGroupSelectElements() { - const root = $("#instance-group-select"); +const GROUP_STATUS_OPTIONS = [ + { value: "enabled", label: "计划已启用", icon: "timer-reset", tone: "success" }, + { value: "disabled", label: "计划已暂停", icon: "hourglass", tone: "muted" }, +]; + +const HISTORY_STATUS_OPTIONS = [ + { value: "queued", label: "排队中", icon: "hourglass", tone: "muted" }, + { value: "running", label: "进行中", icon: "loader-circle", tone: "info" }, + { value: "needs_attention", label: "待处理", icon: "triangle-alert", tone: "warning" }, + { value: "cleanup_pending", label: "待清理", icon: "trash-2", tone: "warning" }, + { value: "succeeded", label: "已成功", icon: "check-circle-2", tone: "success" }, + { value: "failed", label: "已失败", icon: "octagon-x", tone: "danger" }, + { value: "cancelled", label: "已放弃", icon: "x", tone: "muted" }, +]; + +function customSelectElements(target) { + const root = target?.matches?.("[data-custom-select]") + ? target + : target?.closest?.("[data-custom-select]"); if (!root) return null; return { root, - input: $('input[name="group_id"]', root), + input: $('input[type="hidden"]', root), trigger: $(".custom-select-trigger", root), - label: $("#instance-group-select-value", root), - menu: $(".custom-select-menu", root), + label: $("[data-custom-select-label]", root), + menu: $("[data-custom-select-menu]", root), }; } -function instanceGroupOptionMarkup(group, selectedValue) { - const value = group ? String(group.id) : ""; - const label = group?.name || "未分组"; +function customSelectOptionMarkup(option, selectedValue) { + const value = String(option.value ?? option.id ?? ""); + const label = String(option.label ?? option.display_name ?? option.name ?? value); + const detail = option.detail ? String(option.detail) : ""; + const toneClass = { + success: "is-success", + info: "is-info", + warning: "is-warning", + danger: "is-danger", + muted: "is-muted", + }[option.tone] || ""; const selected = value === selectedValue; - const detail = group - ? (group.enabled ? `${Number(group.interval_minutes || 60)} 分钟周期` : "计划已停用") - : "不跟随组计划"; return ``; } -function syncInstanceGroupSelection(value, { emitChange = false } = {}) { - const elements = instanceGroupSelectElements(); - if (!elements) return; +function syncCustomSelectSelection(target, value, { emitChange = false } = {}) { + const elements = customSelectElements(target); + if (!elements) return ""; const requested = String(value || ""); const options = $$(".custom-select-option", elements.menu); const selected = options.find((option) => option.dataset.value === requested) || options[0]; const resolved = selected?.dataset.value || ""; + const label = selected?.dataset.label || ""; elements.input.value = resolved; - elements.label.textContent = selected?.dataset.label || "未分组"; + elements.label.textContent = label; + elements.label.title = label; + const accessibleLabel = elements.trigger.dataset.customSelectName + || elements.trigger.getAttribute("aria-label"); + if (accessibleLabel) { + elements.trigger.dataset.customSelectName = accessibleLabel; + elements.trigger.setAttribute("aria-label", `${accessibleLabel}:${label}`); + } options.forEach((option) => option.setAttribute("aria-selected", String(option === selected))); if (emitChange) elements.input.dispatchEvent(new Event("change", { bubbles: true })); + return resolved; } -function populateInstanceGroupSelect(items, selectedValue = null) { - const elements = instanceGroupSelectElements(); - if (!elements) return; +function populateCustomSelect(target, options, selectedValue = null) { + const elements = customSelectElements(target); + if (!elements) return ""; const previous = selectedValue === null ? elements.input.value : String(selectedValue || ""); - elements.menu.innerHTML = [ - instanceGroupOptionMarkup(null, previous), - ...items.map((group) => instanceGroupOptionMarkup(group, previous)), - ].join(""); - syncInstanceGroupSelection(previous); + const focusedOption = elements.root.contains(document.activeElement) + ? document.activeElement.closest?.(".custom-select-option") + : null; + const focusedValue = focusedOption?.dataset.value; + elements.menu.innerHTML = options + .map((option) => customSelectOptionMarkup(option, previous)) + .join(""); + const resolved = syncCustomSelectSelection(elements.root, previous); refreshIcons(); + if (focusedValue !== undefined) { + window.requestAnimationFrame(() => { + const currentOptions = $$(".custom-select-option", elements.menu); + const option = currentOptions.find((item) => item.dataset.value === focusedValue) + || currentOptions.find((item) => item.getAttribute("aria-selected") === "true"); + option?.focus(); + }); + } + return resolved; } -function setInstanceGroupSelectOpen(open, focusTarget = null) { - const elements = instanceGroupSelectElements(); +function setCustomSelectOpen(target, open, focusTarget = null) { + const elements = customSelectElements(target); if (!elements) return; + if (open) { + $$('[data-custom-select].is-open').forEach((root) => { + if (root !== elements.root) setCustomSelectOpen(root, false); + }); + } elements.root.classList.toggle("is-open", open); elements.trigger.setAttribute("aria-expanded", String(open)); elements.menu.hidden = !open; + if (!open) { + delete elements.root.dataset.typeaheadQuery; + delete elements.root.dataset.typeaheadAt; + } if (!open || !focusTarget) return; window.requestAnimationFrame(() => { const options = $$(".custom-select-option", elements.menu); - const target = focusTarget === "last" + const option = focusTarget === "last" ? options.at(-1) - : options.find((option) => option.getAttribute("aria-selected") === "true") || options[0]; - target?.focus(); + : focusTarget === "first" + ? options[0] + : options.find((item) => item.getAttribute("aria-selected") === "true") || options[0]; + option?.focus(); }); } -function selectInstanceGroupOption(option) { - if (!option) return; - syncInstanceGroupSelection(option.dataset.value, { emitChange: true }); - setInstanceGroupSelectOpen(false); - instanceGroupSelectElements()?.trigger.focus(); +function closeCustomSelects() { + $$('[data-custom-select].is-open').forEach((root) => setCustomSelectOpen(root, false)); } -function handleInstanceGroupSelectKeydown(event) { - const elements = instanceGroupSelectElements(); - if (!elements) return; - const options = $$(".custom-select-option", elements.menu); - if (event.target === elements.trigger && ["ArrowDown", "ArrowUp"].includes(event.key)) { - event.preventDefault(); - setInstanceGroupSelectOpen(true, event.key === "ArrowUp" ? "last" : "selected"); +function selectCustomSelectOption(option) { + const elements = customSelectElements(option); + if (!elements || !option) return; + syncCustomSelectSelection(elements.root, option.dataset.value, { emitChange: true }); + setCustomSelectOpen(elements.root, false); + elements.trigger.focus(); +} + +function handleCustomSelectClick(event) { + const option = event.target.closest?.(".custom-select-option"); + if (option) { + selectCustomSelectOption(option); return; } + const trigger = event.target.closest?.(".custom-select-trigger"); + if (trigger) { + const root = trigger.closest("[data-custom-select]"); + const open = !root.classList.contains("is-open"); + setCustomSelectOpen(root, open, open ? "selected" : null); + return; + } + if (!event.target.closest?.("[data-custom-select]")) closeCustomSelects(); +} + +function focusCustomSelectByText(root, options, currentIndex, key) { + const now = Date.now(); + const previousAt = Number(root.dataset.typeaheadAt || 0); + const prefix = now - previousAt < 700 ? root.dataset.typeaheadQuery || "" : ""; + const query = `${prefix}${key}`.toLocaleLowerCase("zh-CN"); + root.dataset.typeaheadQuery = query; + root.dataset.typeaheadAt = String(now); + const ordered = [ + ...options.slice(currentIndex + 1), + ...options.slice(0, currentIndex + 1), + ]; + const match = ordered.find((option) => + String(option.dataset.label || "").toLocaleLowerCase("zh-CN").startsWith(query) + ); + match?.focus(); +} + +function handleCustomSelectKeydown(event) { + const root = event.target.closest?.("[data-custom-select]"); + if (!root) { + if (event.key === "Escape") closeCustomSelects(); + return; + } + const elements = customSelectElements(root); + const options = $$(".custom-select-option", elements.menu); + if (event.target === elements.trigger) { + const focusTarget = { + ArrowDown: "selected", + ArrowUp: "last", + Home: "first", + End: "last", + }[event.key]; + if (focusTarget) { + event.preventDefault(); + setCustomSelectOpen(root, true, focusTarget); + return; + } + if ( + event.key.length === 1 + && event.key.trim() + && !event.altKey + && !event.ctrlKey + && !event.metaKey + ) { + event.preventDefault(); + setCustomSelectOpen(root, true); + window.requestAnimationFrame(() => { + focusCustomSelectByText(root, options, -1, event.key); + }); + return; + } + } const currentIndex = options.indexOf(document.activeElement); if (currentIndex < 0) { - if (event.key === "Escape") setInstanceGroupSelectOpen(false); + if (event.key === "Escape") setCustomSelectOpen(root, false); return; } if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { @@ -746,20 +863,104 @@ function handleInstanceGroupSelectKeydown(event) { if (event.key === "Home") nextIndex = 0; if (event.key === "End") nextIndex = options.length - 1; options[nextIndex]?.focus(); + } else if ( + event.key.length === 1 + && event.key.trim() + && !event.altKey + && !event.ctrlKey + && !event.metaKey + ) { + event.preventDefault(); + focusCustomSelectByText(root, options, currentIndex, event.key); } else if (event.key === "Escape") { event.preventDefault(); - setInstanceGroupSelectOpen(false); + setCustomSelectOpen(root, false); elements.trigger.focus(); } else if (event.key === "Tab") { - setInstanceGroupSelectOpen(false); + window.setTimeout(() => setCustomSelectOpen(root, false), 0); } } +function populateInstanceGroupSelect(items, selectedValue = null) { + const options = [ + { value: "", label: "未分组", detail: "不跟随组计划", icon: "minus", tone: "muted" }, + ...items.map((group) => ({ + value: group.id, + label: group.name, + detail: group.enabled ? `${Number(group.interval_minutes || 60)} 分钟周期` : "计划已停用", + icon: "boxes", + tone: group.enabled ? "success" : "muted", + })), + ]; + return populateCustomSelect($("#instance-form").elements.group_id, options, selectedValue); +} + +function initializeCustomSelects() { + populateCustomSelect($("#instance-group-filter"), [ + { value: "", label: "全部分组", icon: "boxes", tone: "muted" }, + ], state.instances.groupId); + populateCustomSelect($("#instance-status-filter"), [ + { value: "", label: "全部状态", icon: "activity", tone: "muted" }, + ...INSTANCE_STATUS_OPTIONS, + ], state.instances.status); + populateCustomSelect($("#group-status-filter"), [ + { value: "", label: "全部状态", icon: "timer-reset", tone: "muted" }, + ...GROUP_STATUS_OPTIONS, + ], state.groups.status); + populateCustomSelect($("#history-instance-filter"), [ + { value: "", label: "全部实例", icon: "server", tone: "muted" }, + ], state.history.instanceId); + populateCustomSelect($("#history-group-filter"), [ + { value: "", label: "全部分组", icon: "boxes", tone: "muted" }, + ], state.history.groupId); + populateCustomSelect($("#history-status-filter"), [ + { value: "", label: "全部结果", icon: "activity", tone: "muted" }, + ...HISTORY_STATUS_OPTIONS, + ], state.history.status); + populateInstanceGroupSelect([], ""); +} + +function resetCustomSelectValues() { + $$('[data-custom-select]').forEach((root) => syncCustomSelectSelection(root, "")); + closeCustomSelects(); +} + function populateResourceSelects() { - populateSelect($("#instance-group-filter"), state.catalog.groups, "全部分组", state.instances.groupId); - populateSelect($("#history-instance-filter"), state.catalog.instances, "全部实例", state.history.instanceId); - populateSelect($("#history-group-filter"), state.catalog.groups, "全部分组", state.history.groupId); + const previousInstanceGroupId = state.instances.groupId; + const previousHistoryInstanceId = state.history.instanceId; + const previousHistoryGroupId = state.history.groupId; + const groupOptions = state.catalog.groups.map((group) => ({ + value: group.id, + label: group.name, + icon: "boxes", + tone: group.enabled ? "success" : "muted", + })); + state.instances.groupId = populateCustomSelect($("#instance-group-filter"), [ + { value: "", label: "全部分组", icon: "boxes", tone: "muted" }, + ...groupOptions, + ], state.instances.groupId); + state.history.instanceId = populateCustomSelect($("#history-instance-filter"), [ + { value: "", label: "全部实例", icon: "server", tone: "muted" }, + ...state.catalog.instances.map((instance) => ({ + value: instance.id, + label: instance.display_name, + icon: "server", + tone: instance.enabled ? "success" : "muted", + })), + ], state.history.instanceId); + state.history.groupId = populateCustomSelect($("#history-group-filter"), [ + { value: "", label: "全部分组", icon: "boxes", tone: "muted" }, + ...groupOptions, + ], state.history.groupId); populateInstanceGroupSelect(state.catalog.groups); + const instanceFilterReset = state.instances.groupId !== previousInstanceGroupId; + const historyFilterReset = ( + state.history.instanceId !== previousHistoryInstanceId + || state.history.groupId !== previousHistoryGroupId + ); + if (instanceFilterReset) state.instances.page = 1; + if (historyFilterReset) state.history.page = 1; + return { instanceFilterReset, historyFilterReset }; } function instanceQuerySignature() { @@ -883,7 +1084,7 @@ async function openInstanceDialog(instanceId = null) { form.elements[name].value = instance?.[name] ?? ""; }); populateInstanceGroupSelect(state.catalog.groups, instance?.group_id || ""); - setInstanceGroupSelectOpen(false); + setCustomSelectOpen(form.elements.group_id, false); $("#instance-dialog-title").textContent = instance ? `编辑 ${instance.display_name}` : "新增实例"; $("#delete-instance").classList.toggle("hidden", !instance); $("#test-instance-dialog").classList.toggle("hidden", !instance); @@ -1367,12 +1568,17 @@ function mapFormErrors(form, error) { async function loadHistory(page = state.history.page) { state.history.page = page; if (state.loading.has("history")) return; - const requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|"); + let requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|"); state.loading.add("history"); const firstLoad = !state.loaded.has("history"); if (firstLoad) $("#history-loading").classList.remove("hidden"); try { - await ensureCatalogs(false); + const catalogChanges = await ensureCatalogs(false); + if (catalogChanges.historyFilterReset) { + page = 1; + state.history.page = 1; + } + requestSignature = [page, state.history.instanceId, state.history.groupId, state.history.status].join("|"); const params = new URLSearchParams({ page: String(page), per_page: "20" }); if (state.history.instanceId) params.set("instance_id", state.history.instanceId); if (state.history.groupId) params.set("group_id", state.history.groupId); @@ -1598,19 +1804,6 @@ function bindEvents() { $("#instances-prev").addEventListener("click", () => { state.instances.page = Math.max(1, state.instances.page - 1); loadInstances(); }); $("#instances-next").addEventListener("click", () => { state.instances.page += 1; loadInstances(); }); $("#instance-form").addEventListener("submit", saveInstance); - const instanceGroupSelect = $("#instance-group-select"); - $(".custom-select-trigger", instanceGroupSelect).addEventListener("click", () => { - const isOpen = instanceGroupSelect.classList.contains("is-open"); - setInstanceGroupSelectOpen(!isOpen, isOpen ? null : "selected"); - }); - $(".custom-select-menu", instanceGroupSelect).addEventListener("click", (event) => { - selectInstanceGroupOption(event.target.closest(".custom-select-option")); - }); - instanceGroupSelect.addEventListener("keydown", handleInstanceGroupSelectKeydown); - document.addEventListener("click", (event) => { - if (!instanceGroupSelect.contains(event.target)) setInstanceGroupSelectOpen(false); - }); - $("#instance-dialog").addEventListener("close", () => setInstanceGroupSelectOpen(false)); $("#test-instance-dialog").addEventListener("click", (event) => testInstance(state.selected.instanceId, event.currentTarget)); $("#delete-instance").addEventListener("click", () => requestDelete("instance", state.selected.instanceId)); @@ -1651,11 +1844,17 @@ function bindEvents() { $("#password-form").addEventListener("submit", changePassword); $("#logout-button").addEventListener("click", logout); + document.addEventListener("click", handleCustomSelectClick); + document.addEventListener("keydown", handleCustomSelectKeydown); $$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => button.closest("dialog").close())); - $$("dialog").forEach((dialog) => dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); })); + $$("dialog").forEach((dialog) => { + dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); }); + dialog.addEventListener("close", closeCustomSelects); + }); } document.addEventListener("DOMContentLoaded", () => { + initializeCustomSelects(); refreshIcons(); bindEvents(); bootstrap(); diff --git a/app/static/index.html b/app/static/index.html index 29c7f52..d695639 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -150,8 +150,16 @@
- - +
+ + + +
+
+ + + +
@@ -167,9 +175,13 @@

AUTOMATION

实例组

-
+
- +
+ + + +
@@ -182,9 +194,21 @@
- - - +
+ + + +
+
+ + + +
+
+ + + +
全部任务0
成功率--
最近成功--
@@ -210,14 +234,14 @@
所属实例组 -
+
- +
diff --git a/app/static/styles.css b/app/static/styles.css index 1de7e59..b0cf39c 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -1181,16 +1181,6 @@ input:checked + .toggle::after { transform: translateX(16px); } } /* Multi-instance control plane */ -select { - font: inherit; - letter-spacing: 0; -} - -select:focus-visible { - outline: 2px solid rgba(71, 118, 231, 0.62); - outline-offset: 2px; -} - .sidebar-action { display: flex; width: 100%; @@ -1355,61 +1345,14 @@ select:focus-visible { grid-template-columns: repeat(3, minmax(150px, 1fr)); } +.group-toolbar { + grid-template-columns: minmax(260px, 1fr) minmax(150px, 190px) 40px; +} + .search-shell { max-width: 460px; } -.select-shell { - display: grid; - width: 100%; - min-height: 44px; - grid-template-columns: 16px minmax(0, 1fr) 14px; - align-items: center; - gap: 8px; - padding: 0 11px; - border: 1px solid var(--line); - border-radius: 7px; - background: rgba(255, 255, 255, 0.72); - transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; -} - -.select-shell:focus-within { - border-color: rgba(71, 118, 231, 0.5); - background: #fff; - box-shadow: 0 0 0 3px rgba(71, 118, 231, 0.1); -} - -.select-shell svg { - width: 15px; - height: 15px; - color: var(--ink-faint); -} - -.select-shell select { - width: 100%; - min-width: 0; - height: 42px; - padding: 0; - border: 0; - outline: 0; - appearance: none; - color: var(--ink); - background: transparent; - font-size: 11px; - font-weight: 600; - text-overflow: ellipsis; -} - -.field-select { - min-height: 44px; -} - -.field-select select { - height: 42px; - font-size: 13px; - font-weight: 500; -} - .custom-select { position: relative; width: 100%; @@ -1457,7 +1400,7 @@ select:focus-visible { color: var(--ink-faint); } -#instance-group-select-value { +.custom-select-value { overflow: hidden; font-size: 13px; font-weight: 550; @@ -1492,6 +1435,8 @@ select:focus-visible { border-radius: 8px; background: rgba(251, 252, 250, 0.96); box-shadow: 0 18px 48px rgba(33, 42, 33, 0.16); + scrollbar-color: rgba(29, 33, 29, 0.22) transparent; + scrollbar-width: thin; backdrop-filter: blur(22px) saturate(1.12); -webkit-backdrop-filter: blur(22px) saturate(1.12); animation: custom-select-enter 150ms ease both; @@ -1501,6 +1446,19 @@ select:focus-visible { display: none; } +.custom-select-menu::-webkit-scrollbar { + width: 6px; +} + +.custom-select-menu::-webkit-scrollbar-thumb { + border-radius: 3px; + background: rgba(29, 33, 29, 0.22); +} + +.custom-select-menu::-webkit-scrollbar-track { + background: transparent; +} + @keyframes custom-select-enter { from { opacity: 0; transform: translateY(-4px) scale(0.99); } to { opacity: 1; transform: translateY(0) scale(1); } @@ -1544,11 +1502,31 @@ select:focus-visible { background: rgba(29, 33, 29, 0.055); } -.custom-select-option-icon.is-enabled { +.custom-select-option-icon.is-success { color: #4f6c13; background: rgba(184, 235, 54, 0.24); } +.custom-select-option-icon.is-info { + color: #3156ad; + background: #dfe8ff; +} + +.custom-select-option-icon.is-warning { + color: #795410; + background: #fff0ca; +} + +.custom-select-option-icon.is-danger { + color: #9a352c; + background: rgba(217, 79, 67, 0.13); +} + +.custom-select-option-icon.is-muted { + color: var(--ink-faint); + background: rgba(29, 33, 29, 0.055); +} + .custom-select-option-icon svg, .custom-select-option-check svg { width: 15px; @@ -1591,6 +1569,46 @@ select:focus-visible { transform: scale(1); } +.custom-select-compact .custom-select-trigger { + grid-template-columns: 15px minmax(0, 1fr) 14px; + gap: 8px; + padding: 0 11px; +} + +.custom-select-compact .custom-select-value { + font-size: 11px; + font-weight: 620; +} + +.custom-select-compact .custom-select-menu { + min-width: max(100%, 180px); + max-height: min(260px, calc(100dvh - 150px)); + gap: 2px; + padding: 5px; +} + +.custom-select-compact .custom-select-option { + min-height: 42px; + grid-template-columns: 28px minmax(0, 1fr) 15px; + gap: 8px; + padding: 5px 7px; +} + +.custom-select-compact .custom-select-option-icon { + width: 28px; + height: 28px; + border-radius: 6px; +} + +.custom-select-compact .custom-select-option-copy strong { + font-size: 11px; + font-weight: 650; +} + +.custom-select-compact .custom-select-option-copy small { + display: none; +} + .resource-loading { margin-bottom: 13px; } @@ -2013,6 +2031,14 @@ select:focus-visible { .history-toolbar { grid-template-columns: repeat(2, minmax(160px, 1fr)); } + + .group-toolbar { + grid-template-columns: minmax(230px, 1fr) minmax(140px, 170px) 40px; + } + + .history-toolbar .custom-select:last-child { + grid-column: 1 / -1; + } } @media (max-width: 760px) { @@ -2020,11 +2046,19 @@ select:focus-visible { grid-template-columns: repeat(2, minmax(0, 1fr)) 40px; } + .resource-toolbar.group-toolbar { + grid-template-columns: minmax(0, 1fr) 40px; + } + .resource-toolbar .search-shell { max-width: none; grid-column: 1 / -1; } + .custom-select-compact .custom-select-menu { + min-width: 100%; + } + .history-toolbar { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2072,7 +2106,11 @@ select:focus-visible { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 40px; } - .resource-toolbar .select-shell { + .resource-toolbar.group-toolbar { + grid-template-columns: minmax(0, 1fr) 40px; + } + + .resource-toolbar .custom-select { min-width: 0; } diff --git a/tests/test_static_frontend.py b/tests/test_static_frontend.py new file mode 100644 index 0000000..3e53934 --- /dev/null +++ b/tests/test_static_frontend.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass, field +from html.parser import HTMLParser +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +INDEX_HTML = PROJECT_ROOT / "app" / "static" / "index.html" +APP_JS = PROJECT_ROOT / "app" / "static" / "app.js" + +FILTER_INPUT_IDS = { + "instance-group-filter", + "instance-status-filter", + "group-status-filter", + "history-instance-filter", + "history-group-filter", + "history-status-filter", +} + +VOID_ELEMENTS = { + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +} + + +@dataclass +class HtmlNode: + tag: str + attrs: dict[str, str | None] + children: list[HtmlNode] = field(default_factory=list) + + +class HtmlTreeParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.root = HtmlNode("document", {}) + self._stack = [self.root] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + self._append_node(tag, attrs, push=tag not in VOID_ELEMENTS) + + def handle_startendtag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + self._append_node(tag, attrs, push=False) + + def handle_endtag(self, tag: str) -> None: + for index in range(len(self._stack) - 1, 0, -1): + if self._stack[index].tag == tag: + del self._stack[index:] + return + + def _append_node( + self, + tag: str, + attrs: list[tuple[str, str | None]], + *, + push: bool, + ) -> None: + node = HtmlNode(tag, dict(attrs)) + self._stack[-1].children.append(node) + if push: + self._stack.append(node) + + +def parse_index() -> HtmlNode: + parser = HtmlTreeParser() + parser.feed(INDEX_HTML.read_text(encoding="utf-8")) + parser.close() + return parser.root + + +def descendants(node: HtmlNode) -> list[HtmlNode]: + result: list[HtmlNode] = [] + pending = list(reversed(node.children)) + while pending: + current = pending.pop() + result.append(current) + pending.extend(reversed(current.children)) + return result + + +def has_class(node: HtmlNode, class_name: str) -> bool: + return class_name in (node.attrs.get("class") or "").split() + + +def test_all_native_selects_are_replaced_by_custom_selects() -> None: + nodes = descendants(parse_index()) + + assert not [node for node in nodes if node.tag == "select"] + assert len([node for node in nodes if "data-custom-select" in node.attrs]) == 7 + + +def test_every_custom_select_has_scoped_hidden_input_and_valid_aria() -> None: + nodes = descendants(parse_index()) + roots = [node for node in nodes if "data-custom-select" in node.attrs] + controlled_ids: list[str] = [] + hidden_inputs: list[HtmlNode] = [] + + for index, root in enumerate(roots, start=1): + scoped_nodes = descendants(root) + scoped_hidden = [ + node + for node in scoped_nodes + if node.tag == "input" + and (node.attrs.get("type") or "").lower() == "hidden" + ] + triggers = [ + node + for node in scoped_nodes + if node.tag == "button" and has_class(node, "custom-select-trigger") + ] + listboxes = [ + node for node in scoped_nodes if node.attrs.get("role") == "listbox" + ] + description = root.attrs.get("id") or f"custom select #{index}" + + assert len(scoped_hidden) == 1, description + assert len(triggers) == 1, description + assert len(listboxes) == 1, description + + hidden_inputs.extend(scoped_hidden) + trigger = triggers[0] + listbox = listboxes[0] + controls = trigger.attrs.get("aria-controls") + listbox_id = listbox.attrs.get("id") + + assert trigger.attrs.get("aria-haspopup") == "listbox", description + assert trigger.attrs.get("aria-expanded") in {"true", "false"}, description + assert controls, description + assert controls == listbox_id, description + assert "data-custom-select-menu" in listbox.attrs, description + controlled_ids.append(controls) + + assert len(controlled_ids) == len(set(controlled_ids)) == 7 + assert { + node.attrs["id"] for node in hidden_inputs if node.attrs.get("id") + } == FILTER_INPUT_IDS + assert len( + [node for node in hidden_inputs if node.attrs.get("name") == "group_id"] + ) == 1 + + +def test_shared_option_template_keeps_listbox_option_aria_contract() -> None: + source = APP_JS.read_text(encoding="utf-8") + marker = "function customSelectOptionMarkup(" + start = source.index(marker) + end = source.index("\nfunction ", start + len(marker)) + function_source = source[start:end] + + assert source.count(marker) == 1 + assert source.count("customSelectOptionMarkup(") >= 2 + assert 'role="option"' in function_source + assert 'aria-selected="${selected}"' in function_source + assert 'tabindex="-1"' in function_source + + +def test_app_javascript_has_valid_syntax() -> None: + node = shutil.which("node") + assert node is not None, "Node.js is required to validate app/static/app.js" + + result = subprocess.run( + [node, "--check", str(APP_JS)], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stderr or result.stdout