@proteos/ui 0.57.1 → 0.59.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.
Files changed (53) hide show
  1. package/dist/index.cjs +552 -192
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +333 -22
  4. package/dist/index.d.ts +333 -22
  5. package/dist/index.js +540 -194
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/badge-multi-select/badge-multi-select.tsx +1 -1
  9. package/src/components/checkbox/checkbox-group.tsx +4 -1
  10. package/src/components/color-input/color-input.tsx +1 -1
  11. package/src/components/combobox/combobox.tsx +1 -1
  12. package/src/components/command/command-hint.tsx +1 -4
  13. package/src/components/command/command.stories.tsx +2 -2
  14. package/src/components/command/command.test.tsx +65 -3
  15. package/src/components/command/command.tsx +52 -7
  16. package/src/components/command/index.ts +16 -8
  17. package/src/components/currency-input/currency-input.tsx +1 -1
  18. package/src/components/date-picker/date-picker.tsx +1 -1
  19. package/src/components/date-time-picker/date-time-picker.tsx +1 -1
  20. package/src/components/field/field.tsx +6 -0
  21. package/src/components/icon-picker/icon-picker.tsx +1 -1
  22. package/src/components/input/input.tsx +1 -1
  23. package/src/components/multi-select/multi-select.tsx +1 -1
  24. package/src/components/progress-bar/progress-bar.stories.tsx +3 -1
  25. package/src/components/progress-bar/progress-bar.tsx +2 -0
  26. package/src/components/progress-ring/index.ts +2 -0
  27. package/src/components/progress-ring/progress-ring.stories.tsx +82 -0
  28. package/src/components/progress-ring/progress-ring.test.tsx +47 -0
  29. package/src/components/progress-ring/progress-ring.tsx +112 -0
  30. package/src/components/radio-group/radio-group.tsx +4 -1
  31. package/src/components/record-update/index.ts +17 -0
  32. package/src/components/record-update/record-update.stories.tsx +141 -0
  33. package/src/components/record-update/record-update.test.tsx +114 -0
  34. package/src/components/record-update/record-update.tsx +259 -0
  35. package/src/components/select/select.tsx +1 -1
  36. package/src/components/switch/switch.stories.tsx +22 -0
  37. package/src/components/switch/switch.test.tsx +6 -0
  38. package/src/components/switch/switch.tsx +40 -3
  39. package/src/components/tag-input/tag-input.tsx +1 -1
  40. package/src/components/text-shimmer/index.ts +2 -0
  41. package/src/components/text-shimmer/text-shimmer.stories.tsx +35 -0
  42. package/src/components/text-shimmer/text-shimmer.test.tsx +16 -0
  43. package/src/components/text-shimmer/text-shimmer.tsx +43 -0
  44. package/src/components/textarea/textarea.tsx +1 -1
  45. package/src/components/time-field/time-field.tsx +1 -1
  46. package/src/index.ts +3 -0
  47. package/src/lib/commands/index.ts +6 -2
  48. package/src/lib/commands/registry.test.ts +54 -1
  49. package/src/lib/commands/registry.ts +89 -4
  50. package/src/lib/commands/use-active-commands.test.tsx +56 -0
  51. package/src/lib/commands/use-active-commands.ts +16 -0
  52. package/src/lib/commands/use-command.ts +16 -5
  53. package/src/styles.css +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -56,7 +56,7 @@
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
58
  "thinking-orbs": "^0.3.1",
59
- "@proteos/sdk": "0.57.1"
59
+ "@proteos/sdk": "0.59.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@biomejs/biome": "^2.4.13",
@@ -206,7 +206,7 @@ export function BadgeMultiSelect<T extends Key>({
206
206
  }
207
207
 
208
208
  return (
209
- <div className={cn('grid grid-cols-1 gap-1.5', className)}>
209
+ <div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
210
210
  {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
211
211
 
212
212
  <div
@@ -36,7 +36,10 @@ export function CheckboxGroup({
36
36
  <AriaCheckboxGroup
37
37
  {...props}
38
38
  className={(renderProps) =>
39
- cn('grid gap-2', typeof className === 'function' ? className(renderProps) : className)
39
+ cn(
40
+ 'grid gap-2 content-start',
41
+ typeof className === 'function' ? className(renderProps) : className,
42
+ )
40
43
  }
41
44
  >
42
45
  {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
@@ -139,7 +139,7 @@ export function ColorInput({
139
139
  // Canonical formatting once the user leaves: "ABC" → "#aabbcc".
140
140
  onBlur={settle}
141
141
  {...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
142
- className={cn('grid grid-cols-1 gap-1.5', className)}
142
+ className={cn('grid grid-cols-1 gap-1.5 content-start', className)}
143
143
  >
144
144
  {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
145
145
 
@@ -91,7 +91,7 @@ export function Combobox<T extends Key>({
91
91
  allowsEmptyCollection={allowsEmptyCollection}
92
92
  className={(renderProps) =>
93
93
  cn(
94
- 'grid grid-cols-1 gap-1.5',
94
+ 'grid grid-cols-1 gap-1.5 content-start',
95
95
  typeof className === 'function' ? className(renderProps) : className,
96
96
  )
97
97
  }
@@ -30,10 +30,7 @@ export function CommandHint({ combo, tone = 'subtle', className }: CommandHintPr
30
30
  const label = platform === 'mac' ? `${modSymbol}${keyLabel}` : `${modSymbol}+${keyLabel}`
31
31
  return (
32
32
  <Kbd
33
- className={cn(
34
- tone === 'prominent' && 'bg-ink text-surface border-transparent',
35
- className,
36
- )}
33
+ className={cn(tone === 'prominent' && 'bg-ink text-surface border-transparent', className)}
37
34
  >
38
35
  {label}
39
36
  </Kbd>
@@ -35,8 +35,8 @@ export const Basic: Story = {
35
35
  return (
36
36
  <div className="flex flex-col items-start gap-3">
37
37
  <p className="text-[13px] text-ink-3">
38
- Press <strong>Cmd+J</strong> (or hold <strong>Cmd+Shift</strong> to peek the
39
- shortcut, then press <strong>J</strong>).
38
+ Press <strong>Cmd+J</strong> (or hold <strong>Cmd+Shift</strong> to peek the shortcut,
39
+ then press <strong>J</strong>).
40
40
  </p>
41
41
  <Command combo="mod+j">
42
42
  <Button onPress={() => setCount((c) => c + 1)}>Increment ({count})</Button>
@@ -1,10 +1,9 @@
1
1
  import { act, render, screen } from '@testing-library/react'
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
-
4
- import { Button } from '../button'
5
3
  import { _resetDiscovery } from '../../lib/commands/discovery-store'
6
4
  import { _setPlatformForTests } from '../../lib/commands/platform'
7
- import { _resetRegistry } from '../../lib/commands/registry'
5
+ import { _resetRegistry, listActiveCommands } from '../../lib/commands/registry'
6
+ import { Button } from '../button'
8
7
 
9
8
  import { Command } from './command'
10
9
 
@@ -100,3 +99,66 @@ describe('Command', () => {
100
99
  expect(onPress).not.toHaveBeenCalled()
101
100
  })
102
101
  })
102
+
103
+ describe('Command — palette actions', () => {
104
+ beforeEach(() => {
105
+ _setPlatformForTests('mac')
106
+ _resetRegistry()
107
+ _resetDiscovery()
108
+ })
109
+ afterEach(() => {
110
+ _setPlatformForTests(undefined)
111
+ })
112
+
113
+ it("registers the wrapped control's accessible name as the action label", () => {
114
+ const onPress = vi.fn()
115
+ render(
116
+ <>
117
+ <Command combo="mod+s">
118
+ <Button onPress={onPress}>Save</Button>
119
+ </Command>
120
+ <Command combo="mod+f">
121
+ <Button aria-label="Filter records">🔍</Button>
122
+ </Command>
123
+ <Command combo="mod+e" label="Edit record">
124
+ <Button>Edit</Button>
125
+ </Command>
126
+ </>,
127
+ )
128
+ const actions = listActiveCommands()
129
+ expect(actions.map((a) => [a.combo.raw, a.label])).toEqual([
130
+ ['mod+s', 'Save'],
131
+ ['mod+f', 'Filter records'],
132
+ ['mod+e', 'Edit record'],
133
+ ])
134
+
135
+ actions[0]?.invoke()
136
+ expect(onPress).toHaveBeenCalledTimes(1)
137
+ })
138
+
139
+ it('advertises the New shortcut as ⌘B on mac', () => {
140
+ render(
141
+ <Command combo="mod+b">
142
+ <Button>New</Button>
143
+ </Command>,
144
+ )
145
+ holdCmdShift()
146
+ expect(screen.getByText('⌘B')).toBeInTheDocument()
147
+ expect(listActiveCommands().map((a) => [a.combo.raw, a.label])).toEqual([['mod+b', 'New']])
148
+ })
149
+
150
+ it('separates adjacent text pieces and skips aria-hidden decorations', () => {
151
+ render(
152
+ <Command combo="mod+f">
153
+ <Button>
154
+ <svg aria-hidden="true">
155
+ <title>funnel</title>
156
+ </svg>
157
+ Filter
158
+ <span>1</span>
159
+ </Button>
160
+ </Command>,
161
+ )
162
+ expect(listActiveCommands().map((a) => a.label)).toEqual(['Filter 1'])
163
+ })
164
+ })
@@ -3,8 +3,8 @@
3
3
  import { type ReactNode, useRef } from 'react'
4
4
 
5
5
  import { cn } from '../../lib/cn'
6
- import { useCommand } from '../../lib/commands/use-command'
7
6
  import { useDiscoveryActive } from '../../lib/commands/discovery-store'
7
+ import { useCommand } from '../../lib/commands/use-command'
8
8
 
9
9
  import { CommandHint } from './command-hint'
10
10
 
@@ -33,6 +33,11 @@ import { CommandHint } from './command-hint'
33
33
  call sites where the wrapper needs to participate in a flex/grid layout.
34
34
  The wrapper stays `relative` either way so the discovery overlay can
35
35
  absolute-position against it.
36
+
37
+ The binding is registered WITH a label — `label` when given, else the
38
+ target control's accessible name (aria-label, then its text) read when the
39
+ list is built — so the command palette can list it under "Actions" and
40
+ trigger it without the keyboard (`useActiveCommands`).
36
41
  ========================================================================= */
37
42
 
38
43
  export type CommandProps = {
@@ -42,21 +47,31 @@ export type CommandProps = {
42
47
  target?: string
43
48
  /** Layout overrides for the wrapper. `relative` is always applied. */
44
49
  className?: string
50
+ /** Name shown for this binding in the command palette. Defaults to the
51
+ * target control's accessible name. */
52
+ label?: string
45
53
  children: ReactNode
46
54
  }
47
55
 
48
- export function Command({ combo, target, className, children }: CommandProps) {
56
+ export function Command({ combo, target, className, label, children }: CommandProps) {
49
57
  const wrapperRef = useRef<HTMLDivElement>(null)
50
58
  const discoveryActive = useDiscoveryActive()
51
59
 
52
- useCommand(combo, () => {
60
+ const resolveTarget = (): HTMLElement | null => {
53
61
  const root = wrapperRef.current
54
- if (!root) return
55
- const el = target
62
+ if (!root) return null
63
+ return target
56
64
  ? root.querySelector<HTMLElement>(target)
57
65
  : (root.firstElementChild as HTMLElement | null)
58
- el?.click()
59
- })
66
+ }
67
+
68
+ useCommand(
69
+ combo,
70
+ () => {
71
+ resolveTarget()?.click()
72
+ },
73
+ { label: () => label ?? accessibleName(resolveTarget()) },
74
+ )
60
75
 
61
76
  return (
62
77
  <div ref={wrapperRef} className={cn('relative inline-block', className)}>
@@ -74,3 +89,33 @@ export function Command({ combo, target, className, children }: CommandProps) {
74
89
  </div>
75
90
  )
76
91
  }
92
+
93
+ /** The control's name as assistive tech would read it: an explicit aria-label
94
+ * first, else its visible text — each text piece separated by a space (so
95
+ * "Filter" next to a count chip reads "Filter 1", not "Filter1"), with
96
+ * `aria-hidden` decorations (icons) left out. */
97
+ function accessibleName(el: HTMLElement | null): string | undefined {
98
+ if (!el) return undefined
99
+ const aria = el.getAttribute('aria-label')?.trim()
100
+ if (aria) return aria
101
+ const pieces: string[] = []
102
+ const walk = (node: Node): void => {
103
+ if (node.nodeType === Node.TEXT_NODE) {
104
+ const text = node.textContent?.trim()
105
+ if (text) pieces.push(text)
106
+ return
107
+ }
108
+ // `Element`, not `HTMLElement`: an inline SVG icon is an SVGElement.
109
+ if (node instanceof Element) {
110
+ if (node.getAttribute('aria-hidden') === 'true') return
111
+ const own = node.getAttribute('aria-label')?.trim()
112
+ if (own) {
113
+ pieces.push(own)
114
+ return
115
+ }
116
+ }
117
+ for (const child of node.childNodes) walk(child)
118
+ }
119
+ walk(el)
120
+ return pieces.length > 0 ? pieces.join(' ') : undefined
121
+ }
@@ -1,16 +1,24 @@
1
- export type { CommandProps } from './command'
2
- export { Command } from './command'
3
- export type { CommandHintProps, CommandHintTone } from './command-hint'
4
- export { CommandHint } from './command-hint'
5
-
6
1
  // Re-export the command-system primitives so consumers can `import { useCommand,
7
- // useEscapeKey, parseCombo, useDiscoveryActive } from '@proteos/ui'`.
8
- export type { KeyCombo, UseCommandOptions, UseEscapeKeyOptions } from '../../lib/commands'
2
+ // useEscapeKey, parseCombo, useDiscoveryActive, useActiveCommands, getPlatform } from '@proteos/ui'`.
3
+ export type {
4
+ ActiveCommand,
5
+ CommandLabel,
6
+ KeyCombo,
7
+ Platform,
8
+ UseCommandOptions,
9
+ UseEscapeKeyOptions,
10
+ } from '../../lib/commands'
9
11
  export {
12
+ getPlatform,
10
13
  isEditableTarget,
11
- parseCombo,
12
14
  PROTECTED_COMBOS,
15
+ parseCombo,
16
+ useActiveCommands,
13
17
  useCommand,
14
18
  useDiscoveryActive,
15
19
  useEscapeKey,
16
20
  } from '../../lib/commands'
21
+ export type { CommandProps } from './command'
22
+ export { Command } from './command'
23
+ export type { CommandHintProps, CommandHintTone } from './command-hint'
24
+ export { CommandHint } from './command-hint'
@@ -101,7 +101,7 @@ export function CurrencyInput({
101
101
  : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
102
102
 
103
103
  return (
104
- <div className={cn('grid grid-cols-1 gap-1.5', className)}>
104
+ <div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
105
105
  <div className={cn(styles.box(), 'flex items-center gap-1 pr-1', focusWithin)}>
106
106
  <input
107
107
  className={cn(
@@ -53,7 +53,7 @@ export function DatePicker<T extends DateValue>({
53
53
  {...props}
54
54
  className={(renderProps) =>
55
55
  cn(
56
- 'grid grid-cols-1 gap-1.5',
56
+ 'grid grid-cols-1 gap-1.5 content-start',
57
57
  typeof className === 'function' ? className(renderProps) : className,
58
58
  )
59
59
  }
@@ -123,7 +123,7 @@ export function DateTimePicker<T extends DateValue>({
123
123
  shouldCloseOnSelect={shouldCloseOnSelect}
124
124
  className={(renderProps) =>
125
125
  cn(
126
- 'grid grid-cols-1 gap-1.5',
126
+ 'grid grid-cols-1 gap-1.5 content-start',
127
127
  typeof className === 'function' ? className(renderProps) : className,
128
128
  )
129
129
  }
@@ -127,6 +127,12 @@ export const fieldChipPadding: Record<FieldSize, string> = {
127
127
  Label / hint / error text styles — applied to RAC's Label / Text / FieldError
128
128
  components inside each form control. Strings rather than components so the
129
129
  existing monolithic APIs can keep their inline composition.
130
+
131
+ Every control stacks them on a `grid grid-cols-1 gap-1.5 content-start`
132
+ root (groups: `grid gap-2 content-start`). `content-start` matters: a
133
+ consumer grid stretches its cells, and without it the auto rows of a
134
+ field with no hint absorb the extra height — its label drifts and its box
135
+ grows to match a sibling that has one. Copy the recipe for new controls.
130
136
  ========================================================================= */
131
137
 
132
138
  export const fieldLabelStyles = 'font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4'
@@ -157,7 +157,7 @@ export function IconPicker({
157
157
  }
158
158
 
159
159
  return (
160
- <div className={cn('grid grid-cols-1 gap-1.5', className)}>
160
+ <div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
161
161
  {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
162
162
 
163
163
  {compact ? (
@@ -56,7 +56,7 @@ export function Input({
56
56
  {...props}
57
57
  className={(renderProps) =>
58
58
  cn(
59
- 'grid grid-cols-1 gap-1.5',
59
+ 'grid grid-cols-1 gap-1.5 content-start',
60
60
  typeof className === 'function' ? className(renderProps) : className,
61
61
  )
62
62
  }
@@ -123,7 +123,7 @@ export function MultiSelect<T extends Key>({
123
123
  }
124
124
 
125
125
  return (
126
- <div className={cn('grid grid-cols-1 gap-1.5', className)}>
126
+ <div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
127
127
  {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
128
128
 
129
129
  <div
@@ -15,7 +15,7 @@ const meta: Meta<typeof ProgressBar> = {
15
15
  argTypes: {
16
16
  size: {
17
17
  control: { type: 'inline-radio' },
18
- options: ['sm', 'md'],
18
+ options: ['xs', 'sm', 'md'],
19
19
  },
20
20
  isIndeterminate: { control: { type: 'boolean' } },
21
21
  value: { control: { type: 'number', min: 0, max: 100 } },
@@ -66,6 +66,8 @@ export const Sizes: Story = {
66
66
  parameters: { controls: { hideNoControlsWarning: true } },
67
67
  render: () => (
68
68
  <div className="grid grid-cols-[80px_1fr] items-center gap-x-6 gap-y-4 w-[360px]">
69
+ <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">xs</div>
70
+ <ProgressBar value={60} size="xs" aria-label="Progress xs" />
69
71
  <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">sm</div>
70
72
  <ProgressBar value={60} size="sm" aria-label="Progress sm" />
71
73
  <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">md</div>
@@ -23,6 +23,8 @@ export const progressBarStyles = tv({
23
23
  },
24
24
  variants: {
25
25
  size: {
26
+ // 2px hairline — an edge meter along a card's bottom, positioned by the consumer.
27
+ xs: { track: 'h-0.5' },
26
28
  sm: { track: 'h-1' },
27
29
  md: { track: 'h-2' },
28
30
  },
@@ -0,0 +1,2 @@
1
+ export type { ProgressRingProps, ProgressRingVariants } from './progress-ring'
2
+ export { ProgressRing, progressRingStyles } from './progress-ring'
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { ProgressRing } from './progress-ring'
4
+
5
+ const meta: Meta<typeof ProgressRing> = {
6
+ title: 'Components/ProgressRing',
7
+ component: ProgressRing,
8
+ tags: ['autodocs'],
9
+ args: {
10
+ 'aria-label': 'Confidence',
11
+ size: 'sm',
12
+ value: 82,
13
+ },
14
+ argTypes: {
15
+ size: {
16
+ control: { type: 'inline-radio' },
17
+ options: ['xs', 'sm', 'md'],
18
+ },
19
+ isIndeterminate: { control: { type: 'boolean' } },
20
+ value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
21
+ },
22
+ }
23
+
24
+ export default meta
25
+ type Story = StoryObj<typeof ProgressRing>
26
+
27
+ export const Playground: Story = {}
28
+
29
+ export const Sizes: Story = {
30
+ parameters: { controls: { hideNoControlsWarning: true } },
31
+ render: () => (
32
+ <div className="grid grid-cols-[80px_auto] items-center gap-x-6 gap-y-4">
33
+ <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">xs</div>
34
+ <ProgressRing value={82} size="xs" aria-label="Confidence xs" />
35
+ <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">sm</div>
36
+ <ProgressRing value={82} size="sm" aria-label="Confidence sm" />
37
+ <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">md</div>
38
+ <ProgressRing value={82} size="md" aria-label="Confidence md" />
39
+ </div>
40
+ ),
41
+ }
42
+
43
+ export const Values: Story = {
44
+ parameters: { controls: { hideNoControlsWarning: true } },
45
+ render: () => (
46
+ <div className="flex items-center gap-4">
47
+ {[0, 33, 82, 100].map((value) => (
48
+ <ProgressRing key={value} value={value} size="md" aria-label={`${value} percent`} />
49
+ ))}
50
+ </div>
51
+ ),
52
+ }
53
+
54
+ export const InARow: Story = {
55
+ parameters: { controls: { hideNoControlsWarning: true } },
56
+ render: () => (
57
+ <div className="flex flex-col gap-1 w-[320px] rounded-md border border-rule-2 px-2.5 py-2">
58
+ <div className="flex items-start gap-2">
59
+ <p className="flex-1 min-w-0 text-[12.5px] text-ink-2">
60
+ <span className="font-medium text-ink">Pricing mentioned</span>
61
+ </p>
62
+ <ProgressRing
63
+ size="xs"
64
+ className="mt-0.5"
65
+ value={82}
66
+ aria-label="Pricing mentioned confidence"
67
+ />
68
+ </div>
69
+ <p className="text-[12px] leading-[1.45] text-ink-2">Asked about the annual plan.</p>
70
+ </div>
71
+ ),
72
+ }
73
+
74
+ export const Indeterminate: Story = {
75
+ parameters: { controls: { hideNoControlsWarning: true } },
76
+ render: () => (
77
+ <div className="flex items-center gap-4">
78
+ <ProgressRing isIndeterminate aria-label="Evaluating" />
79
+ <ProgressRing isIndeterminate size="md" aria-label="Evaluating md" />
80
+ </div>
81
+ ),
82
+ }
@@ -0,0 +1,47 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { ProgressRing } from './progress-ring'
5
+
6
+ describe('ProgressRing', () => {
7
+ it('renders with the progressbar role and accessible name', () => {
8
+ render(<ProgressRing value={40} aria-label="Confidence" />)
9
+ expect(screen.getByRole('progressbar', { name: 'Confidence' })).toBeInTheDocument()
10
+ })
11
+
12
+ it('reflects the value in aria-valuenow and centres the percent text', () => {
13
+ render(<ProgressRing value={82} aria-label="Confidence" />)
14
+ expect(screen.getByRole('progressbar', { name: 'Confidence' })).toHaveAttribute(
15
+ 'aria-valuenow',
16
+ '82',
17
+ )
18
+ expect(screen.getByText('82%')).toBeInTheDocument()
19
+ })
20
+
21
+ it('shows a custom valueLabel instead of the percent', () => {
22
+ render(<ProgressRing value={4} maxValue={5} valueLabel="4/5" aria-label="Steps" />)
23
+ expect(screen.getByText('4/5')).toBeInTheDocument()
24
+ expect(screen.queryByText('80%')).not.toBeInTheDocument()
25
+ })
26
+
27
+ it('omits aria-valuenow and the value text when indeterminate', () => {
28
+ render(<ProgressRing isIndeterminate aria-label="Evaluating" />)
29
+ const ring = screen.getByRole('progressbar', { name: 'Evaluating' })
30
+ expect(ring).not.toHaveAttribute('aria-valuenow')
31
+ expect(ring).toHaveTextContent('')
32
+ })
33
+
34
+ it('sets the value beside a 14px xs ring', () => {
35
+ render(<ProgressRing size="xs" value={82} aria-label="Confidence" />)
36
+ const ring = screen.getByRole('progressbar', { name: 'Confidence' })
37
+ expect(ring).toHaveAttribute('aria-valuenow', '82')
38
+ expect(ring.querySelector('svg')).toHaveAttribute('viewBox', '0 0 14 14')
39
+ expect(screen.getByText('82%')).toBeInTheDocument()
40
+ })
41
+
42
+ it('still renders the value at zero', () => {
43
+ render(<ProgressRing value={0} aria-label="Empty" />)
44
+ expect(screen.getByRole('progressbar', { name: 'Empty' })).toHaveAttribute('aria-valuenow', '0')
45
+ expect(screen.getByText('0%')).toBeInTheDocument()
46
+ })
47
+ })
@@ -0,0 +1,112 @@
1
+ import {
2
+ ProgressBar as AriaProgressBar,
3
+ type ProgressBarProps as AriaProgressBarProps,
4
+ } from 'react-aria-components'
5
+ import { tv, type VariantProps } from 'tailwind-variants'
6
+
7
+ import { cn } from '../../lib/cn'
8
+
9
+ /* =========================================================================
10
+ ProgressRing — the circular sibling of ProgressBar: RAC's headless
11
+ ProgressBar (state + ARIA) around an SVG track + arc, with the formatted
12
+ value (RAC's `valueText`, percent by default) set beside or inside the
13
+ ring. For the compact "how sure / how far" read beside a title — a
14
+ confidence, a quota, a completion — where a full-width bar would
15
+ dominate the row.
16
+
17
+ Sizes: `xs` is a 14px glyph with the value in mono BESIDE it (digits
18
+ cannot live inside a ring that small); `sm` (28px) and `md` (40px) centre
19
+ the value inside. Determinate (`value`) draws the arc clockwise from
20
+ 12 o'clock; both circles carry `pathLength={100}` so the dash length IS
21
+ the percentage. Indeterminate (`isIndeterminate`) spins a quarter arc
22
+ and hides the value. `aria-label` (or `aria-labelledby`) is required —
23
+ the ring has no visible label of its own.
24
+ ========================================================================= */
25
+
26
+ export const progressRingStyles = tv({
27
+ slots: {
28
+ root: 'shrink-0 align-middle',
29
+ svg: 'block shrink-0 -rotate-90',
30
+ track: 'fill-none',
31
+ arc: 'fill-none stroke-accent transition-[stroke-dasharray] duration-300 ease-out',
32
+ value: 'font-mono tabular-nums leading-none',
33
+ },
34
+ variants: {
35
+ size: {
36
+ xs: {
37
+ root: 'inline-flex items-center gap-1.5',
38
+ svg: 'size-3.5',
39
+ // A hair darker than the larger rings' track: a 2px hairline on a
40
+ // tinted card vanishes at surface-2.
41
+ track: 'stroke-surface-3',
42
+ value: 'text-[11px] text-ink-4',
43
+ },
44
+ sm: {
45
+ root: 'relative inline-grid place-items-center size-7',
46
+ svg: 'col-start-1 row-start-1 size-full',
47
+ track: 'stroke-surface-2',
48
+ value: 'col-start-1 row-start-1 text-[8.5px] text-ink-2',
49
+ },
50
+ md: {
51
+ root: 'relative inline-grid place-items-center size-10',
52
+ svg: 'col-start-1 row-start-1 size-full',
53
+ track: 'stroke-surface-2',
54
+ value: 'col-start-1 row-start-1 text-[10px] text-ink-2',
55
+ },
56
+ },
57
+ },
58
+ defaultVariants: { size: 'sm' },
59
+ })
60
+
61
+ /** Pixel geometry per size — tv() carries classes, the SVG needs numbers. */
62
+ const GEOMETRY = {
63
+ xs: { box: 14, stroke: 2 },
64
+ sm: { box: 28, stroke: 2.5 },
65
+ md: { box: 40, stroke: 3 },
66
+ } as const
67
+
68
+ export type ProgressRingVariants = VariantProps<typeof progressRingStyles>
69
+ export type ProgressRingProps = Omit<AriaProgressBarProps, 'children' | 'className'> &
70
+ ProgressRingVariants & {
71
+ className?: string
72
+ }
73
+
74
+ export function ProgressRing({ size, className, ...props }: ProgressRingProps) {
75
+ const resolvedSize = size ?? 'sm'
76
+ const { root, svg, track, arc, value } = progressRingStyles({ size: resolvedSize })
77
+ const { box, stroke } = GEOMETRY[resolvedSize]
78
+ const center = box / 2
79
+ const radius = (box - stroke) / 2
80
+ return (
81
+ <AriaProgressBar {...props} className={cn(root(), className)}>
82
+ {({ percentage, valueText, isIndeterminate }) => {
83
+ const shown = isIndeterminate ? 25 : Math.max(0, Math.min(100, percentage ?? 0))
84
+ return (
85
+ <>
86
+ <svg
87
+ aria-hidden
88
+ viewBox={`0 0 ${box} ${box}`}
89
+ className={cn(svg(), isIndeterminate && 'animate-spin')}
90
+ >
91
+ <circle cx={center} cy={center} r={radius} strokeWidth={stroke} className={track()} />
92
+ {/* A zero-length dash with round caps would still paint a dot. */}
93
+ {shown > 0 ? (
94
+ <circle
95
+ cx={center}
96
+ cy={center}
97
+ r={radius}
98
+ strokeWidth={stroke}
99
+ strokeLinecap="round"
100
+ pathLength={100}
101
+ strokeDasharray={`${shown} 100`}
102
+ className={arc()}
103
+ />
104
+ ) : null}
105
+ </svg>
106
+ {isIndeterminate ? null : <span className={value()}>{valueText}</span>}
107
+ </>
108
+ )
109
+ }}
110
+ </AriaProgressBar>
111
+ )
112
+ }
@@ -39,7 +39,10 @@ export function RadioGroup({
39
39
  {...props}
40
40
  orientation={orientation}
41
41
  className={(renderProps) =>
42
- cn('grid gap-2', typeof className === 'function' ? className(renderProps) : className)
42
+ cn(
43
+ 'grid gap-2 content-start',
44
+ typeof className === 'function' ? className(renderProps) : className,
45
+ )
43
46
  }
44
47
  >
45
48
  {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}