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:
@@ -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": "从",
|
||||
|
||||
Reference in New Issue
Block a user