124 lines
5.4 KiB
TypeScript
124 lines
5.4 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
|
import { Button } from "./Button";
|
|
import { SkeletonShapes, type SkeletonShape } from "./Skeleton";
|
|
|
|
// A loading state fades in only after a delay: most loads here answer in tens of milliseconds, and a
|
|
// spinner shown for two frames before the content replaces it reads as a flicker, not as feedback. A
|
|
// load that really takes a while still gets the spinner, a third of a second in.
|
|
//
|
|
// The delay is ONE clock across loading states that hand over to each other, not a per-element CSS
|
|
// delay. One wait is often several elements in a row — the boot screen, then the page chunk's
|
|
// Suspense fallback, then the page's own "fetching" state — and a delay restarted by each would blank
|
|
// an already-visible spinner for another 300 ms at every hand-off. A loading state that mounts while
|
|
// another is still on screen (or has just left it) continues that one's clock instead.
|
|
const LOADING_REVEAL_MS = 300;
|
|
const HANDOFF_MS = 100;
|
|
const reveal = { live: 0, at: 0, lastGone: -Infinity };
|
|
function revealWait(since: number | undefined, delayMs: number): number {
|
|
const now = performance.now();
|
|
if (reveal.live === 0 && now - reveal.lastGone > HANDOFF_MS) reveal.at = (since ?? now) + delayMs;
|
|
return reveal.at - now;
|
|
}
|
|
|
|
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
|
|
// visual for a page/section's first load, so all of them read the same. A grid, a list or a table
|
|
// passes its `shape` instead (B8): the skeleton of what is coming, on the same reveal clock, with the
|
|
// label kept for a screen reader only. `role=status`/`aria-live` announces it either way.
|
|
export function LoadingState({
|
|
label,
|
|
className = "",
|
|
since,
|
|
delayMs = LOADING_REVEAL_MS,
|
|
shape,
|
|
}: {
|
|
label?: ReactNode;
|
|
/** The content's shape: its skeleton replaces the spinner (B8). */
|
|
shape?: SkeletonShape;
|
|
className?: string;
|
|
/** When the wait began (a `performance.now()` time), if earlier than this mount — the boot screen
|
|
* passes 0, navigation start. Only used when this state starts a new clock. */
|
|
since?: number;
|
|
delayMs?: number;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [wait] = useState(() => revealWait(since, delayMs));
|
|
useEffect(() => {
|
|
reveal.live++;
|
|
return () => {
|
|
reveal.live--;
|
|
reveal.lastGone = performance.now();
|
|
};
|
|
}, []);
|
|
const style = wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined;
|
|
if (shape)
|
|
return (
|
|
<div className={`w-full ${className}`} style={style} role="status" aria-live="polite">
|
|
<span className="sr-only">{label ?? t("common.loading")}</span>
|
|
<SkeletonShapes shape={shape} />
|
|
</div>
|
|
);
|
|
return (
|
|
<div
|
|
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
|
|
style={style}
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<Loader2 className="w-6 h-6 animate-spin text-accent" aria-hidden />
|
|
<span>{label ?? t("common.loading")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Centered inline status line for a page/section's loading and error(+retry) states, in the house
|
|
// style (mirrors Playlists' error block). Text-only by design — R3's goal is to stop pages
|
|
// rendering an error or a still-loading fetch AS an "empty" state, not to add skeletons. The
|
|
// `role=status`/`aria-live` also gives screen readers the state change (the app had zero live
|
|
// regions before).
|
|
export function StateMessage({
|
|
children,
|
|
onRetry,
|
|
tone = "muted",
|
|
compact = false,
|
|
className = "",
|
|
}: {
|
|
children: ReactNode;
|
|
onRetry?: () => void;
|
|
tone?: "muted" | "error";
|
|
/** A rung down in vertical rhythm, for a message that sits INSIDE a section that already has its
|
|
* own heading and padding (a rail's empty state) rather than standing alone on a page. Here
|
|
* rather than at the call site because the only two consumers so far had to reach for `!py-6` to
|
|
* beat this component's own `py-10` — the first `!important` overrides in the frontend, and the
|
|
* copyable idiom for the next surface that wants a compact empty state. */
|
|
compact?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div
|
|
className={`flex flex-col items-center gap-3 ${compact ? "py-6" : "py-10"} text-sm text-muted ${className}`}
|
|
// An error is announced promptly (assertive); a plain loading line is polite.
|
|
role={tone === "error" ? "alert" : "status"}
|
|
aria-live={tone === "error" ? "assertive" : "polite"}
|
|
>
|
|
<span className="inline-flex items-center gap-2 text-center">
|
|
{tone === "error" && <AlertTriangle className="w-4 h-4 shrink-0 text-danger" />}
|
|
{children}
|
|
</span>
|
|
{onRetry && (
|
|
<Button variant="outline" size="xs" type="button" onClick={onRetry}>
|
|
{t("common.retry")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The honest-state convention every page now follows: check `isError && <no data>` BEFORE the empty
|
|
// branch, so a failed fetch renders this message (with Retry) instead of the page's "empty" text
|
|
// — the R3 bug where a backend restart read as "No channels". The `&& <no data>` guard matters: a
|
|
// transient refetch error while data is already on screen keeps the data rather than blanking it to
|
|
// an error. `StateMessage` is the shared building block pages wire into their loading/empty ternary.
|