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:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user