diff --git a/README.md b/README.md index 57126a6a..b5169522 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ import { ResizablePanelGroup } from '@signozhq/ui'; import { Select } from '@signozhq/ui'; import { Skeleton } from '@signozhq/ui'; import { Slider } from '@signozhq/ui'; +import { Spinner } from '@signozhq/ui'; import { Switch } from '@signozhq/ui'; import { Table } from '@signozhq/ui'; import { Tabs } from '@signozhq/ui'; diff --git a/apps/docs/stories/intro.mdx b/apps/docs/stories/intro.mdx index 8c94b5d4..c08348ca 100644 --- a/apps/docs/stories/intro.mdx +++ b/apps/docs/stories/intro.mdx @@ -90,6 +90,7 @@ import { ResizablePanelGroup } from '@signozhq/ui'; import { Select } from '@signozhq/ui'; import { Skeleton } from '@signozhq/ui'; import { Slider } from '@signozhq/ui'; +import { Spinner } from '@signozhq/ui'; import { Switch } from '@signozhq/ui'; import { Table } from '@signozhq/ui'; import { Tabs } from '@signozhq/ui'; diff --git a/apps/docs/stories/spinner.mdx b/apps/docs/stories/spinner.mdx new file mode 100644 index 00000000..85ee91f7 --- /dev/null +++ b/apps/docs/stories/spinner.mdx @@ -0,0 +1,40 @@ +import { Meta, Controls, Primary } from '@storybook/addon-docs/blocks'; +import * as SpinnerStories from './spinner.stories'; + + + +# Spinner + +A ring that rotates while work of unknown length is in flight. It is what `Button` shows while +`loading`. + +It carries no role and no text of its own, so it says nothing to a screen reader. The element whose +state it describes announces it: `aria-busy` on a button, or `role="status"` plus an `aria-label` +here when the spinner stands alone. + +## How to use + +```tsx +import { Spinner } from '@signozhq/ui'; + +export default function MyComponent({ isLoading }: { isLoading: boolean }) { + return isLoading ? : null; +} +``` + +The ring always turns. Mount it only while the work runs, or freeze it from the outside with +`animation-play-state: paused` when it has to stay in the layout, which is what `Button` does. + +Size, thickness and both ring colours are `--spinner-*` custom properties. The colours derive from +`currentColor`, so the spinner takes the colour of whatever it sits in: + +```tsx + +``` + +Under `prefers-reduced-motion` the rotation slows to `--spinner-reduced-motion-duration` instead of +stopping, because it is the only signal that anything is happening. + + + + diff --git a/apps/docs/stories/spinner.stories.tsx b/apps/docs/stories/spinner.stories.tsx new file mode 100644 index 00000000..6732c871 --- /dev/null +++ b/apps/docs/stories/spinner.stories.tsx @@ -0,0 +1,83 @@ +import { Spinner } from '@signozhq/ui'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta: Meta = { + title: 'Primitive Components/Spinner', + component: Spinner, + parameters: { + docs: { + description: { + component: + 'A ring that rotates while work of unknown length is in flight. Size comes from the `size` prop, thickness and both ring colours are `--spinner-*` custom properties, and the colours derive from `currentColor`, so it takes the colour of whatever it sits in.', + }, + }, + }, + argTypes: { + size: { + control: 'text', + description: 'Diameter of the ring, written to `--spinner-size`. A number is read as pixels.', + table: { + category: 'Appearance', + type: { summary: 'number | string' }, + defaultValue: { summary: '12px' }, + }, + }, + role: { + control: 'text', + description: + 'Left unset, the spinner says nothing to a screen reader. Set `role="status"` with an `aria-label` when it stands alone instead of inside an `aria-busy` element.', + table: { category: 'Accessibility', type: { summary: 'string' } }, + }, + testId: { + control: 'text', + description: 'Test ID for the spinner.', + table: { category: 'Testing', type: { summary: 'string' } }, + }, + className: { + control: 'text', + description: 'Additional CSS classes.', + table: { category: 'Styling', type: { summary: 'string' } }, + }, + style: { + control: false, + description: 'Inline styles, the place to set the `--spinner-*` custom properties.', + table: { category: 'Styling', type: { summary: 'CSSProperties' } }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: { + role: 'status', + 'aria-label': 'Loading', + }, +}; + +export const Sizes: Story = { + render: () => ( +
+ {[12, 16, 24, 40].map((size) => ( + + ))} +
+ ), +}; + +export const InheritsColor: Story = { + render: () => ( +
+ + + + + + + + + +
+ ), +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 2390a70a..4165a40d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -327,6 +327,16 @@ "require": "./dist/sonner/index.cjs" } }, + "./spinner": { + "import": { + "types": "./dist/spinner/index.d.ts", + "import": "./dist/spinner/index.mjs" + }, + "require": { + "types": "./dist/spinner/index.d.cts", + "require": "./dist/spinner/index.cjs" + } + }, "./switch": { "import": { "types": "./dist/switch/index.d.ts", diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 6b425f48..d52913fa 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -28,6 +28,7 @@ export * from './select/index.js'; export * from './skeleton/index.js'; export * from './slider/index.js'; export * from './sonner/index.js'; +export * from './spinner/index.js'; export * from './switch/index.js'; export * from './table/index.js'; export * from './tabs/index.js'; diff --git a/packages/ui/src/spinner/index.ts b/packages/ui/src/spinner/index.ts new file mode 100644 index 00000000..b3fccf5d --- /dev/null +++ b/packages/ui/src/spinner/index.ts @@ -0,0 +1,22 @@ +// #region css-tokens +/** + * CSS Tokens for spinner + * Prefix: `--spinner-` + * + * | Token | Default | + * |-------|---------| + * | `--spinner-border-radius` | `50%` | + * | `--spinner-border-style` | `solid` | + * | `--spinner-duration` | `0.7s` | + * | `--spinner-flex-shrink` | `0` | + * | `--spinner-head-color` | `color-mix(in srgb, currentColor 88%, transparent)` | + * | `--spinner-reduced-motion-duration` | `1.6s` | + * | `--spinner-rotate-transform` | `rotate(360deg)` | + * | `--spinner-size` | `12px` | + * | `--spinner-thickness` | `1.5px` | + * | `--spinner-track-color` | `color-mix(in srgb, currentColor 22%, transparent)` | + */ +// #endregion css-tokens + +export type * from './spinner.js'; +export { Spinner } from './spinner.js'; diff --git a/packages/ui/src/spinner/spinner.forward-ref.test.tsx b/packages/ui/src/spinner/spinner.forward-ref.test.tsx new file mode 100644 index 00000000..1d40d733 --- /dev/null +++ b/packages/ui/src/spinner/spinner.forward-ref.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Spinner } from './index.js'; + +describe('Spinner forwardRef', () => { + it('forwards ref', () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveAttribute('data-slot', 'spinner'); + }); +}); diff --git a/packages/ui/src/spinner/spinner.module.scss b/packages/ui/src/spinner/spinner.module.scss new file mode 100644 index 00000000..0bada6de --- /dev/null +++ b/packages/ui/src/spinner/spinner.module.scss @@ -0,0 +1,25 @@ +.spinner { + inline-size: var(--spinner-size, 12px); + block-size: var(--spinner-size, 12px); + flex-shrink: var(--spinner-flex-shrink, 0); + border-radius: var(--spinner-border-radius, 50%); + border-style: var(--spinner-border-style, solid); + border-width: var(--spinner-thickness, 1.5px); + border-color: var(--spinner-track-color, color-mix(in srgb, currentColor 22%, transparent)); + border-block-start-color: var(--spinner-head-color, color-mix(in srgb, currentColor 88%, transparent)); + animation: spinner-rotate var(--spinner-duration, 0.7s) linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + // The rotation is the only thing saying work is in flight, so it is slowed + // rather than stopped. + .spinner { + animation-duration: var(--spinner-reduced-motion-duration, 1.6s); + } +} + +@keyframes spinner-rotate { + to { + transform: var(--spinner-rotate-transform, rotate(360deg)); + } +} diff --git a/packages/ui/src/spinner/spinner.test.tsx b/packages/ui/src/spinner/spinner.test.tsx new file mode 100644 index 00000000..c91dd222 --- /dev/null +++ b/packages/ui/src/spinner/spinner.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Spinner } from './index.js'; + +describe('Spinner', () => { + it('writes size to --spinner-size, pixels for a number', () => { + render(); + + expect(screen.getByTestId('spinner')).toHaveStyle({ '--spinner-size': '24px' }); + }); + + it('takes a size with its own unit', () => { + render(); + + expect(screen.getByTestId('spinner')).toHaveStyle({ '--spinner-size': '2rem' }); + }); + + it('says nothing on its own, the busy element announces it', () => { + render(); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByTestId('spinner')).not.toHaveAttribute('aria-label'); + }); + + it('takes a role and a name when it stands alone', () => { + render(); + + expect(screen.getByRole('status')).toHaveAccessibleName('Loading results'); + }); +}); diff --git a/packages/ui/src/spinner/spinner.tsx b/packages/ui/src/spinner/spinner.tsx new file mode 100644 index 00000000..25e3affa --- /dev/null +++ b/packages/ui/src/spinner/spinner.tsx @@ -0,0 +1,98 @@ +import type { AriaAttributes, ComponentProps, CSSProperties, ReactElement } from 'react'; +import { forwardRef } from 'react'; +import { cn } from '../lib/utils.js'; +import styles from './spinner.module.scss'; + +export interface SpinnerProps + extends Pick, 'className' | 'id' | 'style' | 'role'>, AriaAttributes { + /** + * Diameter of the ring, written to `--spinner-size`. A number is read as pixels. + * @default 12px + */ + size?: number | string; + /** + * Test ID for the spinner. + */ + testId?: string; +} + +/** + * Renders a `
` drawn as a ring, rotating while work is in flight. + * + * Only the props in {@link SpinnerProps} are forwarded: `className`, `id`, `style`, `role` and + * any `aria-*`. Other native `
` attributes are not. + * + * Visual values are `--spinner-*` custom properties, defaults in the `css-tokens` region of + * [./index.ts](./index.ts). Both ring colours derive from `currentColor`, so it takes the + * colour of whatever it sits in. + * + * ### Accessibility + * + * It carries no role and no text of its own, so it says nothing to a screen reader. + * + * The element whose state it describes announces it: `aria-busy` on a button, or `role="status"` + * plus an `aria-label` here when the spinner stands alone. + * + * ### Indeterminate only + * + * The ring always rotates, saying work of unknown length is in flight. There is no determinate + * mode. Use `Progress` when the share done is known. + * + * Mount it only while the work runs, or freeze it from the outside with + * `animation-play-state: paused` when it has to stay in the layout (this is what `Button` does + * with its idle spinner). + * + * ### Size + * + * `size` is written as an inline `--spinner-size`, so it composes with the tokens instead of + * overwriting `style.width`. Numbers are written as `px`. + * + * Any `style` you pass is kept, and a `--spinner-size` in it wins over the prop. + * + * ### Reduced motion + * + * Under `prefers-reduced-motion` the rotation slows to `--spinner-reduced-motion-duration` + * instead of stopping, because it is the only signal that anything is happening. + * + * ### Asserting on it + * + * `testId` is `data-testid`. Otherwise use the data attributes, never the hashed class names. + * There is a single element, no sub-slots. + * + * | root attribute | value | + * |---|---| + * | `data-slot` | `"spinner"` | + * + * @example + * ```tsx + * + * ``` + * + * @example + * ```tsx + * // Bigger + * + * ``` + */ +export const Spinner = forwardRef(function Spinner( + { className, size, style, testId, ...props }, + ref, +): ReactElement { + return ( +
+ ); +}); diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index dc0e994c..5cbc65c0 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -35,6 +35,7 @@ export const entries: Record = { 'select/index': 'src/select/index.ts', 'slider/index': 'src/slider/index.ts', 'sonner/index': 'src/sonner/index.ts', + 'spinner/index': 'src/spinner/index.ts', 'switch/index': 'src/switch/index.ts', 'table/index': 'src/table/index.ts', 'tabs/index': 'src/tabs/index.ts',