@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,155 @@
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 { BadgeMultiSelect, type BadgeMultiSelectOption } from './badge-multi-select'
7
+
8
+ const statuses: BadgeMultiSelectOption<string>[] = [
9
+ { id: 'in-transit', label: 'in transit', variant: 'active' },
10
+ { id: 'quoted', label: 'quoted' },
11
+ { id: 'reconciled', label: 'reconciled', variant: 'success' },
12
+ { id: 'past-due', label: 'past due', variant: 'warning' },
13
+ { id: 'exception', label: 'exception', variant: 'danger' },
14
+ ]
15
+
16
+ function Harness({
17
+ onSelectionChange,
18
+ initial = new Set<string>(),
19
+ }: {
20
+ onSelectionChange?: (keys: Set<string>) => void
21
+ initial?: Set<string>
22
+ }) {
23
+ const [selected, setSelected] = useState<Set<string>>(initial)
24
+ return (
25
+ <BadgeMultiSelect<string>
26
+ label="Status"
27
+ placeholder="Any status"
28
+ options={statuses}
29
+ selectedKeys={selected}
30
+ onSelectionChange={(keys) => {
31
+ setSelected(keys)
32
+ onSelectionChange?.(keys)
33
+ }}
34
+ />
35
+ )
36
+ }
37
+
38
+ describe('BadgeMultiSelect', () => {
39
+ it('renders placeholder when nothing is selected', () => {
40
+ render(<Harness />)
41
+ expect(screen.getByText('Any status')).toBeInTheDocument()
42
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
43
+ })
44
+
45
+ it('opens the listbox on trigger click', async () => {
46
+ const user = userEvent.setup()
47
+ render(<Harness />)
48
+ await user.click(screen.getByRole('combobox'))
49
+ expect(await screen.findByRole('listbox')).toBeInTheDocument()
50
+ expect(screen.getAllByRole('option')).toHaveLength(statuses.length)
51
+ })
52
+
53
+ it('selects multiple options and renders them as removable badges', 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: 'in transit' }))
60
+ await user.click(screen.getByRole('option', { name: 'past due' }))
61
+
62
+ const lastCall = onChange.mock.calls.at(-1)?.[0] as Set<string> | undefined
63
+ expect(lastCall?.has('in-transit')).toBe(true)
64
+ expect(lastCall?.has('past-due')).toBe(true)
65
+ expect(lastCall?.size).toBe(2)
66
+
67
+ // Two selections = two × remove buttons inside the trigger.
68
+ expect(screen.getAllByRole('button', { name: 'Remove', hidden: true })).toHaveLength(2)
69
+ })
70
+
71
+ it('removes a selection via the × inside the badge', async () => {
72
+ const user = userEvent.setup()
73
+ const onChange = vi.fn()
74
+ render(<Harness onSelectionChange={onChange} initial={new Set(['in-transit', 'reconciled'])} />)
75
+
76
+ const [firstRemove, ...rest] = screen.getAllByRole('button', { name: 'Remove' })
77
+ expect(rest).toHaveLength(1)
78
+ if (!firstRemove) throw new Error('expected a remove button')
79
+ await user.click(firstRemove)
80
+
81
+ expect(onChange).toHaveBeenCalledWith(new Set(['reconciled']))
82
+ })
83
+
84
+ it('hides selected options from the dropdown when hideSelectedOptions is set', async () => {
85
+ const user = userEvent.setup()
86
+ render(
87
+ <BadgeMultiSelect<string>
88
+ label="Status"
89
+ options={statuses}
90
+ selectedKeys={new Set<string>(['in-transit'])}
91
+ onSelectionChange={() => {}}
92
+ hideSelectedOptions
93
+ />,
94
+ )
95
+
96
+ await user.click(screen.getByRole('combobox'))
97
+ await screen.findByRole('listbox')
98
+
99
+ // "in transit" is already selected — it should NOT be in the listbox.
100
+ expect(screen.queryByRole('option', { name: 'in transit' })).not.toBeInTheDocument()
101
+ // The other five are still available.
102
+ expect(screen.getAllByRole('option')).toHaveLength(statuses.length - 1)
103
+ })
104
+
105
+ it('shows "All options selected" when every option is picked and hidden', async () => {
106
+ const user = userEvent.setup()
107
+ render(
108
+ <BadgeMultiSelect<string>
109
+ label="Status"
110
+ options={statuses}
111
+ selectedKeys={new Set(statuses.map((s) => s.id))}
112
+ onSelectionChange={() => {}}
113
+ hideSelectedOptions
114
+ />,
115
+ )
116
+
117
+ await user.click(screen.getByRole('combobox'))
118
+ // RAC's renderEmptyState renders inside the listbox; the copy landing
119
+ // is enough proof — we don't poke at internal listbox semantics.
120
+ expect(await screen.findByText('All options selected')).toBeInTheDocument()
121
+ })
122
+
123
+ it('does not open when disabled', async () => {
124
+ const user = userEvent.setup()
125
+ render(
126
+ <BadgeMultiSelect
127
+ label="Status"
128
+ options={statuses}
129
+ selectedKeys={new Set<string>()}
130
+ onSelectionChange={() => {}}
131
+ isDisabled
132
+ />,
133
+ )
134
+
135
+ await user.click(screen.getByRole('combobox'))
136
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
137
+ })
138
+
139
+ it('renders description and error slots', () => {
140
+ render(
141
+ <BadgeMultiSelect
142
+ label="Status"
143
+ description="Filter the board."
144
+ errorMessage="Pick at least one."
145
+ isInvalid
146
+ options={statuses}
147
+ selectedKeys={new Set<string>()}
148
+ onSelectionChange={() => {}}
149
+ />,
150
+ )
151
+ expect(screen.getByText('Filter the board.')).toBeInTheDocument()
152
+ expect(screen.getByText('Pick at least one.')).toBeInTheDocument()
153
+ expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true')
154
+ })
155
+ })
@@ -0,0 +1,272 @@
1
+ import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react'
2
+ import { type Key, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'
3
+ import { cn } from '../../lib/cn'
4
+ import { Badge, type BadgeVariants } from '../badge/badge'
5
+ import {
6
+ type FieldAppearance,
7
+ FieldCaret,
8
+ fieldChipPadding,
9
+ fieldControlStyles,
10
+ fieldErrorStyles,
11
+ fieldHintStyles,
12
+ fieldLabelStyles,
13
+ fieldMinHeight,
14
+ type FieldSize,
15
+ fieldTextStyles,
16
+ } from '../field/field'
17
+
18
+ /* =========================================================================
19
+ BadgeMultiSelect — a MultiSelect specialized for picking status-like
20
+ values. Each option is a <Badge/> — both in the dropdown and as the
21
+ selected pill. The × remove button lives INSIDE each selected badge
22
+ (no extra chip chrome), so the color coding from the Badge variant is
23
+ what you see, cleanly.
24
+ ========================================================================= */
25
+
26
+ export type BadgeMultiSelectOption<T extends Key = Key> = {
27
+ id: T
28
+ /** Text shown inside the badge. Used as the accessible name too. */
29
+ label: string
30
+ /** Badge color register. Defaults to `default`. */
31
+ variant?: BadgeVariants['variant']
32
+ isDisabled?: boolean
33
+ }
34
+
35
+ export type BadgeMultiSelectProps<T extends Key = Key> = {
36
+ label?: ReactNode
37
+ description?: ReactNode
38
+ errorMessage?: ReactNode
39
+ placeholder?: string
40
+ options: ReadonlyArray<BadgeMultiSelectOption<T>>
41
+ /** Controlled selection. Pass a `Set<T>` or any `Iterable<T>`. */
42
+ selectedKeys: Iterable<T>
43
+ onSelectionChange: (keys: Set<T>) => void
44
+ isDisabled?: boolean
45
+ isInvalid?: boolean
46
+ /** Hide already-selected options from the dropdown. Removal then only
47
+ * happens via the chip × in the trigger. Default `false` (selected
48
+ * options stay visible and can be toggled from the listbox). */
49
+ hideSelectedOptions?: boolean
50
+ /** Field size. Defaults to `md`. */
51
+ size?: FieldSize
52
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
53
+ appearance?: FieldAppearance
54
+ /** Focus the trigger on mount. Mirrors RAC's `autoFocus` prop on native
55
+ * inputs; useful for keyboard chains where the trigger should take focus
56
+ * when its surrounding context becomes ready. */
57
+ autoFocus?: boolean
58
+ className?: string
59
+ }
60
+
61
+ export function BadgeMultiSelect<T extends Key>({
62
+ label,
63
+ description,
64
+ errorMessage,
65
+ placeholder = 'Select…',
66
+ options,
67
+ selectedKeys,
68
+ onSelectionChange,
69
+ isDisabled = false,
70
+ isInvalid = false,
71
+ hideSelectedOptions = false,
72
+ size = 'md',
73
+ appearance,
74
+ autoFocus = false,
75
+ className,
76
+ }: BadgeMultiSelectProps<T>) {
77
+ const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
78
+ const focusWithin =
79
+ appearance === 'cell'
80
+ ? 'focus-within:border-accent focus-within:shadow-none'
81
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
82
+ const minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
83
+ const [isOpen, setIsOpen] = useState(false)
84
+ const triggerRef = useRef<HTMLDivElement>(null)
85
+
86
+ useEffect(() => {
87
+ if (autoFocus) triggerRef.current?.focus()
88
+ // mount-only — autoFocus is a one-shot signal, not sustained.
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [])
91
+
92
+ const selectedSet = new Set(selectedKeys)
93
+ const selectedOptions = options.filter((o) => selectedSet.has(o.id))
94
+ const listboxOptions = hideSelectedOptions
95
+ ? options.filter((o) => !selectedSet.has(o.id))
96
+ : options
97
+
98
+ const remove = (id: T) => {
99
+ const next = new Set(selectedSet)
100
+ next.delete(id)
101
+ onSelectionChange(next)
102
+ }
103
+
104
+ const onTriggerKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
105
+ if (isDisabled) return
106
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
107
+ e.preventDefault()
108
+ setIsOpen(true)
109
+ }
110
+ }
111
+
112
+ return (
113
+ <div className={cn('grid grid-cols-1 gap-1.5', className)}>
114
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
115
+
116
+ <div
117
+ ref={triggerRef}
118
+ role="combobox"
119
+ aria-expanded={isOpen}
120
+ aria-haspopup="listbox"
121
+ aria-disabled={isDisabled || undefined}
122
+ aria-invalid={isInvalid || undefined}
123
+ tabIndex={isDisabled ? -1 : 0}
124
+ onClick={() => !isDisabled && setIsOpen((v) => !v)}
125
+ onKeyDown={onTriggerKeyDown}
126
+ data-invalid={isInvalid || undefined}
127
+ data-disabled={isDisabled || undefined}
128
+ className={cn(
129
+ styles.box(),
130
+ 'flex flex-wrap items-center gap-1.5 cursor-pointer',
131
+ minHeight,
132
+ fieldChipPadding[size],
133
+ 'pr-2',
134
+ focusWithin,
135
+ )}
136
+ >
137
+ {selectedOptions.length === 0 ? (
138
+ // No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
139
+ // overall height so it matches the Input/Select trigger at the same
140
+ // size.
141
+ <span className={cn('text-ink-4 font-sans px-1', fieldTextStyles[size])}>
142
+ {placeholder}
143
+ </span>
144
+ ) : (
145
+ selectedOptions.map((o) => (
146
+ <RemovableBadge
147
+ key={o.id}
148
+ variant={o.variant}
149
+ onRemove={() => remove(o.id)}
150
+ isDisabled={isDisabled}
151
+ >
152
+ {o.label}
153
+ </RemovableBadge>
154
+ ))
155
+ )}
156
+ <span className="ml-auto pr-1">
157
+ <FieldCaret />
158
+ </span>
159
+ </div>
160
+
161
+ {description ? <p className={fieldHintStyles}>{description}</p> : null}
162
+ {errorMessage && appearance !== 'cell' ? (
163
+ <p className={fieldErrorStyles}>{errorMessage}</p>
164
+ ) : null}
165
+
166
+ <Popover
167
+ isOpen={isOpen}
168
+ onOpenChange={setIsOpen}
169
+ triggerRef={triggerRef}
170
+ offset={6}
171
+ placement="bottom start"
172
+ className={cn(
173
+ 'w-[var(--trigger-width)]',
174
+ 'rounded-md border border-rule bg-surface p-1',
175
+ 'shadow-[var(--shadow-raised)]',
176
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
177
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
178
+ )}
179
+ >
180
+ <ListBox
181
+ aria-label={typeof label === 'string' ? label : 'Options'}
182
+ selectionMode="multiple"
183
+ selectedKeys={selectedSet}
184
+ onSelectionChange={(keys) => {
185
+ if (keys === 'all') {
186
+ onSelectionChange(new Set(options.map((o) => o.id)))
187
+ return
188
+ }
189
+ onSelectionChange(keys as Set<T>)
190
+ }}
191
+ className="outline-none max-h-72 overflow-auto"
192
+ renderEmptyState={() => (
193
+ <div className={cn('px-3 py-2', fieldHintStyles)}>All options selected</div>
194
+ )}
195
+ >
196
+ {listboxOptions.map((option) => (
197
+ <ListBoxItem
198
+ key={option.id}
199
+ id={option.id}
200
+ textValue={option.label}
201
+ {...(option.isDisabled ? { isDisabled: true } : {})}
202
+ className={cn(
203
+ 'group flex items-center',
204
+ 'rounded-[6px] px-3 py-2',
205
+ 'cursor-pointer outline-none select-none',
206
+ 'data-[hovered]:bg-surface-2',
207
+ 'data-[focused]:bg-surface-2',
208
+ 'data-[selected]:bg-surface-3',
209
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
210
+ )}
211
+ >
212
+ <Badge variant={option.variant}>{option.label}</Badge>
213
+ </ListBoxItem>
214
+ ))}
215
+ </ListBox>
216
+ </Popover>
217
+ </div>
218
+ )
219
+ }
220
+
221
+ /* =========================================================================
222
+ Internal: a Badge with a trailing × button baked into the pill.
223
+ The × inherits the badge's text color via `currentColor`, so it reads
224
+ correctly against every variant's fill.
225
+ ========================================================================= */
226
+
227
+ function RemovableBadge({
228
+ children,
229
+ variant,
230
+ onRemove,
231
+ isDisabled,
232
+ }: {
233
+ children: ReactNode
234
+ variant?: BadgeVariants['variant']
235
+ onRemove: () => void
236
+ isDisabled: boolean
237
+ }) {
238
+ return (
239
+ <Badge variant={variant}>
240
+ <span>{children}</span>
241
+ <button
242
+ type="button"
243
+ aria-label="Remove"
244
+ disabled={isDisabled}
245
+ onClick={(e) => {
246
+ e.stopPropagation()
247
+ onRemove()
248
+ }}
249
+ className={cn(
250
+ 'inline-flex items-center justify-center shrink-0',
251
+ 'size-3.5 rounded-full',
252
+ 'text-current/70 hover:text-current hover:bg-current/10',
253
+ 'outline-none cursor-pointer',
254
+ 'focus-visible:shadow-[var(--shadow-focus)]',
255
+ 'disabled:opacity-45 disabled:cursor-not-allowed',
256
+ // Pull the button flush with the right edge so the pill still feels
257
+ // symmetric around the leading dot.
258
+ '-mr-1',
259
+ )}
260
+ >
261
+ <svg aria-hidden viewBox="0 0 10 10" className="size-2.5" fill="none">
262
+ <path
263
+ d="M2 2l6 6M8 2l-6 6"
264
+ stroke="currentColor"
265
+ strokeWidth="1.4"
266
+ strokeLinecap="round"
267
+ />
268
+ </svg>
269
+ </button>
270
+ </Badge>
271
+ )
272
+ }
@@ -0,0 +1,2 @@
1
+ export type { BadgeMultiSelectOption, BadgeMultiSelectProps } from './badge-multi-select'
2
+ export { BadgeMultiSelect } from './badge-multi-select'
@@ -0,0 +1,43 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Breadcrumbs } from './breadcrumbs'
4
+
5
+ const meta: Meta<typeof Breadcrumbs> = {
6
+ title: 'Components/Breadcrumbs',
7
+ component: Breadcrumbs,
8
+ tags: ['autodocs'],
9
+ parameters: { layout: 'padded' },
10
+ }
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof Breadcrumbs>
14
+
15
+ export const Playground: Story = {
16
+ args: {
17
+ items: [
18
+ { id: 1, label: 'Operations', href: '/operations' },
19
+ { id: 2, label: 'Shipments', href: '/operations/shipments' },
20
+ { id: 3, label: 'Open' },
21
+ ],
22
+ },
23
+ }
24
+
25
+ export const TwoLevels: Story = {
26
+ args: {
27
+ items: [
28
+ { id: 1, label: 'Workspace', href: '/' },
29
+ { id: 2, label: 'Home' },
30
+ ],
31
+ },
32
+ }
33
+
34
+ export const WithChevronSeparator: Story = {
35
+ args: {
36
+ separator: '›',
37
+ items: [
38
+ { id: 1, label: 'Finance', href: '/finance' },
39
+ { id: 2, label: 'Reconciliation', href: '/finance/reconciliation' },
40
+ { id: 3, label: 'Disputes' },
41
+ ],
42
+ },
43
+ }
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Breadcrumb as AriaBreadcrumb,
4
+ Breadcrumbs as AriaBreadcrumbs,
5
+ type BreadcrumbsProps as AriaBreadcrumbsProps,
6
+ Link as AriaLink,
7
+ } from 'react-aria-components'
8
+
9
+ import { cn } from '../../lib/cn'
10
+
11
+ /* =========================================================================
12
+ Breadcrumbs — RAC Breadcrumbs/Breadcrumb under the hood. Last item is
13
+ rendered as a non-link span with `aria-current="page"`; everything before
14
+ it is a RAC Link with hover + focus-visible chrome.
15
+ ========================================================================= */
16
+
17
+ export type BreadcrumbItem = {
18
+ /** Stable key for the row. */
19
+ id: string | number
20
+ /** Display label. */
21
+ label: ReactNode
22
+ /** Optional href. Omit (or leave the last item without one) to render the
23
+ * current page as plain text. */
24
+ href?: string
25
+ }
26
+
27
+ export type BreadcrumbsProps = Omit<AriaBreadcrumbsProps<never>, 'children' | 'items'> & {
28
+ items: ReadonlyArray<BreadcrumbItem>
29
+ /** Glyph between crumbs. Default `/`. */
30
+ separator?: ReactNode
31
+ }
32
+
33
+ export function Breadcrumbs({ items, separator = '/', className, ...props }: BreadcrumbsProps) {
34
+ return (
35
+ <AriaBreadcrumbs
36
+ {...props}
37
+ className={cn(
38
+ 'flex items-center gap-2',
39
+ 'font-mono text-[12px] text-ink-3',
40
+ // RAC renders Breadcrumbs as <ol> — neutralise list defaults.
41
+ 'list-none m-0 p-0',
42
+ className,
43
+ )}
44
+ >
45
+ {items.map((item, i) => {
46
+ const isLast = i === items.length - 1
47
+ return (
48
+ <AriaBreadcrumb
49
+ key={item.id}
50
+ className="flex items-center gap-2"
51
+ // The last crumb is the current page. Hides the trailing
52
+ // separator and skips the link affordance.
53
+ data-current={isLast || undefined}
54
+ >
55
+ {item.href !== undefined && !isLast ? (
56
+ <AriaLink
57
+ href={item.href}
58
+ className={cn(
59
+ 'rounded-sm outline-none cursor-pointer',
60
+ 'transition-colors duration-150',
61
+ 'hover:text-ink',
62
+ 'data-[hovered]:text-ink',
63
+ 'data-[focus-visible]:shadow-[var(--shadow-focus)]',
64
+ )}
65
+ >
66
+ {item.label}
67
+ </AriaLink>
68
+ ) : (
69
+ <span
70
+ className={cn(isLast && 'text-ink font-semibold')}
71
+ aria-current={isLast ? 'page' : undefined}
72
+ >
73
+ {item.label}
74
+ </span>
75
+ )}
76
+ {!isLast ? (
77
+ <span aria-hidden className="text-ink-4 select-none">
78
+ {separator}
79
+ </span>
80
+ ) : null}
81
+ </AriaBreadcrumb>
82
+ )
83
+ })}
84
+ </AriaBreadcrumbs>
85
+ )
86
+ }
@@ -0,0 +1,2 @@
1
+ export type { BreadcrumbItem, BreadcrumbsProps } from './breadcrumbs'
2
+ export { Breadcrumbs } from './breadcrumbs'