feat(ui): spinner component (#9590)

This commit is contained in:
Mislav Lukach
2025-07-09 16:42:29 +02:00
committed by GitHub
parent 9331f5e8a7
commit 5cb534217a
3 changed files with 116 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Spinner } from "./Spinner";
import { useEffect, useState } from "react";
const meta = {
title: "Components/Spinner",
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
const DeterminateSpinner = () => {
const [percentage, setPercentage] = useState(10);
useEffect(() => {
setTimeout(() => setPercentage(Math.min(100, percentage + 30)), 600);
}, [percentage]);
return <Spinner determinate value={percentage} />;
};
export const Determinate: Story = {
render: () => <DeterminateSpinner />,
};
export const Indeterminate: Story = {
render: () => <Spinner />,
};

View File

@@ -0,0 +1,66 @@
import { useMemo } from "react";
import type { HTMLProps } from "../../shared/types";
import { cn } from "../../shared/utils/cn";
import "./index.css";
type BaseSpinnerProps = HTMLProps<"svg">;
export type DeterminateSpinnerProps = BaseSpinnerProps & {
determinate: true;
value: number;
};
export type IndeterminateSpinnerProps = BaseSpinnerProps & {
determinate?: false | null | undefined;
value?: never;
};
export type SpinnerProps = DeterminateSpinnerProps | IndeterminateSpinnerProps;
const SIZE = 48;
const STROKE_WIDTH = 6;
const radius = (SIZE - STROKE_WIDTH) / 2;
const circumference = 2 * Math.PI * radius;
export const Spinner = ({
value = 10,
determinate = false,
className,
...props
}: SpinnerProps) => {
const offset = useMemo(
() => circumference - (value / 100) * circumference,
[value]
);
return (
<svg width={SIZE} height={SIZE} className={className} {...props}>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-grey-970"
strokeWidth={STROKE_WIDTH}
/>
<g
className={cn(
!determinate && "animate-indeterminate-spinner origin-center"
)}
>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
className="stroke-primary-500 animate-determinate-spinner"
strokeWidth={STROKE_WIDTH}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
/>
</g>
</svg>
);
};

View File

@@ -0,0 +1,18 @@
@layer utilities {
@keyframes spinner-animate {
0% {
transform: rotate(-90deg);
}
100% {
transform: rotate(270deg);
}
}
.animate-indeterminate-spinner {
animation: spinner-animate 2s linear infinite;
}
.animate-determinate-spinner {
transition: stroke-dashoffset 0.3s ease;
}
}