/**
* Spinner (web). Indeterminate loading indicator built on an SVG arc +
* a CSS keyframe. Theme-colored by default, override with `color`.
*
*
*
* }>Save
*
* Renders an aria-label by default so the loading state is announced to
* screen readers; pass `label={null}` to suppress when there's already a
* visible "Loading…" text alongside.
*/
import { useTheme } from "@plyxui/styles";
export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl";
const sizeMap: Record = {
xs: 12,
sm: 16,
md: 20,
lg: 28,
xl: 40,
};
export interface SpinnerProps {
size?: SpinnerSize | number;
/** Defaults to primaryOrange. */
color?: string;
/** Stroke thickness as a fraction of size. Default 0.12. */
thickness?: number;
/** Screen-reader label. Default "Loading". Pass null to suppress. */
label?: string | null;
className?: string;
}
// Inject the keyframes once per page. Avoids a global stylesheet dependency.
const KEYFRAMES_ID = "plyxui-spinner-keyframes";
function ensureKeyframes() {
if (typeof document === "undefined") return;
if (document.getElementById(KEYFRAMES_ID)) return;
const style = document.createElement("style");
style.id = KEYFRAMES_ID;
style.textContent = `
@keyframes plyxui-spin {
to { transform: rotate(360deg); }
}`;
document.head.appendChild(style);
}
export function Spinner({
size = "md",
color,
thickness = 0.12,
label = "Loading",
className,
}: SpinnerProps) {
ensureKeyframes();
const { colors } = useTheme();
const px = typeof size === "number" ? size : sizeMap[size];
const stroke = Math.max(1, Math.round(px * thickness));
const finalColor = color ?? colors.primaryOrange;
const r = (px - stroke) / 2;
const c = px / 2;
const circumference = 2 * Math.PI * r;
// Show ~25% of the ring at any time.
const dash = circumference * 0.25;
return (
);
}