lerobot-visualizer-test / src /components /loading-component.tsx
Chunte's picture
Chunte HF Staff
Minimal UI: nav icons + motion pass (transitions-dev + apple-design), UX unchanged
e63a744 verified
Raw History Blame Contribute Delete
3.47 kB
"use client";
import { useEffect, useState } from "react";
import { cx, Spinner } from "@/components/ui";
import { exitMs, usePresence } from "@/utils/motion";
/**
* Loading veil over the content area (`absolute`: the nearest positioned ancestor is the episode
* page's content column, which keeps the same geometry from the data-loading shell to the loaded
* page, so the spinner holds its spot through both phases while the header and sidebar stay put).
*
* - `visible={false}` fades the veil out (with clicks passing through) and then unmounts it, so
* the content appears through it instead of popping.
* - `appear={false}` is for taking over from the previous phase's veil: it shows at once, without
* replaying the fade-in (which would read as a blink) or announcing the status a second time.
* A later reopen of the same instance fades in again. Taking over with nothing left to load
* (`visible={false}` from the start, e.g. a view with no loading phase of its own) plays the
* previous veil's fade-out from here instead of letting it vanish.
*/
export default function Loading({
visible = true,
appear = true,
}: {
visible?: boolean;
appear?: boolean;
} = {}) {
const presence = usePresence(visible, "--duration-fast", 250);
const [handoff, setHandoff] = useState(!appear && !visible);
useEffect(() => {
if (!handoff) return;
const t = window.setTimeout(
() => setHandoff(false),
exitMs("--duration-fast", 250),
);
return () => window.clearTimeout(t);
}, [handoff]);
const state = handoff && presence === "closed" ? "closing" : presence;
const [fresh, setFresh] = useState(appear);
if (state === "closed" && !fresh) setFresh(true);
if (state === "closed") return null;
const closing = state === "closing";
return (
<div
data-state={state}
inert={closing}
aria-hidden={closing || undefined}
role={fresh ? "status" : undefined}
aria-live={fresh ? "polite" : undefined}
className={cx(
"absolute inset-0 z-10 flex flex-col items-center justify-center bg-bg/70 backdrop-blur-md",
// Opacity only: never animate the blur radius of a layer this size. `starting:` only
// applies to a veil mounted already closing (the hand-off above): it fades from opaque.
"transition-opacity duration-(--duration-fast) ease-(--ease-out)",
closing
? "pointer-events-none opacity-0 starting:opacity-100"
: "opacity-100",
)}
>
<div
className={cx(
"flex flex-col items-center gap-3",
fresh && "animate-fade-in",
)}
>
{/* The rotation lives on this div, not on the SVG: a transformed HTML box runs on the
compositor, so it keeps spinning while the main thread is busy decoding parquet and
building charts. Stepped like the shared Spinner. */}
<div className="h-7 w-7 animate-spin text-fg-secondary [animation-duration:900ms] [animation-timing-function:steps(12)] [will-change:transform]">
<Spinner className="h-7 w-7 animate-none!" />
</div>
<div className="text-center">
<h1 className="text-13 font-medium text-fg-secondary">Loading</h1>
<p className="mt-0.5 text-2xs text-fg-secondary">
Preparing data and videos
</p>
</div>
</div>
</div>
);
}