@carbonid1/design-system 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @carbonid1/design-system
2
+
3
+ Shared React UI primitives themed via [`@carbonid1/tailwind-config`](../tailwind-config).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @carbonid1/design-system @carbonid1/tailwind-config react react-dom @base-ui/react class-variance-authority clsx tailwind-merge lucide-react sonner next-themes react-hotkeys-hook
9
+ ```
10
+
11
+ And Tailwind CSS v4 (with `tw-animate-css` + `shadcn/tailwind.css` if you want matching utilities/base):
12
+
13
+ ```sh
14
+ pnpm add -D tailwindcss tw-animate-css shadcn
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### 1. Wire the theme
20
+
21
+ In your entry CSS (Next.js: `src/app/globals.css`):
22
+
23
+ ```css
24
+ @import 'tailwindcss';
25
+ @import 'tw-animate-css';
26
+ @import 'shadcn/tailwind.css';
27
+ @import '@carbonid1/tailwind-config/reader';
28
+ ```
29
+
30
+ ### 2. Transpile the package in Next.js
31
+
32
+ This package ships raw `.tsx` — Next.js needs to compile it. In `next.config.ts`:
33
+
34
+ ```ts
35
+ const config = {
36
+ transpilePackages: ['@carbonid1/design-system'],
37
+ }
38
+ export default config
39
+ ```
40
+
41
+ ### 3. Import components
42
+
43
+ ```tsx
44
+ import { Button, Tooltip, ThemeProvider, ThemeCycler, Toaster } from '@carbonid1/design-system'
45
+ ```
46
+
47
+ ### 4. Root layout
48
+
49
+ ```tsx
50
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
51
+ return (
52
+ <html lang="en" suppressHydrationWarning>
53
+ <body>
54
+ <ThemeProvider>
55
+ {children}
56
+ <ThemeCycler />
57
+ <Toaster />
58
+ </ThemeProvider>
59
+ </body>
60
+ </html>
61
+ )
62
+ }
63
+ ```
64
+
65
+ ## Components
66
+
67
+ | Component | Use for |
68
+ | --- | --- |
69
+ | `Button` | Standard button primitive with variants (`ghost`, `primary`, `outline`, `destructive`, `attention`, `subtle`, `danger`, `link`) |
70
+ | `Kbd` | Keyboard key display (`⌘K`, `⇧B`) |
71
+ | `ProgressRing` | Circular progress indicator |
72
+ | `Slider` | Range slider |
73
+ | `Switch` | Toggle switch |
74
+ | `Select` | Custom dropdown — never use native `<select>` |
75
+ | `Tooltip` | Hover/focus tooltip (wraps a single child) |
76
+ | `Toaster` | Toast notification root (renders once in layout) |
77
+ | `ThemeProvider` | Wraps `next-themes` — provides dark/light mode |
78
+ | `ThemeCycler` | `shift+t` hotkey cycles `system → light → dark` with toast |
79
+
80
+ ## Helpers
81
+
82
+ - `cn(...inputs)` — `clsx` + `tailwind-merge`
83
+ - `getModKey()` — returns `'Cmd'` on Mac, `'Ctrl'` elsewhere
84
+
85
+ ## Theming
86
+
87
+ Components use semantic color utilities (`bg-primary`, `text-muted-foreground`, etc.) that resolve via `@carbonid1/tailwind-config`. Switching theme → swap the tailwind-config import. Dark mode is toggled by adding `class="dark"` to a root ancestor (`ThemeProvider` handles this).
88
+
89
+ ## Build
90
+
91
+ No build step — package ships raw `.tsx` via `files: ["src"]`. Consumers transpile at build time. Stories (`*.stories.tsx`) and tests (`*.vi.{ts,tsx}`) are excluded from the published tarball.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@carbonid1/design-system",
3
+ "version": "0.1.0",
4
+ "description": "Shared React UI primitives themed via @carbonid1/tailwind-config",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/carbonid1/packages",
8
+ "directory": "packages/design-system"
9
+ },
10
+ "license": "MIT",
11
+ "author": "Andrew Korin",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "peerDependencies": {
20
+ "@base-ui/react": "^1.0.0-alpha",
21
+ "@carbonid1/tailwind-config": "^0.1.0",
22
+ "class-variance-authority": "^0.7.0",
23
+ "clsx": "^2.0.0",
24
+ "lucide-react": ">=0.400.0",
25
+ "next-themes": "^0.4.0",
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0",
28
+ "react-hotkeys-hook": "^5.0.0",
29
+ "sonner": "^2.0.0",
30
+ "tailwind-merge": "^3.0.0",
31
+ "tailwindcss": "^4.0.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@base-ui/react": "^1.4.0",
38
+ "@types/react": "^19.2.14",
39
+ "@types/react-dom": "^19.2.3",
40
+ "class-variance-authority": "^0.7.1",
41
+ "clsx": "^2.1.1",
42
+ "lucide-react": "^1.8.0",
43
+ "next-themes": "^0.4.6",
44
+ "react": "^19.2.5",
45
+ "react-dom": "^19.2.5",
46
+ "react-hotkeys-hook": "^5.2.4",
47
+ "sonner": "^2.0.7",
48
+ "tailwind-merge": "^3.5.0",
49
+ "@carbonid1/tsconfig": "0.1.0"
50
+ }
51
+ }
@@ -0,0 +1,121 @@
1
+ import type { ComponentProps } from 'react'
2
+ import { expect, fn } from 'storybook/test'
3
+ import preview from '../../../../.storybook/preview'
4
+ import { Button } from './Button'
5
+
6
+ type ButtonProps = ComponentProps<typeof Button>
7
+
8
+ // TODO: Remove `.type<>()` workaround after upgrading to Storybook 11.
9
+ // SB11 adds `const` modifier to factory generics, fixing args inference for intersection types.
10
+ // See: https://github.com/storybookjs/storybook/issues/32829
11
+ const meta = preview.type<{ args: ButtonProps }>().meta({
12
+ component: Button,
13
+ args: {
14
+ onClick: fn(),
15
+ },
16
+ argTypes: {
17
+ variant: {
18
+ control: 'select',
19
+ options: ['ghost', 'primary', 'outline', 'destructive', 'subtle', 'danger', 'link'],
20
+ },
21
+ size: {
22
+ control: 'select',
23
+ options: ['default', 'small', 'large', 'icon', 'smallIcon', 'largeIcon'],
24
+ },
25
+ fullWidth: { control: 'boolean' },
26
+ loading: { control: 'boolean' },
27
+ disabled: { control: 'boolean' },
28
+ },
29
+ })
30
+
31
+ // --- Variants ---
32
+
33
+ export const Ghost = meta.story({
34
+ args: { children: 'Ghost', variant: 'ghost' },
35
+ })
36
+
37
+ export const Primary = meta.story({
38
+ args: { children: 'Primary', variant: 'primary' },
39
+ })
40
+ Primary.test('fires onClick when clicked', async ({ canvas, userEvent, args }) => {
41
+ const button = await canvas.findByRole('button')
42
+ await userEvent.click(button)
43
+ await expect(args.onClick).toHaveBeenCalledOnce()
44
+ })
45
+
46
+ export const Outline = meta.story({
47
+ args: { children: 'Outline', variant: 'outline' },
48
+ })
49
+
50
+ export const Destructive = meta.story({
51
+ args: { children: 'Delete Account', variant: 'destructive' },
52
+ })
53
+
54
+ export const Subtle = meta.story({
55
+ args: { children: '▶', size: 'icon', variant: 'subtle', 'aria-label': 'Play source' },
56
+ })
57
+
58
+ export const Danger = meta.story({
59
+ args: { children: '✕', size: 'icon', variant: 'danger', 'aria-label': 'Delete' },
60
+ })
61
+
62
+ export const Link = meta.story({
63
+ args: { children: 'Learn more', variant: 'link' },
64
+ })
65
+
66
+ // --- Sizes ---
67
+
68
+ export const IconSize = meta.story({
69
+ args: { children: '✕', size: 'icon', 'aria-label': 'Close' },
70
+ })
71
+
72
+ export const SmallIconSize = meta.story({
73
+ args: { children: '✕', size: 'smallIcon', 'aria-label': 'Dismiss' },
74
+ })
75
+
76
+ export const LargeIconSize = meta.story({
77
+ args: { children: '▶', size: 'largeIcon', variant: 'primary', 'aria-label': 'Play' },
78
+ })
79
+
80
+ export const SmallSize = meta.story({
81
+ args: { children: 'Copy Text', size: 'small' },
82
+ })
83
+
84
+ export const LargeSize = meta.story({
85
+ args: { children: 'Continue', size: 'large', variant: 'primary' },
86
+ })
87
+
88
+ // --- FullWidth ---
89
+
90
+ export const FullWidthPrimary = meta.story({
91
+ args: {
92
+ children: 'Continue to Next Chapter',
93
+ variant: 'primary',
94
+ size: 'large',
95
+ fullWidth: true,
96
+ },
97
+ })
98
+
99
+ export const FullWidthMenuItem = meta.story({
100
+ args: { children: 'Copy Text', size: 'small', fullWidth: true },
101
+ })
102
+
103
+ // --- States ---
104
+
105
+ export const Loading = meta.story({
106
+ args: { children: 'Generating...', variant: 'primary', loading: true },
107
+ })
108
+ Loading.test('shows spinner and disables button', async ({ canvas }) => {
109
+ const button = await canvas.findByRole('button')
110
+ await expect(button).toBeDisabled()
111
+ await expect(button).toHaveAttribute('aria-busy', 'true')
112
+ })
113
+
114
+ export const Disabled = meta.story({
115
+ args: { children: 'Disabled', variant: 'primary', disabled: true },
116
+ })
117
+ Disabled.test('is disabled and does not respond to clicks', async ({ canvas }) => {
118
+ const button = await canvas.findByRole('button')
119
+ await expect(button).toBeDisabled()
120
+ await expect(button).toHaveStyle({ pointerEvents: 'none' })
121
+ })
@@ -0,0 +1,67 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../helpers/cn/cn'
4
+ import { Button as ButtonPrimitive } from '@base-ui/react/button'
5
+ import { cva } from 'class-variance-authority'
6
+ import { Loader2 } from 'lucide-react'
7
+ import type { ButtonProps } from './Button.types'
8
+
9
+ const buttonVariants = cva(
10
+ "group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-hidden select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
11
+ {
12
+ variants: {
13
+ variant: {
14
+ ghost: 'hover:bg-accent aria-expanded:bg-accent dark:hover:bg-accent/50',
15
+ primary: 'bg-primary text-primary-foreground font-medium hover:bg-primary/90',
16
+ outline:
17
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
18
+ destructive:
19
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
20
+ attention: 'text-attention-foreground hover:bg-attention-muted',
21
+ subtle: 'text-muted-foreground hover:text-primary',
22
+ danger: 'text-muted-foreground hover:text-destructive',
23
+ link: 'text-primary underline-offset-4 hover:underline',
24
+ },
25
+ size: {
26
+ small: 'h-7 gap-1 px-2.5 text-[0.8rem]',
27
+ default: 'h-8 gap-1.5 px-2.5',
28
+ large: 'h-9 gap-1.5 px-3',
29
+ icon: 'size-8 rounded-full',
30
+ smallIcon: 'size-6 rounded-full',
31
+ largeIcon: 'size-12 rounded-full',
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: 'ghost',
36
+ size: 'default',
37
+ },
38
+ },
39
+ )
40
+
41
+ const Button = ({
42
+ className,
43
+ variant,
44
+ size,
45
+ fullWidth,
46
+ loading,
47
+ disabled,
48
+ children,
49
+ ...props
50
+ }: ButtonProps) => (
51
+ <ButtonPrimitive
52
+ data-slot="button"
53
+ className={cn(
54
+ buttonVariants({ variant, size }),
55
+ fullWidth && 'w-full justify-start',
56
+ className,
57
+ )}
58
+ disabled={disabled ?? loading}
59
+ aria-busy={loading ?? undefined}
60
+ {...props}
61
+ >
62
+ {loading && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
63
+ {children}
64
+ </ButtonPrimitive>
65
+ )
66
+
67
+ export { Button, buttonVariants }
@@ -0,0 +1,11 @@
1
+ import type { Button as ButtonPrimitive } from '@base-ui/react/button'
2
+ import type { VariantProps } from 'class-variance-authority'
3
+ import type { buttonVariants } from './Button'
4
+
5
+ type ButtonProps = ButtonPrimitive.Props &
6
+ VariantProps<typeof buttonVariants> & {
7
+ fullWidth?: boolean
8
+ loading?: boolean
9
+ }
10
+
11
+ export type { ButtonProps }
@@ -0,0 +1,112 @@
1
+ import type { ComponentProps } from 'react'
2
+ import { expect } from 'storybook/test'
3
+ import preview from '../../../../.storybook/preview'
4
+ import { Kbd } from './Kbd'
5
+
6
+ type KbdProps = ComponentProps<typeof Kbd>
7
+
8
+ const meta = preview.type<{ args: KbdProps }>().meta({
9
+ component: Kbd,
10
+ argTypes: {
11
+ size: {
12
+ control: 'select',
13
+ options: ['default', 'sm'],
14
+ },
15
+ },
16
+ decorators: [
17
+ Story => (
18
+ <div style={{ padding: 24, display: 'flex', alignItems: 'center', gap: 8 }}>
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ })
24
+
25
+ // --- Single keys ---
26
+
27
+ /** Plain letter key — the simplest case. */
28
+ export const SingleKey = meta.story({
29
+ args: { keys: 'T' },
30
+ })
31
+
32
+ /** Token name resolved to its standard symbol. */
33
+ export const EscapeKey = meta.story({
34
+ name: 'Symbolic: Escape',
35
+ args: { keys: 'escape' },
36
+ })
37
+
38
+ /** Spacebar rendered as the ␣ symbol. */
39
+ export const SpaceKey = meta.story({
40
+ name: 'Symbolic: Space',
41
+ args: { keys: 'space' },
42
+ })
43
+
44
+ /** Arrow key rendered as a directional symbol. */
45
+ export const ArrowKey = meta.story({
46
+ name: 'Symbolic: Arrow',
47
+ args: { keys: 'left' },
48
+ })
49
+
50
+ // --- Combos ---
51
+
52
+ /** Two-key combo — each key renders as a separate styled element. */
53
+ export const ShiftCombo = meta.story({
54
+ name: 'Combo: Shift + T',
55
+ args: { keys: ['shift', 'T'] },
56
+ })
57
+ ShiftCombo.test('renders separate kbd per key in combo', async ({ canvas }) => {
58
+ const wrapper = canvas.getAllByRole('presentation')[0]
59
+ if (!wrapper) throw new Error('No presentation wrapper found')
60
+ const kbdElements = wrapper.querySelectorAll('kbd')
61
+ await expect(kbdElements.length).toBe(2)
62
+ await expect(kbdElements[0]?.textContent).toBe('⇧')
63
+ await expect(kbdElements[1]?.textContent).toBe('T')
64
+ })
65
+
66
+ /** Mod token resolved to ⌘ on Mac or Ctrl on other platforms. */
67
+ export const ModCombo = meta.story({
68
+ name: 'Combo: Mod + F (OS-aware)',
69
+ args: { keys: ['mod', 'F'] },
70
+ })
71
+
72
+ /** Three-key combo showing modifier stacking. */
73
+ export const TripleCombo = meta.story({
74
+ name: 'Combo: Mod + Shift + K',
75
+ args: { keys: ['mod', 'shift', 'K'] },
76
+ })
77
+
78
+ // --- Sizes ---
79
+
80
+ /** Small size used inline within tooltip overlays. */
81
+ export const Small = meta.story({
82
+ name: 'Size: Small',
83
+ args: { keys: ['shift', 'B'], size: 'sm' },
84
+ })
85
+
86
+ // --- In context ---
87
+
88
+ /** Shortcut hint next to a settings label, matching the AppearanceCard layout. */
89
+ export const InlineWithText = meta.story({
90
+ name: 'Context: Inline with text',
91
+ args: { keys: ['shift', 'T'], size: 'sm' },
92
+ decorators: [
93
+ Story => (
94
+ <span className="text-muted-foreground flex items-center gap-2 text-sm">
95
+ Theme <Story />
96
+ </span>
97
+ ),
98
+ ],
99
+ })
100
+
101
+ /** Small Kbd on an inverted tooltip background — needs color overrides to stay visible. */
102
+ export const TooltipStyle = meta.story({
103
+ name: 'Context: Tooltip background',
104
+ args: { keys: ['mod', 'F'], size: 'sm', className: 'border-transparent bg-background/15' },
105
+ decorators: [
106
+ Story => (
107
+ <div className="bg-foreground text-background flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs">
108
+ Search <Story />
109
+ </div>
110
+ ),
111
+ ],
112
+ })
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import { getModKey } from '../helpers/getModKey/getModKey'
4
+ import { cn } from '../helpers/cn/cn'
5
+ import { cva } from 'class-variance-authority'
6
+ import type { KbdProps } from './Kbd.types'
7
+
8
+ const MOD_SYMBOLS: Record<string, string> = { Cmd: '⌘', Ctrl: 'Ctrl' }
9
+
10
+ const KEY_SYMBOLS: Record<string, string> = {
11
+ shift: '⇧',
12
+ alt: '⌥',
13
+ enter: '↵',
14
+ backspace: '⌫',
15
+ escape: 'Esc',
16
+ space: '␣',
17
+ left: '←',
18
+ right: '→',
19
+ up: '↑',
20
+ down: '↓',
21
+ }
22
+
23
+ const resolveKey = (key: string): string => {
24
+ const lower = key.toLowerCase()
25
+ if (lower === 'mod') return MOD_SYMBOLS[getModKey()] ?? 'Ctrl'
26
+ return KEY_SYMBOLS[lower] ?? key
27
+ }
28
+
29
+ const kbdVariants = cva(
30
+ 'inline-flex items-center justify-center rounded-sm border font-mono leading-none select-none',
31
+ {
32
+ variants: {
33
+ size: {
34
+ sm: 'min-w-4 px-1 py-0.5 text-[10px] border-foreground/10 bg-foreground/10',
35
+ default: 'min-w-5 px-1.5 py-1 text-[11px] border-border bg-muted',
36
+ },
37
+ },
38
+ defaultVariants: { size: 'default' },
39
+ },
40
+ )
41
+
42
+ const Kbd = ({ keys, size, className }: KbdProps) => {
43
+ const keyList = Array.isArray(keys) ? keys : [keys]
44
+
45
+ return (
46
+ <span className="inline-flex items-center gap-0.5" role="presentation">
47
+ {keyList.map((key, i) => (
48
+ <kbd key={i} className={cn(kbdVariants({ size }), className)}>
49
+ {resolveKey(key)}
50
+ </kbd>
51
+ ))}
52
+ </span>
53
+ )
54
+ }
55
+
56
+ export { Kbd, kbdVariants }
@@ -0,0 +1,10 @@
1
+ import type { VariantProps } from 'class-variance-authority'
2
+ import type { kbdVariants } from './Kbd'
3
+
4
+ type KbdProps = {
5
+ /** Single key or array of keys for combos. 'mod' auto-resolves to Cmd/Ctrl. */
6
+ keys: string | string[]
7
+ className?: string
8
+ } & VariantProps<typeof kbdVariants>
9
+
10
+ export type { KbdProps }
@@ -0,0 +1,4 @@
1
+ export const RING_SIZE = 16
2
+ export const STROKE_WIDTH = 2
3
+ export const RADIUS = (RING_SIZE - STROKE_WIDTH) / 2
4
+ export const CIRCUMFERENCE = 2 * Math.PI * RADIUS
@@ -0,0 +1,66 @@
1
+ import type { ComponentProps } from 'react'
2
+ import preview from '../../../../.storybook/preview'
3
+ import { ProgressRing } from './ProgressRing'
4
+
5
+ type ProgressRingProps = ComponentProps<typeof ProgressRing>
6
+
7
+ const meta = preview.type<{ args: ProgressRingProps }>().meta({
8
+ component: ProgressRing,
9
+ argTypes: {
10
+ progress: { control: { type: 'range', min: 0, max: 1, step: 0.01 } },
11
+ colorClass: {
12
+ control: 'select',
13
+ options: ['text-primary', 'text-success', 'text-muted-foreground'],
14
+ },
15
+ animate: { control: 'boolean' },
16
+ pendingStyle: {
17
+ control: 'select',
18
+ options: ['none', 'dashed'],
19
+ },
20
+ },
21
+ args: {
22
+ label: 'Generation progress',
23
+ colorClass: 'text-primary',
24
+ },
25
+ decorators: [
26
+ Story => (
27
+ <div style={{ padding: 24 }}>
28
+ <Story />
29
+ </div>
30
+ ),
31
+ ],
32
+ })
33
+
34
+ // --- Active generation ---
35
+
36
+ export const Spinning = meta.story({
37
+ name: 'Active: Spinning (low progress)',
38
+ args: { progress: 0, animate: true, label: 'Starting generation' },
39
+ })
40
+
41
+ export const InProgress = meta.story({
42
+ name: 'Active: In Progress',
43
+ args: { progress: 0.35, animate: true, label: '35 of 100 paragraphs' },
44
+ })
45
+
46
+ export const AlmostDone = meta.story({
47
+ name: 'Active: Almost Done',
48
+ args: { progress: 0.92, animate: true, label: '92 of 100 paragraphs' },
49
+ })
50
+
51
+ export const Completed = meta.story({
52
+ name: 'Completed',
53
+ args: { progress: 1, animate: false, colorClass: 'text-success', label: '100 paragraphs' },
54
+ })
55
+
56
+ // --- Pending ---
57
+
58
+ export const PendingQueued = meta.story({
59
+ name: 'Pending: Queued',
60
+ args: { progress: 0, animate: false, pendingStyle: 'dashed', label: 'Queued' },
61
+ })
62
+
63
+ export const PendingPaused = meta.story({
64
+ name: 'Pending: Paused (mid-generation)',
65
+ args: { progress: 0.4, animate: false, pendingStyle: 'dashed', label: 'Paused at 40%' },
66
+ })
@@ -0,0 +1,68 @@
1
+ import { CIRCUMFERENCE, RADIUS, RING_SIZE, STROKE_WIDTH } from './ProgressRing.consts'
2
+
3
+ type ProgressRingProps = {
4
+ progress: number
5
+ colorClass: string
6
+ label: string
7
+ animate?: boolean
8
+ pendingStyle?: 'none' | 'dashed'
9
+ testId?: string
10
+ }
11
+
12
+ const DASH_SEGMENT = CIRCUMFERENCE / 8
13
+
14
+ export const ProgressRing = ({
15
+ progress,
16
+ colorClass,
17
+ label,
18
+ animate = false,
19
+ pendingStyle = 'none',
20
+ testId,
21
+ }: ProgressRingProps) => {
22
+ const MIN_VISIBLE = 0.08
23
+ const visibleProgress = animate ? Math.max(progress, MIN_VISIBLE) : progress
24
+ const dashoffset = CIRCUMFERENCE * (1 - visibleProgress)
25
+
26
+ const shouldSpin = animate && progress < 0.15
27
+ const isDashed = !animate && pendingStyle === 'dashed'
28
+ const center = RING_SIZE / 2
29
+
30
+ return (
31
+ <svg
32
+ width={RING_SIZE}
33
+ height={RING_SIZE}
34
+ viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
35
+ role="img"
36
+ aria-label={label}
37
+ className={animate && !shouldSpin ? 'animate-buffer-pulse motion-reduce:animate-none' : ''}
38
+ >
39
+ {/* Track */}
40
+ <circle
41
+ cx={center}
42
+ cy={center}
43
+ r={RADIUS}
44
+ fill="none"
45
+ stroke="currentColor"
46
+ strokeWidth={STROKE_WIDTH}
47
+ className={`text-border ${isDashed ? 'animate-ring-drift motion-reduce:animate-none' : ''}`}
48
+ strokeDasharray={isDashed ? `${DASH_SEGMENT * 0.6} ${DASH_SEGMENT * 0.4}` : undefined}
49
+ style={isDashed ? { transformOrigin: `${center}px ${center}px` } : undefined}
50
+ />
51
+ {/* Fill */}
52
+ <circle
53
+ data-testid={testId}
54
+ cx={center}
55
+ cy={center}
56
+ r={RADIUS}
57
+ fill="none"
58
+ stroke="currentColor"
59
+ strokeWidth={STROKE_WIDTH}
60
+ strokeDasharray={CIRCUMFERENCE}
61
+ strokeDashoffset={dashoffset}
62
+ strokeLinecap="round"
63
+ className={`${colorClass} transition-[stroke-dashoffset,color] duration-500 ${shouldSpin ? 'animate-ring-spin motion-reduce:animate-none' : ''}`}
64
+ style={{ transformOrigin: `${center}px ${center}px` }}
65
+ />
66
+ </svg>
67
+ )
68
+ }