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 (