@proteos/ui 0.31.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 (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. package/src/test/setup.ts +9 -0
@@ -0,0 +1,75 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useEffect, useState } from 'react'
3
+
4
+ import { ProgressBar } from './progress-bar'
5
+
6
+ const meta: Meta<typeof ProgressBar> = {
7
+ title: 'Components/ProgressBar',
8
+ component: ProgressBar,
9
+ tags: ['autodocs'],
10
+ args: {
11
+ 'aria-label': 'Loading',
12
+ size: 'sm',
13
+ isIndeterminate: true,
14
+ },
15
+ argTypes: {
16
+ size: {
17
+ control: { type: 'inline-radio' },
18
+ options: ['sm', 'md'],
19
+ },
20
+ isIndeterminate: { control: { type: 'boolean' } },
21
+ value: { control: { type: 'number', min: 0, max: 100 } },
22
+ },
23
+ }
24
+
25
+ export default meta
26
+ type Story = StoryObj<typeof ProgressBar>
27
+
28
+ export const Playground: Story = {
29
+ render: (args) => (
30
+ <div className="w-[280px]">
31
+ <ProgressBar {...args} />
32
+ </div>
33
+ ),
34
+ }
35
+
36
+ export const Indeterminate: Story = {
37
+ parameters: { controls: { hideNoControlsWarning: true } },
38
+ render: () => (
39
+ <div className="flex flex-col gap-6 w-[280px]">
40
+ <ProgressBar isIndeterminate aria-label="Running action" />
41
+ <ProgressBar isIndeterminate size="md" aria-label="Uploading file" />
42
+ </div>
43
+ ),
44
+ }
45
+
46
+ export const Determinate: Story = {
47
+ parameters: { controls: { hideNoControlsWarning: true } },
48
+ render: () => {
49
+ const [value, setValue] = useState(0)
50
+ useEffect(() => {
51
+ const timer = setInterval(() => {
52
+ setValue((current) => (current >= 100 ? 0 : current + 5))
53
+ }, 300)
54
+ return () => clearInterval(timer)
55
+ }, [])
56
+ return (
57
+ <div className="flex flex-col gap-3 w-[280px]">
58
+ <ProgressBar value={value} aria-label="Upload progress" />
59
+ <p className="font-mono text-[11px] text-ink-4">{value}%</p>
60
+ </div>
61
+ )
62
+ },
63
+ }
64
+
65
+ export const Sizes: Story = {
66
+ parameters: { controls: { hideNoControlsWarning: true } },
67
+ render: () => (
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">sm</div>
70
+ <ProgressBar value={60} size="sm" aria-label="Progress sm" />
71
+ <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4">md</div>
72
+ <ProgressBar value={60} size="md" aria-label="Progress md" />
73
+ </div>
74
+ ),
75
+ }
@@ -0,0 +1,29 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { ProgressBar } from './progress-bar'
5
+
6
+ describe('ProgressBar', () => {
7
+ it('renders with the progressbar role and accessible name', () => {
8
+ render(<ProgressBar value={40} aria-label="Upload progress" />)
9
+ expect(screen.getByRole('progressbar', { name: 'Upload progress' })).toBeInTheDocument()
10
+ })
11
+
12
+ it('reflects the supplied value in aria-valuenow when determinate', () => {
13
+ render(<ProgressBar value={73} aria-label="Sync" />)
14
+ const bar = screen.getByRole('progressbar', { name: 'Sync' })
15
+ expect(bar).toHaveAttribute('aria-valuenow', '73')
16
+ })
17
+
18
+ it('omits aria-valuenow when indeterminate', () => {
19
+ render(<ProgressBar isIndeterminate aria-label="Running action" />)
20
+ const bar = screen.getByRole('progressbar', { name: 'Running action' })
21
+ expect(bar).not.toHaveAttribute('aria-valuenow')
22
+ })
23
+
24
+ it('applies the indeterminate animation utility on the inner fill', () => {
25
+ const { container } = render(<ProgressBar isIndeterminate aria-label="Running" />)
26
+ const fill = container.querySelector('.animate-indeterminate-progress')
27
+ expect(fill).not.toBeNull()
28
+ })
29
+ })
@@ -0,0 +1,57 @@
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
+ ProgressBar — wraps RAC's headless ProgressBar (state + ARIA only) and
11
+ adds the visual track + fill. Supports both determinate (`value`) and
12
+ indeterminate (`isIndeterminate`) modes.
13
+
14
+ The indeterminate fill rides the `animate-indeterminate-progress` utility
15
+ declared in styles.css — a 35%-wide segment that slides off the right edge
16
+ and re-enters from the left on a continuous loop.
17
+ ========================================================================= */
18
+
19
+ export const progressBarStyles = tv({
20
+ slots: {
21
+ track: 'relative overflow-hidden rounded-pill bg-surface-2 w-full',
22
+ fill: 'h-full rounded-pill bg-accent',
23
+ },
24
+ variants: {
25
+ size: {
26
+ sm: { track: 'h-1' },
27
+ md: { track: 'h-2' },
28
+ },
29
+ },
30
+ defaultVariants: { size: 'sm' },
31
+ })
32
+
33
+ export type ProgressBarVariants = VariantProps<typeof progressBarStyles>
34
+ export type ProgressBarProps = Omit<AriaProgressBarProps, 'children' | 'className'> &
35
+ ProgressBarVariants & {
36
+ className?: string
37
+ }
38
+
39
+ export function ProgressBar({ size, className, ...props }: ProgressBarProps) {
40
+ const { track, fill } = progressBarStyles({ size })
41
+ return (
42
+ <AriaProgressBar {...props} className={cn('block', className)}>
43
+ {({ percentage, isIndeterminate }) => (
44
+ <div className={track()}>
45
+ {isIndeterminate ? (
46
+ <div className={cn(fill(), 'absolute w-[35%] animate-indeterminate-progress')} />
47
+ ) : (
48
+ <div
49
+ className={cn(fill(), 'transition-[width] duration-150')}
50
+ style={{ width: `${percentage ?? 0}%` }}
51
+ />
52
+ )}
53
+ </div>
54
+ )}
55
+ </AriaProgressBar>
56
+ )
57
+ }
@@ -0,0 +1,2 @@
1
+ export type { RadioGroupProps, RadioProps } from './radio-group'
2
+ export { Radio, RadioGroup } from './radio-group'
@@ -0,0 +1,113 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Radio, RadioGroup } from './radio-group'
4
+
5
+ const meta: Meta<typeof RadioGroup> = {
6
+ title: 'Components/RadioGroup',
7
+ component: RadioGroup,
8
+ tags: ['autodocs'],
9
+ args: {
10
+ label: 'Environment',
11
+ },
12
+ argTypes: {
13
+ orientation: {
14
+ control: { type: 'inline-radio' },
15
+ options: ['vertical', 'horizontal'],
16
+ },
17
+ isDisabled: { control: { type: 'boolean' } },
18
+ isInvalid: { control: { type: 'boolean' } },
19
+ },
20
+ decorators: [
21
+ (Story) => (
22
+ <div className="w-[360px]">
23
+ <Story />
24
+ </div>
25
+ ),
26
+ ],
27
+ }
28
+
29
+ export default meta
30
+ type Story = StoryObj<typeof RadioGroup>
31
+
32
+ export const Playground: Story = {
33
+ render: (args) => (
34
+ <RadioGroup {...args}>
35
+ <Radio value="staging">Staging</Radio>
36
+ <Radio value="production">Production</Radio>
37
+ <Radio value="sandbox">Sandbox</Radio>
38
+ </RadioGroup>
39
+ ),
40
+ }
41
+
42
+ export const Horizontal: Story = {
43
+ args: { orientation: 'horizontal' },
44
+ render: (args) => (
45
+ <RadioGroup {...args}>
46
+ <Radio value="staging">Staging</Radio>
47
+ <Radio value="production">Production</Radio>
48
+ <Radio value="sandbox">Sandbox</Radio>
49
+ </RadioGroup>
50
+ ),
51
+ }
52
+
53
+ export const WithDescription: Story = {
54
+ args: { description: 'Where Proteos should run the compose.' },
55
+ render: (args) => (
56
+ <RadioGroup {...args} defaultValue="production">
57
+ <Radio value="staging">Staging</Radio>
58
+ <Radio value="production">Production</Radio>
59
+ <Radio value="sandbox">Sandbox</Radio>
60
+ </RadioGroup>
61
+ ),
62
+ }
63
+
64
+ export const Invalid: Story = {
65
+ args: { isInvalid: true, errorMessage: 'Pick an environment before composing.' },
66
+ render: (args) => (
67
+ <RadioGroup {...args}>
68
+ <Radio value="staging">Staging</Radio>
69
+ <Radio value="production">Production</Radio>
70
+ <Radio value="sandbox">Sandbox</Radio>
71
+ </RadioGroup>
72
+ ),
73
+ }
74
+
75
+ export const Disabled: Story = {
76
+ args: { isDisabled: true, defaultValue: 'production' },
77
+ render: (args) => (
78
+ <RadioGroup {...args}>
79
+ <Radio value="staging">Staging</Radio>
80
+ <Radio value="production">Production</Radio>
81
+ <Radio value="sandbox">Sandbox</Radio>
82
+ </RadioGroup>
83
+ ),
84
+ }
85
+
86
+ export const AllStates: Story = {
87
+ parameters: { controls: { hideNoControlsWarning: true } },
88
+ render: () => (
89
+ <div className="grid gap-6 w-[360px]">
90
+ <RadioGroup label="Rest">
91
+ <Radio value="a">Staging</Radio>
92
+ <Radio value="b">Production</Radio>
93
+ <Radio value="c">Sandbox</Radio>
94
+ </RadioGroup>
95
+
96
+ <RadioGroup label="Selected" defaultValue="b">
97
+ <Radio value="a">Staging</Radio>
98
+ <Radio value="b">Production</Radio>
99
+ <Radio value="c">Sandbox</Radio>
100
+ </RadioGroup>
101
+
102
+ <RadioGroup label="Invalid" isInvalid errorMessage="Pick one before continuing.">
103
+ <Radio value="a">Staging</Radio>
104
+ <Radio value="b">Production</Radio>
105
+ </RadioGroup>
106
+
107
+ <RadioGroup label="Disabled" isDisabled defaultValue="b">
108
+ <Radio value="a">Staging</Radio>
109
+ <Radio value="b">Production</Radio>
110
+ </RadioGroup>
111
+ </div>
112
+ ),
113
+ }
@@ -0,0 +1,87 @@
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 { Radio, RadioGroup } from './radio-group'
6
+
7
+ describe('RadioGroup', () => {
8
+ it('renders a labelled group with radio options', () => {
9
+ render(
10
+ <RadioGroup label="Environment">
11
+ <Radio value="staging">Staging</Radio>
12
+ <Radio value="production">Production</Radio>
13
+ <Radio value="sandbox">Sandbox</Radio>
14
+ </RadioGroup>,
15
+ )
16
+ expect(screen.getByRole('radiogroup', { name: 'Environment' })).toBeInTheDocument()
17
+ expect(screen.getByRole('radio', { name: 'Staging' })).toBeInTheDocument()
18
+ expect(screen.getByRole('radio', { name: 'Production' })).toBeInTheDocument()
19
+ })
20
+
21
+ it('fires onChange with the selected value', async () => {
22
+ const user = userEvent.setup()
23
+ const onChange = vi.fn()
24
+ render(
25
+ <RadioGroup label="Environment" onChange={onChange}>
26
+ <Radio value="staging">Staging</Radio>
27
+ <Radio value="production">Production</Radio>
28
+ </RadioGroup>,
29
+ )
30
+
31
+ await user.click(screen.getByRole('radio', { name: 'Production' }))
32
+ expect(onChange).toHaveBeenCalledWith('production')
33
+ })
34
+
35
+ it('reflects a controlled selected value', () => {
36
+ render(
37
+ <RadioGroup label="Environment" value="production">
38
+ <Radio value="staging">Staging</Radio>
39
+ <Radio value="production">Production</Radio>
40
+ </RadioGroup>,
41
+ )
42
+ expect(screen.getByRole('radio', { name: 'Production' })).toBeChecked()
43
+ expect(screen.getByRole('radio', { name: 'Staging' })).not.toBeChecked()
44
+ })
45
+
46
+ it('navigates between options with arrow keys', async () => {
47
+ const user = userEvent.setup()
48
+ const onChange = vi.fn()
49
+ render(
50
+ <RadioGroup label="Environment" defaultValue="staging" onChange={onChange}>
51
+ <Radio value="staging">Staging</Radio>
52
+ <Radio value="production">Production</Radio>
53
+ <Radio value="sandbox">Sandbox</Radio>
54
+ </RadioGroup>,
55
+ )
56
+
57
+ await user.tab()
58
+ expect(screen.getByRole('radio', { name: 'Staging' })).toHaveFocus()
59
+ await user.keyboard('{ArrowDown}')
60
+ expect(onChange).toHaveBeenCalledWith('production')
61
+ })
62
+
63
+ it('shows description and error message slots', () => {
64
+ render(
65
+ <RadioGroup label="Env" description="Pick one." isInvalid errorMessage="Required">
66
+ <Radio value="a">A</Radio>
67
+ <Radio value="b">B</Radio>
68
+ </RadioGroup>,
69
+ )
70
+ expect(screen.getByText('Pick one.')).toBeInTheDocument()
71
+ expect(screen.getByText('Required')).toBeInTheDocument()
72
+ })
73
+
74
+ it('does not fire onChange when the group is disabled', async () => {
75
+ const user = userEvent.setup()
76
+ const onChange = vi.fn()
77
+ render(
78
+ <RadioGroup label="Env" isDisabled onChange={onChange}>
79
+ <Radio value="a">A</Radio>
80
+ <Radio value="b">B</Radio>
81
+ </RadioGroup>,
82
+ )
83
+
84
+ await user.click(screen.getByRole('radio', { name: 'A' }))
85
+ expect(onChange).not.toHaveBeenCalled()
86
+ })
87
+ })
@@ -0,0 +1,104 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Radio as AriaRadio,
4
+ RadioGroup as AriaRadioGroup,
5
+ type RadioGroupProps as AriaRadioGroupProps,
6
+ type RadioProps as AriaRadioProps,
7
+ FieldError,
8
+ Label,
9
+ Text,
10
+ } from 'react-aria-components'
11
+ import { cn } from '../../lib/cn'
12
+ import { fieldErrorStyles, fieldHintStyles, fieldLabelStyles } from '../field/field'
13
+
14
+ /* =========================================================================
15
+ RadioGroup — exclusive-choice field. Monolithic API to match Input/Select/
16
+ Combobox/DatePicker: label / description / errorMessage props on the group,
17
+ `<Radio>` children for each option.
18
+ ========================================================================= */
19
+
20
+ export type RadioGroupProps = Omit<AriaRadioGroupProps, 'children'> & {
21
+ label?: ReactNode
22
+ description?: ReactNode
23
+ errorMessage?: ReactNode
24
+ /** `<Radio>` elements. Function-as-children is not supported at the group level. */
25
+ children: ReactNode
26
+ }
27
+
28
+ export function RadioGroup({
29
+ label,
30
+ description,
31
+ errorMessage,
32
+ orientation = 'vertical',
33
+ children,
34
+ className,
35
+ ...props
36
+ }: RadioGroupProps) {
37
+ return (
38
+ <AriaRadioGroup
39
+ {...props}
40
+ orientation={orientation}
41
+ className={(renderProps) =>
42
+ cn('grid gap-2', typeof className === 'function' ? className(renderProps) : className)
43
+ }
44
+ >
45
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
46
+ <div
47
+ className={cn(
48
+ orientation === 'horizontal'
49
+ ? 'flex flex-wrap items-center gap-x-4 gap-y-2'
50
+ : 'grid gap-3',
51
+ )}
52
+ >
53
+ {children}
54
+ </div>
55
+ {description ? (
56
+ <Text slot="description" className={fieldHintStyles}>
57
+ {description}
58
+ </Text>
59
+ ) : null}
60
+ {errorMessage ? <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError> : null}
61
+ </AriaRadioGroup>
62
+ )
63
+ }
64
+
65
+ export type RadioProps = Omit<AriaRadioProps, 'children'> & {
66
+ children?: ReactNode
67
+ }
68
+
69
+ export function Radio({ children, className, ...props }: RadioProps) {
70
+ return (
71
+ <AriaRadio
72
+ {...props}
73
+ className={(renderProps) =>
74
+ cn(
75
+ 'group inline-flex items-center gap-2.5 cursor-pointer',
76
+ 'font-sans text-[14px] text-ink-2 select-none',
77
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
78
+ typeof className === 'function' ? className(renderProps) : className,
79
+ )
80
+ }
81
+ >
82
+ <span
83
+ aria-hidden
84
+ className={cn(
85
+ 'relative inline-flex items-center justify-center shrink-0',
86
+ 'size-[18px] rounded-full border bg-surface border-ink-4',
87
+ 'transition-[border-color,box-shadow] duration-150',
88
+ 'group-data-[hovered]:border-ink',
89
+ 'group-data-[selected]:border-ink',
90
+ 'group-data-[focus-visible]:shadow-[var(--shadow-focus)]',
91
+ )}
92
+ >
93
+ <span
94
+ className={cn(
95
+ 'block size-[10px] rounded-full bg-accent',
96
+ 'transition-opacity duration-150 opacity-0',
97
+ 'group-data-[selected]:opacity-100',
98
+ )}
99
+ />
100
+ </span>
101
+ {children}
102
+ </AriaRadio>
103
+ )
104
+ }
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from '../../lib/cn'
3
+ import { Combobox, type ComboboxOption } from '../combobox/combobox'
4
+ import type { FieldSize } from '../field/field'
5
+ import type { FilterField } from './types'
6
+
7
+ /* =========================================================================
8
+ AttributePicker — inline typeahead picker for filterable fields. Thin
9
+ wrapper over lib `Combobox` that maps `FilterField[]` into options and
10
+ surfaces the type tag in each row's description slot.
11
+
12
+ Rendered inline (no outer popover) so it can occupy the field cell of a
13
+ filter row directly. The Combobox's internal popover hosts the dropdown.
14
+ With `autoFocus + menuTrigger="focus"`, the menu opens immediately on
15
+ mount — the standard "click + Add → row appears with picker open"
16
+ experience.
17
+ ========================================================================= */
18
+
19
+ export type AttributePickerProps = {
20
+ fields: ReadonlyArray<FilterField>
21
+ /** Currently picked field path. Empty string means "no field picked yet". */
22
+ value: string
23
+ onChange: (picked: FilterField) => void
24
+ autoFocus?: boolean
25
+ isDisabled?: boolean
26
+ /** Field size. Defaults to `sm` since this picker only ships inside the
27
+ * filter row; `md` is exposed for future stand-alone uses. */
28
+ size?: FieldSize
29
+ /** Visual override for the field wrapper. */
30
+ className?: string
31
+ }
32
+
33
+ export function AttributePicker({
34
+ fields,
35
+ value,
36
+ onChange,
37
+ autoFocus = false,
38
+ isDisabled,
39
+ size = 'sm',
40
+ className,
41
+ }: AttributePickerProps) {
42
+ const options: ComboboxOption<string>[] = fields.map((f) => ({
43
+ id: f.path,
44
+ label: f.label,
45
+ description: <FieldTypeTag>{f.type}</FieldTypeTag>,
46
+ }))
47
+
48
+ return (
49
+ <Combobox<string>
50
+ aria-label="Find attribute"
51
+ placeholder="Find attribute…"
52
+ options={options}
53
+ selectedKey={value === '' ? null : value}
54
+ onSelectionChange={(key) => {
55
+ if (key == null) return
56
+ const picked = fields.find((f) => f.path === String(key))
57
+ if (picked) onChange(picked)
58
+ }}
59
+ // Auto-open the dropdown on focus so a freshly-mounted picker shows
60
+ // its options without a second click.
61
+ menuTrigger="focus"
62
+ autoFocus={autoFocus}
63
+ size={size}
64
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
65
+ className={cn('w-full', className)}
66
+ />
67
+ )
68
+ }
69
+
70
+ function FieldTypeTag({ children }: { children: ReactNode }) {
71
+ return (
72
+ <span className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">{children}</span>
73
+ )
74
+ }