@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,52 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ CheckboxGroup as AriaCheckboxGroup,
4
+ type CheckboxGroupProps as AriaCheckboxGroupProps,
5
+ FieldError,
6
+ Label,
7
+ Text,
8
+ } from 'react-aria-components'
9
+ import { cn } from '../../lib/cn'
10
+ import { fieldErrorStyles, fieldHintStyles, fieldLabelStyles } from '../field/field'
11
+
12
+ /* =========================================================================
13
+ CheckboxGroup — multi-choice field, the checkbox counterpart to RadioGroup.
14
+ Monolithic API to match it: label / description / errorMessage props on the
15
+ group, `<Checkbox value="…">` children. `value` is a string[] of selected
16
+ values (controlled via value/onChange, or defaultValue).
17
+ ========================================================================= */
18
+
19
+ export type CheckboxGroupProps = Omit<AriaCheckboxGroupProps, 'children'> & {
20
+ label?: ReactNode
21
+ description?: ReactNode
22
+ errorMessage?: ReactNode
23
+ /** `<Checkbox value="…">` elements. Function-as-children is not supported at the group level. */
24
+ children: ReactNode
25
+ }
26
+
27
+ export function CheckboxGroup({
28
+ label,
29
+ description,
30
+ errorMessage,
31
+ children,
32
+ className,
33
+ ...props
34
+ }: CheckboxGroupProps) {
35
+ return (
36
+ <AriaCheckboxGroup
37
+ {...props}
38
+ className={(renderProps) =>
39
+ cn('grid gap-2', typeof className === 'function' ? className(renderProps) : className)
40
+ }
41
+ >
42
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
43
+ <div className="grid gap-3">{children}</div>
44
+ {description ? (
45
+ <Text slot="description" className={fieldHintStyles}>
46
+ {description}
47
+ </Text>
48
+ ) : null}
49
+ {errorMessage ? <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError> : null}
50
+ </AriaCheckboxGroup>
51
+ )
52
+ }
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Checkbox } from './checkbox'
4
+
5
+ const meta: Meta<typeof Checkbox> = {
6
+ title: 'Components/Checkbox',
7
+ component: Checkbox,
8
+ tags: ['autodocs'],
9
+ args: {
10
+ children: 'Audit log',
11
+ },
12
+ argTypes: {
13
+ isDisabled: { control: { type: 'boolean' } },
14
+ isSelected: { control: { type: 'boolean' } },
15
+ isIndeterminate: { control: { type: 'boolean' } },
16
+ },
17
+ }
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof Checkbox>
21
+
22
+ export const Playground: Story = {}
23
+
24
+ export const Group: Story = {
25
+ parameters: { controls: { hideNoControlsWarning: true } },
26
+ render: () => (
27
+ <div className="grid gap-3">
28
+ <Checkbox defaultSelected>Audit log</Checkbox>
29
+ <Checkbox defaultSelected>Row-level permissions</Checkbox>
30
+ <Checkbox>Weekly architect review</Checkbox>
31
+ </div>
32
+ ),
33
+ }
34
+
35
+ export const AllStates: Story = {
36
+ parameters: { controls: { hideNoControlsWarning: true } },
37
+ render: () => (
38
+ <div className="grid gap-3">
39
+ <Checkbox>Unchecked</Checkbox>
40
+ <Checkbox defaultSelected>Checked</Checkbox>
41
+ <Checkbox isIndeterminate>Indeterminate</Checkbox>
42
+ <Checkbox isDisabled>Disabled</Checkbox>
43
+ <Checkbox isDisabled defaultSelected>
44
+ Disabled · checked
45
+ </Checkbox>
46
+ </div>
47
+ ),
48
+ }
@@ -0,0 +1,50 @@
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 { Checkbox } from './checkbox'
6
+
7
+ describe('Checkbox', () => {
8
+ it('renders its label', () => {
9
+ render(<Checkbox>Audit log</Checkbox>)
10
+ expect(screen.getByRole('checkbox', { name: 'Audit log' })).toBeInTheDocument()
11
+ })
12
+
13
+ it('toggles on click', async () => {
14
+ const user = userEvent.setup()
15
+ const onChange = vi.fn()
16
+ render(<Checkbox onChange={onChange}>Audit log</Checkbox>)
17
+
18
+ await user.click(screen.getByRole('checkbox', { name: 'Audit log' }))
19
+ expect(onChange).toHaveBeenCalledWith(true)
20
+ })
21
+
22
+ it('toggles on keyboard Space', async () => {
23
+ const user = userEvent.setup()
24
+ const onChange = vi.fn()
25
+ render(<Checkbox onChange={onChange}>Audit log</Checkbox>)
26
+
27
+ await user.tab()
28
+ expect(screen.getByRole('checkbox')).toHaveFocus()
29
+ await user.keyboard(' ')
30
+ expect(onChange).toHaveBeenCalledWith(true)
31
+ })
32
+
33
+ it('does not toggle when disabled', async () => {
34
+ const user = userEvent.setup()
35
+ const onChange = vi.fn()
36
+ render(
37
+ <Checkbox onChange={onChange} isDisabled>
38
+ Audit log
39
+ </Checkbox>,
40
+ )
41
+
42
+ await user.click(screen.getByRole('checkbox', { name: 'Audit log' }))
43
+ expect(onChange).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it('reflects a controlled checked state', () => {
47
+ render(<Checkbox isSelected>Row-level permissions</Checkbox>)
48
+ expect(screen.getByRole('checkbox', { name: 'Row-level permissions' })).toBeChecked()
49
+ })
50
+ })
@@ -0,0 +1,71 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Checkbox as AriaCheckbox,
4
+ type CheckboxProps as AriaCheckboxProps,
5
+ } from 'react-aria-components'
6
+
7
+ import { cn } from '../../lib/cn'
8
+
9
+ export type CheckboxProps = Omit<AriaCheckboxProps, 'children'> & {
10
+ children?: ReactNode
11
+ }
12
+
13
+ export function Checkbox({ children, className, ...props }: CheckboxProps) {
14
+ return (
15
+ <AriaCheckbox
16
+ {...props}
17
+ className={(renderProps) =>
18
+ cn(
19
+ 'group inline-flex items-center gap-2.5 cursor-pointer',
20
+ 'font-sans text-[14px] text-ink-2 select-none',
21
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
22
+ typeof className === 'function' ? className(renderProps) : className,
23
+ )
24
+ }
25
+ >
26
+ {(renderProps) => (
27
+ <>
28
+ <span
29
+ aria-hidden
30
+ className={cn(
31
+ 'relative inline-flex items-center justify-center shrink-0',
32
+ 'size-[18px] rounded-[4px] border bg-surface',
33
+ 'border-ink-4',
34
+ 'transition-[background-color,border-color,box-shadow] duration-150',
35
+ // hovered
36
+ 'group-data-[hovered]:border-ink',
37
+ // checked / indeterminate — fill with ink
38
+ 'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
39
+ 'group-data-[indeterminate]:bg-ink group-data-[indeterminate]:border-ink',
40
+ // focus ring on the box
41
+ 'group-data-[focus-visible]:shadow-[var(--shadow-focus)]',
42
+ )}
43
+ >
44
+ {renderProps.isIndeterminate ? (
45
+ <span className="block h-px w-2.5 bg-surface" />
46
+ ) : (
47
+ <svg
48
+ viewBox="0 0 12 12"
49
+ className={cn(
50
+ 'size-3 text-surface transition-opacity duration-150',
51
+ renderProps.isSelected ? 'opacity-100' : 'opacity-0',
52
+ )}
53
+ aria-hidden
54
+ >
55
+ <path
56
+ d="M2.5 6.25 5 8.75 9.5 3.75"
57
+ stroke="currentColor"
58
+ strokeWidth="1.6"
59
+ strokeLinecap="round"
60
+ strokeLinejoin="round"
61
+ fill="none"
62
+ />
63
+ </svg>
64
+ )}
65
+ </span>
66
+ {children}
67
+ </>
68
+ )}
69
+ </AriaCheckbox>
70
+ )
71
+ }
@@ -0,0 +1,4 @@
1
+ export type { CheckboxProps } from './checkbox'
2
+ export { Checkbox } from './checkbox'
3
+ export type { CheckboxGroupProps } from './checkbox-group'
4
+ export { CheckboxGroup } from './checkbox-group'
@@ -0,0 +1,97 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Combobox } from './combobox'
4
+
5
+ const carriers = [
6
+ { id: 'blue-ridge', label: 'Blue Ridge', description: 'FTL · 42 trucks' },
7
+ { id: 'north-star', label: 'North Star', description: 'LTL · 118 trucks' },
8
+ { id: 'sunline', label: 'Sunline', description: 'FTL · 24 trucks' },
9
+ { id: 'cascade', label: 'Cascade', description: 'Reefer · 56 trucks' },
10
+ { id: 'atlantic', label: 'Atlantic', description: 'Intermodal · 81 trucks' },
11
+ { id: 'pioneer', label: 'Pioneer', description: 'FTL · 17 trucks' },
12
+ { id: 'meridian', label: 'Meridian', description: 'LTL · 93 trucks' },
13
+ { id: 'heartland', label: 'Heartland', description: 'FTL · 211 trucks' },
14
+ ]
15
+
16
+ const meta: Meta<typeof Combobox> = {
17
+ title: 'Components/Combobox',
18
+ component: Combobox,
19
+ tags: ['autodocs'],
20
+ args: {
21
+ label: 'Carrier',
22
+ placeholder: 'Search carriers…',
23
+ options: carriers,
24
+ },
25
+ argTypes: {
26
+ isDisabled: { control: { type: 'boolean' } },
27
+ isInvalid: { control: { type: 'boolean' } },
28
+ allowsCustomValue: { control: { type: 'boolean' } },
29
+ menuTrigger: {
30
+ control: { type: 'inline-radio' },
31
+ options: ['input', 'focus', 'manual'],
32
+ },
33
+ },
34
+ decorators: [
35
+ (Story) => (
36
+ <div className="w-[440px]">
37
+ <Story />
38
+ </div>
39
+ ),
40
+ ],
41
+ }
42
+
43
+ export default meta
44
+ type Story = StoryObj<typeof Combobox>
45
+
46
+ export const Playground: Story = {}
47
+
48
+ export const WithDescription: Story = {
49
+ args: {
50
+ description: 'Type to filter the list, or open the menu to browse.',
51
+ },
52
+ }
53
+
54
+ export const OpensOnFocus: Story = {
55
+ args: {
56
+ menuTrigger: 'focus',
57
+ description: 'Tab into the field — the menu opens immediately.',
58
+ },
59
+ }
60
+
61
+ export const AllowsCustomValue: Story = {
62
+ args: {
63
+ allowsCustomValue: true,
64
+ description: 'Pick an existing carrier, or type a new one. Useful for tag-style entry.',
65
+ },
66
+ }
67
+
68
+ export const Invalid: Story = {
69
+ args: {
70
+ isInvalid: true,
71
+ errorMessage: 'Pick a carrier before dispatching.',
72
+ },
73
+ }
74
+
75
+ export const Disabled: Story = {
76
+ args: {
77
+ isDisabled: true,
78
+ defaultSelectedKey: 'blue-ridge',
79
+ },
80
+ }
81
+
82
+ export const AllStates: Story = {
83
+ parameters: { controls: { hideNoControlsWarning: true } },
84
+ render: () => (
85
+ <div className="grid gap-5 w-[440px]">
86
+ <Combobox label="Rest" options={carriers} />
87
+ <Combobox label="Selected" options={carriers} defaultSelectedKey="north-star" />
88
+ <Combobox
89
+ label="Invalid"
90
+ options={carriers}
91
+ isInvalid
92
+ errorMessage="Pick a carrier before dispatching."
93
+ />
94
+ <Combobox label="Disabled" options={carriers} defaultSelectedKey="cascade" isDisabled />
95
+ </div>
96
+ ),
97
+ }
@@ -0,0 +1,123 @@
1
+ import { fireEvent, 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 { Combobox } from './combobox'
6
+
7
+ const carriers = [
8
+ { id: 'blue-ridge', label: 'Blue Ridge' },
9
+ { id: 'north-star', label: 'North Star' },
10
+ { id: 'sunline', label: 'Sunline' },
11
+ { id: 'cascade', label: 'Cascade' },
12
+ { id: 'atlantic', label: 'Atlantic' },
13
+ ] as const
14
+
15
+ describe('Combobox', () => {
16
+ it('renders a labelled combobox input', () => {
17
+ render(<Combobox label="Carrier" options={[...carriers]} />)
18
+ expect(screen.getByRole('combobox', { name: 'Carrier' })).toBeInTheDocument()
19
+ })
20
+
21
+ it('filters options as the user types', async () => {
22
+ const user = userEvent.setup()
23
+ render(<Combobox label="Carrier" options={[...carriers]} />)
24
+
25
+ // Open the menu via the trigger button (deterministic in jsdom),
26
+ // then type into the input to filter the now-visible list.
27
+ await user.click(screen.getByRole('button'))
28
+ await screen.findByRole('listbox')
29
+
30
+ const input = screen.getByRole('combobox', { name: 'Carrier' })
31
+ await user.click(input)
32
+ await user.keyboard('su')
33
+
34
+ expect(await screen.findByRole('option', { name: 'Sunline' })).toBeInTheDocument()
35
+ expect(screen.queryByRole('option', { name: 'Blue Ridge' })).not.toBeInTheDocument()
36
+ })
37
+
38
+ it('fires onSelectionChange when an option is picked', async () => {
39
+ const user = userEvent.setup()
40
+ const onSelectionChange = vi.fn()
41
+ render(
42
+ <Combobox label="Carrier" options={[...carriers]} onSelectionChange={onSelectionChange} />,
43
+ )
44
+
45
+ await user.click(screen.getByRole('button'))
46
+ await user.click(await screen.findByRole('option', { name: 'North Star' }))
47
+ expect(onSelectionChange).toHaveBeenCalledWith('north-star')
48
+ })
49
+
50
+ it('opens the listbox via the trigger button', async () => {
51
+ const user = userEvent.setup()
52
+ render(<Combobox label="Carrier" options={[...carriers]} />)
53
+
54
+ const trigger = screen.getByRole('button')
55
+ await user.click(trigger)
56
+
57
+ expect(await screen.findByRole('listbox')).toBeInTheDocument()
58
+ expect(screen.getAllByRole('option')).toHaveLength(carriers.length)
59
+ })
60
+
61
+ it('shows the empty-state message when no option matches', async () => {
62
+ const user = userEvent.setup()
63
+ render(<Combobox label="Carrier" options={[...carriers]} emptyMessage="No carriers match" />)
64
+
65
+ await user.click(screen.getByRole('button'))
66
+ await screen.findByRole('listbox')
67
+
68
+ const input = screen.getByRole('combobox', { name: 'Carrier' })
69
+ await user.click(input)
70
+ await user.keyboard('zzzz')
71
+
72
+ expect(await screen.findByText('No carriers match')).toBeInTheDocument()
73
+ })
74
+
75
+ it('renders the error message when invalid', () => {
76
+ render(
77
+ <Combobox label="Carrier" options={[...carriers]} isInvalid errorMessage="Pick a carrier" />,
78
+ )
79
+ expect(screen.getByText('Pick a carrier')).toBeInTheDocument()
80
+ })
81
+
82
+ it('disables the input when isDisabled is set', () => {
83
+ render(<Combobox label="Carrier" options={[...carriers]} isDisabled />)
84
+ expect(screen.getByRole('combobox', { name: 'Carrier' })).toBeDisabled()
85
+ })
86
+
87
+ it('renders a per-option trailing slot whose clicks do not select the row', async () => {
88
+ const user = userEvent.setup()
89
+ const onTrailing = vi.fn()
90
+ const onSelectionChange = vi.fn()
91
+ render(
92
+ <Combobox
93
+ label="Carrier"
94
+ onSelectionChange={onSelectionChange}
95
+ options={[
96
+ {
97
+ id: 'blue-ridge',
98
+ label: 'Blue Ridge',
99
+ trailing: (
100
+ <button type="button" aria-label="Peek Blue Ridge" onClick={onTrailing}>
101
+ ↗
102
+ </button>
103
+ ),
104
+ },
105
+ { id: 'north-star', label: 'North Star' },
106
+ ]}
107
+ />,
108
+ )
109
+
110
+ // Before the popover opens, the trigger is the only button in the DOM.
111
+ await user.click(screen.getByRole('button'))
112
+ // The peek button should be in the DOM (CSS hides it until hover/focus,
113
+ // which jsdom can't simulate; presence is what we assert here).
114
+ const peek = await screen.findByRole('button', { name: 'Peek Blue Ridge' })
115
+
116
+ // userEvent's full pointer-event sequence gets tangled with RAC's
117
+ // listbox press handling in jsdom; a direct synthetic click on the
118
+ // trailing button is enough to verify the wiring + propagation guard.
119
+ fireEvent.click(peek)
120
+ expect(onTrailing).toHaveBeenCalledTimes(1)
121
+ expect(onSelectionChange).not.toHaveBeenCalled()
122
+ })
123
+ })
@@ -0,0 +1,239 @@
1
+ import { type ReactNode, useContext } from 'react'
2
+ import {
3
+ ComboBox as AriaComboBox,
4
+ type ComboBoxProps as AriaComboBoxProps,
5
+ ComboBoxStateContext,
6
+ Button as AriaComboButton,
7
+ Input as AriaInput,
8
+ FieldError,
9
+ type Key,
10
+ Label,
11
+ ListBox,
12
+ ListBoxItem,
13
+ type ListBoxItemProps,
14
+ Popover,
15
+ Text,
16
+ } from 'react-aria-components'
17
+ import { cn } from '../../lib/cn'
18
+ import {
19
+ type FieldAppearance,
20
+ FieldCaret,
21
+ fieldControlStyles,
22
+ fieldErrorStyles,
23
+ type FieldSize,
24
+ fieldHintStyles,
25
+ fieldLabelStyles,
26
+ } from '../field/field'
27
+
28
+ export type ComboboxOption<T extends Key = Key> = {
29
+ id: T
30
+ label: string
31
+ description?: ReactNode
32
+ isDisabled?: boolean
33
+ /**
34
+ * Per-row action UI rendered at the trailing edge of the option. Hidden
35
+ * by default; revealed when the row is hovered or keyboard-focused. The
36
+ * slot intercepts `pointerdown` / `click` propagation so interactions
37
+ * inside it don't trigger option selection — put a real `<button>` /
38
+ * `<Link>` here and it Just Works.
39
+ */
40
+ trailing?: ReactNode
41
+ }
42
+
43
+ export type ComboboxProps<T extends Key = Key> = Omit<
44
+ AriaComboBoxProps<never>,
45
+ 'children' | 'items' | 'defaultItems'
46
+ > & {
47
+ label?: ReactNode
48
+ description?: ReactNode
49
+ errorMessage?: ReactNode
50
+ placeholder?: string
51
+ options: ReadonlyArray<ComboboxOption<T>>
52
+ /** Field size. Defaults to `md`. */
53
+ size?: FieldSize
54
+ /** Chrome variant. Defaults to `field`. `cell` flattens the box. */
55
+ appearance?: FieldAppearance
56
+ /** Visual override for the trigger input wrapper only. */
57
+ inputWrapperClassName?: string
58
+ /** Visual override for the inner `<input>` (size, padding, font). */
59
+ inputClassName?: string
60
+ /** Empty-state message shown in the popover when no options match. */
61
+ emptyMessage?: ReactNode
62
+ }
63
+
64
+ export function Combobox<T extends Key>({
65
+ label,
66
+ description,
67
+ errorMessage,
68
+ placeholder = 'Type to search…',
69
+ options,
70
+ size,
71
+ appearance,
72
+ inputWrapperClassName,
73
+ inputClassName,
74
+ emptyMessage = 'No matches',
75
+ className,
76
+ // Default to keeping the popover open on an empty filter so the
77
+ // `emptyMessage` slot actually renders. Callers can opt out.
78
+ allowsEmptyCollection = true,
79
+ ...props
80
+ }: ComboboxProps<T>) {
81
+ const styles = fieldControlStyles({ size, appearance })
82
+ // In cell mode the wrapper's own focus-within halo must go too — only the
83
+ // accent border remains on focus.
84
+ const focusWithin =
85
+ appearance === 'cell'
86
+ ? 'focus-within:border-accent focus-within:shadow-none'
87
+ : 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
88
+ return (
89
+ <AriaComboBox
90
+ {...props}
91
+ allowsEmptyCollection={allowsEmptyCollection}
92
+ className={(renderProps) =>
93
+ cn(
94
+ 'grid grid-cols-1 gap-1.5',
95
+ typeof className === 'function' ? className(renderProps) : className,
96
+ )
97
+ }
98
+ >
99
+ {label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
100
+
101
+ <div
102
+ className={cn(
103
+ // Box chrome on the wrapper; the inner <input> carries the padding
104
+ // + font so the chevron button sits inside the same border + ring.
105
+ styles.box(),
106
+ 'flex items-center gap-1 pr-2',
107
+ focusWithin,
108
+ inputWrapperClassName,
109
+ )}
110
+ >
111
+ <AriaInput
112
+ placeholder={placeholder}
113
+ className={cn(
114
+ 'flex-1 min-w-0 bg-transparent outline-none',
115
+ styles.content(),
116
+ inputClassName,
117
+ )}
118
+ />
119
+ <AriaComboButton
120
+ className={cn(
121
+ 'shrink-0 flex items-center justify-center',
122
+ 'size-7 rounded-pill',
123
+ 'text-ink-3 hover:text-ink outline-none cursor-pointer',
124
+ )}
125
+ >
126
+ <FieldCaret />
127
+ </AriaComboButton>
128
+ </div>
129
+
130
+ {description ? (
131
+ <Text slot="description" className={fieldHintStyles}>
132
+ {description}
133
+ </Text>
134
+ ) : null}
135
+ {errorMessage && appearance !== 'cell' ? (
136
+ <FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
137
+ ) : null}
138
+
139
+ <Popover
140
+ offset={6}
141
+ className={cn(
142
+ 'min-w-[var(--trigger-width)]',
143
+ 'rounded-md border border-rule bg-surface p-1',
144
+ 'shadow-[var(--shadow-raised)]',
145
+ 'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
146
+ 'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
147
+ )}
148
+ >
149
+ <ListBox
150
+ className="outline-none max-h-72 overflow-auto"
151
+ renderEmptyState={() => (
152
+ <div className={cn('px-3 py-2', fieldHintStyles)}>{emptyMessage}</div>
153
+ )}
154
+ >
155
+ {options.map((option) => (
156
+ <ComboboxItem
157
+ key={option.id}
158
+ id={option.id}
159
+ textValue={option.label}
160
+ {...(option.isDisabled ? { isDisabled: true } : {})}
161
+ >
162
+ <span className="truncate">{option.label}</span>
163
+ {option.description ? (
164
+ <span className="ml-3 shrink-0 font-mono text-[11.5px] text-ink-4">
165
+ {option.description}
166
+ </span>
167
+ ) : null}
168
+ {option.trailing ? <TrailingSlot>{option.trailing}</TrailingSlot> : null}
169
+ </ComboboxItem>
170
+ ))}
171
+ </ListBox>
172
+ </Popover>
173
+ </AriaComboBox>
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Wraps the per-option trailing UI so that:
179
+ * - The full pointer→click sequence is intercepted (so RAC's listbox
180
+ * press handling on the surrounding ListBoxItem can never claim it
181
+ * and select the row).
182
+ * - On click, after the consumer's own handler runs (bubble order:
183
+ * inner element → this wrapper), the combobox popover closes.
184
+ * The trailing action is by nature "do something with this option" —
185
+ * e.g. open a peek panel — which always takes the user away from the
186
+ * field, so leaving the popover open underneath would be a UX bug.
187
+ */
188
+ function TrailingSlot({ children }: { children: ReactNode }) {
189
+ const state = useContext(ComboBoxStateContext)
190
+ return (
191
+ <span
192
+ className={cn(
193
+ 'ml-auto pl-2 shrink-0 inline-flex items-center',
194
+ // Hidden by default; revealed when the row is hovered or
195
+ // keyboard-focused. `group/combobox-item` is set on ComboboxItem.
196
+ 'hidden',
197
+ 'group-data-[hovered]/combobox-item:inline-flex',
198
+ 'group-data-[focused]/combobox-item:inline-flex',
199
+ 'focus-within:inline-flex',
200
+ )}
201
+ // Block the full press lifecycle: `pointerdown` prevents usePress
202
+ // from arming on the option, the rest cover the test-env mousedown
203
+ // path and any third-party handlers.
204
+ onPointerDown={(e) => e.stopPropagation()}
205
+ onPointerUp={(e) => e.stopPropagation()}
206
+ onMouseDown={(e) => e.stopPropagation()}
207
+ onMouseUp={(e) => e.stopPropagation()}
208
+ onClick={(e) => {
209
+ e.stopPropagation()
210
+ // Bubble order means the consumer's onClick already ran; now
211
+ // close the popover so the trailing action's target UI (e.g. a
212
+ // peek modal) isn't sitting on top of an open dropdown.
213
+ state?.close()
214
+ }}
215
+ >
216
+ {children}
217
+ </span>
218
+ )
219
+ }
220
+
221
+ function ComboboxItem(props: ListBoxItemProps) {
222
+ return (
223
+ <ListBoxItem
224
+ {...props}
225
+ className={cn(
226
+ // `group/combobox-item` exposes hover/focus state to the trailing
227
+ // slot so it can reveal itself only when the row is engaged.
228
+ 'group/combobox-item',
229
+ 'flex items-center justify-between gap-3',
230
+ 'rounded-[6px] px-3 py-2 text-[14px] text-ink-2',
231
+ 'cursor-pointer outline-none select-none',
232
+ 'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
233
+ 'data-[focused]:bg-surface-2 data-[focused]:text-ink',
234
+ 'data-[selected]:bg-surface-3 data-[selected]:text-ink',
235
+ 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
236
+ )}
237
+ />
238
+ )
239
+ }
@@ -0,0 +1,2 @@
1
+ export type { ComboboxOption, ComboboxProps } from './combobox'
2
+ export { Combobox } from './combobox'