@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,211 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { MultiSelect, type MultiSelectOption } from './multi-select'
5
+
6
+ const meta: Meta<typeof MultiSelect> = {
7
+ title: 'Components/MultiSelect',
8
+ component: MultiSelect,
9
+ tags: ['autodocs'],
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[440px]">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ }
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof MultiSelect>
21
+
22
+ /* ===== example data ======================================================= */
23
+
24
+ // Plain text options — the chip and listbox row both render the string.
25
+ const plainCarriers: MultiSelectOption<string>[] = [
26
+ { id: 'blue-ridge', label: 'Blue Ridge' },
27
+ { id: 'north-star', label: 'North Star' },
28
+ { id: 'sunline', label: 'Sunline' },
29
+ { id: 'cascade', label: 'Cascade' },
30
+ { id: 'atlantic', label: 'Atlantic' },
31
+ { id: 'pioneer', label: 'Pioneer' },
32
+ ]
33
+
34
+ // Options with a small decoration alongside the label — shows that
35
+ // `content` can be anything, not just a Badge.
36
+ const carriersWithMeta: MultiSelectOption<string>[] = [
37
+ {
38
+ id: 'blue-ridge',
39
+ label: 'Blue Ridge',
40
+ content: (
41
+ <span className="inline-flex items-center gap-2">
42
+ <span>Blue Ridge</span>
43
+ <span className="font-mono text-[11px] text-ink-4">FTL · 42 trucks</span>
44
+ </span>
45
+ ),
46
+ },
47
+ {
48
+ id: 'north-star',
49
+ label: 'North Star',
50
+ content: (
51
+ <span className="inline-flex items-center gap-2">
52
+ <span>North Star</span>
53
+ <span className="font-mono text-[11px] text-ink-4">LTL · 118 trucks</span>
54
+ </span>
55
+ ),
56
+ },
57
+ {
58
+ id: 'sunline',
59
+ label: 'Sunline',
60
+ content: (
61
+ <span className="inline-flex items-center gap-2">
62
+ <span>Sunline</span>
63
+ <span className="font-mono text-[11px] text-ink-4">FTL · 24 trucks</span>
64
+ </span>
65
+ ),
66
+ },
67
+ ]
68
+
69
+ /* ===== stories ============================================================ */
70
+
71
+ /** Plain text options — the default. Each chip shows the option's label. */
72
+ export const PlainText: Story = {
73
+ render: () => {
74
+ const [selected, setSelected] = useState<Set<string>>(new Set(['blue-ridge', 'sunline']))
75
+ return (
76
+ <MultiSelect
77
+ label="Carriers"
78
+ placeholder="Pick carriers"
79
+ description="Click to add, × to remove."
80
+ options={plainCarriers}
81
+ selectedKeys={selected}
82
+ onSelectionChange={setSelected}
83
+ />
84
+ )
85
+ },
86
+ }
87
+
88
+ /** `hideSelectedOptions` — already-selected items disappear from the
89
+ * dropdown. Removal happens only via the chip × in the trigger. */
90
+ export const HideSelected: Story = {
91
+ render: () => {
92
+ const [selected, setSelected] = useState<Set<string>>(new Set(['blue-ridge']))
93
+ return (
94
+ <MultiSelect
95
+ label="Carriers"
96
+ placeholder="Pick carriers"
97
+ description="Selected carriers are hidden from the menu — remove them via × above."
98
+ options={plainCarriers}
99
+ selectedKeys={selected}
100
+ onSelectionChange={setSelected}
101
+ hideSelectedOptions
102
+ />
103
+ )
104
+ },
105
+ }
106
+
107
+ /** Mix a label with small meta on the right — shows that `content` is just
108
+ * ReactNode, no constraints on structure. */
109
+ export const WithInlineMeta: Story = {
110
+ render: () => {
111
+ const [selected, setSelected] = useState<Set<string>>(new Set(['blue-ridge']))
112
+ return (
113
+ <MultiSelect
114
+ label="Carriers"
115
+ placeholder="Pick carriers"
116
+ options={carriersWithMeta}
117
+ selectedKeys={selected}
118
+ onSelectionChange={setSelected}
119
+ />
120
+ )
121
+ },
122
+ }
123
+
124
+ /** Empty state — no selection yet, trigger shows only the placeholder. */
125
+ export const Empty: Story = {
126
+ render: () => {
127
+ const [selected, setSelected] = useState<Set<string>>(new Set())
128
+ return (
129
+ <MultiSelect
130
+ label="Carriers"
131
+ placeholder="Pick one or more carriers"
132
+ options={plainCarriers}
133
+ selectedKeys={selected}
134
+ onSelectionChange={setSelected}
135
+ />
136
+ )
137
+ },
138
+ }
139
+
140
+ export const Invalid: Story = {
141
+ render: () => {
142
+ const [selected, setSelected] = useState<Set<string>>(new Set())
143
+ return (
144
+ <MultiSelect
145
+ label="Carriers"
146
+ placeholder="Pick carriers"
147
+ options={plainCarriers}
148
+ selectedKeys={selected}
149
+ onSelectionChange={setSelected}
150
+ isInvalid
151
+ errorMessage="Pick at least one carrier to dispatch."
152
+ />
153
+ )
154
+ },
155
+ }
156
+
157
+ export const Disabled: Story = {
158
+ render: () => (
159
+ <MultiSelect
160
+ label="Carriers"
161
+ placeholder="Pick carriers"
162
+ options={plainCarriers}
163
+ selectedKeys={new Set(['blue-ridge', 'sunline'])}
164
+ onSelectionChange={() => {}}
165
+ isDisabled
166
+ />
167
+ ),
168
+ }
169
+
170
+ export const AllStates: Story = {
171
+ parameters: { controls: { hideNoControlsWarning: true } },
172
+ render: () => {
173
+ const [a, setA] = useState<Set<string>>(new Set())
174
+ const [b, setB] = useState<Set<string>>(new Set(['blue-ridge', 'sunline']))
175
+ return (
176
+ <div className="grid gap-5 w-[440px]">
177
+ <MultiSelect
178
+ label="Rest"
179
+ placeholder="Pick carriers"
180
+ options={plainCarriers}
181
+ selectedKeys={a}
182
+ onSelectionChange={setA}
183
+ />
184
+ <MultiSelect
185
+ label="With text chips"
186
+ placeholder="Pick carriers"
187
+ options={plainCarriers}
188
+ selectedKeys={b}
189
+ onSelectionChange={setB}
190
+ />
191
+ <MultiSelect
192
+ label="Invalid"
193
+ placeholder="Pick carriers"
194
+ options={plainCarriers}
195
+ selectedKeys={new Set<string>()}
196
+ onSelectionChange={() => {}}
197
+ isInvalid
198
+ errorMessage="Pick at least one."
199
+ />
200
+ <MultiSelect
201
+ label="Disabled"
202
+ placeholder="Pick carriers"
203
+ options={plainCarriers}
204
+ selectedKeys={new Set(['blue-ridge'])}
205
+ onSelectionChange={() => {}}
206
+ isDisabled
207
+ />
208
+ </div>
209
+ )
210
+ },
211
+ }
@@ -0,0 +1,151 @@
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 { MultiSelect, type MultiSelectOption } from './multi-select'
7
+
8
+ const carriers: MultiSelectOption<string>[] = [
9
+ { id: 'blue-ridge', label: 'Blue Ridge' },
10
+ { id: 'north-star', label: 'North Star' },
11
+ { id: 'sunline', label: 'Sunline' },
12
+ { id: 'cascade', label: 'Cascade' },
13
+ ]
14
+
15
+ function Harness({
16
+ onSelectionChange,
17
+ initial = new Set<string>(),
18
+ }: {
19
+ onSelectionChange?: (keys: Set<string>) => void
20
+ initial?: Set<string>
21
+ }) {
22
+ const [selected, setSelected] = useState<Set<string>>(initial)
23
+ return (
24
+ <MultiSelect<string>
25
+ label="Carriers"
26
+ placeholder="Pick carriers"
27
+ options={carriers}
28
+ selectedKeys={selected}
29
+ onSelectionChange={(keys) => {
30
+ setSelected(keys)
31
+ onSelectionChange?.(keys)
32
+ }}
33
+ />
34
+ )
35
+ }
36
+
37
+ describe('MultiSelect', () => {
38
+ it('renders the trigger with a placeholder when nothing is selected', () => {
39
+ render(<Harness />)
40
+ expect(screen.getByText('Pick carriers')).toBeInTheDocument()
41
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
42
+ })
43
+
44
+ it('opens the listbox on trigger click', async () => {
45
+ const user = userEvent.setup()
46
+ render(<Harness />)
47
+
48
+ await user.click(screen.getByRole('combobox'))
49
+ expect(await screen.findByRole('listbox')).toBeInTheDocument()
50
+ expect(screen.getAllByRole('option')).toHaveLength(carriers.length)
51
+ })
52
+
53
+ it('selects multiple options and renders them as chips', async () => {
54
+ const user = userEvent.setup()
55
+ const onChange = vi.fn()
56
+ render(<Harness onSelectionChange={onChange} />)
57
+
58
+ await user.click(screen.getByRole('combobox'))
59
+ await user.click(screen.getByRole('option', { name: 'Blue Ridge' }))
60
+ await user.click(screen.getByRole('option', { name: 'Sunline' }))
61
+
62
+ // Both selections make it back to the consumer. Checking membership
63
+ // explicitly avoids Set-equality quirks from structural matchers.
64
+ const lastCall = onChange.mock.calls.at(-1)?.[0] as Set<string> | undefined
65
+ expect(lastCall?.has('blue-ridge')).toBe(true)
66
+ expect(lastCall?.has('sunline')).toBe(true)
67
+ expect(lastCall?.size).toBe(2)
68
+
69
+ // Two chips = two × remove buttons. RAC's popover aria-hides the
70
+ // trigger's ancestors while open, so we include hidden matches to see
71
+ // the chips inside the trigger div.
72
+ expect(screen.getAllByRole('button', { name: 'Remove', hidden: true })).toHaveLength(2)
73
+ })
74
+
75
+ it('removes a selection via the chip × button', async () => {
76
+ const user = userEvent.setup()
77
+ const onChange = vi.fn()
78
+ render(<Harness onSelectionChange={onChange} initial={new Set(['blue-ridge', 'north-star'])} />)
79
+
80
+ // Two chips, two × buttons. Click the first to remove Blue Ridge.
81
+ const [firstRemove, ...rest] = screen.getAllByRole('button', { name: 'Remove' })
82
+ expect(rest).toHaveLength(1)
83
+ if (!firstRemove) throw new Error('expected a remove button')
84
+ await user.click(firstRemove)
85
+
86
+ expect(onChange).toHaveBeenCalledWith(new Set(['north-star']))
87
+ })
88
+
89
+ it('opens via keyboard (ArrowDown / Enter / Space)', async () => {
90
+ const user = userEvent.setup()
91
+ render(<Harness />)
92
+
93
+ await user.tab()
94
+ expect(screen.getByRole('combobox')).toHaveFocus()
95
+ await user.keyboard('{ArrowDown}')
96
+ expect(await screen.findByRole('listbox')).toBeInTheDocument()
97
+ })
98
+
99
+ it('does not open when disabled', async () => {
100
+ const user = userEvent.setup()
101
+ render(
102
+ <MultiSelect
103
+ label="Carriers"
104
+ options={carriers}
105
+ selectedKeys={new Set<string>()}
106
+ onSelectionChange={() => {}}
107
+ isDisabled
108
+ />,
109
+ )
110
+
111
+ await user.click(screen.getByRole('combobox'))
112
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
113
+ })
114
+
115
+ it('hides selected options from the dropdown when hideSelectedOptions is set', async () => {
116
+ const user = userEvent.setup()
117
+ render(
118
+ <MultiSelect<string>
119
+ label="Carriers"
120
+ options={carriers}
121
+ selectedKeys={new Set<string>(['blue-ridge'])}
122
+ onSelectionChange={() => {}}
123
+ hideSelectedOptions
124
+ />,
125
+ )
126
+
127
+ await user.click(screen.getByRole('combobox'))
128
+ await screen.findByRole('listbox')
129
+
130
+ expect(screen.queryByRole('option', { name: 'Blue Ridge' })).not.toBeInTheDocument()
131
+ expect(screen.getAllByRole('option')).toHaveLength(carriers.length - 1)
132
+ })
133
+
134
+ it('renders description and error message slots', () => {
135
+ render(
136
+ <MultiSelect
137
+ label="Carriers"
138
+ description="Pick one or more."
139
+ errorMessage="Required."
140
+ isInvalid
141
+ options={carriers}
142
+ selectedKeys={new Set<string>()}
143
+ onSelectionChange={() => {}}
144
+ />,
145
+ )
146
+
147
+ expect(screen.getByText('Pick one or more.')).toBeInTheDocument()
148
+ expect(screen.getByText('Required.')).toBeInTheDocument()
149
+ expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true')
150
+ })
151
+ })
@@ -0,0 +1,339 @@
1
+ import { type KeyboardEvent, type ReactNode, useRef, useState } from 'react'
2
+ import { type Key, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'
3
+ import { cn } from '../../lib/cn'
4
+ import {
5
+ type FieldAppearance,
6
+ FieldCaret,
7
+ type FieldSize,
8
+ fieldChipPadding,
9
+ fieldControlStyles,
10
+ fieldErrorStyles,
11
+ fieldHintStyles,
12
+ fieldLabelStyles,
13
+ fieldMinHeight,
14
+ fieldTextStyles,
15
+ } from '../field/field'
16
+
17
+ /* =========================================================================
18
+ MultiSelect — pick many from a list. Trigger shows selected items as
19
+ removable chips; popover hosts a multi-select ListBox.
20
+
21
+ Each option accepts a `content: ReactNode` so consumers can render plain
22
+ text, a Badge, an icon + label, or anything else — and the same content
23
+ is reused inside the selected chip.
24
+ ========================================================================= */
25
+
26
+ export type MultiSelectOption<T extends Key = Key> = {
27
+ id: T
28
+ /** Plain-text label — used as the option's accessible name and fallback
29
+ * when `content` isn't provided. */
30
+ label: string
31
+ /** Optional rich render. Applied to BOTH the listbox row and the selected
32
+ * chip (wrapped by the chip's × button). Falls back to `label`. */
33
+ content?: ReactNode
34
+ isDisabled?: boolean
35
+ }
36
+
37
+ export type MultiSelectProps<T extends Key = Key> = {
38
+ label?: ReactNode
39
+ description?: ReactNode
40
+ errorMessage?: ReactNode
41
+ placeholder?: string
42
+ options: ReadonlyArray<MultiSelectOption<T>>
43
+ /** Controlled selection. Pass a `Set<T>` or any `Iterable<T>`. */
44
+ selectedKeys: Iterable<T>
45
+ onSelectionChange: (keys: Set<T>) => void
46
+ isDisabled?: boolean
47
+ isInvalid?: boolean
48
+ /** Hide already-selected options from the dropdown. Removal then only
49
+ * happens via the chip × in the trigger. Default `false` (selected
50
+ * options stay visible and can be toggled off from the listbox). */
51
+ hideSelectedOptions?: boolean
52
+ /** Render a search input at the top of the popover that filters options
53
+ * by label (case-insensitive substring). Default `false`. */
54
+ isSearchable?: boolean
55
+ /** Fires as the search text changes — lets consumers drive a server-side
56
+ * directory search alongside the local filter. */
57
+ onSearchChange?: (needle: string) => void
58
+ /** Field size. Defaults to `md`. */
59
+ size?: FieldSize
60
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
61
+ appearance?: FieldAppearance
62
+ className?: string
63
+ }
64
+
65
+ export function MultiSelect<T extends Key>({
66
+ label,
67
+ description,
68
+ errorMessage,
69
+ placeholder = 'Select…',
70
+ options,
71
+ selectedKeys,
72
+ onSelectionChange,
73
+ isDisabled = false,
74
+ isInvalid = false,
75
+ hideSelectedOptions = false,
76
+ isSearchable = false,
77
+ onSearchChange,
78
+ size = 'md',
79
+ appearance,
80
+ className,
81
+ }: MultiSelectProps<T>) {
82
+ const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
83
+ const focusWithin =
84
+ appearance === 'cell'
85
+ ? 'focus-within:border-accent focus-within:shadow-none'
86
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
87
+ // Cell mode sits under the table row so the border reads clean instead of
88
+ // merging with the row gridline.
89
+ const minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
90
+ const [isOpen, setIsOpen] = useState(false)
91
+ const [search, setSearch] = useState('')
92
+ const triggerRef = useRef<HTMLDivElement>(null)
93
+
94
+ const selectedSet = new Set(selectedKeys)
95
+ const selectedOptions = options.filter((o) => selectedSet.has(o.id))
96
+ const needle = search.trim().toLowerCase()
97
+ const listboxOptions = (
98
+ hideSelectedOptions ? options.filter((o) => !selectedSet.has(o.id)) : options
99
+ ).filter((o) => needle === '' || o.label.toLowerCase().includes(needle))
100
+
101
+ const updateSearch = (next: string) => {
102
+ setSearch(next)
103
+ onSearchChange?.(next)
104
+ }
105
+ const openPopover = (open: boolean) => {
106
+ setIsOpen(open)
107
+ if (!open && search !== '') updateSearch('')
108
+ }
109
+
110
+ const remove = (id: T) => {
111
+ const next = new Set(selectedSet)
112
+ next.delete(id)
113
+ onSelectionChange(next)
114
+ }
115
+
116
+ const onTriggerKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
117
+ if (isDisabled) return
118
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
119
+ e.preventDefault()
120
+ openPopover(true)
121
+ }
122
+ }
123
+
124
+ return (
125
+ <div className={cn('grid grid-cols-1 gap-1.5', className)}>
126
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
127
+
128
+ <div
129
+ ref={triggerRef}
130
+ role="combobox"
131
+ aria-expanded={isOpen}
132
+ aria-haspopup="listbox"
133
+ aria-disabled={isDisabled || undefined}
134
+ aria-invalid={isInvalid || undefined}
135
+ tabIndex={isDisabled ? -1 : 0}
136
+ onClick={() => !isDisabled && openPopover(!isOpen)}
137
+ onKeyDown={onTriggerKeyDown}
138
+ data-invalid={isInvalid || undefined}
139
+ data-disabled={isDisabled || undefined}
140
+ className={cn(
141
+ styles.box(),
142
+ 'flex flex-wrap items-center gap-1.5 cursor-pointer',
143
+ minHeight,
144
+ fieldChipPadding[size],
145
+ 'pr-2',
146
+ // Let focus-within light up the ring on both the trigger and any
147
+ // chip's remove button without any extra JS.
148
+ focusWithin,
149
+ )}
150
+ >
151
+ {selectedOptions.length === 0 ? (
152
+ // No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
153
+ // overall height so it matches the Input/Select trigger at the same
154
+ // size.
155
+ <span className={cn('text-ink-4 font-sans px-1', fieldTextStyles[size])}>
156
+ {placeholder}
157
+ </span>
158
+ ) : (
159
+ selectedOptions.map((o) => (
160
+ <Chip key={o.id} onRemove={() => remove(o.id)} isDisabled={isDisabled}>
161
+ {o.content ?? o.label}
162
+ </Chip>
163
+ ))
164
+ )}
165
+ <span className="ml-auto pr-1">
166
+ <FieldCaret />
167
+ </span>
168
+ </div>
169
+
170
+ {description ? <p className={fieldHintStyles}>{description}</p> : null}
171
+ {errorMessage && appearance !== 'cell' ? (
172
+ <p className={fieldErrorStyles}>{errorMessage}</p>
173
+ ) : null}
174
+
175
+ <Popover
176
+ isOpen={isOpen}
177
+ onOpenChange={openPopover}
178
+ triggerRef={triggerRef}
179
+ offset={6}
180
+ placement="bottom start"
181
+ className={cn(
182
+ 'w-[var(--trigger-width)]',
183
+ 'rounded-md border border-rule bg-surface p-1',
184
+ 'shadow-[var(--shadow-raised)]',
185
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
186
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
187
+ )}
188
+ >
189
+ {isSearchable ? (
190
+ <div className="border-b border-rule-2 px-1 pb-1 mb-1">
191
+ <input
192
+ // biome-ignore lint/a11y/noAutofocus: the popover just opened by explicit user action — focus belongs in its search box.
193
+ autoFocus
194
+ value={search}
195
+ onChange={(e) => updateSearch(e.target.value)}
196
+ placeholder="Search…"
197
+ aria-label="Search options"
198
+ className={cn(
199
+ 'w-full bg-transparent outline-none rounded-[6px] px-2 py-1.5',
200
+ 'font-sans text-[13px] text-ink placeholder:text-ink-4',
201
+ )}
202
+ />
203
+ </div>
204
+ ) : null}
205
+ <ListBox
206
+ aria-label={typeof label === 'string' ? label : 'Options'}
207
+ selectionMode="multiple"
208
+ selectedKeys={selectedSet}
209
+ onSelectionChange={(keys) => {
210
+ if (keys === 'all') {
211
+ onSelectionChange(new Set(options.map((o) => o.id)))
212
+ return
213
+ }
214
+ onSelectionChange(keys as Set<T>)
215
+ }}
216
+ className="outline-none max-h-72 overflow-auto"
217
+ renderEmptyState={() => (
218
+ <div className={cn('px-3 py-2', fieldHintStyles)}>
219
+ {needle !== '' ? 'No matches' : 'All options selected'}
220
+ </div>
221
+ )}
222
+ >
223
+ {listboxOptions.map((option) => (
224
+ <ListBoxItem
225
+ key={option.id}
226
+ id={option.id}
227
+ textValue={option.label}
228
+ {...(option.isDisabled ? { isDisabled: true } : {})}
229
+ className={cn(
230
+ 'group flex items-center gap-2.5',
231
+ 'rounded-[6px] px-3 py-2 text-[14px] text-ink-2',
232
+ 'cursor-pointer outline-none select-none',
233
+ 'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
234
+ 'data-[focused]:bg-surface-2 data-[focused]:text-ink',
235
+ 'data-[selected]:bg-surface-3 data-[selected]:text-ink',
236
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
237
+ )}
238
+ >
239
+ <Checkmark />
240
+ <span className="flex-1 truncate">{option.content ?? option.label}</span>
241
+ </ListBoxItem>
242
+ ))}
243
+ </ListBox>
244
+ </Popover>
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /* =========================================================================
250
+ Internal: selected chip with × remove button.
251
+ Minimal chrome (sand bg, hairline border, rounded-6) so that rich content
252
+ like a <Badge/> still reads cleanly inside it.
253
+ ========================================================================= */
254
+
255
+ function Chip({
256
+ children,
257
+ onRemove,
258
+ isDisabled,
259
+ }: {
260
+ children: ReactNode
261
+ onRemove: () => void
262
+ isDisabled: boolean
263
+ }) {
264
+ return (
265
+ <span
266
+ className={cn(
267
+ 'inline-flex items-center gap-1.5',
268
+ 'pl-2 pr-1 py-0.5 rounded-[6px]',
269
+ 'bg-surface-2 border border-rule',
270
+ 'font-sans text-[13px] text-ink',
271
+ )}
272
+ >
273
+ <span className="inline-flex items-center">{children}</span>
274
+ <button
275
+ type="button"
276
+ aria-label="Remove"
277
+ disabled={isDisabled}
278
+ onClick={(e) => {
279
+ // Keep the click from bubbling to the trigger div (which would
280
+ // toggle the popover open/closed).
281
+ e.stopPropagation()
282
+ onRemove()
283
+ }}
284
+ className={cn(
285
+ 'inline-flex items-center justify-center shrink-0',
286
+ 'size-4 rounded-[3px]',
287
+ 'text-ink-3 hover:text-ink hover:bg-surface-3 cursor-pointer',
288
+ 'outline-none focus-visible:shadow-[var(--shadow-focus)]',
289
+ 'disabled:opacity-45 disabled:cursor-not-allowed',
290
+ )}
291
+ >
292
+ <svg aria-hidden viewBox="0 0 10 10" className="size-2.5" fill="none">
293
+ <path
294
+ d="M2 2l6 6M8 2l-6 6"
295
+ stroke="currentColor"
296
+ strokeWidth="1.4"
297
+ strokeLinecap="round"
298
+ />
299
+ </svg>
300
+ </button>
301
+ </span>
302
+ )
303
+ }
304
+
305
+ /* =========================================================================
306
+ Internal: empty box → indigo tick when the row is selected.
307
+ ========================================================================= */
308
+
309
+ function Checkmark() {
310
+ return (
311
+ <span
312
+ aria-hidden
313
+ className={cn(
314
+ 'relative inline-flex items-center justify-center shrink-0',
315
+ 'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
316
+ 'transition-[background-color,border-color] duration-150',
317
+ 'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
318
+ )}
319
+ >
320
+ <svg
321
+ viewBox="0 0 12 12"
322
+ className={cn(
323
+ 'size-3 text-surface transition-opacity duration-150',
324
+ 'opacity-0 group-data-[selected]:opacity-100',
325
+ )}
326
+ aria-hidden
327
+ >
328
+ <path
329
+ d="M2.5 6.25 5 8.75 9.5 3.75"
330
+ stroke="currentColor"
331
+ strokeWidth="1.6"
332
+ strokeLinecap="round"
333
+ strokeLinejoin="round"
334
+ fill="none"
335
+ />
336
+ </svg>
337
+ </span>
338
+ )
339
+ }