@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
@@ -0,0 +1,17 @@
1
+ export type {
2
+ RecordUpdateErrorsProps,
3
+ RecordUpdateProps,
4
+ RecordUpdateRowProps,
5
+ RecordUpdateRowState,
6
+ RecordUpdateRowVariants,
7
+ } from './record-update'
8
+ export {
9
+ RecordUpdate,
10
+ RecordUpdateErrors,
11
+ RecordUpdateFooter,
12
+ RecordUpdateRow,
13
+ RecordUpdateRows,
14
+ RecordUpdateSummary,
15
+ recordUpdateRowStyles,
16
+ recordUpdateStyles,
17
+ } from './record-update'
@@ -0,0 +1,141 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { type ReactNode, useState } from 'react'
3
+
4
+ import { Badge } from '../badge/badge'
5
+ import { Button } from '../button/button'
6
+ import { Input } from '../input/input'
7
+ import { Select } from '../select/select'
8
+ import {
9
+ RecordUpdate,
10
+ RecordUpdateErrors,
11
+ RecordUpdateFooter,
12
+ RecordUpdateRow,
13
+ type RecordUpdateRowState,
14
+ RecordUpdateRows,
15
+ RecordUpdateSummary,
16
+ } from './record-update'
17
+
18
+ const meta: Meta<typeof RecordUpdateRow> = {
19
+ title: 'Components/RecordUpdate',
20
+ component: RecordUpdateRow,
21
+ tags: ['autodocs'],
22
+ argTypes: {
23
+ state: { control: 'radio', options: ['changed', 'unresolved', 'rejected', 'removed'] },
24
+ },
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof RecordUpdateRow>
29
+
30
+ const STATUS_OPTIONS = [
31
+ { id: 'open', label: 'Open' },
32
+ { id: 'won', label: 'Won' },
33
+ { id: 'churned', label: 'Churned' },
34
+ ] as const
35
+
36
+ function ReadOnly({ children }: { children: ReactNode }) {
37
+ return (
38
+ <div className="flex items-center min-h-9 bg-surface-2 rounded-md px-3 py-2 text-[13.5px] text-ink">
39
+ {children}
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export const Row: Story = {
45
+ args: {
46
+ label: 'Status',
47
+ from: <ReadOnly>Open</ReadOnly>,
48
+ to: <ReadOnly>Churned</ReadOnly>,
49
+ state: 'changed',
50
+ onRemove: () => {},
51
+ onRestore: () => {},
52
+ },
53
+ }
54
+
55
+ export const Review: Story = {
56
+ parameters: { controls: { hideNoControlsWarning: true } },
57
+ render: () => {
58
+ const [status, setStatus] = useState('churned')
59
+ const [amount, setAmount] = useState('13200')
60
+ const [removed, setRemoved] = useState<Record<string, boolean>>({})
61
+ const stateFor = (key: string, fallback: RecordUpdateRowState = 'changed') =>
62
+ removed[key] ? 'removed' : fallback
63
+ const toggle = (key: string, value: boolean) =>
64
+ setRemoved((prev) => ({ ...prev, [key]: value }))
65
+ return (
66
+ <div className="w-[600px] border border-rule rounded-lg bg-surface">
67
+ <RecordUpdate>
68
+ <RecordUpdateSummary>
69
+ Marks the company as churned and raises the amount by 10%.
70
+ </RecordUpdateSummary>
71
+ <RecordUpdateRows>
72
+ <RecordUpdateRow
73
+ label="Status"
74
+ state={stateFor('status')}
75
+ from={
76
+ <ReadOnly>
77
+ <Badge>Open</Badge>
78
+ </ReadOnly>
79
+ }
80
+ to={
81
+ <Select
82
+ aria-label="Status"
83
+ options={STATUS_OPTIONS}
84
+ selectedKey={status}
85
+ onSelectionChange={(key) => setStatus(String(key))}
86
+ />
87
+ }
88
+ onRemove={() => toggle('status', true)}
89
+ onRestore={() => toggle('status', false)}
90
+ />
91
+ <RecordUpdateRow
92
+ label="Amount"
93
+ state={stateFor('amount')}
94
+ from={<ReadOnly>12,000</ReadOnly>}
95
+ to={<Input aria-label="Amount" value={amount} onChange={setAmount} />}
96
+ onRemove={() => toggle('amount', true)}
97
+ onRestore={() => toggle('amount', false)}
98
+ />
99
+ <RecordUpdateRow
100
+ label="Owner"
101
+ state={stateFor('owner', 'unresolved')}
102
+ from={<ReadOnly>Tonio Knuth</ReadOnly>}
103
+ to={<Input aria-label="Owner" placeholder="Pick a user…" />}
104
+ hint="“Julia” is a name, not an id — pick the user."
105
+ onRemove={() => toggle('owner', true)}
106
+ onRestore={() => toggle('owner', false)}
107
+ />
108
+ <RecordUpdateRow
109
+ label="Tier"
110
+ state="rejected"
111
+ from={
112
+ <ReadOnly>
113
+ <Badge>Silver</Badge>
114
+ </ReadOnly>
115
+ }
116
+ to={
117
+ <span className="text-[13px] text-danger">
118
+ “platinum” is not one of the options (silver, gold).
119
+ </span>
120
+ }
121
+ />
122
+ <RecordUpdateRow
123
+ label="Website"
124
+ from={<ReadOnly>—</ReadOnly>}
125
+ to={<Input aria-label="Website" value="acme" onChange={() => {}} isInvalid />}
126
+ errorMessage="Must be a valid URL"
127
+ onRemove={() => {}}
128
+ />
129
+ </RecordUpdateRows>
130
+ <RecordUpdateErrors
131
+ errors={['“Created at” is managed by the platform and cannot be changed.']}
132
+ />
133
+ <RecordUpdateFooter>
134
+ <Button variant="ghost">Cancel</Button>
135
+ <Button variant="cta">Apply 2 changes</Button>
136
+ </RecordUpdateFooter>
137
+ </RecordUpdate>
138
+ </div>
139
+ )
140
+ },
141
+ }
@@ -0,0 +1,114 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import {
6
+ RecordUpdate,
7
+ RecordUpdateErrors,
8
+ RecordUpdateRow,
9
+ RecordUpdateRows,
10
+ RecordUpdateSummary,
11
+ } from './record-update'
12
+
13
+ describe('RecordUpdateRow', () => {
14
+ it('names the group after the label and shows both cells', () => {
15
+ render(<RecordUpdateRow label="Status" from={<span>Open</span>} to={<span>Won</span>} />)
16
+ expect(screen.getByRole('group')).toHaveAttribute('data-state', 'changed')
17
+ expect(screen.getByText('Status')).toBeInTheDocument()
18
+ expect(screen.getByText('Open')).toBeInTheDocument()
19
+ expect(screen.getByText('Won')).toBeInTheDocument()
20
+ })
21
+
22
+ it('offers remove while changed and restore once removed', async () => {
23
+ const user = userEvent.setup()
24
+ const onRemove = vi.fn()
25
+ const onRestore = vi.fn()
26
+ const { rerender } = render(
27
+ <RecordUpdateRow
28
+ label="Status"
29
+ from="Open"
30
+ to="Won"
31
+ onRemove={onRemove}
32
+ onRestore={onRestore}
33
+ />,
34
+ )
35
+ await user.click(screen.getByRole('button', { name: 'Remove change' }))
36
+ expect(onRemove).toHaveBeenCalledTimes(1)
37
+ expect(screen.queryByRole('button', { name: 'Restore change' })).not.toBeInTheDocument()
38
+
39
+ rerender(
40
+ <RecordUpdateRow
41
+ label="Status"
42
+ from="Open"
43
+ to="Won"
44
+ state="removed"
45
+ onRemove={onRemove}
46
+ onRestore={onRestore}
47
+ />,
48
+ )
49
+ expect(screen.queryByText('Won')).not.toBeInTheDocument()
50
+ expect(screen.getByText('Kept as it is')).toBeInTheDocument()
51
+ await user.click(screen.getByRole('button', { name: 'Restore change' }))
52
+ expect(onRestore).toHaveBeenCalledTimes(1)
53
+ })
54
+
55
+ it('renders the proposed value alone when there is no current value', () => {
56
+ const { container } = render(<RecordUpdateRow label="Name" to={<span>Julia</span>} />)
57
+ expect(screen.getByText('Julia')).toBeInTheDocument()
58
+ expect(container.querySelector('svg')).toBeNull()
59
+ })
60
+
61
+ it('tags a rejected row and keeps its cells (the explanation rides in `to`)', () => {
62
+ render(
63
+ <RecordUpdateRow
64
+ label="Created at"
65
+ from="2026-09-01"
66
+ to={<span>Read-only field</span>}
67
+ state="rejected"
68
+ />,
69
+ )
70
+ expect(screen.getByRole('group')).toHaveAttribute('data-state', 'rejected')
71
+ expect(screen.getByText('not possible')).toBeInTheDocument()
72
+ expect(screen.getByText('Read-only field')).toBeInTheDocument()
73
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
74
+ })
75
+
76
+ it('tags an unresolved row and shows the hint; an error is an alert', () => {
77
+ render(
78
+ <RecordUpdateRow
79
+ label="Owner"
80
+ from="Tonio"
81
+ to={<input aria-label="Owner" />}
82
+ state="unresolved"
83
+ hint="Pick the user."
84
+ errorMessage="Required"
85
+ />,
86
+ )
87
+ expect(screen.getByRole('group')).toHaveAttribute('data-state', 'unresolved')
88
+ expect(screen.getByText('needs a value')).toBeInTheDocument()
89
+ expect(screen.getByText('Pick the user.')).toBeInTheDocument()
90
+ expect(screen.getByRole('alert')).toHaveTextContent('Required')
91
+ })
92
+ })
93
+
94
+ describe('RecordUpdate', () => {
95
+ it('composes summary, rows and the general errors alert', () => {
96
+ render(
97
+ <RecordUpdate>
98
+ <RecordUpdateSummary>Two changes.</RecordUpdateSummary>
99
+ <RecordUpdateRows>
100
+ <RecordUpdateRow label="Status" from="Open" to="Won" />
101
+ </RecordUpdateRows>
102
+ <RecordUpdateErrors errors={['Created at cannot be changed.']} />
103
+ </RecordUpdate>,
104
+ )
105
+ expect(screen.getByText('Two changes.')).toBeInTheDocument()
106
+ expect(screen.getByRole('group', { name: '' })).toBeInTheDocument()
107
+ expect(screen.getByRole('alert')).toHaveTextContent('Created at cannot be changed.')
108
+ })
109
+
110
+ it('renders no errors block when the list is empty', () => {
111
+ render(<RecordUpdateErrors errors={[]} />)
112
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
113
+ })
114
+ })
@@ -0,0 +1,259 @@
1
+ import { AlertCircle, ArrowRight, Undo2, X } from 'lucide-react'
2
+ import type { HTMLAttributes, ReactNode } from 'react'
3
+ import { tv, type VariantProps } from 'tailwind-variants'
4
+
5
+ import { cn } from '../../lib/cn'
6
+ import { Button } from '../button/button'
7
+
8
+ /* =========================================================================
9
+ RecordUpdate — a proposed set of field changes to ONE record, laid out
10
+ as a mini page: a summary line, one row per field (`from → to`), the
11
+ problems the proposer ran into, and a footer with the decision buttons.
12
+ Chrome only — the consumer renders the two cells of every row (a
13
+ read-only display on the left, an editor on the right), so the same
14
+ surface reviews an AI-prepared record update in the command palette, in
15
+ a chat client tool, or in a prepared-action popover.
16
+
17
+ Slot composition (Card pattern): the root carries no padding; each slot
18
+ owns its own spacing so they compose freely.
19
+
20
+ <RecordUpdate>
21
+ <RecordUpdateSummary>…</RecordUpdateSummary>
22
+ <RecordUpdateRows>
23
+ <RecordUpdateRow label from to state … />
24
+ </RecordUpdateRows>
25
+ <RecordUpdateErrors errors={[…]} />
26
+ <RecordUpdateFooter>…buttons…</RecordUpdateFooter>
27
+ </RecordUpdate>
28
+
29
+ Row states:
30
+ changed — a value the reviewer can still edit or drop.
31
+ unresolved — the proposer knew WHICH field but not the value; the
32
+ right cell is expected to be an empty editor and `hint`
33
+ says why. Consumers block "apply" while any row is here.
34
+ rejected — the proposer could NOT do what was asked for this field
35
+ (read-only, no such option …); the right cell carries the
36
+ explanation and nothing is applied for the row.
37
+ removed — dropped from the change set; collapses to one line with a
38
+ restore button so the decision stays reversible.
39
+ ========================================================================= */
40
+
41
+ export const recordUpdateStyles = tv({
42
+ slots: {
43
+ root: 'flex flex-col min-w-0 text-ink',
44
+ summary: 'px-4 pt-3 pb-1 text-[13px] text-ink-3',
45
+ rows: 'flex flex-col px-4 divide-y divide-rule',
46
+ errors: [
47
+ 'mx-4 my-3 flex gap-2 rounded-md px-3 py-2 text-[13px] text-danger',
48
+ 'bg-[color-mix(in_oklab,var(--color-danger)_8%,var(--color-surface))]',
49
+ ],
50
+ footer: 'flex items-center justify-end gap-2 px-4 py-3',
51
+ },
52
+ })
53
+
54
+ export const recordUpdateRowStyles = tv({
55
+ slots: {
56
+ // fieldset resets (UA border / padding / min-content width) included.
57
+ root: 'flex flex-col gap-1.5 m-0 border-0 px-0 py-2.5 min-w-0',
58
+ labelRow: [
59
+ 'flex items-center gap-2 min-h-7',
60
+ 'font-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-4',
61
+ ],
62
+ tag: 'normal-case tracking-normal text-[10px] rounded-full px-1.5 py-px',
63
+ cells:
64
+ 'grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 sm:gap-3',
65
+ from: 'min-w-0 opacity-70',
66
+ arrow: 'size-3.5 shrink-0 text-ink-4 rotate-90 sm:rotate-0 justify-self-center',
67
+ to: 'min-w-0',
68
+ hint: 'text-[12px] text-ink-4',
69
+ error: 'text-[12px] text-danger',
70
+ removed: 'text-[13px] italic text-ink-4',
71
+ },
72
+ variants: {
73
+ state: {
74
+ changed: {},
75
+ unresolved: {
76
+ tag: 'text-warning bg-[color-mix(in_oklab,var(--color-warning)_12%,var(--color-surface))]',
77
+ },
78
+ rejected: {
79
+ tag: 'text-danger bg-[color-mix(in_oklab,var(--color-danger)_10%,var(--color-surface))]',
80
+ },
81
+ removed: { labelRow: 'line-through decoration-ink-4/60' },
82
+ },
83
+ },
84
+ defaultVariants: { state: 'changed' },
85
+ })
86
+
87
+ export type RecordUpdateRowVariants = VariantProps<typeof recordUpdateRowStyles>
88
+ export type RecordUpdateRowState = NonNullable<RecordUpdateRowVariants['state']>
89
+
90
+ /* ---------- root + slots ---------- */
91
+
92
+ export type RecordUpdateProps = HTMLAttributes<HTMLDivElement>
93
+
94
+ export function RecordUpdate({ className, children, ...props }: RecordUpdateProps) {
95
+ return (
96
+ <div {...props} className={cn(recordUpdateStyles().root(), className)}>
97
+ {children}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ export function RecordUpdateSummary({
103
+ className,
104
+ children,
105
+ ...props
106
+ }: HTMLAttributes<HTMLElement>) {
107
+ return (
108
+ <p {...props} className={cn(recordUpdateStyles().summary(), className)}>
109
+ {children}
110
+ </p>
111
+ )
112
+ }
113
+
114
+ export function RecordUpdateRows({
115
+ className,
116
+ children,
117
+ ...props
118
+ }: HTMLAttributes<HTMLDivElement>) {
119
+ return (
120
+ <div {...props} className={cn(recordUpdateStyles().rows(), className)}>
121
+ {children}
122
+ </div>
123
+ )
124
+ }
125
+
126
+ export interface RecordUpdateErrorsProps extends HTMLAttributes<HTMLDivElement> {
127
+ /** Problems that are not tied to one field. Renders nothing when empty. */
128
+ errors: ReadonlyArray<string>
129
+ }
130
+
131
+ export function RecordUpdateErrors({ errors, className, ...props }: RecordUpdateErrorsProps) {
132
+ if (errors.length === 0) return null
133
+ return (
134
+ <div {...props} role="alert" className={cn(recordUpdateStyles().errors(), className)}>
135
+ <AlertCircle className="size-4 shrink-0 mt-px" aria-hidden />
136
+ <ul className="flex flex-col gap-1 min-w-0">
137
+ {errors.map((error) => (
138
+ <li key={error}>{error}</li>
139
+ ))}
140
+ </ul>
141
+ </div>
142
+ )
143
+ }
144
+
145
+ export function RecordUpdateFooter({
146
+ className,
147
+ children,
148
+ ...props
149
+ }: HTMLAttributes<HTMLDivElement>) {
150
+ return (
151
+ <div {...props} className={cn(recordUpdateStyles().footer(), className)}>
152
+ {children}
153
+ </div>
154
+ )
155
+ }
156
+
157
+ /* ---------- row ---------- */
158
+
159
+ export interface RecordUpdateRowProps
160
+ extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'children' | 'className'> {
161
+ /** The field's label — rendered in the page-layout field-label style. */
162
+ label: ReactNode
163
+ /** Current value cell (typically a read-only display). Omit for a value
164
+ * that has no "before" — a record being created — and the row shows
165
+ * `to` alone, without the arrow. */
166
+ from?: ReactNode
167
+ /** Proposed value cell (an editor bound to the change, or the rejection text). */
168
+ to: ReactNode
169
+ state?: RecordUpdateRowState
170
+ /** Why the value is unresolved, or any other one-line note under the cells. */
171
+ hint?: ReactNode
172
+ /** Validation error for the proposed value. */
173
+ errorMessage?: string
174
+ /** Drop this change. Renders the remove button when given (not in `removed`). */
175
+ onRemove?: () => void
176
+ /** Bring a removed change back. Renders the restore button in `removed`. */
177
+ onRestore?: () => void
178
+ /** Accessible names for the two buttons. */
179
+ removeLabel?: string
180
+ restoreLabel?: string
181
+ /** Text of the `unresolved` / `rejected` tags next to the label. */
182
+ unresolvedLabel?: string
183
+ rejectedLabel?: string
184
+ /** Text shown in place of the cells while `removed`. */
185
+ removedText?: string
186
+ className?: string
187
+ }
188
+
189
+ export function RecordUpdateRow({
190
+ label,
191
+ from,
192
+ to,
193
+ state = 'changed',
194
+ hint,
195
+ errorMessage,
196
+ onRemove,
197
+ onRestore,
198
+ removeLabel = 'Remove change',
199
+ restoreLabel = 'Restore change',
200
+ unresolvedLabel = 'needs a value',
201
+ rejectedLabel = 'not possible',
202
+ removedText = 'Kept as it is',
203
+ className,
204
+ ...props
205
+ }: RecordUpdateRowProps) {
206
+ const styles = recordUpdateRowStyles({ state })
207
+ const isRemoved = state === 'removed'
208
+ // A fieldset: the row IS a group of one field's cells (implicit `group`
209
+ // role), reset from the UA's border / padding / min-content width.
210
+ return (
211
+ <fieldset
212
+ {...props}
213
+ data-state={state}
214
+ {...(errorMessage ? { 'aria-invalid': true } : {})}
215
+ className={cn(styles.root(), className)}
216
+ >
217
+ <div className={styles.labelRow()}>
218
+ <span className="truncate">{label}</span>
219
+ {state === 'unresolved' ? <span className={styles.tag()}>{unresolvedLabel}</span> : null}
220
+ {state === 'rejected' ? <span className={styles.tag()}>{rejectedLabel}</span> : null}
221
+ <span className="ml-auto flex items-center">
222
+ {isRemoved
223
+ ? onRestore && (
224
+ <Button
225
+ variant="ghost"
226
+ size="icon-sm"
227
+ aria-label={restoreLabel}
228
+ onPress={onRestore}
229
+ >
230
+ <Undo2 />
231
+ </Button>
232
+ )
233
+ : onRemove && (
234
+ <Button variant="ghost" size="icon-sm" aria-label={removeLabel} onPress={onRemove}>
235
+ <X />
236
+ </Button>
237
+ )}
238
+ </span>
239
+ </div>
240
+ {isRemoved ? (
241
+ <div className={styles.removed()}>{removedText}</div>
242
+ ) : from === undefined ? (
243
+ <div className={styles.to()}>{to}</div>
244
+ ) : (
245
+ <div className={styles.cells()}>
246
+ <div className={styles.from()}>{from}</div>
247
+ <ArrowRight className={styles.arrow()} aria-hidden />
248
+ <div className={styles.to()}>{to}</div>
249
+ </div>
250
+ )}
251
+ {!isRemoved && hint ? <div className={styles.hint()}>{hint}</div> : null}
252
+ {!isRemoved && errorMessage ? (
253
+ <div className={styles.error()} role="alert">
254
+ {errorMessage}
255
+ </div>
256
+ ) : null}
257
+ </fieldset>
258
+ )
259
+ }
@@ -74,7 +74,7 @@ export function Select<T extends Key>({
74
74
  {...props}
75
75
  className={(renderProps) =>
76
76
  cn(
77
- 'grid grid-cols-1 gap-1.5',
77
+ 'grid grid-cols-1 gap-1.5 content-start',
78
78
  typeof className === 'function' ? className(renderProps) : className,
79
79
  )
80
80
  }
@@ -12,6 +12,7 @@ const meta: Meta<typeof Switch> = {
12
12
  argTypes: {
13
13
  isDisabled: { control: { type: 'boolean' } },
14
14
  isSelected: { control: { type: 'boolean' } },
15
+ description: { control: { type: 'text' } },
15
16
  labelPlacement: {
16
17
  control: { type: 'inline-radio' },
17
18
  options: ['start', 'end'],
@@ -45,6 +46,27 @@ export const Row: Story = {
45
46
  ),
46
47
  }
47
48
 
49
+ export const WithDescription: Story = {
50
+ parameters: { controls: { hideNoControlsWarning: true } },
51
+ render: () => (
52
+ <div className="grid gap-5 w-[420px]">
53
+ <Switch description="Asks the decision model first and skips the reasoning call when nothing applies.">
54
+ Screen with decision model first
55
+ </Switch>
56
+ <Switch
57
+ labelPlacement="start"
58
+ defaultSelected
59
+ description="Label first: the copy sits under the label, the track stays on the right."
60
+ >
61
+ Live architect
62
+ </Switch>
63
+ <Switch isDisabled description="Disabled: label, track and copy all fade together.">
64
+ Version history
65
+ </Switch>
66
+ </div>
67
+ ),
68
+ }
69
+
48
70
  export const AllStates: Story = {
49
71
  parameters: { controls: { hideNoControlsWarning: true } },
50
72
  render: () => (
@@ -47,4 +47,10 @@ describe('Switch', () => {
47
47
  render(<Switch isSelected>Version history</Switch>)
48
48
  expect(screen.getByRole('switch', { name: 'Version history' })).toBeChecked()
49
49
  })
50
+
51
+ it('exposes a description through aria-describedby without changing the name', () => {
52
+ render(<Switch description="Off by default.">Live architect</Switch>)
53
+ const control = screen.getByRole('switch', { name: 'Live architect' })
54
+ expect(control).toHaveAccessibleDescription('Off by default.')
55
+ })
50
56
  })
@@ -1,18 +1,37 @@
1
- import type { ReactNode } from 'react'
1
+ import { type ReactNode, useId } from 'react'
2
2
  import { Switch as AriaSwitch, type SwitchProps as AriaSwitchProps } from 'react-aria-components'
3
3
 
4
4
  import { cn } from '../../lib/cn'
5
+ import { fieldHintStyles } from '../field/field'
5
6
 
6
7
  export type SwitchProps = Omit<AriaSwitchProps, 'children'> & {
7
8
  children?: ReactNode
8
9
  /** Render the label before the track instead of after it. */
9
10
  labelPlacement?: 'start' | 'end'
11
+ /**
12
+ * Helper copy under the label, aligned with it and exposed through
13
+ * `aria-describedby`. Lives OUTSIDE the label so it never joins the
14
+ * switch's accessible name. With a description the root becomes a `<div>`
15
+ * wrapping the label; without one the label stays the root, as before.
16
+ */
17
+ description?: ReactNode
10
18
  }
11
19
 
12
- export function Switch({ children, labelPlacement = 'end', className, ...props }: SwitchProps) {
13
- return (
20
+ export function Switch({
21
+ children,
22
+ labelPlacement = 'end',
23
+ description,
24
+ className,
25
+ ...props
26
+ }: SwitchProps) {
27
+ const descriptionId = useId()
28
+ const describedBy = [props['aria-describedby'], description ? descriptionId : undefined]
29
+ .filter(Boolean)
30
+ .join(' ')
31
+ const control = (
14
32
  <AriaSwitch
15
33
  {...props}
34
+ {...(describedBy ? { 'aria-describedby': describedBy } : {})}
16
35
  className={(renderProps) =>
17
36
  cn(
18
37
  // `relative`: RAC's visually-hidden <input> is absolutely positioned;
@@ -50,4 +69,22 @@ export function Switch({ children, labelPlacement = 'end', className, ...props }
50
69
  {children}
51
70
  </AriaSwitch>
52
71
  )
72
+ if (!description) return control
73
+ return (
74
+ <div className="grid gap-1 content-start">
75
+ {control}
76
+ <span
77
+ id={descriptionId}
78
+ className={cn(
79
+ fieldHintStyles,
80
+ 'leading-[1.45]',
81
+ // Track (38px) + gap-3 (12px): the copy starts under the label text.
82
+ labelPlacement === 'end' && 'pl-[50px]',
83
+ props.isDisabled && 'opacity-45',
84
+ )}
85
+ >
86
+ {description}
87
+ </span>
88
+ </div>
89
+ )
53
90
  }
@@ -142,7 +142,7 @@ export function TagInput({
142
142
  }
143
143
 
144
144
  return (
145
- <div className={cn('grid grid-cols-1 gap-1.5', className)}>
145
+ <div className={cn('grid grid-cols-1 gap-1.5 content-start', className)}>
146
146
  {label ? (
147
147
  <label htmlFor={inputId} className={fieldLabelStyles}>
148
148
  {label}
@@ -0,0 +1,2 @@
1
+ export type { TextShimmerProps, TextShimmerVariants } from './text-shimmer'
2
+ export { TextShimmer, textShimmerStyles } from './text-shimmer'