@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,150 @@
1
+ import type { AttributeType } from '../attribute-type-icon'
2
+ import type { BadgeVariants } from '../badge/badge'
3
+
4
+ /* =========================================================================
5
+ RecordFilter — types
6
+
7
+ The lib speaks a normalized "field schema" dialect (`FilterField[]`) and
8
+ a canonical filter state (`FilterGroup`). Consumers are responsible for
9
+ mapping their domain entity into `FilterField[]` (e.g. flattening nested
10
+ objects, projecting enum options) and for serializing `FilterGroup` to
11
+ their backend's query format. The lib never touches the SDK or the
12
+ record-service URL convention directly.
13
+ ========================================================================= */
14
+
15
+ /* `FilterFieldType` is the subset of the lib-wide `AttributeType` that can
16
+ sit on a leaf filter row. `'object'` is excluded because objects are
17
+ opaque containers — consumers flatten them into dot-paths
18
+ (`'address.street'`) before handing them to the filter, so an object type
19
+ never appears here. Deriving from `AttributeType` keeps the icon mapping
20
+ and the filter type catalogue in lockstep. */
21
+ export type FilterFieldType = Exclude<AttributeType, 'object'>
22
+
23
+ export type FilterFieldOption = {
24
+ value: string
25
+ label: string
26
+ /** Badge color register, used when this option is rendered as a pill. */
27
+ variant?: BadgeVariants['variant']
28
+ }
29
+
30
+ export type FilterField = {
31
+ /** Dot-path to the attribute, e.g. `'owner.email'`. Stable identifier. */
32
+ path: string
33
+ /** Full nested label, e.g. `'Owner / Email'`. Used in pickers. */
34
+ label: string
35
+ /** Leaf label only, e.g. `'Email'`. Used in chips. */
36
+ shortLabel: string
37
+ /** Parent label for breadcrumb in chips, e.g. `'Owner'`. Omitted for top-level. */
38
+ parentLabel?: string
39
+ type: FilterFieldType
40
+ /** Required when `type === 'enum'`; ignored otherwise. */
41
+ options?: ReadonlyArray<FilterFieldOption>
42
+ /** Hint for `<Input type=…>`. Defaults derived from `type`. */
43
+ inputType?: 'text' | 'number' | 'date' | 'datetime' | 'email'
44
+ /**
45
+ * Relationship metadata for fields that traverse a foreign key into a
46
+ * related entity. Consumers that auto-emit JOINs (e.g. the report
47
+ * builder) read this to determine how to join the related table; the
48
+ * core record-filter UI just renders these like any other field.
49
+ *
50
+ * Path convention: `<attribute>.<relatedFieldPath>`, e.g.
51
+ * `'createdBy.firstName'` or `'accountId.address.city'`. The local FK
52
+ * attribute name is the prefix (not the related entity slug) so multiple
53
+ * relationships to the same target stay distinguishable.
54
+ */
55
+ relation?: FilterFieldRelation
56
+ /**
57
+ * LUM-34: pointer to the page-layout control that drives editor + chip
58
+ * rendering. When set, the consumer's `renderValueInput` dispatches via
59
+ * the control registry (`registry[controlSlug].Filter`) instead of the
60
+ * lib's built-in type-switch. The lib treats this as opaque metadata —
61
+ * it never touches the registry itself.
62
+ */
63
+ controlSlug?: string
64
+ /**
65
+ * LUM-34: the raw SDK `Attribute` for this field, threaded through to
66
+ * the descriptor's `Filter` / `Display` components which need full
67
+ * type+meta context (enum values, relation target, etc.). Typed
68
+ * `unknown` because the lib has no `@proteos/sdk` dependency;
69
+ * consumers cast back to `Attribute` in their renderer.
70
+ */
71
+ attribute?: unknown
72
+ /**
73
+ * LUM-34: per-field operator allowlist. When set, the operator picker
74
+ * uses this list verbatim instead of the per-type default in
75
+ * `operatorsForType(type)`. Lets the registry's per-control overrides
76
+ * (e.g. `password` → `[]`) flow through to the picker without coupling
77
+ * the lib to the registry.
78
+ */
79
+ filterOperators?: ReadonlyArray<ComparisonOperator>
80
+ }
81
+
82
+ export type FilterFieldRelation = {
83
+ /** Local FK attribute on this entity, e.g. `'accountId'`. Used as JOIN alias. */
84
+ attribute: string
85
+ /** Slug of the related entity table, e.g. `'account'`. */
86
+ relatedEntitySlug: string
87
+ /** Attribute on the related entity to join against, typically `'id'`. */
88
+ relatedAttribute: string
89
+ /** Dot-path of the field on the related entity, e.g. `'name'` or `'address.city'`. */
90
+ relatedFieldPath: string
91
+ }
92
+
93
+ /* =========================================================================
94
+ Filter state — mirrors `@proteos/sdk`'s FilterGroup/FilterElement so app
95
+ and lib share one canonical shape. Re-declared locally so the lib has no
96
+ SDK dependency; callers can `as` between them since the structures match.
97
+ ========================================================================= */
98
+
99
+ export type ComparisonOperator =
100
+ | 'eq'
101
+ | 'gt'
102
+ | 'lt'
103
+ | 'gte'
104
+ | 'lte'
105
+ | 'ne'
106
+ | 'in'
107
+ | 'not_in'
108
+ | 'contains'
109
+ | 'starts_with'
110
+ | 'ends_with'
111
+ | 'empty'
112
+ | 'not_empty'
113
+
114
+ export type LogicalOperator = 'and' | 'or'
115
+
116
+ export type FilterValue = string | string[] | boolean | null
117
+
118
+ export type FilterElement = {
119
+ field: string
120
+ operator: ComparisonOperator
121
+ /**
122
+ * In-memory value shape. Richer than the SDK's `string`-only `value`
123
+ * because the UI needs structured arrays for `in`/`not_in` and booleans
124
+ * for boolean filters. App-level serializers collapse this back to the
125
+ * SDK's wire format (pipe-joined strings, etc.) at the boundary.
126
+ */
127
+ value: FilterValue
128
+ }
129
+
130
+ export type FilterGroup = {
131
+ logicalOperator: LogicalOperator
132
+ elements?: FilterElement[]
133
+ groups?: FilterGroup[]
134
+ }
135
+
136
+ /* =========================================================================
137
+ Operator descriptor — one row of the catalogue.
138
+ ========================================================================= */
139
+
140
+ export type OperatorArity = 0 | 1 | 'n'
141
+
142
+ export type OperatorDescriptor = {
143
+ id: ComparisonOperator
144
+ /** Human label shown in the operator picker. */
145
+ label: string
146
+ /** Number of values the operator takes. */
147
+ arity: OperatorArity
148
+ /** Field types this operator applies to. */
149
+ types: ReadonlyArray<FilterFieldType>
150
+ }
@@ -0,0 +1,6 @@
1
+ export type {
2
+ SegmentedControlOption,
3
+ SegmentedControlProps,
4
+ SegmentedControlVariants,
5
+ } from './segmented-control'
6
+ export { SegmentedControl, segmentedControlStyles } from './segmented-control'
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { SegmentedControl } from './segmented-control'
5
+
6
+ const meta: Meta<typeof SegmentedControl> = {
7
+ title: 'Components/SegmentedControl',
8
+ component: SegmentedControl,
9
+ tags: ['autodocs'],
10
+ }
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof SegmentedControl>
14
+
15
+ export const AndOrJoiner: Story = {
16
+ render: () => {
17
+ const [value, setValue] = useState<'and' | 'or'>('and')
18
+ return (
19
+ <SegmentedControl
20
+ aria-label="Joiner"
21
+ options={[
22
+ { id: 'and', label: 'AND' },
23
+ { id: 'or', label: 'OR' },
24
+ ]}
25
+ value={value}
26
+ onChange={setValue}
27
+ />
28
+ )
29
+ },
30
+ }
31
+
32
+ export const BooleanTrueFalse: Story = {
33
+ render: () => {
34
+ const [value, setValue] = useState<'true' | 'false'>('true')
35
+ return (
36
+ <SegmentedControl
37
+ size="md"
38
+ aria-label="Boolean"
39
+ options={[
40
+ { id: 'true', label: 'true' },
41
+ { id: 'false', label: 'false' },
42
+ ]}
43
+ value={value}
44
+ onChange={setValue}
45
+ />
46
+ )
47
+ },
48
+ }
49
+
50
+ export const ThreeSegments: Story = {
51
+ render: () => {
52
+ const [value, setValue] = useState<'compact' | 'cozy' | 'comfy'>('cozy')
53
+ return (
54
+ <SegmentedControl
55
+ size="md"
56
+ aria-label="Density"
57
+ options={[
58
+ { id: 'compact', label: 'Compact' },
59
+ { id: 'cozy', label: 'Cozy' },
60
+ { id: 'comfy', label: 'Comfy' },
61
+ ]}
62
+ value={value}
63
+ onChange={setValue}
64
+ />
65
+ )
66
+ },
67
+ }
@@ -0,0 +1,47 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { useState } from 'react'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ import { SegmentedControl } from './segmented-control'
7
+
8
+ function Harness({ onChange }: { onChange?: (v: 'and' | 'or') => void }) {
9
+ const [value, setValue] = useState<'and' | 'or'>('and')
10
+ return (
11
+ <SegmentedControl
12
+ aria-label="Joiner"
13
+ options={[
14
+ { id: 'and', label: 'AND' },
15
+ { id: 'or', label: 'OR' },
16
+ ]}
17
+ value={value}
18
+ onChange={(next) => {
19
+ setValue(next)
20
+ onChange?.(next)
21
+ }}
22
+ />
23
+ )
24
+ }
25
+
26
+ describe('SegmentedControl', () => {
27
+ it('renders options as a radiogroup', () => {
28
+ render(<Harness />)
29
+ expect(screen.getByRole('radiogroup', { name: 'Joiner' })).toBeInTheDocument()
30
+ expect(screen.getAllByRole('radio')).toHaveLength(2)
31
+ })
32
+
33
+ it('reflects the controlled value', () => {
34
+ render(<Harness />)
35
+ expect(screen.getByRole('radio', { name: 'AND' })).toBeChecked()
36
+ expect(screen.getByRole('radio', { name: 'OR' })).not.toBeChecked()
37
+ })
38
+
39
+ it('changes value on click', async () => {
40
+ const user = userEvent.setup()
41
+ const onChange = vi.fn()
42
+ render(<Harness onChange={onChange} />)
43
+ await user.click(screen.getByRole('radio', { name: 'OR' }))
44
+ expect(onChange).toHaveBeenLastCalledWith('or')
45
+ expect(screen.getByRole('radio', { name: 'OR' })).toBeChecked()
46
+ })
47
+ })
@@ -0,0 +1,105 @@
1
+ import type { Key, ReactNode } from 'react'
2
+ import {
3
+ Radio as AriaRadio,
4
+ RadioGroup as AriaRadioGroup,
5
+ type RadioGroupProps as AriaRadioGroupProps,
6
+ } from 'react-aria-components'
7
+ import { tv, type VariantProps } from 'tailwind-variants'
8
+
9
+ import { cn } from '../../lib/cn'
10
+
11
+ /* =========================================================================
12
+ SegmentedControl — small horizontal exclusive-choice group with pill
13
+ chrome. Wraps RAC RadioGroup so keyboard nav and focus management are
14
+ correct (arrow keys move between segments). Used for compact toggles
15
+ like AND/OR joiners and true/false booleans where a full RadioGroup
16
+ feels heavy.
17
+ ========================================================================= */
18
+
19
+ export const segmentedControlStyles = tv({
20
+ slots: {
21
+ // `w-fit` keeps the group content-width even as a flex/grid item under a
22
+ // stretching parent (e.g. a `flex flex-col` card body, whose default
23
+ // align-items:stretch would otherwise pull it to full width). Consumers
24
+ // can still opt into full width by passing `w-full` via className.
25
+ group: 'inline-flex w-fit rounded-sm border border-rule-strong bg-surface p-0.5 gap-0.5',
26
+ segment: [
27
+ 'inline-flex items-center justify-center cursor-pointer',
28
+ 'font-mono tracking-[0.04em] rounded-[3px] outline-none select-none',
29
+ 'transition-[background-color,color] duration-150',
30
+ 'data-[hovered]:text-ink',
31
+ 'data-[selected]:bg-ink data-[selected]:text-surface',
32
+ 'data-[focus-visible]:shadow-[var(--shadow-focus)]',
33
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
34
+ ],
35
+ },
36
+ variants: {
37
+ size: {
38
+ sm: { segment: 'px-2 py-0.5 text-[10.5px] tracking-[0.08em]' },
39
+ md: { segment: 'px-2.5 py-1 text-[12px]' },
40
+ },
41
+ tone: {
42
+ // Default: ink fill on selected.
43
+ default: { segment: 'text-ink-3' },
44
+ },
45
+ },
46
+ defaultVariants: { size: 'sm', tone: 'default' },
47
+ })
48
+
49
+ export type SegmentedControlVariants = VariantProps<typeof segmentedControlStyles>
50
+
51
+ export type SegmentedControlOption<T extends Key = Key> = {
52
+ id: T
53
+ label: ReactNode
54
+ isDisabled?: boolean
55
+ }
56
+
57
+ export type SegmentedControlProps<T extends Key = Key> = Omit<
58
+ AriaRadioGroupProps,
59
+ 'children' | 'value' | 'onChange'
60
+ > & {
61
+ options: ReadonlyArray<SegmentedControlOption<T>>
62
+ value: T
63
+ onChange: (value: T) => void
64
+ size?: SegmentedControlVariants['size']
65
+ /** Focus the selected segment on mount. RAC's RadioGroup doesn't surface
66
+ * `autoFocus` itself — it's a per-Radio prop — so we apply it to the
67
+ * Radio whose value matches `value`. */
68
+ autoFocus?: boolean
69
+ /** Visual override for the group wrapper. */
70
+ className?: string
71
+ }
72
+
73
+ export function SegmentedControl<T extends Key>({
74
+ options,
75
+ value,
76
+ onChange,
77
+ size,
78
+ autoFocus = false,
79
+ className,
80
+ orientation = 'horizontal',
81
+ ...props
82
+ }: SegmentedControlProps<T>) {
83
+ const styles = segmentedControlStyles({ size })
84
+ return (
85
+ <AriaRadioGroup
86
+ {...props}
87
+ orientation={orientation}
88
+ value={String(value)}
89
+ onChange={(next) => onChange(next as T)}
90
+ className={cn(styles.group(), className)}
91
+ >
92
+ {options.map((option) => (
93
+ <AriaRadio
94
+ key={String(option.id)}
95
+ value={String(option.id)}
96
+ {...(option.isDisabled ? { isDisabled: true } : {})}
97
+ {...(autoFocus && option.id === value ? { autoFocus: true } : {})}
98
+ className={styles.segment()}
99
+ >
100
+ {option.label}
101
+ </AriaRadio>
102
+ ))}
103
+ </AriaRadioGroup>
104
+ )
105
+ }
@@ -0,0 +1,2 @@
1
+ export type { SelectOption, SelectProps } from './select'
2
+ export { Select } from './select'
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Select } from './select'
4
+
5
+ const industries = [
6
+ { id: 'freight', label: 'Freight & logistics' },
7
+ { id: 'lending', label: 'Specialty lending' },
8
+ { id: 'clinical', label: 'Clinical trials' },
9
+ { id: 'services', label: 'Professional services' },
10
+ { id: 'insurance', label: 'Insurance MGA' },
11
+ { id: 'b2b', label: 'B2B SaaS' },
12
+ ]
13
+
14
+ const meta: Meta<typeof Select> = {
15
+ title: 'Components/Select',
16
+ component: Select,
17
+ tags: ['autodocs'],
18
+ args: {
19
+ label: 'Industry',
20
+ placeholder: 'Pick an industry',
21
+ options: industries,
22
+ },
23
+ argTypes: {
24
+ isDisabled: { control: { type: 'boolean' } },
25
+ isInvalid: { control: { type: 'boolean' } },
26
+ },
27
+ decorators: [
28
+ (Story) => (
29
+ <div className="w-[440px]">
30
+ <Story />
31
+ </div>
32
+ ),
33
+ ],
34
+ }
35
+
36
+ export default meta
37
+ type Story = StoryObj<typeof Select>
38
+
39
+ export const Playground: Story = {}
40
+
41
+ export const WithDescription: Story = {
42
+ args: {
43
+ description: 'Proteos pre-seeds the entity model from this.',
44
+ },
45
+ }
46
+
47
+ export const Invalid: Story = {
48
+ args: {
49
+ isInvalid: true,
50
+ errorMessage: 'Select an industry to continue.',
51
+ },
52
+ }
53
+
54
+ export const Disabled: Story = {
55
+ args: {
56
+ defaultSelectedKey: 'freight',
57
+ isDisabled: true,
58
+ },
59
+ }
60
+
61
+ export const AllStates: Story = {
62
+ parameters: { controls: { hideNoControlsWarning: true } },
63
+ render: () => (
64
+ <div className="grid gap-5 w-[440px]">
65
+ <Select label="Rest" options={industries} />
66
+ <Select label="Selected" options={industries} defaultSelectedKey="lending" />
67
+ <Select
68
+ label="Invalid"
69
+ options={industries}
70
+ isInvalid
71
+ errorMessage="Select an industry to continue."
72
+ />
73
+ <Select label="Disabled" options={industries} defaultSelectedKey="clinical" isDisabled />
74
+ </div>
75
+ ),
76
+ }
@@ -0,0 +1,56 @@
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 { Select } from './select'
6
+
7
+ const industries = [
8
+ { id: 'freight', label: 'Freight & logistics' },
9
+ { id: 'lending', label: 'Specialty lending' },
10
+ { id: 'clinical', label: 'Clinical trials' },
11
+ ] as const
12
+
13
+ describe('Select', () => {
14
+ it('renders a labelled select trigger', () => {
15
+ render(<Select label="Industry" options={[...industries]} />)
16
+ expect(screen.getByRole('button', { name: /industry/i })).toBeInTheDocument()
17
+ })
18
+
19
+ it('opens the listbox on click and selects an option', async () => {
20
+ const user = userEvent.setup()
21
+ const onSelectionChange = vi.fn()
22
+ render(
23
+ <Select label="Industry" options={[...industries]} onSelectionChange={onSelectionChange} />,
24
+ )
25
+
26
+ await user.click(screen.getByRole('button', { name: /industry/i }))
27
+ const listbox = await screen.findByRole('listbox')
28
+ expect(listbox).toBeInTheDocument()
29
+
30
+ await user.click(screen.getByRole('option', { name: 'Specialty lending' }))
31
+ expect(onSelectionChange).toHaveBeenCalledWith('lending')
32
+ })
33
+
34
+ it('supports keyboard navigation', async () => {
35
+ const user = userEvent.setup()
36
+ render(<Select label="Industry" options={[...industries]} />)
37
+
38
+ await user.tab()
39
+ expect(screen.getByRole('button', { name: /industry/i })).toHaveFocus()
40
+
41
+ await user.keyboard('{Enter}')
42
+ expect(await screen.findByRole('listbox')).toBeInTheDocument()
43
+ })
44
+
45
+ it('shows placeholder when nothing is selected', () => {
46
+ render(<Select label="Industry" placeholder="Pick one" options={[...industries]} />)
47
+ const trigger = screen.getByRole('button', { name: /industry/i })
48
+ expect(trigger).toHaveTextContent('Pick one')
49
+ })
50
+
51
+ it('renders a controlled selection', () => {
52
+ render(<Select label="Industry" options={[...industries]} selectedKey="clinical" />)
53
+ const trigger = screen.getByRole('button', { name: /industry/i })
54
+ expect(trigger).toHaveTextContent('Clinical trials')
55
+ })
56
+ })
@@ -0,0 +1,159 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Select as AriaSelect,
4
+ Button as AriaSelectButton,
5
+ type SelectProps as AriaSelectProps,
6
+ FieldError,
7
+ type Key,
8
+ Label,
9
+ ListBox,
10
+ ListBoxItem,
11
+ type ListBoxItemProps,
12
+ Popover,
13
+ SelectValue,
14
+ Text,
15
+ } from 'react-aria-components'
16
+ import { cn } from '../../lib/cn'
17
+ import {
18
+ type FieldAppearance,
19
+ FieldCaret,
20
+ type FieldSize,
21
+ fieldControlStyles,
22
+ fieldErrorStyles,
23
+ fieldHintStyles,
24
+ fieldLabelStyles,
25
+ } from '../field/field'
26
+
27
+ export type SelectOption<T extends Key = Key> = {
28
+ id: T
29
+ label: ReactNode
30
+ /** Plain-text stand-in for typeahead/accessibility when `label` is a
31
+ * ReactNode. Optional for string labels (RAC derives the text itself). */
32
+ textValue?: string
33
+ description?: ReactNode
34
+ isDisabled?: boolean
35
+ }
36
+
37
+ export type SelectProps<T extends Key = Key> = Omit<AriaSelectProps<never>, 'children'> & {
38
+ label?: ReactNode
39
+ description?: ReactNode
40
+ errorMessage?: ReactNode
41
+ placeholder?: string
42
+ options: ReadonlyArray<SelectOption<T>>
43
+ /** Optional custom renderer for a ListBoxItem. */
44
+ renderItem?: (option: SelectOption<T>) => ReactNode
45
+ /** Field size. Defaults to `md`. */
46
+ size?: FieldSize
47
+ /** Chrome variant. Defaults to `field`. `cell` flattens the trigger box. */
48
+ appearance?: FieldAppearance
49
+ /** Visual override for the trigger only. */
50
+ triggerClassName?: string
51
+ /** Visual override for the popover — e.g. `min-w-[var(--trigger-width)]
52
+ * w-max` to size to content when the trigger is intrinsic-width. Defaults
53
+ * to matching the trigger width exactly. */
54
+ popoverClassName?: string
55
+ }
56
+
57
+ export function Select<T extends Key>({
58
+ label,
59
+ description,
60
+ errorMessage,
61
+ placeholder = 'Select…',
62
+ options,
63
+ renderItem,
64
+ size,
65
+ appearance,
66
+ triggerClassName,
67
+ popoverClassName,
68
+ className,
69
+ ...props
70
+ }: SelectProps<T>) {
71
+ const styles = fieldControlStyles({ size, appearance })
72
+ return (
73
+ <AriaSelect
74
+ {...props}
75
+ className={(renderProps) =>
76
+ cn(
77
+ 'grid grid-cols-1 gap-1.5',
78
+ typeof className === 'function' ? className(renderProps) : className,
79
+ )
80
+ }
81
+ >
82
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
83
+ <AriaSelectButton
84
+ className={cn(
85
+ styles.box(),
86
+ styles.content(),
87
+ 'flex items-center justify-between gap-3 text-left cursor-pointer',
88
+ triggerClassName,
89
+ )}
90
+ >
91
+ <SelectValue className="truncate">
92
+ {({ defaultChildren, isPlaceholder }) =>
93
+ isPlaceholder ? <span className="text-ink-4">{placeholder}</span> : defaultChildren
94
+ }
95
+ </SelectValue>
96
+ <FieldCaret />
97
+ </AriaSelectButton>
98
+
99
+ {description ? (
100
+ <Text slot="description" className={fieldHintStyles}>
101
+ {description}
102
+ </Text>
103
+ ) : null}
104
+ {errorMessage && appearance !== 'cell' ? (
105
+ <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
106
+ ) : null}
107
+
108
+ <Popover
109
+ offset={6}
110
+ className={cn(
111
+ // Match the trigger width exactly (RAC exposes --trigger-width on
112
+ // the Popover when anchored to a Select button).
113
+ 'w-[var(--trigger-width)]',
114
+ // Float above modals + full-screen editors (e.g. agent-studio's
115
+ // .as-editor z-1000 / a raised modal overlay), matching Menu's z-[1200].
116
+ 'z-[1300]',
117
+ 'rounded-md border border-rule bg-surface p-1',
118
+ 'shadow-[var(--shadow-raised)]',
119
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
120
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
121
+ popoverClassName,
122
+ )}
123
+ >
124
+ <ListBox className="outline-none max-h-72 overflow-auto">
125
+ {options.map((option) => (
126
+ <SelectItem
127
+ key={option.id}
128
+ id={option.id}
129
+ {...(option.textValue ? { textValue: option.textValue } : {})}
130
+ {...(option.isDisabled ? { isDisabled: true } : {})}
131
+ >
132
+ {renderItem ? renderItem(option) : option.label}
133
+ </SelectItem>
134
+ ))}
135
+ </ListBox>
136
+ {options.length === 0 ? (
137
+ <div className={cn('px-3 py-2', fieldHintStyles)}>{placeholder}</div>
138
+ ) : null}
139
+ </Popover>
140
+ </AriaSelect>
141
+ )
142
+ }
143
+
144
+ function SelectItem(props: ListBoxItemProps) {
145
+ return (
146
+ <ListBoxItem
147
+ {...props}
148
+ className={cn(
149
+ 'flex items-center justify-between gap-3',
150
+ 'rounded-[6px] px-3 py-2 text-[14px] text-ink-2',
151
+ 'cursor-pointer outline-none select-none',
152
+ 'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
153
+ 'data-[focused]:bg-surface-2 data-[focused]:text-ink',
154
+ 'data-[selected]:bg-surface-3 data-[selected]:text-ink',
155
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
156
+ )}
157
+ />
158
+ )
159
+ }
@@ -0,0 +1,2 @@
1
+ export type { SeparatorProps } from './separator'
2
+ export { Separator } from './separator'