Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the refresh picker shows a spinner in place of its clock while its page is fetching

usePolled reports when a request is in flight (through a new onBusy of the
poller) and holds it on for at least 0.4 seconds, so a quick answer still
reads as a refresh. The picker swaps its clock for the spinner, in the same
box so its width stays, and the spinner stands still where motion is off.
Every page with a picker passes it; a page with several polled requests shows
it while any of them runs. The Jobs page fetches through shared components
and has none yet.
This commit is contained in:
iris 2026-11-05 23:09:07 +01:00
commit 47c447409b
5 changed files with 296 additions and 23 deletions

View file

@ -0,0 +1,60 @@
// What a refresh indicator shows, from whether a request is in flight: it turns
// on at once and, once the request is over, stays on for the rest of `minMs`
// since it came on, so an answer that takes a blink still shows as a refresh
// instead of a flash. Free of Preact (and of the real clock, for the tests).
export const MIN_BUSY_MS = 400;
export function createBusyHold(opts: {
/** Called with what to show, only when it changes. */
show: (shown: boolean) => void;
minMs?: number;
now?: () => number;
after?: (fn: () => void, ms: number) => unknown;
cancel?: (timer: unknown) => void;
}): { busy(busy: boolean): void; stop(): void } {
const minMs = opts.minMs ?? MIN_BUSY_MS;
const now = opts.now ?? Date.now;
const after = opts.after ?? ((fn, ms) => setTimeout(fn, ms));
const cancel =
opts.cancel ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
let shown = false;
let since = 0;
let timer: unknown = null;
function set(next: boolean) {
if (next === shown) return;
shown = next;
opts.show(next);
}
function clear() {
if (timer === null) return;
cancel(timer);
timer = null;
}
return {
busy(busy) {
if (busy) {
clear();
if (!shown) since = now();
set(true);
return;
}
if (!shown) return;
const left = minMs - (now() - since);
if (left <= 0) {
set(false);
return;
}
clear();
timer = after(() => {
timer = null;
set(false);
}, left);
},
stop() {
clear();
},
};
}

View file

@ -4,7 +4,9 @@
// request in flight and asks again, for a refresh that follows a change.
// `stop` drops the request in flight. An answer from a dropped request never
// reaches `onData` or `onError`, so an older response cannot overwrite a
// newer one or land on a page that is gone.
// newer one or land on a page that is gone. `onBusy` tells when a request goes
// in flight and when none is any more, however it ended, so a page can show
// that it is refreshing.
export interface Poller {
tick(): void;
refetch(): void;
@ -15,29 +17,35 @@ export function createPoller<T>(opts: {
fetch: (signal: AbortSignal) => Promise<T>;
onData: (data: T) => void;
onError: (error: unknown) => void;
onBusy?: (busy: boolean) => void;
}): Poller {
let current: { controller: AbortController } | null = null;
function start() {
const run = { controller: new AbortController() };
current = run;
opts.onBusy?.(true);
opts.fetch(run.controller.signal).then(
(data) => {
if (current !== run) return;
current = null;
opts.onBusy?.(false);
opts.onData(data);
},
(error: unknown) => {
if (current !== run) return;
current = null;
opts.onBusy?.(false);
opts.onError(error);
},
);
}
function stop() {
current?.controller.abort();
if (current === null) return;
current.controller.abort();
current = null;
opts.onBusy?.(false);
}
return {

View file

@ -1,4 +1,4 @@
// A resource refetched on a timer: `{ data, error, refresh, setData }`.
// A resource refetched on a timer: `{ data, error, refreshing, refresh, setData }`.
// `data` is `null` until the first answer; a failed fetch sets `error` and
// keeps the last good `data`; the next good answer clears `error`. The
// request discipline (skip a tick while one is in flight, drop answers that
@ -8,12 +8,15 @@
// does that for a GET. It may change between renders: the latest one runs.
// `refresh` refetches now, dropping a request in flight, for after a change
// the page made. `setData` patches what is shown without a request.
// `refreshing` is true while a request is in flight, and for a short minimum
// after it starts (`createBusyHold`), for the refresh picker's spinner.
import { useEffect, useRef, useState } from "preact/hooks";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import {
useRefreshInterval,
type RefreshIntervalMs,
} from "../refresh-interval/RefreshInterval.js";
import { createBusyHold } from "./busy-hold.js";
import { createPoller, type Poller } from "./poller.js";
/** A failed request, carrying the problem the API answered with. */
@ -43,15 +46,22 @@ export function usePolled<T>(
): {
data: T | null;
error: ProblemDetails | null;
refreshing: boolean;
refresh: () => void;
setData: (update: (prev: T | null) => T | null) => void;
} {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const pollerRef = useRef<Poller | null>(null);
const holdRef = useRef<ReturnType<typeof createBusyHold> | null>(null);
if (holdRef.current === null) {
holdRef.current = createBusyHold({ show: setRefreshing });
}
const hold = holdRef.current;
if (pollerRef.current === null) {
pollerRef.current = createPoller<T>({
fetch: (signal) => fetcherRef.current(signal),
@ -60,12 +70,19 @@ export function usePolled<T>(
setError(null);
},
onError: (e) => setError(toProblem(e)),
onBusy: (busy) => hold.busy(busy),
});
}
const poller = pollerRef.current;
useEffect(() => () => poller.stop(), [poller]);
useEffect(
() => () => {
poller.stop();
hold.stop();
},
[poller, hold],
);
useRefreshInterval(intervalMs, () => poller.tick());
return { data, error, refresh: () => poller.refetch(), setData };
return { data, error, refreshing, refresh: () => poller.refetch(), setData };
}

View file

@ -35,6 +35,40 @@
color: inherit;
}
.ui-refresh-picker-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2em;
height: 1.2em;
font-size: 0.85em;
line-height: 1;
}
/* The spinner takes the clock's place: the same box, so the picker keeps its
width. Motion follows the page's setting, the same three rules the page
switch uses; where motion is off it is a static arc. */
.ui-refresh-picker-spinner {
box-sizing: border-box;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-refresh-spin 0.8s linear infinite;
}
@keyframes ui-refresh-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-refresh-picker-spinner {
animation: none;
}
}
:root[data-motion="reduce"] .ui-refresh-picker-spinner {
animation: none;
}
:root[data-motion="allow"] .ui-refresh-picker-spinner {
animation: ui-refresh-spin 0.8s linear infinite;
}

View file

@ -43,16 +43,23 @@ export function RefreshIntervalPicker({
id,
value,
onChange,
refreshing = false,
}: {
id: string;
value: RefreshIntervalMs;
onChange: (value: RefreshIntervalMs) => void;
/** A request is in flight: a spinner takes the clock's place, so the width stays. */
refreshing?: boolean;
}) {
const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0];
return (
<div class="ui-refresh-picker">
<span class="ui-refresh-picker-icon" aria-hidden="true">
🕐
<div class="ui-refresh-picker" aria-busy={refreshing}>
<span
class="ui-refresh-picker-icon"
aria-hidden="true"
title={refreshing ? "refreshing" : undefined}
>
{refreshing ? <span class="ui-refresh-picker-spinner" /> : "🕐"}
</span>
<Listbox
id={id}