fix(activity-log): no spinner flash on instant filtering

- re-query keeps current rows visible instead of clearing + showing the full 'Loading' spinner
- loading affordance is delayed ~180ms: instant responses show nothing; slow ones get a subtle dim (aria-busy)
- full spinner reserved for the genuine first load; append (load-more) shows no indicator
This commit is contained in:
2026-06-10 15:30:48 +03:00
parent ae74cca132
commit 077c99c7d1
2 changed files with 70 additions and 8 deletions
@@ -577,6 +577,16 @@
min-height: 0; min-height: 0;
} }
/* Subtle busy state while a slow re-query is in flight: the current rows stay
visible (no spinner flash) but dim slightly and stop accepting clicks until
the fresh results swap in. Only applied after a short delay, so instant
filtering shows nothing. */
.al-list-container.al-busy {
opacity: 0.55;
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-out);
}
/* ── Tabular-nums utility ────────────────────────────────────────────────── */ /* ── Tabular-nums utility ────────────────────────────────────────────────── */
.tabular-nums { font-variant-numeric: tabular-nums; } .tabular-nums { font-variant-numeric: tabular-nums; }
@@ -74,6 +74,13 @@ let _total = 0;
let _expandedIds = new Set<string>(); let _expandedIds = new Set<string>();
let _debounceTimer: ReturnType<typeof setTimeout> | null = null; let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
let _liveEventListener: ((e: Event) => void) | null = null; let _liveEventListener: ((e: Event) => void) | null = null;
// Loading UX: `_showSpinner` gates the full-panel spinner so it only appears
// after a short delay (slow requests), never flashing on instant filtering.
// `_hasLoadedOnce` distinguishes the genuine first load (spinner immediately)
// from re-queries (keep current rows, subtle delayed busy hint).
let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null;
let _showSpinner = false;
let _hasLoadedOnce = false;
const _filters: ActiveFilters = { const _filters: ActiveFilters = {
categories: [], categories: [],
@@ -353,7 +360,7 @@ function _renderFilterToolbar(): string {
// ─── List and state rendering ──────────────────────────────── // ─── List and state rendering ────────────────────────────────
function _renderList(): string { function _renderList(): string {
if (_loading && _entries.length === 0) { if (_showSpinner && _entries.length === 0) {
return `<div class="al-state al-loading" role="status" aria-live="polite"> return `<div class="al-state al-loading" role="status" aria-live="polite">
<div class="al-spinner"></div> <div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span> <span>${escapeHtml(t('activity_log.loading'))}</span>
@@ -496,15 +503,58 @@ function _updateListContainer(): void {
// ─── Data fetching ─────────────────────────────────────────── // ─── Data fetching ───────────────────────────────────────────
/** Surface a loading affordance only when a request is slow enough to notice. */
function _showDelayedBusy(): void {
if (!_loading) return;
if (_entries.length === 0) {
// Nothing to keep on screen — fall back to the full spinner.
_showSpinner = true;
_updateListContainer();
} else {
// Re-query of a populated list: keep the current rows, just dim them.
const c = document.getElementById('al-list-container');
c?.classList.add('al-busy');
c?.setAttribute('aria-busy', 'true');
}
}
/** Clear all loading affordances (timer, spinner flag, busy dim). Idempotent. */
function _clearBusy(): void {
if (_loadingDelayTimer) {
clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = null;
}
_showSpinner = false;
const c = document.getElementById('al-list-container');
c?.classList.remove('al-busy');
c?.removeAttribute('aria-busy');
}
async function _fetchPage(beforeSeq: number | null = null, append = false): Promise<void> { async function _fetchPage(beforeSeq: number | null = null, append = false): Promise<void> {
if (_loading) return; if (_loading) return;
_loading = true; _loading = true;
if (!append) { if (!append) {
_entries = []; // Reset the cursor for a fresh query, but DON'T clear `_entries` — keep
// the current rows on screen so filtering an already-populated list
// never flashes the full "Loading" state (the new results replace them
// on arrival).
_nextBeforeSeq = null; _nextBeforeSeq = null;
_hasMore = false; _hasMore = false;
} }
_updateListContainer();
if (!_hasLoadedOnce && !append) {
// Genuine first load — there's nothing to show yet, so the spinner is
// the correct (and expected) initial state. Show it immediately.
_showSpinner = true;
_updateListContainer();
} else if (!append) {
// Re-query (filter change / language change): defer any loading hint so
// near-instant responses show nothing at all; a slow request gets a
// subtle dim after the delay.
if (_loadingDelayTimer) clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = setTimeout(_showDelayedBusy, 180);
}
// append (load-more): keep existing rows, no loading indicator.
try { try {
const qs = _buildQuery(beforeSeq); const qs = _buildQuery(beforeSeq);
@@ -524,15 +574,16 @@ async function _fetchPage(beforeSeq: number | null = null, append = false): Prom
_nextBeforeSeq = page.next_before_seq; _nextBeforeSeq = page.next_before_seq;
_hasMore = page.has_more; _hasMore = page.has_more;
_total = page.total; _total = page.total;
// Reset the loading flag BEFORE rendering: _renderList() shows the _hasLoadedOnce = true;
// spinner whenever (_loading && _entries.length === 0), so a zero-result // Clear loading affordances BEFORE rendering so a zero-result page
// page (e.g. an unmatched entity-type filter, or a fresh install) would // renders the empty state (not the spinner) and a re-query swaps in the
// otherwise render the spinner here and spin forever — the finally below // fresh, undimmed rows.
// clears _loading but does not re-render. _clearBusy();
_loading = false; _loading = false;
_updateListContainer(); _updateListContainer();
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return; if (e && typeof e === 'object' && 'isAuth' in e) return;
_clearBusy();
const container = document.getElementById('al-list-container'); const container = document.getElementById('al-list-container');
if (container) { if (container) {
container.innerHTML = `<div class="al-state al-error" role="alert"> container.innerHTML = `<div class="al-state al-error" role="alert">
@@ -542,6 +593,7 @@ async function _fetchPage(beforeSeq: number | null = null, append = false): Prom
} }
} finally { } finally {
_loading = false; _loading = false;
_clearBusy();
} }
} }