@duro-app/ui 0.24.0 → 0.26.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 (96) hide show
  1. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
  2. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  3. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
  4. package/dist/components/Combobox/Combobox.d.ts +44 -0
  5. package/dist/components/Combobox/Combobox.d.ts.map +1 -0
  6. package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
  7. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
  8. package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
  9. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
  10. package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
  11. package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
  12. package/dist/components/Combobox/styles.css.d.ts +112 -0
  13. package/dist/components/Combobox/styles.css.d.ts.map +1 -0
  14. package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
  15. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
  16. package/dist/components/Field/Field.d.ts +4 -0
  17. package/dist/components/Field/Field.d.ts.map +1 -1
  18. package/dist/components/Field/Field.stories.d.ts +3 -0
  19. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  20. package/dist/components/Field/FieldContext.d.ts +5 -0
  21. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  22. package/dist/components/Field/styles.css.d.ts +26 -0
  23. package/dist/components/Field/styles.css.d.ts.map +1 -1
  24. package/dist/components/Form/Form.d.ts +7 -2
  25. package/dist/components/Form/Form.d.ts.map +1 -1
  26. package/dist/components/Form/Form.stories.d.ts +6 -0
  27. package/dist/components/Form/Form.stories.d.ts.map +1 -1
  28. package/dist/components/Form/FormContext.d.ts +9 -2
  29. package/dist/components/Form/FormContext.d.ts.map +1 -1
  30. package/dist/components/Input/Input.d.ts.map +1 -1
  31. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  32. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
  33. package/dist/components/Table/SortIndicator.d.ts.map +1 -1
  34. package/dist/components/Table/Table.d.ts.map +1 -1
  35. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  36. package/dist/components/Table/useDataTable.d.ts.map +1 -1
  37. package/dist/components/Tag/Tag.d.ts +18 -0
  38. package/dist/components/Tag/Tag.d.ts.map +1 -0
  39. package/dist/components/Tag/Tag.meta.d.ts +3 -0
  40. package/dist/components/Tag/Tag.meta.d.ts.map +1 -0
  41. package/dist/components/Tag/Tag.stories.d.ts +11 -0
  42. package/dist/components/Tag/Tag.stories.d.ts.map +1 -0
  43. package/dist/components/Tag/styles.css.d.ts +80 -0
  44. package/dist/components/Tag/styles.css.d.ts.map +1 -0
  45. package/dist/components/TagGroup/TagGroup.d.ts +30 -0
  46. package/dist/components/TagGroup/TagGroup.d.ts.map +1 -0
  47. package/dist/components/TagGroup/TagGroup.meta.d.ts +3 -0
  48. package/dist/components/TagGroup/TagGroup.meta.d.ts.map +1 -0
  49. package/dist/components/TagGroup/TagGroup.stories.d.ts +11 -0
  50. package/dist/components/TagGroup/TagGroup.stories.d.ts.map +1 -0
  51. package/dist/components/TagGroup/TagGroupContext.d.ts +17 -0
  52. package/dist/components/TagGroup/TagGroupContext.d.ts.map +1 -0
  53. package/dist/components/TagGroup/styles.css.d.ts +75 -0
  54. package/dist/components/TagGroup/styles.css.d.ts.map +1 -0
  55. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  56. package/dist/index.css +1 -1
  57. package/dist/index.d.ts +4 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3256 -2318
  60. package/dist/index.js.map +1 -1
  61. package/package.json +2 -2
  62. package/src/components/ButtonGroup/ButtonGroup.meta.ts +10 -2
  63. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
  64. package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
  65. package/src/components/Combobox/Combobox.meta.ts +41 -0
  66. package/src/components/Combobox/Combobox.stories.tsx +157 -0
  67. package/src/components/Combobox/Combobox.tsx +235 -0
  68. package/src/components/Combobox/ComboboxContext.ts +26 -0
  69. package/src/components/Combobox/styles.css.ts +119 -0
  70. package/src/components/Combobox/useComboboxRoot.ts +235 -0
  71. package/src/components/Field/Field.stories.tsx +62 -0
  72. package/src/components/Field/Field.tsx +94 -9
  73. package/src/components/Field/FieldContext.ts +5 -0
  74. package/src/components/Field/styles.css.ts +26 -0
  75. package/src/components/Form/Form.stories.tsx +275 -0
  76. package/src/components/Form/Form.tsx +69 -7
  77. package/src/components/Form/FormContext.ts +10 -1
  78. package/src/components/Input/Input.tsx +2 -1
  79. package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
  80. package/src/components/RadioGroup/RadioGroup.tsx +1 -4
  81. package/src/components/Table/SortIndicator.tsx +1 -4
  82. package/src/components/Table/Table.stories.tsx +190 -84
  83. package/src/components/Table/Table.tsx +10 -5
  84. package/src/components/Table/useDataTable.ts +34 -48
  85. package/src/components/Tag/Tag.meta.ts +22 -0
  86. package/src/components/Tag/Tag.stories.tsx +92 -0
  87. package/src/components/Tag/Tag.tsx +139 -0
  88. package/src/components/Tag/styles.css.ts +99 -0
  89. package/src/components/TagGroup/TagGroup.meta.ts +27 -0
  90. package/src/components/TagGroup/TagGroup.stories.tsx +193 -0
  91. package/src/components/TagGroup/TagGroup.tsx +302 -0
  92. package/src/components/TagGroup/TagGroupContext.ts +22 -0
  93. package/src/components/TagGroup/styles.css.ts +97 -0
  94. package/src/components/Textarea/Textarea.tsx +2 -1
  95. package/src/index.ts +4 -0
  96. package/src/strict.css +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
- "@duro-app/tokens": "^0.24.0"
56
+ "@duro-app/tokens": "^0.26.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/preset-typescript": "^7.28.0",
@@ -14,8 +14,16 @@ export const meta: ComponentMeta = {
14
14
  'Navigation items — use Inline or SideNav',
15
15
  ],
16
16
  relatedTo: [
17
- {component: 'Inline', relationship: 'Inline is a general-purpose horizontal layout; ButtonGroup adds role="group" and disabled support'},
18
- {component: 'ActionBar', relationship: 'ActionBar is a floating bulk-selection toolbar; ButtonGroup is a static grouping'},
17
+ {
18
+ component: 'Inline',
19
+ relationship:
20
+ 'Inline is a general-purpose horizontal layout; ButtonGroup adds role="group" and disabled support',
21
+ },
22
+ {
23
+ component: 'ActionBar',
24
+ relationship:
25
+ 'ActionBar is a floating bulk-selection toolbar; ButtonGroup is a static grouping',
26
+ },
19
27
  ],
20
28
  example: `<ButtonGroup align="end" gap="sm">
21
29
  <Button variant="secondary">Cancel</Button>
@@ -13,7 +13,10 @@ export const meta: ComponentMeta = {
13
13
  'Many options (10+) — consider a filterable list or Select with multi-select',
14
14
  ],
15
15
  relatedTo: [
16
- {component: 'RadioGroup', relationship: 'RadioGroup for single-select; CheckboxGroup for multi-select'},
16
+ {
17
+ component: 'RadioGroup',
18
+ relationship: 'RadioGroup for single-select; CheckboxGroup for multi-select',
19
+ },
17
20
  {component: 'Checkbox', relationship: 'CheckboxGroup.Item wraps Checkbox internally'},
18
21
  {component: 'ToggleGroup', relationship: 'ToggleGroup for toolbar-style multi-select'},
19
22
  ],
@@ -46,9 +46,7 @@ function Root({
46
46
  const onToggle = useCallback(
47
47
  (itemValue: string, checked: boolean) => {
48
48
  if (disabled) return
49
- const next = checked
50
- ? [...value, itemValue]
51
- : value.filter((v) => v !== itemValue)
49
+ const next = checked ? [...value, itemValue] : value.filter((v) => v !== itemValue)
52
50
  setValue(next)
53
51
  },
54
52
  [disabled, value, setValue],
@@ -88,12 +86,7 @@ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
88
86
  )
89
87
 
90
88
  return (
91
- <Checkbox
92
- value={value}
93
- checked={isChecked}
94
- disabled={isDisabled}
95
- onChange={handleChange}
96
- >
89
+ <Checkbox value={value} checked={isChecked} disabled={isDisabled} onChange={handleChange}>
97
90
  {children}
98
91
  </Checkbox>
99
92
  )
@@ -0,0 +1,41 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Searchable dropdown for selecting a value from a filterable list. Combines a text input with a popup listbox. Compound component — Root is required.',
6
+ whenToUse: [
7
+ 'Large option lists where users benefit from typing to filter (e.g. country, city, user picker)',
8
+ 'Autocomplete or typeahead scenarios',
9
+ 'Table column filters with known categorical values',
10
+ ],
11
+ whenNotToUse: [
12
+ 'Small lists (fewer than 7 options) — use Select instead',
13
+ 'Free-form text entry — use Input',
14
+ 'Multiple selections — use CheckboxGroup or ToggleGroup',
15
+ ],
16
+ anatomy: {
17
+ required: ['Root', 'Input', 'Popup', 'Item'],
18
+ optional: ['Empty'],
19
+ },
20
+ relatedTo: [
21
+ {
22
+ component: 'Select',
23
+ relationship: 'Select for short lists without search; Combobox for long filterable lists',
24
+ },
25
+ {
26
+ component: 'Input',
27
+ relationship: 'Input for free-form text; Combobox for constrained selection',
28
+ },
29
+ {component: 'Menu', relationship: 'Menu triggers actions; Combobox picks a value'},
30
+ ],
31
+ example: `<Combobox.Root name="country" defaultValue="us" initialLabels={{us: 'United States'}}>
32
+ <Combobox.Input placeholder="Search countries…" />
33
+ <Combobox.Popup>
34
+ <Combobox.Item value="us">United States</Combobox.Item>
35
+ <Combobox.Item value="gb">United Kingdom</Combobox.Item>
36
+ <Combobox.Item value="fr">France</Combobox.Item>
37
+ <Combobox.Item value="de">Germany</Combobox.Item>
38
+ <Combobox.Empty>No countries found</Combobox.Empty>
39
+ </Combobox.Popup>
40
+ </Combobox.Root>`,
41
+ }
@@ -0,0 +1,157 @@
1
+ import {useState} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect} from 'storybook/test'
4
+ import {Combobox} from './Combobox'
5
+
6
+ const meta: Meta = {
7
+ title: 'Components/Combobox',
8
+ }
9
+
10
+ export default meta
11
+ type Story = StoryObj
12
+
13
+ const fruits = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape', 'Honeydew']
14
+
15
+ export const Default: Story = {
16
+ render: () => (
17
+ <Combobox.Root>
18
+ <Combobox.Input placeholder="Search fruit...">
19
+ <Combobox.Trigger />
20
+ </Combobox.Input>
21
+ <Combobox.Popup>
22
+ {fruits.map((fruit) => (
23
+ <Combobox.Item key={fruit} value={fruit.toLowerCase()}>
24
+ <Combobox.ItemText>{fruit}</Combobox.ItemText>
25
+ </Combobox.Item>
26
+ ))}
27
+ <Combobox.Empty>No fruits found</Combobox.Empty>
28
+ </Combobox.Popup>
29
+ </Combobox.Root>
30
+ ),
31
+ play: async ({canvas}) => {
32
+ const input = canvas.getByRole('combobox')
33
+ await expect(input).toBeInTheDocument()
34
+ await expect(input).toHaveAttribute('aria-expanded', 'false')
35
+ },
36
+ }
37
+
38
+ export const WithDefaultValue: Story = {
39
+ render: () => (
40
+ <Combobox.Root defaultValue="cherry" initialLabels={{cherry: 'Cherry'}}>
41
+ <Combobox.Input placeholder="Search fruit...">
42
+ <Combobox.Trigger />
43
+ </Combobox.Input>
44
+ <Combobox.Popup>
45
+ {fruits.map((fruit) => (
46
+ <Combobox.Item key={fruit} value={fruit.toLowerCase()}>
47
+ <Combobox.ItemText>{fruit}</Combobox.ItemText>
48
+ </Combobox.Item>
49
+ ))}
50
+ <Combobox.Empty>No fruits found</Combobox.Empty>
51
+ </Combobox.Popup>
52
+ </Combobox.Root>
53
+ ),
54
+ play: async ({canvas}) => {
55
+ const input = canvas.getByRole('combobox')
56
+ await expect(input).toHaveValue('Cherry')
57
+ },
58
+ }
59
+
60
+ function ControlledDemo() {
61
+ const [value, setValue] = useState<string | null>(null)
62
+
63
+ return (
64
+ <div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
65
+ <Combobox.Root value={value} onValueChange={setValue}>
66
+ <Combobox.Input placeholder="Pick a role...">
67
+ <Combobox.Trigger />
68
+ </Combobox.Input>
69
+ <Combobox.Popup>
70
+ <Combobox.Item value="admin">
71
+ <Combobox.ItemText>Admin</Combobox.ItemText>
72
+ </Combobox.Item>
73
+ <Combobox.Item value="editor">
74
+ <Combobox.ItemText>Editor</Combobox.ItemText>
75
+ </Combobox.Item>
76
+ <Combobox.Item value="viewer">
77
+ <Combobox.ItemText>Viewer</Combobox.ItemText>
78
+ </Combobox.Item>
79
+ <Combobox.Item value="moderator">
80
+ <Combobox.ItemText>Moderator</Combobox.ItemText>
81
+ </Combobox.Item>
82
+ <Combobox.Item value="analyst">
83
+ <Combobox.ItemText>Analyst</Combobox.ItemText>
84
+ </Combobox.Item>
85
+ <Combobox.Empty>No roles found</Combobox.Empty>
86
+ </Combobox.Popup>
87
+ </Combobox.Root>
88
+ <span style={{fontSize: 14, color: '#888'}}>Selected: {value ?? 'none'}</span>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export const Controlled: Story = {
94
+ render: () => <ControlledDemo />,
95
+ play: async ({canvas}) => {
96
+ await expect(canvas.getByText('Selected: none')).toBeInTheDocument()
97
+ },
98
+ }
99
+
100
+ export const ManyItems: Story = {
101
+ render: () => {
102
+ const countries = [
103
+ 'Argentina',
104
+ 'Australia',
105
+ 'Austria',
106
+ 'Belgium',
107
+ 'Brazil',
108
+ 'Canada',
109
+ 'Chile',
110
+ 'China',
111
+ 'Colombia',
112
+ 'Denmark',
113
+ 'Egypt',
114
+ 'Finland',
115
+ 'France',
116
+ 'Germany',
117
+ 'Greece',
118
+ 'India',
119
+ 'Indonesia',
120
+ 'Ireland',
121
+ 'Italy',
122
+ 'Japan',
123
+ 'Kenya',
124
+ 'Mexico',
125
+ 'Netherlands',
126
+ 'New Zealand',
127
+ 'Norway',
128
+ 'Peru',
129
+ 'Poland',
130
+ 'Portugal',
131
+ 'South Korea',
132
+ 'Spain',
133
+ 'Sweden',
134
+ 'Switzerland',
135
+ 'Thailand',
136
+ 'Turkey',
137
+ 'United Kingdom',
138
+ 'United States',
139
+ ]
140
+
141
+ return (
142
+ <Combobox.Root>
143
+ <Combobox.Input placeholder="Search country...">
144
+ <Combobox.Trigger />
145
+ </Combobox.Input>
146
+ <Combobox.Popup>
147
+ {countries.map((country) => (
148
+ <Combobox.Item key={country} value={country.toLowerCase()}>
149
+ <Combobox.ItemText>{country}</Combobox.ItemText>
150
+ </Combobox.Item>
151
+ ))}
152
+ <Combobox.Empty>No countries found</Combobox.Empty>
153
+ </Combobox.Popup>
154
+ </Combobox.Root>
155
+ )
156
+ },
157
+ }
@@ -0,0 +1,235 @@
1
+ import {type ReactNode, useRef, useId, useEffect, useState} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {ComboboxContext, useCombobox} from './ComboboxContext'
5
+ import {useComboboxRoot} from './useComboboxRoot'
6
+
7
+ // --- Root ---
8
+ interface RootProps {
9
+ name?: string
10
+ defaultValue?: string
11
+ value?: string
12
+ onValueChange?: (value: string | null) => void
13
+ inputValue?: string
14
+ onInputChange?: (value: string) => void
15
+ initialLabels?: Record<string, string>
16
+ children: ReactNode
17
+ }
18
+
19
+ function Root({
20
+ name,
21
+ defaultValue,
22
+ value,
23
+ onValueChange,
24
+ inputValue,
25
+ onInputChange,
26
+ initialLabels,
27
+ children,
28
+ }: RootProps) {
29
+ const {ctx, rootRef} = useComboboxRoot({
30
+ defaultValue,
31
+ value,
32
+ onValueChange,
33
+ inputValue,
34
+ onInputChange,
35
+ initialLabels,
36
+ })
37
+
38
+ return (
39
+ <ComboboxContext.Provider value={ctx}>
40
+ <html.div ref={rootRef} style={styles.root}>
41
+ {name && <html.input type="hidden" name={name} value={ctx.value ?? ''} />}
42
+ {children}
43
+ </html.div>
44
+ </ComboboxContext.Provider>
45
+ )
46
+ }
47
+
48
+ // --- Input ---
49
+ function Input({placeholder, children}: {placeholder?: string; children?: ReactNode}) {
50
+ const {open, openPopup, close, inputValue, setInputValue, listboxId, highlightedId, inputRef} =
51
+ useCombobox()
52
+ const localRef = useRef<HTMLInputElement>(null)
53
+ const [focused, setFocused] = useState(false)
54
+
55
+ useEffect(() => {
56
+ inputRef.current = localRef.current
57
+ })
58
+
59
+ return (
60
+ <html.div style={[styles.inputWrapper, focused && styles.inputWrapperFocused]}>
61
+ <html.input
62
+ ref={localRef}
63
+ type="text"
64
+ role={'combobox' as 'listbox'}
65
+ value={inputValue}
66
+ placeholder={placeholder}
67
+ aria-expanded={open}
68
+ aria-haspopup="listbox"
69
+ aria-controls={open ? listboxId : undefined}
70
+ aria-activedescendant={highlightedId ?? undefined}
71
+ aria-autocomplete={'list' as 'none'}
72
+ autoComplete="off"
73
+ style={styles.input}
74
+ onFocus={() => {
75
+ setFocused(true)
76
+ openPopup()
77
+ }}
78
+ onBlur={() => {
79
+ setFocused(false)
80
+ // Delay close to allow item click to register
81
+ setTimeout(close, 150)
82
+ }}
83
+ onChange={(e: any) => {
84
+ setInputValue(e.target.value)
85
+ if (!open) openPopup()
86
+ }}
87
+ />
88
+ {children}
89
+ </html.div>
90
+ )
91
+ }
92
+
93
+ // --- Trigger ---
94
+ function Trigger({children}: {children?: ReactNode}) {
95
+ const {open, openPopup, close, inputRef} = useCombobox()
96
+
97
+ return (
98
+ <html.button
99
+ type="button"
100
+ tabIndex={-1}
101
+ aria-label={open ? 'Close' : 'Open'}
102
+ onClick={() => {
103
+ if (open) {
104
+ close()
105
+ } else {
106
+ openPopup()
107
+ }
108
+ inputRef.current?.focus()
109
+ }}
110
+ style={styles.trigger}
111
+ >
112
+ {children ?? (
113
+ <svg
114
+ width="12"
115
+ height="12"
116
+ viewBox="0 0 24 24"
117
+ fill="none"
118
+ stroke="currentColor"
119
+ strokeWidth="2"
120
+ strokeLinecap="round"
121
+ strokeLinejoin="round"
122
+ aria-hidden="true"
123
+ >
124
+ <path d="M6 9l6 6 6-6" />
125
+ </svg>
126
+ )}
127
+ </html.button>
128
+ )
129
+ }
130
+
131
+ // --- Popup ---
132
+ function Popup({children}: {children: ReactNode}) {
133
+ const {open, listboxId} = useCombobox()
134
+
135
+ if (!open) return null
136
+
137
+ return (
138
+ <html.div id={listboxId} role="listbox" style={styles.popup}>
139
+ {children}
140
+ </html.div>
141
+ )
142
+ }
143
+
144
+ // --- Item ---
145
+ interface ItemProps {
146
+ value: string
147
+ children: ReactNode
148
+ }
149
+
150
+ function Item({value: itemValue, children}: ItemProps) {
151
+ const {
152
+ value: selectedValue,
153
+ setValue,
154
+ inputValue,
155
+ labels,
156
+ registerLabel,
157
+ highlightedId,
158
+ setHighlightedId,
159
+ registerItem,
160
+ } = useCombobox()
161
+ const id = useId()
162
+ const ref = useRef<HTMLDivElement>(null)
163
+ const isSelected = selectedValue === itemValue
164
+ const isHighlighted = highlightedId === id
165
+
166
+ // Filter: check if this item matches the input
167
+ const label = labels[itemValue] ?? itemValue
168
+ const query = inputValue.toLowerCase()
169
+ const isVisible = !query || label.toLowerCase().includes(query)
170
+
171
+ useEffect(() => {
172
+ const el = ref.current
173
+ if (!el) return
174
+ const text = el.textContent
175
+ if (text) registerLabel(itemValue, text)
176
+ }, [itemValue, registerLabel])
177
+
178
+ useEffect(() => {
179
+ const el = ref.current
180
+ if (!el) return
181
+ return registerItem(id, itemValue, el)
182
+ }, [id, itemValue, registerItem])
183
+
184
+ if (!isVisible) return null
185
+
186
+ return (
187
+ <html.div
188
+ ref={ref}
189
+ id={id}
190
+ role="option"
191
+ aria-selected={isSelected}
192
+ onClick={() => setValue(itemValue)}
193
+ onPointerEnter={() => setHighlightedId(id)}
194
+ style={[
195
+ styles.item,
196
+ isSelected && styles.itemSelected,
197
+ isHighlighted && styles.itemHighlighted,
198
+ ]}
199
+ >
200
+ {children}
201
+ </html.div>
202
+ )
203
+ }
204
+
205
+ // --- ItemText ---
206
+ function ItemText({children}: {children: ReactNode}) {
207
+ return <html.span>{children}</html.span>
208
+ }
209
+
210
+ // --- Empty ---
211
+ function Empty({children}: {children: ReactNode}) {
212
+ const {open, inputValue, labels} = useCombobox()
213
+
214
+ if (!open) return null
215
+
216
+ // Check if any items are visible
217
+ const query = inputValue.toLowerCase()
218
+ const hasVisibleItems = query
219
+ ? Object.values(labels).some((label) => label.toLowerCase().includes(query))
220
+ : true
221
+
222
+ if (hasVisibleItems) return null
223
+
224
+ return <html.div style={styles.empty}>{children}</html.div>
225
+ }
226
+
227
+ export const Combobox = {
228
+ Root,
229
+ Input,
230
+ Trigger,
231
+ Popup,
232
+ Item,
233
+ ItemText,
234
+ Empty,
235
+ }
@@ -0,0 +1,26 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface ComboboxContextValue {
4
+ open: boolean
5
+ openPopup: () => void
6
+ close: () => void
7
+ value: string | null
8
+ setValue: (value: string) => void
9
+ inputValue: string
10
+ setInputValue: (value: string) => void
11
+ labels: Record<string, string>
12
+ registerLabel: (value: string, label: string) => void
13
+ listboxId: string
14
+ highlightedId: string | null
15
+ setHighlightedId: (id: string | null) => void
16
+ registerItem: (id: string, value: string, element: HTMLElement) => () => void
17
+ inputRef: React.RefObject<HTMLInputElement | null>
18
+ }
19
+
20
+ export const ComboboxContext = createContext<ComboboxContextValue | null>(null)
21
+
22
+ export function useCombobox() {
23
+ const ctx = useContext(ComboboxContext)
24
+ if (!ctx) throw new Error('Combobox compound components must be used within Combobox.Root')
25
+ return ctx
26
+ }
@@ -0,0 +1,119 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
+
7
+ export const styles = css.create({
8
+ root: {
9
+ position: 'relative',
10
+ display: 'inline-flex',
11
+ },
12
+ inputWrapper: {
13
+ display: 'inline-flex',
14
+ alignItems: 'center',
15
+ gap: spacing.sm,
16
+ paddingLeft: spacing.md,
17
+ paddingRight: spacing.xs,
18
+ backgroundColor: colors.bgCard,
19
+ borderWidth: 1,
20
+ borderStyle: 'solid',
21
+ borderColor: {
22
+ default: colors.border,
23
+ ':hover': colors.accent,
24
+ },
25
+ borderRadius: radii.sm,
26
+ transitionProperty: 'border-color',
27
+ transitionDuration: '150ms',
28
+ },
29
+ inputWrapperFocused: {
30
+ borderColor: colors.accent,
31
+ },
32
+ input: {
33
+ flex: 1,
34
+ paddingTop: spacing.sm,
35
+ paddingBottom: spacing.sm,
36
+ fontFamily: typography.fontFamily,
37
+ fontSize: typography.fontSizeSm,
38
+ color: colors.text,
39
+ backgroundColor: 'transparent',
40
+ borderWidth: 0,
41
+ outline: 'none',
42
+ minWidth: 0,
43
+ },
44
+ trigger: {
45
+ display: 'flex',
46
+ alignItems: 'center',
47
+ justifyContent: 'center',
48
+ paddingTop: spacing.xs,
49
+ paddingBottom: spacing.xs,
50
+ paddingLeft: spacing.xs,
51
+ paddingRight: spacing.xs,
52
+ backgroundColor: 'transparent',
53
+ borderWidth: 0,
54
+ color: colors.textMuted,
55
+ cursor: 'pointer',
56
+ },
57
+ backdrop: {
58
+ position: 'fixed',
59
+ top: 0,
60
+ left: 0,
61
+ right: 0,
62
+ bottom: 0,
63
+ zIndex: 49,
64
+ },
65
+ popup: {
66
+ position: 'absolute',
67
+ top: '100%',
68
+ left: 0,
69
+ right: 0,
70
+ marginTop: spacing.xs,
71
+ backgroundColor: colors.bgCard,
72
+ borderWidth: 1,
73
+ borderStyle: 'solid',
74
+ borderColor: colors.border,
75
+ borderRadius: radii.sm,
76
+ boxShadow: shadows.md,
77
+ paddingTop: spacing.xs,
78
+ paddingBottom: spacing.xs,
79
+ maxHeight: 200,
80
+ overflowY: 'auto',
81
+ zIndex: 50,
82
+ },
83
+ item: {
84
+ display: 'flex',
85
+ alignItems: 'center',
86
+ paddingTop: spacing.sm,
87
+ paddingBottom: spacing.sm,
88
+ paddingLeft: spacing.md,
89
+ paddingRight: spacing.md,
90
+ fontSize: typography.fontSizeSm,
91
+ fontFamily: typography.fontFamily,
92
+ color: colors.text,
93
+ cursor: 'pointer',
94
+ backgroundColor: 'transparent',
95
+ transitionProperty: 'background-color',
96
+ transitionDuration: '150ms',
97
+ },
98
+ itemSelected: {
99
+ color: colors.accent,
100
+ fontWeight: typography.fontWeightMedium,
101
+ },
102
+ itemHighlighted: {
103
+ backgroundColor: colors.bgCardHover,
104
+ },
105
+ itemHidden: {
106
+ display: 'none',
107
+ },
108
+ empty: {
109
+ paddingTop: spacing.sm,
110
+ paddingBottom: spacing.sm,
111
+ paddingLeft: spacing.md,
112
+ paddingRight: spacing.md,
113
+ fontSize: typography.fontSizeSm,
114
+ color: colors.textMuted,
115
+ },
116
+ hidden: {
117
+ display: 'none',
118
+ },
119
+ })