chore: add spinner component (#16014)

This component is necessary to move forward with the "[Define a global
button style](https://github.com/coder/coder/issues/14978)" issue, as it
requires buttons to support loading states with spinners. The `Spinner`
component is heavily inspired by the [Radix UI Spinner
component](https://www.radix-ui.com/themes/docs/components/spinner) and
was developed with the help of [v0](https://v0.dev/).

**Preview**
![Screen Recording 2025-01-02 at 14 37
18](https://github.com/user-attachments/assets/838f6bb2-2125-4a55-9bee-3b3a52852d40)

---------

Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
This commit is contained in:
Bruno Quaresma
2025-01-02 16:10:38 -03:00
committed by GitHub
parent 21a45cf4be
commit cee365670d
5 changed files with 128 additions and 20 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Interpolation, Theme } from "@emotion/react";
import { Spinner } from "components/Spinner/Spinner";
import { Spinner } from "components/deprecated/Spinner/Spinner";
import type { FC, HTMLAttributes } from "react";
interface LoaderProps extends HTMLAttributes<HTMLDivElement> {
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import { PlusIcon } from "lucide-react";
import { Spinner } from "./Spinner";
const meta: Meta<typeof Spinner> = {
title: "components/Spinner",
component: Spinner,
args: {
children: <PlusIcon className="size-icon-lg" />,
},
};
export default meta;
type Story = StoryObj<typeof Spinner>;
export const Idle: Story = {};
export const Loading: Story = {
args: { loading: true },
};
+74 -19
View File
@@ -1,22 +1,77 @@
import CircularProgress, {
type CircularProgressProps,
} from "@mui/material/CircularProgress";
import isChromatic from "chromatic/isChromatic";
import type { FC } from "react";
/**
* Spinner component used to indicate loading states. This component abstracts
* the MUI CircularProgress to provide better control over its rendering,
* especially in snapshot tests with Chromatic.
* This component was inspired by
* https://www.radix-ui.com/themes/docs/components/spinner and developed using
* https://v0.dev/ help.
*/
export const Spinner: FC<CircularProgressProps> = (props) => {
/**
* During Chromatic snapshots, we render the spinner as determinate to make it
* static without animations, using a deterministic value (75%).
*/
if (isChromatic()) {
props.variant = "determinate";
props.value = 75;
import isChromatic from "chromatic/isChromatic";
import { type VariantProps, cva } from "class-variance-authority";
import type { ReactNode } from "react";
import { cn } from "utils/cn";
const leaves = 8;
const spinnerVariants = cva("", {
variants: {
size: {
lg: "size-icon-lg",
sm: "size-icon-sm",
},
},
defaultVariants: {
size: "lg",
},
});
type SpinnerProps = React.SVGProps<SVGSVGElement> &
VariantProps<typeof spinnerVariants> & {
children?: ReactNode;
loading?: boolean;
};
export function Spinner({
className,
size,
loading,
children,
...props
}: SpinnerProps) {
if (!loading) {
return children;
}
return <CircularProgress {...props} />;
};
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn(spinnerVariants({ size, className }))}
{...props}
>
<title>Loading spinner</title>
{[...Array(leaves)].map((_, i) => {
const rotation = i * (360 / leaves);
return (
<rect
key={i}
x="10.9"
y="2"
width="2"
height="5.5"
rx="1"
// 0.8 = leaves * 0.1
className={
isChromatic() ? "" : "animate-[loading_0.8s_ease-in-out_infinite]"
}
style={{
transform: `rotate(${rotation}deg)`,
transformOrigin: "center",
animationDelay: `${-i * 0.1}s`,
}}
/>
);
})}
</svg>
);
}
@@ -0,0 +1,24 @@
import CircularProgress, {
type CircularProgressProps,
} from "@mui/material/CircularProgress";
import isChromatic from "chromatic/isChromatic";
import type { FC } from "react";
/**
* Spinner component used to indicate loading states. This component abstracts
* the MUI CircularProgress to provide better control over its rendering,
* especially in snapshot tests with Chromatic.
*
* @deprecated prefer `components.Spinner`
*/
export const Spinner: FC<CircularProgressProps> = (props) => {
/**
* During Chromatic snapshots, we render the spinner as determinate to make it
* static without animations, using a deterministic value (75%).
*/
if (isChromatic()) {
props.variant = "determinate";
props.value = 75;
}
return <CircularProgress {...props} />;
};
+9
View File
@@ -58,6 +58,15 @@ module.exports = {
5: "hsl(var(--chart-5))",
},
},
keyframes: {
loading: {
"0%": { opacity: 0.85 },
"25%": { opacity: 0.7 },
"50%": { opacity: 0.4 },
"75%": { opacity: 0.3 },
"100%": { opacity: 0.2 },
},
},
},
},
plugins: [require("tailwindcss-animate")],