Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions apps/docs/stories/intro.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
40 changes: 40 additions & 0 deletions apps/docs/stories/spinner.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Meta, Controls, Primary } from '@storybook/addon-docs/blocks';
import * as SpinnerStories from './spinner.stories';

<Meta of={SpinnerStories} />

# 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 ? <Spinner role="status" aria-label="Loading results" /> : 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
<Spinner size={24} style={{ '--spinner-thickness': '2px' }} />
```

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.

<Primary />

<Controls of={SpinnerStories.Playground} />
83 changes: 83 additions & 0 deletions apps/docs/stories/spinner.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Spinner } from '@signozhq/ui';
import type { Meta, StoryObj } from '@storybook/react-vite';

const meta: Meta<typeof Spinner> = {
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<typeof Spinner>;

export const Playground: Story = {
args: {
role: 'status',
'aria-label': 'Loading',
},
};

export const Sizes: Story = {
render: () => (
<div className="story-row">
{[12, 16, 24, 40].map((size) => (
<Spinner key={size} size={size} />
))}
</div>
),
};

export const InheritsColor: Story = {
render: () => (
<div className="story-row">
<span style={{ color: 'var(--primary-background)' }}>
<Spinner />
</span>
<span style={{ color: 'var(--danger-background)' }}>
<Spinner />
</span>
<span style={{ color: 'var(--success-background)' }}>
<Spinner />
</span>
</div>
),
};
10 changes: 10 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/spinner/index.ts
Original file line number Diff line number Diff line change
@@ -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';
14 changes: 14 additions & 0 deletions packages/ui/src/spinner/spinner.forward-ref.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>();
render(<Spinner ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current).toHaveAttribute('data-slot', 'spinner');
});
});
25 changes: 25 additions & 0 deletions packages/ui/src/spinner/spinner.module.scss
Original file line number Diff line number Diff line change
@@ -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));
}
}
31 changes: 31 additions & 0 deletions packages/ui/src/spinner/spinner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Spinner testId="spinner" size={24} />);

expect(screen.getByTestId('spinner')).toHaveStyle({ '--spinner-size': '24px' });
});

it('takes a size with its own unit', () => {
render(<Spinner testId="spinner" size="2rem" />);

expect(screen.getByTestId('spinner')).toHaveStyle({ '--spinner-size': '2rem' });
});

it('says nothing on its own, the busy element announces it', () => {
render(<Spinner testId="spinner" />);

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(<Spinner role="status" aria-label="Loading results" />);

expect(screen.getByRole('status')).toHaveAccessibleName('Loading results');
});
});
98 changes: 98 additions & 0 deletions packages/ui/src/spinner/spinner.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentProps<'div'>, '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 `<div>` 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 `<div>` 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
* <Spinner role="status" aria-label="Loading results" />
* ```
*
* @example
* ```tsx
* // Bigger
* <Spinner size={24} />
* ```
*/
export const Spinner = forwardRef<HTMLDivElement, SpinnerProps>(function Spinner(
{ className, size, style, testId, ...props },
ref,
): ReactElement {
return (
<div
ref={ref}
data-slot="spinner"
className={cn(styles.spinner, className)}
style={
size === undefined
? style
: ({
'--spinner-size': typeof size === 'number' ? `${size}px` : size,
...style,
} as CSSProperties)
}
{...props}
{...(testId === undefined ? {} : { 'data-testid': testId })}
/>
);
});
1 change: 1 addition & 0 deletions packages/ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const entries: Record<string, string> = {
'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',
Expand Down
Loading