fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix

- export button is now a click-toggle menu (aria-haspopup/expanded, role=menu, caret, Escape + click-outside close)
- filter placeholders moved to i18n (actor/entity_type .placeholder keys, en/ru/zh)
- _fetchPage clears _loading before render so a zero-result page doesn't spin forever
- toolbar/entry use elevated card surface (--lux-bg-1); light-theme device badge contrast; mobile message grid
This commit is contained in:
2026-06-10 15:24:45 +03:00
parent 77284e8e7b
commit ae74cca132
5 changed files with 90 additions and 23 deletions
+31 -12
View File
@@ -24,8 +24,11 @@
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) var(--space-md);
background: var(--bg-secondary);
border: var(--lux-hairline) solid var(--border-color);
/* Match the elevated card surface used by entity cards (.dashboard-target),
not the near-black --bg-secondary, so the panel reads as one of the app's
cards rather than a separate flat sheet. */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
}
@@ -137,6 +140,16 @@
.al-export-btn .icon { width: 14px; height: 14px; }
/* Caret signals this button opens a menu (rather than firing a direct action),
and rotates to point up while the menu is open. */
.al-export-caret {
display: inline-flex;
margin-left: 1px;
transition: transform var(--duration-fast) var(--ease-out);
}
.al-export-caret .icon { width: 12px; height: 12px; }
.al-export-wrap.open .al-export-caret { transform: rotate(180deg); }
.al-export-menu {
display: none;
position: absolute;
@@ -151,8 +164,7 @@
overflow: hidden;
}
.al-export-wrap:hover .al-export-menu,
.al-export-wrap:focus-within .al-export-menu { display: block; }
.al-export-wrap.open .al-export-menu { display: block; }
.al-export-menu button {
display: block;
@@ -166,7 +178,8 @@
cursor: pointer;
}
.al-export-menu button:hover { background: var(--bg-secondary); }
.al-export-menu button:hover,
.al-export-menu button:focus-visible { background: var(--bg-secondary); outline: none; }
/* Filter label */
.al-filter-label {
@@ -311,8 +324,9 @@
}
.al-entry {
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
/* Same elevated surface + hairline as entity cards (see .al-toolbar). */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
border-radius: var(--radius-sm);
overflow: hidden;
transition: border-color var(--duration-fast);
@@ -320,10 +334,11 @@
.al-entry:hover { border-color: var(--text-muted); }
/* New-entry flash */
/* New-entry flash — settles on the card surface (animation-fill-mode: forwards
holds the 100% frame, so it must match the .al-entry background exactly). */
@keyframes al-new-flash {
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--card-bg)); }
100% { background: var(--card-bg); }
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--lux-bg-1, var(--card-bg))); }
100% { background: var(--lux-bg-1, var(--card-bg)); }
}
.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; }
@@ -382,7 +397,9 @@
.al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); }
[data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); }
[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); }
/* Darker purple text in light theme — the dark-theme #ab47bc fails AA contrast
on the pale tinted background at this small badge size. */
[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); color: #8e24aa; }
[data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); }
[data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); }
[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); }
@@ -585,9 +602,11 @@
gap: 4px var(--space-xs);
}
/* Row 1: [sev] [message] [badge] [chevron]; Row 2: [time] under the message.
message stays in its own 1fr column so it never overlaps the badge. */
.al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; }
.al-cat-badge{ grid-column: 3; grid-row: 1; }
.al-msg { grid-column: 2 / span 2; grid-row: 1; }
.al-msg { grid-column: 2; grid-row: 1; }
.al-toolbar-advanced .al-field-group { min-width: 100%; }
}
@@ -259,7 +259,7 @@ function _renderFilterToolbar(): string {
const catChips = CATEGORIES.map(cat => {
const active = _filters.categories.includes(cat);
return `<button class="al-chip al-cat-chip${active ? ' active' : ''} al-cat-${cat}"
type="button" onclick="activityLogToggleCat(${JSON.stringify(cat)})"
type="button" onclick="activityLogToggleCat('${cat}')"
aria-pressed="${active}">${escapeHtml(_categoryLabel(cat))}</button>`;
}).join('');
@@ -267,7 +267,7 @@ function _renderFilterToolbar(): string {
const active = _filters.severities.includes(sev);
const icon = _severityIcon(sev);
return `<button class="al-chip al-sev-chip${active ? ' active' : ''} al-sev-chip-${sev}"
type="button" onclick="activityLogToggleSev(${JSON.stringify(sev)})"
type="button" onclick="activityLogToggleSev('${sev}')"
aria-pressed="${active}">${icon} ${escapeHtml(t(`activity_log.severity.${sev}`))}</button>`;
}).join('');
@@ -278,7 +278,7 @@ function _renderFilterToolbar(): string {
{ key: 'devices', label: t('activity_log.preset.devices') },
];
const presetBtns = presets.map(p =>
`<button class="al-preset-btn" type="button" onclick="activityLogPreset(${JSON.stringify(p.key)})">${escapeHtml(p.label)}</button>`
`<button class="al-preset-btn" type="button" onclick="activityLogPreset('${p.key}')">${escapeHtml(p.label)}</button>`
).join('');
const hasFilters = _filters.categories.length || _filters.severities.length ||
@@ -300,12 +300,12 @@ function _renderFilterToolbar(): string {
aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}</button>` : ''}
<div class="al-export-wrap">
<button class="btn btn-secondary al-export-btn" type="button"
onclick="activityLogExport('csv')"
title="${escapeHtml(t('activity_log.export.csv'))}"
aria-label="${escapeHtml(t('activity_log.export.csv'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span></button>
<div class="al-export-menu">
<button type="button" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
<button type="button" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
data-al-export-toggle aria-haspopup="menu" aria-expanded="false"
title="${escapeHtml(t('activity_log.export'))}"
aria-label="${escapeHtml(t('activity_log.export'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span><span class="al-export-caret" aria-hidden="true">${ICON_CHEVRON_DOWN}</span></button>
<div class="al-export-menu" role="menu">
<button type="button" role="menuitem" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
<button type="button" role="menuitem" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
</div>
</div>
</div>
@@ -320,7 +320,7 @@ function _renderFilterToolbar(): string {
<label for="al-actor-input" class="al-field-label">${escapeHtml(t('activity_log.filter.actor'))}</label>
<input type="text" id="al-actor-input" class="al-field-input"
value="${_escapeAttr(_filters.actor)}"
placeholder="system, api-key-name…"
placeholder="${_escapeAttr(t('activity_log.filter.actor.placeholder'))}"
oninput="activityLogOnActor(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.actor'))}">
</div>
@@ -328,7 +328,7 @@ function _renderFilterToolbar(): string {
<label for="al-entity-type-input" class="al-field-label">${escapeHtml(t('activity_log.filter.entity_type'))}</label>
<input type="text" id="al-entity-type-input" class="al-field-input"
value="${_escapeAttr(_filters.entity_type)}"
placeholder="output_target, led_device…"
placeholder="${_escapeAttr(t('activity_log.filter.entity_type.placeholder'))}"
oninput="activityLogOnEntityType(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.entity_type'))}">
</div>
@@ -395,6 +395,15 @@ function _renderList(): string {
let _delegatedClickAttached = false;
/** Collapse the export dropdown if open (idempotent). */
function _closeExportMenu(): void {
const wrap = document.getElementById('tab-activity_log')
?.querySelector<HTMLElement>('.al-export-wrap.open');
if (!wrap) return;
wrap.classList.remove('open');
wrap.querySelector('[data-al-export-toggle]')?.setAttribute('aria-expanded', 'false');
}
function _attachDelegatedClicks(): void {
if (_delegatedClickAttached) return;
const panel = document.getElementById('tab-activity_log');
@@ -404,6 +413,24 @@ function _attachDelegatedClicks(): void {
panel.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Export menu: toggle button opens/closes the CSV/JSON dropdown.
const exportToggle = target.closest<HTMLElement>('[data-al-export-toggle]');
if (exportToggle) {
const wrap = exportToggle.closest<HTMLElement>('.al-export-wrap');
const open = wrap?.classList.toggle('open') ?? false;
exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
return;
}
// Export menu: a menu item's inline onclick triggers the download (it
// runs first, on the deeper element) — we just collapse the menu after.
if (target.closest('.al-export-menu')) {
_closeExportMenu();
return;
}
// Any other click in the panel dismisses an open export menu, then
// continues to row / entity handling below.
_closeExportMenu();
// Entity navigation: click on data-entity-type button
const entityBtn = target.closest<HTMLElement>('button.al-entity-link[data-entity-type]');
if (entityBtn) {
@@ -424,6 +451,15 @@ function _attachDelegatedClicks(): void {
});
panel.addEventListener('keydown', (e: KeyboardEvent) => {
// Escape closes the export menu and restores focus to its trigger.
if (e.key === 'Escape') {
const toggle = panel.querySelector<HTMLElement>('.al-export-wrap.open [data-al-export-toggle]');
if (toggle) {
_closeExportMenu();
toggle.focus();
}
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = (e.target as HTMLElement).closest<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
@@ -488,6 +524,12 @@ async function _fetchPage(beforeSeq: number | null = null, append = false): Prom
_nextBeforeSeq = page.next_before_seq;
_hasMore = page.has_more;
_total = page.total;
// Reset the loading flag BEFORE rendering: _renderList() shows the
// spinner whenever (_loading && _entries.length === 0), so a zero-result
// page (e.g. an unmatched entity-type filter, or a fresh install) would
// otherwise render the spinner here and spin forever — the finally below
// clears _loading but does not re-render.
_loading = false;
_updateListContainer();
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
@@ -30,9 +30,11 @@
"activity_log.export.error": "Export failed.",
"activity_log.export.json": "Export JSON",
"activity_log.filter.actor": "Actor",
"activity_log.filter.actor.placeholder": "system, api-key-name…",
"activity_log.filter.category": "Category",
"activity_log.filter.clear": "Clear filters",
"activity_log.filter.entity_type": "Entity type",
"activity_log.filter.entity_type.placeholder": "output_target, device…",
"activity_log.filter.search": "Search messages…",
"activity_log.filter.severity": "Severity",
"activity_log.filter.since": "From",
@@ -30,9 +30,11 @@
"activity_log.export.error": "Ошибка экспорта.",
"activity_log.export.json": "Экспорт JSON",
"activity_log.filter.actor": "Субъект",
"activity_log.filter.actor.placeholder": "system, имя API-ключа…",
"activity_log.filter.category": "Категория",
"activity_log.filter.clear": "Сбросить фильтры",
"activity_log.filter.entity_type": "Тип сущности",
"activity_log.filter.entity_type.placeholder": "output_target, device…",
"activity_log.filter.search": "Поиск сообщений…",
"activity_log.filter.severity": "Уровень",
"activity_log.filter.since": "С",
@@ -30,9 +30,11 @@
"activity_log.export.error": "导出失败。",
"activity_log.export.json": "导出 JSON",
"activity_log.filter.actor": "操作者",
"activity_log.filter.actor.placeholder": "system、API 密钥名…",
"activity_log.filter.category": "类别",
"activity_log.filter.clear": "清除过滤",
"activity_log.filter.entity_type": "实体类型",
"activity_log.filter.entity_type.placeholder": "output_target, device…",
"activity_log.filter.search": "搜索消息…",
"activity_log.filter.severity": "严重性",
"activity_log.filter.since": "从",