@lglab/compose-ui-mcp 0.0.1
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.
- package/README.md +11 -0
- package/dist/assets/llms/accordion.md +184 -0
- package/dist/assets/llms/alert-dialog.md +306 -0
- package/dist/assets/llms/autocomplete.md +756 -0
- package/dist/assets/llms/avatar.md +166 -0
- package/dist/assets/llms/badge.md +478 -0
- package/dist/assets/llms/button.md +238 -0
- package/dist/assets/llms/card.md +264 -0
- package/dist/assets/llms/checkbox-group.md +158 -0
- package/dist/assets/llms/checkbox.md +83 -0
- package/dist/assets/llms/collapsible.md +165 -0
- package/dist/assets/llms/combobox.md +1255 -0
- package/dist/assets/llms/context-menu.md +371 -0
- package/dist/assets/llms/dialog.md +592 -0
- package/dist/assets/llms/drawer.md +437 -0
- package/dist/assets/llms/field.md +74 -0
- package/dist/assets/llms/form.md +1931 -0
- package/dist/assets/llms/input.md +47 -0
- package/dist/assets/llms/menu.md +484 -0
- package/dist/assets/llms/menubar.md +804 -0
- package/dist/assets/llms/meter.md +181 -0
- package/dist/assets/llms/navigation-menu.md +187 -0
- package/dist/assets/llms/number-field.md +243 -0
- package/dist/assets/llms/pagination.md +514 -0
- package/dist/assets/llms/popover.md +206 -0
- package/dist/assets/llms/preview-card.md +146 -0
- package/dist/assets/llms/progress.md +60 -0
- package/dist/assets/llms/radio-group.md +105 -0
- package/dist/assets/llms/scroll-area.md +132 -0
- package/dist/assets/llms/select.md +276 -0
- package/dist/assets/llms/separator.md +49 -0
- package/dist/assets/llms/skeleton.md +96 -0
- package/dist/assets/llms/slider.md +161 -0
- package/dist/assets/llms/switch.md +101 -0
- package/dist/assets/llms/table.md +1325 -0
- package/dist/assets/llms/tabs.md +327 -0
- package/dist/assets/llms/textarea.md +38 -0
- package/dist/assets/llms/toast.md +349 -0
- package/dist/assets/llms/toggle-group.md +261 -0
- package/dist/assets/llms/toggle.md +161 -0
- package/dist/assets/llms/toolbar.md +148 -0
- package/dist/assets/llms/tooltip.md +486 -0
- package/dist/assets/llms-full.txt +14515 -0
- package/dist/assets/llms.txt +65 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +161 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
# Combobox
|
|
2
|
+
|
|
3
|
+
An input combined with a list of predefined items to select, with filtering support.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @lglab/compose-ui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Import
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { BaseCombobox as Combobox, ComboboxRoot, ComboboxValue, ComboboxIcon, ComboboxInput, ComboboxControl, ComboboxClear, ComboboxTrigger, ComboboxBackdrop, ComboboxPortal, ComboboxPositioner, ComboboxPopup, ComboboxList, ComboboxEmpty, ComboboxItem, ComboboxItemText, ComboboxItemIndicator, ComboboxGroup, ComboboxGroupLabel, ComboboxCollection, ComboboxSeparator, ComboboxStatus, ComboboxChips, ComboboxChip, ComboboxChipRemove, ComboboxArrow } from '@lglab/compose-ui'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Examples
|
|
18
|
+
|
|
19
|
+
### Default
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import {
|
|
23
|
+
ComboboxClear,
|
|
24
|
+
ComboboxControl,
|
|
25
|
+
ComboboxEmpty,
|
|
26
|
+
ComboboxInput,
|
|
27
|
+
ComboboxItem,
|
|
28
|
+
ComboboxItemIndicator,
|
|
29
|
+
ComboboxItemText,
|
|
30
|
+
ComboboxList,
|
|
31
|
+
ComboboxPopup,
|
|
32
|
+
ComboboxPortal,
|
|
33
|
+
ComboboxPositioner,
|
|
34
|
+
ComboboxRoot,
|
|
35
|
+
ComboboxTrigger,
|
|
36
|
+
} from '@lglab/compose-ui/combobox'
|
|
37
|
+
import { FieldLabel, FieldRoot } from '@lglab/compose-ui/field'
|
|
38
|
+
import { Check, ChevronDown, X } from 'lucide-react'
|
|
39
|
+
|
|
40
|
+
interface Fruit {
|
|
41
|
+
label: string
|
|
42
|
+
value: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fruits: Fruit[] = [
|
|
46
|
+
{ label: 'Apple', value: 'apple' },
|
|
47
|
+
{ label: 'Banana', value: 'banana' },
|
|
48
|
+
{ label: 'Orange', value: 'orange' },
|
|
49
|
+
{ label: 'Pineapple', value: 'pineapple' },
|
|
50
|
+
{ label: 'Grape', value: 'grape' },
|
|
51
|
+
{ label: 'Mango', value: 'mango' },
|
|
52
|
+
{ label: 'Strawberry', value: 'strawberry' },
|
|
53
|
+
{ label: 'Blueberry', value: 'blueberry' },
|
|
54
|
+
{ label: 'Raspberry', value: 'raspberry' },
|
|
55
|
+
{ label: 'Blackberry', value: 'blackberry' },
|
|
56
|
+
{ label: 'Cherry', value: 'cherry' },
|
|
57
|
+
{ label: 'Peach', value: 'peach' },
|
|
58
|
+
{ label: 'Pear', value: 'pear' },
|
|
59
|
+
{ label: 'Plum', value: 'plum' },
|
|
60
|
+
{ label: 'Kiwi', value: 'kiwi' },
|
|
61
|
+
{ label: 'Watermelon', value: 'watermelon' },
|
|
62
|
+
{ label: 'Cantaloupe', value: 'cantaloupe' },
|
|
63
|
+
{ label: 'Honeydew', value: 'honeydew' },
|
|
64
|
+
{ label: 'Papaya', value: 'papaya' },
|
|
65
|
+
{ label: 'Guava', value: 'guava' },
|
|
66
|
+
{ label: 'Lychee', value: 'lychee' },
|
|
67
|
+
{ label: 'Pomegranate', value: 'pomegranate' },
|
|
68
|
+
{ label: 'Apricot', value: 'apricot' },
|
|
69
|
+
{ label: 'Grapefruit', value: 'grapefruit' },
|
|
70
|
+
{ label: 'Passionfruit', value: 'passionfruit' },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
export default function DefaultExample() {
|
|
74
|
+
return (
|
|
75
|
+
<FieldRoot>
|
|
76
|
+
<FieldLabel>Choose a fruit</FieldLabel>
|
|
77
|
+
<ComboboxRoot items={fruits}>
|
|
78
|
+
<ComboboxControl>
|
|
79
|
+
<ComboboxInput placeholder='e.g. Apple' />
|
|
80
|
+
<ComboboxClear aria-label='Clear selection'>
|
|
81
|
+
<X className='size-4' />
|
|
82
|
+
</ComboboxClear>
|
|
83
|
+
<ComboboxTrigger aria-label='Open popup'>
|
|
84
|
+
<ChevronDown className='size-4' />
|
|
85
|
+
</ComboboxTrigger>
|
|
86
|
+
</ComboboxControl>
|
|
87
|
+
<ComboboxPortal>
|
|
88
|
+
<ComboboxPositioner>
|
|
89
|
+
<ComboboxPopup>
|
|
90
|
+
<ComboboxEmpty>No fruits found.</ComboboxEmpty>
|
|
91
|
+
<ComboboxList>
|
|
92
|
+
{(item: Fruit) => (
|
|
93
|
+
<ComboboxItem key={item.value} value={item}>
|
|
94
|
+
<ComboboxItemText>{item.label}</ComboboxItemText>
|
|
95
|
+
<ComboboxItemIndicator>
|
|
96
|
+
<Check className='size-3.5' />
|
|
97
|
+
</ComboboxItemIndicator>
|
|
98
|
+
</ComboboxItem>
|
|
99
|
+
)}
|
|
100
|
+
</ComboboxList>
|
|
101
|
+
</ComboboxPopup>
|
|
102
|
+
</ComboboxPositioner>
|
|
103
|
+
</ComboboxPortal>
|
|
104
|
+
</ComboboxRoot>
|
|
105
|
+
</FieldRoot>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Multiselect
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
import {
|
|
114
|
+
ComboboxChip,
|
|
115
|
+
ComboboxChipRemove,
|
|
116
|
+
ComboboxChips,
|
|
117
|
+
ComboboxEmpty,
|
|
118
|
+
ComboboxInput,
|
|
119
|
+
ComboboxItem,
|
|
120
|
+
ComboboxItemIndicator,
|
|
121
|
+
ComboboxItemText,
|
|
122
|
+
ComboboxList,
|
|
123
|
+
ComboboxPopup,
|
|
124
|
+
ComboboxPortal,
|
|
125
|
+
ComboboxPositioner,
|
|
126
|
+
ComboboxRoot,
|
|
127
|
+
ComboboxValue,
|
|
128
|
+
} from '@lglab/compose-ui/combobox'
|
|
129
|
+
import { FieldLabel, FieldRoot } from '@lglab/compose-ui/field'
|
|
130
|
+
import { Check, X } from 'lucide-react'
|
|
131
|
+
import * as React from 'react'
|
|
132
|
+
|
|
133
|
+
interface ProgrammingLanguage {
|
|
134
|
+
id: string
|
|
135
|
+
value: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const langs: ProgrammingLanguage[] = [
|
|
139
|
+
{ id: 'js', value: 'JavaScript' },
|
|
140
|
+
{ id: 'ts', value: 'TypeScript' },
|
|
141
|
+
{ id: 'py', value: 'Python' },
|
|
142
|
+
{ id: 'java', value: 'Java' },
|
|
143
|
+
{ id: 'cpp', value: 'C++' },
|
|
144
|
+
{ id: 'cs', value: 'C#' },
|
|
145
|
+
{ id: 'php', value: 'PHP' },
|
|
146
|
+
{ id: 'ruby', value: 'Ruby' },
|
|
147
|
+
{ id: 'go', value: 'Go' },
|
|
148
|
+
{ id: 'rust', value: 'Rust' },
|
|
149
|
+
{ id: 'swift', value: 'Swift' },
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
export default function MultiselectExample() {
|
|
153
|
+
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<FieldRoot>
|
|
157
|
+
<FieldLabel>Programming languages</FieldLabel>
|
|
158
|
+
<ComboboxRoot items={langs} multiple>
|
|
159
|
+
<ComboboxChips ref={containerRef} className='max-w-xs'>
|
|
160
|
+
<ComboboxValue>
|
|
161
|
+
{(value: ProgrammingLanguage[]) => (
|
|
162
|
+
<React.Fragment>
|
|
163
|
+
{value.map((language) => (
|
|
164
|
+
<ComboboxChip key={language.id} aria-label={language.value}>
|
|
165
|
+
{language.value}
|
|
166
|
+
<ComboboxChipRemove aria-label='Remove'>
|
|
167
|
+
<X className='size-3' />
|
|
168
|
+
</ComboboxChipRemove>
|
|
169
|
+
</ComboboxChip>
|
|
170
|
+
))}
|
|
171
|
+
<ComboboxInput placeholder={value.length > 0 ? '' : 'e.g. TypeScript'} />
|
|
172
|
+
</React.Fragment>
|
|
173
|
+
)}
|
|
174
|
+
</ComboboxValue>
|
|
175
|
+
</ComboboxChips>
|
|
176
|
+
<ComboboxPortal>
|
|
177
|
+
<ComboboxPositioner sideOffset={4} anchor={containerRef}>
|
|
178
|
+
<ComboboxPopup>
|
|
179
|
+
<ComboboxEmpty>No languages found.</ComboboxEmpty>
|
|
180
|
+
<ComboboxList>
|
|
181
|
+
{(language: ProgrammingLanguage) => (
|
|
182
|
+
<ComboboxItem key={language.id} value={language}>
|
|
183
|
+
<ComboboxItemText>{language.value}</ComboboxItemText>
|
|
184
|
+
<ComboboxItemIndicator>
|
|
185
|
+
<Check className='size-3.5' />
|
|
186
|
+
</ComboboxItemIndicator>
|
|
187
|
+
</ComboboxItem>
|
|
188
|
+
)}
|
|
189
|
+
</ComboboxList>
|
|
190
|
+
</ComboboxPopup>
|
|
191
|
+
</ComboboxPositioner>
|
|
192
|
+
</ComboboxPortal>
|
|
193
|
+
</ComboboxRoot>
|
|
194
|
+
</FieldRoot>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Grouped
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import {
|
|
203
|
+
ComboboxClear,
|
|
204
|
+
ComboboxCollection,
|
|
205
|
+
ComboboxControl,
|
|
206
|
+
ComboboxEmpty,
|
|
207
|
+
ComboboxGroup,
|
|
208
|
+
ComboboxGroupLabel,
|
|
209
|
+
ComboboxInput,
|
|
210
|
+
ComboboxItem,
|
|
211
|
+
ComboboxItemIndicator,
|
|
212
|
+
ComboboxItemText,
|
|
213
|
+
ComboboxList,
|
|
214
|
+
ComboboxPopup,
|
|
215
|
+
ComboboxPortal,
|
|
216
|
+
ComboboxPositioner,
|
|
217
|
+
ComboboxRoot,
|
|
218
|
+
ComboboxSeparator,
|
|
219
|
+
ComboboxTrigger,
|
|
220
|
+
} from '@lglab/compose-ui/combobox'
|
|
221
|
+
import { FieldLabel, FieldRoot } from '@lglab/compose-ui/field'
|
|
222
|
+
import { Check, ChevronDown, X } from 'lucide-react'
|
|
223
|
+
import * as React from 'react'
|
|
224
|
+
|
|
225
|
+
interface Produce {
|
|
226
|
+
id: string
|
|
227
|
+
label: string
|
|
228
|
+
group: 'Fruits' | 'Vegetables' | 'Grains'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface ProduceGroup {
|
|
232
|
+
value: string
|
|
233
|
+
items: Produce[]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const produceData: Produce[] = [
|
|
237
|
+
{ id: 'fruit-apple', label: 'Apple', group: 'Fruits' },
|
|
238
|
+
{ id: 'fruit-banana', label: 'Banana', group: 'Fruits' },
|
|
239
|
+
{ id: 'fruit-mango', label: 'Mango', group: 'Fruits' },
|
|
240
|
+
{ id: 'fruit-kiwi', label: 'Kiwi', group: 'Fruits' },
|
|
241
|
+
{ id: 'veg-broccoli', label: 'Broccoli', group: 'Vegetables' },
|
|
242
|
+
{ id: 'veg-carrot', label: 'Carrot', group: 'Vegetables' },
|
|
243
|
+
{ id: 'veg-cauliflower', label: 'Cauliflower', group: 'Vegetables' },
|
|
244
|
+
{ id: 'veg-cucumber', label: 'Cucumber', group: 'Vegetables' },
|
|
245
|
+
{ id: 'veg-kale', label: 'Kale', group: 'Vegetables' },
|
|
246
|
+
{ id: 'veg-pepper', label: 'Bell pepper', group: 'Vegetables' },
|
|
247
|
+
{ id: 'veg-spinach', label: 'Spinach', group: 'Vegetables' },
|
|
248
|
+
{ id: 'veg-zucchini', label: 'Zucchini', group: 'Vegetables' },
|
|
249
|
+
{ id: 'grain-rice', label: 'Rice', group: 'Grains' },
|
|
250
|
+
{ id: 'grain-wheat', label: 'Wheat', group: 'Grains' },
|
|
251
|
+
{ id: 'grain-oats', label: 'Oats', group: 'Grains' },
|
|
252
|
+
{ id: 'grain-barley', label: 'Barley', group: 'Grains' },
|
|
253
|
+
{ id: 'grain-quinoa', label: 'Quinoa', group: 'Grains' },
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
function groupProduce(items: Produce[]): ProduceGroup[] {
|
|
257
|
+
const groups: Record<string, Produce[]> = {}
|
|
258
|
+
items.forEach((item) => {
|
|
259
|
+
;(groups[item.group] ??= []).push(item)
|
|
260
|
+
})
|
|
261
|
+
const order = ['Fruits', 'Vegetables', 'Grains']
|
|
262
|
+
return order.map((value) => ({ value, items: groups[value] ?? [] }))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const groupedProduce: ProduceGroup[] = groupProduce(produceData)
|
|
266
|
+
|
|
267
|
+
export default function GroupedExample() {
|
|
268
|
+
return (
|
|
269
|
+
<FieldRoot>
|
|
270
|
+
<FieldLabel>Select produce</FieldLabel>
|
|
271
|
+
<ComboboxRoot items={groupedProduce}>
|
|
272
|
+
<ComboboxControl>
|
|
273
|
+
<ComboboxInput placeholder='e.g. Mango' />
|
|
274
|
+
<ComboboxClear aria-label='Clear selection'>
|
|
275
|
+
<X className='size-4' />
|
|
276
|
+
</ComboboxClear>
|
|
277
|
+
<ComboboxTrigger aria-label='Open popup'>
|
|
278
|
+
<ChevronDown className='size-4' />
|
|
279
|
+
</ComboboxTrigger>
|
|
280
|
+
</ComboboxControl>
|
|
281
|
+
<ComboboxPortal>
|
|
282
|
+
<ComboboxPositioner>
|
|
283
|
+
<ComboboxPopup>
|
|
284
|
+
<ComboboxEmpty>No produce found.</ComboboxEmpty>
|
|
285
|
+
<ComboboxList>
|
|
286
|
+
{(group: ProduceGroup, index: number) => (
|
|
287
|
+
<React.Fragment key={group.value}>
|
|
288
|
+
<ComboboxGroup items={group.items}>
|
|
289
|
+
<ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>
|
|
290
|
+
<ComboboxCollection>
|
|
291
|
+
{(item: Produce) => (
|
|
292
|
+
<ComboboxItem key={item.id} value={item}>
|
|
293
|
+
<ComboboxItemText>{item.label}</ComboboxItemText>
|
|
294
|
+
<ComboboxItemIndicator>
|
|
295
|
+
<Check className='size-3.5' />
|
|
296
|
+
</ComboboxItemIndicator>
|
|
297
|
+
</ComboboxItem>
|
|
298
|
+
)}
|
|
299
|
+
</ComboboxCollection>
|
|
300
|
+
</ComboboxGroup>
|
|
301
|
+
{index < groupedProduce.length - 1 && <ComboboxSeparator />}
|
|
302
|
+
</React.Fragment>
|
|
303
|
+
)}
|
|
304
|
+
</ComboboxList>
|
|
305
|
+
</ComboboxPopup>
|
|
306
|
+
</ComboboxPositioner>
|
|
307
|
+
</ComboboxPortal>
|
|
308
|
+
</ComboboxRoot>
|
|
309
|
+
</FieldRoot>
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Popup
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
import { Button } from '@lglab/compose-ui/button'
|
|
318
|
+
import {
|
|
319
|
+
ComboboxEmpty,
|
|
320
|
+
ComboboxIcon,
|
|
321
|
+
ComboboxInput,
|
|
322
|
+
ComboboxItem,
|
|
323
|
+
ComboboxItemIndicator,
|
|
324
|
+
ComboboxItemText,
|
|
325
|
+
ComboboxList,
|
|
326
|
+
ComboboxPopup,
|
|
327
|
+
ComboboxPortal,
|
|
328
|
+
ComboboxPositioner,
|
|
329
|
+
ComboboxRoot,
|
|
330
|
+
ComboboxTrigger,
|
|
331
|
+
ComboboxValue,
|
|
332
|
+
} from '@lglab/compose-ui/combobox'
|
|
333
|
+
import { FieldLabel, FieldRoot } from '@lglab/compose-ui/field'
|
|
334
|
+
import { Check, ChevronsUpDown } from 'lucide-react'
|
|
335
|
+
|
|
336
|
+
interface Country {
|
|
337
|
+
label: string
|
|
338
|
+
value: string
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const countries: Country[] = [
|
|
342
|
+
{ label: 'Argentina', value: 'ar' },
|
|
343
|
+
{ label: 'Australia', value: 'au' },
|
|
344
|
+
{ label: 'Brazil', value: 'br' },
|
|
345
|
+
{ label: 'Canada', value: 'ca' },
|
|
346
|
+
{ label: 'China', value: 'cn' },
|
|
347
|
+
{ label: 'France', value: 'fr' },
|
|
348
|
+
{ label: 'Germany', value: 'de' },
|
|
349
|
+
{ label: 'India', value: 'in' },
|
|
350
|
+
{ label: 'Italy', value: 'it' },
|
|
351
|
+
{ label: 'Japan', value: 'jp' },
|
|
352
|
+
{ label: 'Mexico', value: 'mx' },
|
|
353
|
+
{ label: 'Netherlands', value: 'nl' },
|
|
354
|
+
{ label: 'Poland', value: 'pl' },
|
|
355
|
+
{ label: 'South Korea', value: 'kr' },
|
|
356
|
+
{ label: 'Spain', value: 'es' },
|
|
357
|
+
{ label: 'Sweden', value: 'se' },
|
|
358
|
+
{ label: 'Switzerland', value: 'ch' },
|
|
359
|
+
{ label: 'United Kingdom', value: 'gb' },
|
|
360
|
+
{ label: 'United States', value: 'us' },
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
export default function InputInsidePopupExample() {
|
|
364
|
+
return (
|
|
365
|
+
<FieldRoot className='w-64'>
|
|
366
|
+
<FieldLabel render={<div />} nativeLabel={false}>
|
|
367
|
+
Country
|
|
368
|
+
</FieldLabel>
|
|
369
|
+
<ComboboxRoot items={countries}>
|
|
370
|
+
<ComboboxTrigger
|
|
371
|
+
render={(props) => (
|
|
372
|
+
<Button {...props} className='justify-between font-normal' variant='outline'>
|
|
373
|
+
<ComboboxValue placeholder='Select a country' />
|
|
374
|
+
<ComboboxIcon>
|
|
375
|
+
<ChevronsUpDown className='size-4' />
|
|
376
|
+
</ComboboxIcon>
|
|
377
|
+
</Button>
|
|
378
|
+
)}
|
|
379
|
+
/>
|
|
380
|
+
|
|
381
|
+
<ComboboxPortal>
|
|
382
|
+
<ComboboxPositioner align='start'>
|
|
383
|
+
<ComboboxPopup>
|
|
384
|
+
<ComboboxInput placeholder='e.g. United Kingdom' />
|
|
385
|
+
<ComboboxEmpty>No countries found.</ComboboxEmpty>
|
|
386
|
+
<ComboboxList>
|
|
387
|
+
{(item: Country) => (
|
|
388
|
+
<ComboboxItem key={item.value} value={item}>
|
|
389
|
+
<ComboboxItemText>{item.label}</ComboboxItemText>
|
|
390
|
+
<ComboboxItemIndicator>
|
|
391
|
+
<Check className='size-3.5' />
|
|
392
|
+
</ComboboxItemIndicator>
|
|
393
|
+
</ComboboxItem>
|
|
394
|
+
)}
|
|
395
|
+
</ComboboxList>
|
|
396
|
+
</ComboboxPopup>
|
|
397
|
+
</ComboboxPositioner>
|
|
398
|
+
</ComboboxPortal>
|
|
399
|
+
</ComboboxRoot>
|
|
400
|
+
</FieldRoot>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Async search
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
import {
|
|
409
|
+
Combobox,
|
|
410
|
+
ComboboxClear,
|
|
411
|
+
ComboboxControl,
|
|
412
|
+
ComboboxEmpty,
|
|
413
|
+
ComboboxInput,
|
|
414
|
+
ComboboxItem,
|
|
415
|
+
ComboboxItemIndicator,
|
|
416
|
+
ComboboxItemText,
|
|
417
|
+
ComboboxList,
|
|
418
|
+
ComboboxPopup,
|
|
419
|
+
ComboboxPortal,
|
|
420
|
+
ComboboxPositioner,
|
|
421
|
+
ComboboxRoot,
|
|
422
|
+
ComboboxStatus,
|
|
423
|
+
ComboboxTrigger,
|
|
424
|
+
} from '@lglab/compose-ui/combobox'
|
|
425
|
+
import { FieldLabel, FieldRoot } from '@lglab/compose-ui/field'
|
|
426
|
+
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
|
|
427
|
+
import { useMemo, useRef, useState, useTransition } from 'react'
|
|
428
|
+
|
|
429
|
+
interface DirectoryUser {
|
|
430
|
+
id: string
|
|
431
|
+
name: string
|
|
432
|
+
email: string
|
|
433
|
+
title: string
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export default function AsyncSearchExample() {
|
|
437
|
+
const [searchResults, setSearchResults] = useState<DirectoryUser[]>([])
|
|
438
|
+
const [selectedValue, setSelectedValue] = useState<DirectoryUser | null>(null)
|
|
439
|
+
const [searchValue, setSearchValue] = useState('')
|
|
440
|
+
const [error, setError] = useState<string | null>(null)
|
|
441
|
+
const [isPending, startTransition] = useTransition()
|
|
442
|
+
|
|
443
|
+
const { contains } = Combobox.useFilter()
|
|
444
|
+
|
|
445
|
+
const abortControllerRef = useRef<AbortController | null>(null)
|
|
446
|
+
|
|
447
|
+
const trimmedSearchValue = searchValue.trim()
|
|
448
|
+
|
|
449
|
+
const items = useMemo(() => {
|
|
450
|
+
if (!selectedValue || searchResults.some((user) => user.id === selectedValue.id)) {
|
|
451
|
+
return searchResults
|
|
452
|
+
}
|
|
453
|
+
return [...searchResults, selectedValue]
|
|
454
|
+
}, [searchResults, selectedValue])
|
|
455
|
+
|
|
456
|
+
function getStatus() {
|
|
457
|
+
if (isPending) {
|
|
458
|
+
return (
|
|
459
|
+
<>
|
|
460
|
+
<Loader2 className='size-3 animate-spin' />
|
|
461
|
+
Searching…
|
|
462
|
+
</>
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (error) {
|
|
467
|
+
return error
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (trimmedSearchValue === '') {
|
|
471
|
+
return selectedValue ? null : 'Start typing to search people…'
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (searchResults.length === 0) {
|
|
475
|
+
return `No matches for "${trimmedSearchValue}".`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return null
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function getEmptyMessage() {
|
|
482
|
+
if (trimmedSearchValue === '' || isPending || searchResults.length > 0 || error) {
|
|
483
|
+
return null
|
|
484
|
+
}
|
|
485
|
+
return 'Try a different search term.'
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<FieldRoot className='w-80'>
|
|
490
|
+
<FieldLabel>Assign reviewer</FieldLabel>
|
|
491
|
+
<ComboboxRoot
|
|
492
|
+
items={items}
|
|
493
|
+
itemToStringLabel={(user: DirectoryUser) => user.name}
|
|
494
|
+
filter={null}
|
|
495
|
+
onOpenChangeComplete={(open) => {
|
|
496
|
+
if (!open && selectedValue) {
|
|
497
|
+
setSearchResults([selectedValue])
|
|
498
|
+
}
|
|
499
|
+
}}
|
|
500
|
+
onValueChange={(nextSelectedValue) => {
|
|
501
|
+
setSelectedValue(nextSelectedValue)
|
|
502
|
+
setSearchValue('')
|
|
503
|
+
setError(null)
|
|
504
|
+
}}
|
|
505
|
+
onInputValueChange={(nextSearchValue, { reason }) => {
|
|
506
|
+
setSearchValue(nextSearchValue)
|
|
507
|
+
|
|
508
|
+
if (nextSearchValue === '') {
|
|
509
|
+
setSearchResults([])
|
|
510
|
+
setError(null)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (reason === 'item-press') {
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const controller = new AbortController()
|
|
519
|
+
abortControllerRef.current?.abort()
|
|
520
|
+
abortControllerRef.current = controller
|
|
521
|
+
|
|
522
|
+
startTransition(async () => {
|
|
523
|
+
setError(null)
|
|
524
|
+
|
|
525
|
+
const result = await searchUsers(nextSearchValue, contains)
|
|
526
|
+
|
|
527
|
+
if (controller.signal.aborted) {
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
startTransition(() => {
|
|
532
|
+
setSearchResults(result.users)
|
|
533
|
+
setError(result.error)
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
}}
|
|
537
|
+
>
|
|
538
|
+
<ComboboxControl>
|
|
539
|
+
<ComboboxInput placeholder='e.g. Michael' />
|
|
540
|
+
<ComboboxClear aria-label='Clear selection'>
|
|
541
|
+
<X className='size-4' />
|
|
542
|
+
</ComboboxClear>
|
|
543
|
+
<ComboboxTrigger aria-label='Open popup'>
|
|
544
|
+
<ChevronDown className='size-4' />
|
|
545
|
+
</ComboboxTrigger>
|
|
546
|
+
</ComboboxControl>
|
|
547
|
+
|
|
548
|
+
<ComboboxPortal>
|
|
549
|
+
<ComboboxPositioner>
|
|
550
|
+
<ComboboxPopup aria-busy={isPending || undefined}>
|
|
551
|
+
<ComboboxStatus>{getStatus()}</ComboboxStatus>
|
|
552
|
+
<ComboboxEmpty>{getEmptyMessage()}</ComboboxEmpty>
|
|
553
|
+
<ComboboxList>
|
|
554
|
+
{(user: DirectoryUser) => (
|
|
555
|
+
<ComboboxItem key={user.id} value={user}>
|
|
556
|
+
<div className='flex flex-col gap-0.5'>
|
|
557
|
+
<ComboboxItemText className='font-medium'>
|
|
558
|
+
{user.name}
|
|
559
|
+
</ComboboxItemText>
|
|
560
|
+
<div className='text-xs text-muted-foreground font-medium'>
|
|
561
|
+
{user.title}
|
|
562
|
+
</div>
|
|
563
|
+
<div className='text-xs text-muted-foreground'>{user.email}</div>
|
|
564
|
+
</div>
|
|
565
|
+
<ComboboxItemIndicator>
|
|
566
|
+
<Check className='size-3.5' />
|
|
567
|
+
</ComboboxItemIndicator>
|
|
568
|
+
</ComboboxItem>
|
|
569
|
+
)}
|
|
570
|
+
</ComboboxList>
|
|
571
|
+
</ComboboxPopup>
|
|
572
|
+
</ComboboxPositioner>
|
|
573
|
+
</ComboboxPortal>
|
|
574
|
+
</ComboboxRoot>
|
|
575
|
+
</FieldRoot>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function searchUsers(
|
|
580
|
+
query: string,
|
|
581
|
+
filter: (item: string, query: string) => boolean,
|
|
582
|
+
): Promise<{ users: DirectoryUser[]; error: string | null }> {
|
|
583
|
+
await new Promise((resolve) => {
|
|
584
|
+
setTimeout(resolve, Math.random() * 500 + 100)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
const users = allUsers.filter((user) => {
|
|
588
|
+
return (
|
|
589
|
+
filter(user.name, query) || filter(user.email, query) || filter(user.title, query)
|
|
590
|
+
)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
users,
|
|
595
|
+
error: null,
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const allUsers: DirectoryUser[] = [
|
|
600
|
+
{
|
|
601
|
+
id: 'leslie-alexander',
|
|
602
|
+
name: 'Leslie Alexander',
|
|
603
|
+
email: 'leslie.alexander@example.com',
|
|
604
|
+
title: 'Product Manager',
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
id: 'kathryn-murphy',
|
|
608
|
+
name: 'Kathryn Murphy',
|
|
609
|
+
email: 'kathryn.murphy@example.com',
|
|
610
|
+
title: 'Marketing Lead',
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
id: 'courtney-henry',
|
|
614
|
+
name: 'Courtney Henry',
|
|
615
|
+
email: 'courtney.henry@example.com',
|
|
616
|
+
title: 'Design Systems',
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
id: 'michael-foster',
|
|
620
|
+
name: 'Michael Foster',
|
|
621
|
+
email: 'michael.foster@example.com',
|
|
622
|
+
title: 'Engineering Manager',
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
id: 'lindsay-walton',
|
|
626
|
+
name: 'Lindsay Walton',
|
|
627
|
+
email: 'lindsay.walton@example.com',
|
|
628
|
+
title: 'Product Designer',
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
id: 'tom-cook',
|
|
632
|
+
name: 'Tom Cook',
|
|
633
|
+
email: 'tom.cook@example.com',
|
|
634
|
+
title: 'Frontend Engineer',
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
id: 'whitney-francis',
|
|
638
|
+
name: 'Whitney Francis',
|
|
639
|
+
email: 'whitney.francis@example.com',
|
|
640
|
+
title: 'Customer Success',
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
id: 'jacob-jones',
|
|
644
|
+
name: 'Jacob Jones',
|
|
645
|
+
email: 'jacob.jones@example.com',
|
|
646
|
+
title: 'Security Engineer',
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
id: 'arlene-mccoy',
|
|
650
|
+
name: 'Arlene McCoy',
|
|
651
|
+
email: 'arlene.mccoy@example.com',
|
|
652
|
+
title: 'Data Analyst',
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
id: 'marvin-mckinney',
|
|
656
|
+
name: 'Marvin McKinney',
|
|
657
|
+
email: 'marvin.mckinney@example.com',
|
|
658
|
+
title: 'QA Specialist',
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
id: 'eleanor-pena',
|
|
662
|
+
name: 'Eleanor Pena',
|
|
663
|
+
email: 'eleanor.pena@example.com',
|
|
664
|
+
title: 'Operations',
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
id: 'jerome-bell',
|
|
668
|
+
name: 'Jerome Bell',
|
|
669
|
+
email: 'jerome.bell@example.com',
|
|
670
|
+
title: 'DevOps Engineer',
|
|
671
|
+
},
|
|
672
|
+
]
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### Async search Multiple
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
import {
|
|
679
|
+
Combobox,
|
|
680
|
+
ComboboxChip,
|
|
681
|
+
ComboboxChipRemove,
|
|
682
|
+
ComboboxChips,
|
|
683
|
+
ComboboxEmpty,
|
|
684
|
+
ComboboxInput,
|
|
685
|
+
ComboboxItem,
|
|
686
|
+
ComboboxItemIndicator,
|
|
687
|
+
ComboboxItemText,
|
|
688
|
+
ComboboxList,
|
|
689
|
+
ComboboxPopup,
|
|
690
|
+
ComboboxPortal,
|
|
691
|
+
ComboboxPositioner,
|
|
692
|
+
ComboboxRoot,
|
|
693
|
+
ComboboxStatus,
|
|
694
|
+
ComboboxValue,
|
|
695
|
+
} from '@lglab/compose-ui/combobox'
|
|
696
|
+
import { FieldLabel, FieldRoot } from '@lglab/compose-ui/field'
|
|
697
|
+
import { Check, Loader2, X } from 'lucide-react'
|
|
698
|
+
import { useMemo, useRef, useState, useTransition } from 'react'
|
|
699
|
+
|
|
700
|
+
interface DirectoryUser {
|
|
701
|
+
id: string
|
|
702
|
+
name: string
|
|
703
|
+
email: string
|
|
704
|
+
title: string
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export default function AsyncSearchMultipleExample() {
|
|
708
|
+
const [searchResults, setSearchResults] = useState<DirectoryUser[]>([])
|
|
709
|
+
const [selectedValues, setSelectedValues] = useState<DirectoryUser[]>([])
|
|
710
|
+
const [searchValue, setSearchValue] = useState('')
|
|
711
|
+
const [error, setError] = useState<string | null>(null)
|
|
712
|
+
const [blockStartStatus, setBlockStartStatus] = useState(false)
|
|
713
|
+
const [isPending, startTransition] = useTransition()
|
|
714
|
+
|
|
715
|
+
const { contains } = Combobox.useFilter()
|
|
716
|
+
|
|
717
|
+
const abortControllerRef = useRef<AbortController | null>(null)
|
|
718
|
+
const selectedValuesRef = useRef<DirectoryUser[]>([])
|
|
719
|
+
|
|
720
|
+
const trimmedSearchValue = searchValue.trim()
|
|
721
|
+
|
|
722
|
+
const items = useMemo(() => {
|
|
723
|
+
if (selectedValues.length === 0) {
|
|
724
|
+
return searchResults
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const merged = [...searchResults]
|
|
728
|
+
|
|
729
|
+
selectedValues.forEach((user) => {
|
|
730
|
+
if (!searchResults.some((result) => result.id === user.id)) {
|
|
731
|
+
merged.push(user)
|
|
732
|
+
}
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
return merged
|
|
736
|
+
}, [searchResults, selectedValues])
|
|
737
|
+
|
|
738
|
+
function getStatus() {
|
|
739
|
+
if (isPending) {
|
|
740
|
+
return (
|
|
741
|
+
<>
|
|
742
|
+
<Loader2 className='size-3 animate-spin' />
|
|
743
|
+
Searching…
|
|
744
|
+
</>
|
|
745
|
+
)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (error) {
|
|
749
|
+
return error
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (trimmedSearchValue === '' && !blockStartStatus) {
|
|
753
|
+
return selectedValues.length > 0 ? null : 'Start typing to search people…'
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (searchResults.length === 0 && !blockStartStatus) {
|
|
757
|
+
return `No matches for "${trimmedSearchValue}".`
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return null
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function getEmptyMessage() {
|
|
764
|
+
if (trimmedSearchValue === '' || isPending || searchResults.length > 0 || error) {
|
|
765
|
+
return null
|
|
766
|
+
}
|
|
767
|
+
return 'Try a different search term.'
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return (
|
|
771
|
+
<FieldRoot className='w-80'>
|
|
772
|
+
<FieldLabel>Assign reviewers</FieldLabel>
|
|
773
|
+
<ComboboxRoot
|
|
774
|
+
items={items}
|
|
775
|
+
itemToStringLabel={(user: DirectoryUser) => user.name}
|
|
776
|
+
multiple
|
|
777
|
+
filter={null}
|
|
778
|
+
onOpenChangeComplete={(open) => {
|
|
779
|
+
if (!open) {
|
|
780
|
+
setSearchResults(selectedValuesRef.current)
|
|
781
|
+
setBlockStartStatus(false)
|
|
782
|
+
}
|
|
783
|
+
}}
|
|
784
|
+
onValueChange={(nextSelectedValues) => {
|
|
785
|
+
selectedValuesRef.current = nextSelectedValues
|
|
786
|
+
setSelectedValues(nextSelectedValues)
|
|
787
|
+
setSearchValue('')
|
|
788
|
+
setError(null)
|
|
789
|
+
|
|
790
|
+
if (nextSelectedValues.length === 0) {
|
|
791
|
+
setSearchResults([])
|
|
792
|
+
setBlockStartStatus(false)
|
|
793
|
+
} else {
|
|
794
|
+
setBlockStartStatus(true)
|
|
795
|
+
}
|
|
796
|
+
}}
|
|
797
|
+
onInputValueChange={(nextSearchValue, { reason }) => {
|
|
798
|
+
setSearchValue(nextSearchValue)
|
|
799
|
+
|
|
800
|
+
const controller = new AbortController()
|
|
801
|
+
abortControllerRef.current?.abort()
|
|
802
|
+
abortControllerRef.current = controller
|
|
803
|
+
|
|
804
|
+
if (nextSearchValue === '') {
|
|
805
|
+
setSearchResults(selectedValuesRef.current)
|
|
806
|
+
setError(null)
|
|
807
|
+
setBlockStartStatus(false)
|
|
808
|
+
return
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (reason === 'item-press') {
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
startTransition(async () => {
|
|
816
|
+
setError(null)
|
|
817
|
+
|
|
818
|
+
const result = await searchUsers(nextSearchValue, contains)
|
|
819
|
+
|
|
820
|
+
if (controller.signal.aborted) {
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
startTransition(() => {
|
|
825
|
+
setSearchResults(result.users)
|
|
826
|
+
setError(result.error)
|
|
827
|
+
})
|
|
828
|
+
})
|
|
829
|
+
}}
|
|
830
|
+
>
|
|
831
|
+
<ComboboxChips>
|
|
832
|
+
<ComboboxValue>
|
|
833
|
+
{(value: DirectoryUser[]) => (
|
|
834
|
+
<>
|
|
835
|
+
{value.map((user) => (
|
|
836
|
+
<ComboboxChip key={user.id} aria-label={user.name}>
|
|
837
|
+
{user.name}
|
|
838
|
+
<ComboboxChipRemove aria-label='Remove'>
|
|
839
|
+
<X className='size-3' />
|
|
840
|
+
</ComboboxChipRemove>
|
|
841
|
+
</ComboboxChip>
|
|
842
|
+
))}
|
|
843
|
+
<ComboboxInput placeholder={value.length > 0 ? '' : 'e.g. Michael'} />
|
|
844
|
+
</>
|
|
845
|
+
)}
|
|
846
|
+
</ComboboxValue>
|
|
847
|
+
</ComboboxChips>
|
|
848
|
+
|
|
849
|
+
<ComboboxPortal>
|
|
850
|
+
<ComboboxPositioner>
|
|
851
|
+
<ComboboxPopup aria-busy={isPending || undefined}>
|
|
852
|
+
<ComboboxStatus>{getStatus()}</ComboboxStatus>
|
|
853
|
+
<ComboboxEmpty>{getEmptyMessage()}</ComboboxEmpty>
|
|
854
|
+
<ComboboxList>
|
|
855
|
+
{(user: DirectoryUser) => (
|
|
856
|
+
<ComboboxItem key={user.id} value={user}>
|
|
857
|
+
<div className='flex flex-col gap-0.5'>
|
|
858
|
+
<ComboboxItemText className='font-medium'>
|
|
859
|
+
{user.name}
|
|
860
|
+
</ComboboxItemText>
|
|
861
|
+
<div className='text-xs text-muted-foreground font-medium'>
|
|
862
|
+
{user.title}
|
|
863
|
+
</div>
|
|
864
|
+
<div className='text-xs text-muted-foreground'>{user.email}</div>
|
|
865
|
+
</div>
|
|
866
|
+
<ComboboxItemIndicator>
|
|
867
|
+
<Check className='size-3.5' />
|
|
868
|
+
</ComboboxItemIndicator>
|
|
869
|
+
</ComboboxItem>
|
|
870
|
+
)}
|
|
871
|
+
</ComboboxList>
|
|
872
|
+
</ComboboxPopup>
|
|
873
|
+
</ComboboxPositioner>
|
|
874
|
+
</ComboboxPortal>
|
|
875
|
+
</ComboboxRoot>
|
|
876
|
+
</FieldRoot>
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function searchUsers(
|
|
881
|
+
query: string,
|
|
882
|
+
filter: (item: string, query: string) => boolean,
|
|
883
|
+
): Promise<{ users: DirectoryUser[]; error: string | null }> {
|
|
884
|
+
await new Promise((resolve) => {
|
|
885
|
+
setTimeout(resolve, Math.random() * 500 + 100)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
const users = allUsers.filter((user) => {
|
|
889
|
+
return (
|
|
890
|
+
filter(user.name, query) || filter(user.email, query) || filter(user.title, query)
|
|
891
|
+
)
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
users,
|
|
896
|
+
error: null,
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const allUsers: DirectoryUser[] = [
|
|
901
|
+
{
|
|
902
|
+
id: 'leslie-alexander',
|
|
903
|
+
name: 'Leslie Alexander',
|
|
904
|
+
email: 'leslie.alexander@example.com',
|
|
905
|
+
title: 'Product Manager',
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
id: 'kathryn-murphy',
|
|
909
|
+
name: 'Kathryn Murphy',
|
|
910
|
+
email: 'kathryn.murphy@example.com',
|
|
911
|
+
title: 'Marketing Lead',
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
id: 'courtney-henry',
|
|
915
|
+
name: 'Courtney Henry',
|
|
916
|
+
email: 'courtney.henry@example.com',
|
|
917
|
+
title: 'Design Systems',
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
id: 'michael-foster',
|
|
921
|
+
name: 'Michael Foster',
|
|
922
|
+
email: 'michael.foster@example.com',
|
|
923
|
+
title: 'Engineering Manager',
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
id: 'lindsay-walton',
|
|
927
|
+
name: 'Lindsay Walton',
|
|
928
|
+
email: 'lindsay.walton@example.com',
|
|
929
|
+
title: 'Product Designer',
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
id: 'tom-cook',
|
|
933
|
+
name: 'Tom Cook',
|
|
934
|
+
email: 'tom.cook@example.com',
|
|
935
|
+
title: 'Frontend Engineer',
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
id: 'whitney-francis',
|
|
939
|
+
name: 'Whitney Francis',
|
|
940
|
+
email: 'whitney.francis@example.com',
|
|
941
|
+
title: 'Customer Success',
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
id: 'jacob-jones',
|
|
945
|
+
name: 'Jacob Jones',
|
|
946
|
+
email: 'jacob.jones@example.com',
|
|
947
|
+
title: 'Security Engineer',
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
id: 'arlene-mccoy',
|
|
951
|
+
name: 'Arlene McCoy',
|
|
952
|
+
email: 'arlene.mccoy@example.com',
|
|
953
|
+
title: 'Data Analyst',
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
id: 'marvin-mckinney',
|
|
957
|
+
name: 'Marvin McKinney',
|
|
958
|
+
email: 'marvin.mckinney@example.com',
|
|
959
|
+
title: 'QA Specialist',
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
id: 'eleanor-pena',
|
|
963
|
+
name: 'Eleanor Pena',
|
|
964
|
+
email: 'eleanor.pena@example.com',
|
|
965
|
+
title: 'Operations',
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
id: 'jerome-bell',
|
|
969
|
+
name: 'Jerome Bell',
|
|
970
|
+
email: 'jerome.bell@example.com',
|
|
971
|
+
title: 'DevOps Engineer',
|
|
972
|
+
},
|
|
973
|
+
]
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
### Creatable
|
|
977
|
+
|
|
978
|
+
```tsx
|
|
979
|
+
import { Button } from '@lglab/compose-ui/button'
|
|
980
|
+
import {
|
|
981
|
+
ComboboxChip,
|
|
982
|
+
ComboboxChipRemove,
|
|
983
|
+
ComboboxChips,
|
|
984
|
+
ComboboxEmpty,
|
|
985
|
+
ComboboxInput,
|
|
986
|
+
ComboboxItem,
|
|
987
|
+
ComboboxItemIndicator,
|
|
988
|
+
ComboboxItemText,
|
|
989
|
+
ComboboxList,
|
|
990
|
+
ComboboxPopup,
|
|
991
|
+
ComboboxPortal,
|
|
992
|
+
ComboboxPositioner,
|
|
993
|
+
ComboboxRoot,
|
|
994
|
+
ComboboxValue,
|
|
995
|
+
} from '@lglab/compose-ui/combobox'
|
|
996
|
+
import {
|
|
997
|
+
DialogBackdrop,
|
|
998
|
+
DialogClose,
|
|
999
|
+
DialogDescription,
|
|
1000
|
+
DialogFooter,
|
|
1001
|
+
DialogHeader,
|
|
1002
|
+
DialogPopup,
|
|
1003
|
+
DialogPortal,
|
|
1004
|
+
DialogRoot,
|
|
1005
|
+
DialogTitle,
|
|
1006
|
+
} from '@lglab/compose-ui/dialog'
|
|
1007
|
+
import { FieldControl, FieldLabel, FieldRoot } from '@lglab/compose-ui/field'
|
|
1008
|
+
import { FormRoot } from '@lglab/compose-ui/form'
|
|
1009
|
+
import { Check, Plus, X } from 'lucide-react'
|
|
1010
|
+
import {
|
|
1011
|
+
type ChangeEvent,
|
|
1012
|
+
type FormEvent,
|
|
1013
|
+
type KeyboardEvent,
|
|
1014
|
+
useMemo,
|
|
1015
|
+
useRef,
|
|
1016
|
+
useState,
|
|
1017
|
+
} from 'react'
|
|
1018
|
+
|
|
1019
|
+
const normalize = (str: string) => str.trim().toLocaleLowerCase()
|
|
1020
|
+
|
|
1021
|
+
function generateUniqueId(baseId: string, existingIds: Set<string>): string {
|
|
1022
|
+
if (!existingIds.has(baseId)) return baseId
|
|
1023
|
+
let counter = 2
|
|
1024
|
+
while (existingIds.has(`${baseId}-${counter}`)) counter++
|
|
1025
|
+
return `${baseId}-${counter}`
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
interface LabelItem {
|
|
1029
|
+
creatable?: string
|
|
1030
|
+
id: string
|
|
1031
|
+
value: string
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const initialLabels: LabelItem[] = [
|
|
1035
|
+
{ id: 'bug', value: 'bug' },
|
|
1036
|
+
{ id: 'docs', value: 'documentation' },
|
|
1037
|
+
{ id: 'enhancement', value: 'enhancement' },
|
|
1038
|
+
{ id: 'help-wanted', value: 'help wanted' },
|
|
1039
|
+
{ id: 'good-first-issue', value: 'good first issue' },
|
|
1040
|
+
]
|
|
1041
|
+
|
|
1042
|
+
export default function CreatableExample() {
|
|
1043
|
+
const [labels, setLabels] = useState<LabelItem[]>(initialLabels)
|
|
1044
|
+
const [selected, setSelected] = useState<LabelItem[]>([])
|
|
1045
|
+
const [query, setQuery] = useState('')
|
|
1046
|
+
const [openDialog, setOpenDialog] = useState(false)
|
|
1047
|
+
const [createValue, setCreateValue] = useState('')
|
|
1048
|
+
|
|
1049
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
1050
|
+
const createInputRef = useRef<HTMLInputElement>(null)
|
|
1051
|
+
const comboboxInputRef = useRef<HTMLInputElement>(null)
|
|
1052
|
+
const highlightedItemRef = useRef<LabelItem>(undefined)
|
|
1053
|
+
|
|
1054
|
+
function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
1055
|
+
if (event.key !== 'Enter' || highlightedItemRef.current) {
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const currentTrimmed = query.trim()
|
|
1060
|
+
if (currentTrimmed === '') {
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const normalized = normalize(currentTrimmed)
|
|
1065
|
+
const existing = labels.find((label) => normalize(label.value) === normalized)
|
|
1066
|
+
|
|
1067
|
+
if (existing) {
|
|
1068
|
+
setSelected((prev) =>
|
|
1069
|
+
prev.some((item) => item.id === existing.id) ? prev : [...prev, existing],
|
|
1070
|
+
)
|
|
1071
|
+
setQuery('')
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
setCreateValue(currentTrimmed)
|
|
1076
|
+
setOpenDialog(true)
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function handleCreate() {
|
|
1080
|
+
const value = createValue.trim() || createInputRef.current?.value.trim() || ''
|
|
1081
|
+
if (!value) {
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const normalized = normalize(value)
|
|
1086
|
+
const baseId = normalized.replace(/\s+/g, '-')
|
|
1087
|
+
const existing = labels.find((label) => normalize(label.value) === normalized)
|
|
1088
|
+
|
|
1089
|
+
if (existing) {
|
|
1090
|
+
setSelected((prev) =>
|
|
1091
|
+
prev.some((item) => item.id === existing.id) ? prev : [...prev, existing],
|
|
1092
|
+
)
|
|
1093
|
+
setOpenDialog(false)
|
|
1094
|
+
setQuery('')
|
|
1095
|
+
return
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const existingIds = new Set(labels.map((label) => label.id))
|
|
1099
|
+
const uniqueId = generateUniqueId(baseId, existingIds)
|
|
1100
|
+
const newItem: LabelItem = { id: uniqueId, value }
|
|
1101
|
+
|
|
1102
|
+
if (!selected.find((item) => item.id === newItem.id)) {
|
|
1103
|
+
setLabels((prev) => [...prev, newItem])
|
|
1104
|
+
setSelected((prev) => [...prev, newItem])
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
setOpenDialog(false)
|
|
1108
|
+
setQuery('')
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function handleCreateSubmit(event: FormEvent<HTMLFormElement>) {
|
|
1112
|
+
event.preventDefault()
|
|
1113
|
+
handleCreate()
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const itemsForView = useMemo(() => {
|
|
1117
|
+
const trimmed = query.trim()
|
|
1118
|
+
const lowered = normalize(trimmed)
|
|
1119
|
+
const exactExists = labels.some((label) => normalize(label.value) === lowered)
|
|
1120
|
+
|
|
1121
|
+
if (trimmed !== '' && !exactExists) {
|
|
1122
|
+
return [
|
|
1123
|
+
...labels,
|
|
1124
|
+
{
|
|
1125
|
+
creatable: trimmed,
|
|
1126
|
+
id: `create:${lowered}`,
|
|
1127
|
+
value: `Create "${trimmed}"`,
|
|
1128
|
+
},
|
|
1129
|
+
]
|
|
1130
|
+
}
|
|
1131
|
+
return labels
|
|
1132
|
+
}, [query, labels])
|
|
1133
|
+
|
|
1134
|
+
return (
|
|
1135
|
+
<>
|
|
1136
|
+
<FieldRoot>
|
|
1137
|
+
<FieldLabel>Labels</FieldLabel>
|
|
1138
|
+
<ComboboxRoot
|
|
1139
|
+
items={itemsForView}
|
|
1140
|
+
multiple
|
|
1141
|
+
onValueChange={(next) => {
|
|
1142
|
+
const creatableSelection = next.find(
|
|
1143
|
+
(item) =>
|
|
1144
|
+
item.creatable && !selected.some((current) => current.id === item.id),
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
if (creatableSelection && creatableSelection.creatable) {
|
|
1148
|
+
setCreateValue(creatableSelection.creatable)
|
|
1149
|
+
setOpenDialog(true)
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
const clean = next.filter((item) => !item.creatable)
|
|
1153
|
+
setSelected(clean)
|
|
1154
|
+
setQuery('')
|
|
1155
|
+
}}
|
|
1156
|
+
value={selected}
|
|
1157
|
+
inputValue={query}
|
|
1158
|
+
onInputValueChange={setQuery}
|
|
1159
|
+
onItemHighlighted={(item) => {
|
|
1160
|
+
highlightedItemRef.current = item
|
|
1161
|
+
}}
|
|
1162
|
+
>
|
|
1163
|
+
<ComboboxChips ref={containerRef} className='max-w-xs'>
|
|
1164
|
+
<ComboboxValue>
|
|
1165
|
+
{(value: LabelItem[]) => (
|
|
1166
|
+
<>
|
|
1167
|
+
{value.map((label) => (
|
|
1168
|
+
<ComboboxChip key={label.id} aria-label={label.value}>
|
|
1169
|
+
{label.value}
|
|
1170
|
+
<ComboboxChipRemove aria-label='Remove'>
|
|
1171
|
+
<X className='size-3' />
|
|
1172
|
+
</ComboboxChipRemove>
|
|
1173
|
+
</ComboboxChip>
|
|
1174
|
+
))}
|
|
1175
|
+
<ComboboxInput
|
|
1176
|
+
ref={comboboxInputRef}
|
|
1177
|
+
placeholder={value.length > 0 ? '' : 'e.g. bug'}
|
|
1178
|
+
onKeyDown={handleInputKeyDown}
|
|
1179
|
+
/>
|
|
1180
|
+
</>
|
|
1181
|
+
)}
|
|
1182
|
+
</ComboboxValue>
|
|
1183
|
+
</ComboboxChips>
|
|
1184
|
+
<ComboboxPortal>
|
|
1185
|
+
<ComboboxPositioner sideOffset={4} anchor={containerRef}>
|
|
1186
|
+
<ComboboxPopup>
|
|
1187
|
+
<ComboboxEmpty>No labels found.</ComboboxEmpty>
|
|
1188
|
+
<ComboboxList>
|
|
1189
|
+
{(item: LabelItem) =>
|
|
1190
|
+
item.creatable ? (
|
|
1191
|
+
<ComboboxItem key={item.id} value={item}>
|
|
1192
|
+
<span className='flex items-center gap-2'>
|
|
1193
|
+
<Plus className='size-3' />
|
|
1194
|
+
<ComboboxItemText>Create {item.creatable}</ComboboxItemText>
|
|
1195
|
+
</span>
|
|
1196
|
+
</ComboboxItem>
|
|
1197
|
+
) : (
|
|
1198
|
+
<ComboboxItem key={item.id} value={item}>
|
|
1199
|
+
<ComboboxItemText>{item.value}</ComboboxItemText>
|
|
1200
|
+
<ComboboxItemIndicator>
|
|
1201
|
+
<Check className='size-3.5' />
|
|
1202
|
+
</ComboboxItemIndicator>
|
|
1203
|
+
</ComboboxItem>
|
|
1204
|
+
)
|
|
1205
|
+
}
|
|
1206
|
+
</ComboboxList>
|
|
1207
|
+
</ComboboxPopup>
|
|
1208
|
+
</ComboboxPositioner>
|
|
1209
|
+
</ComboboxPortal>
|
|
1210
|
+
</ComboboxRoot>
|
|
1211
|
+
</FieldRoot>
|
|
1212
|
+
|
|
1213
|
+
<DialogRoot
|
|
1214
|
+
open={openDialog}
|
|
1215
|
+
onOpenChange={(open) => {
|
|
1216
|
+
setOpenDialog(open)
|
|
1217
|
+
if (!open) setCreateValue('')
|
|
1218
|
+
}}
|
|
1219
|
+
>
|
|
1220
|
+
<DialogPortal>
|
|
1221
|
+
<DialogBackdrop />
|
|
1222
|
+
<DialogPopup size='sm' initialFocus={createInputRef}>
|
|
1223
|
+
<DialogHeader>
|
|
1224
|
+
<DialogTitle>Create new label</DialogTitle>
|
|
1225
|
+
<DialogDescription>Add a new label to select.</DialogDescription>
|
|
1226
|
+
</DialogHeader>
|
|
1227
|
+
<FormRoot onSubmit={handleCreateSubmit}>
|
|
1228
|
+
<FieldRoot name='labelName'>
|
|
1229
|
+
<FieldLabel>Label name</FieldLabel>
|
|
1230
|
+
<FieldControl
|
|
1231
|
+
ref={createInputRef}
|
|
1232
|
+
placeholder='Label name'
|
|
1233
|
+
value={createValue}
|
|
1234
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
1235
|
+
setCreateValue(e.target.value)
|
|
1236
|
+
}
|
|
1237
|
+
/>
|
|
1238
|
+
</FieldRoot>
|
|
1239
|
+
<DialogFooter>
|
|
1240
|
+
<DialogClose variant='ghost'>Cancel</DialogClose>
|
|
1241
|
+
<Button type='submit'>Create</Button>
|
|
1242
|
+
</DialogFooter>
|
|
1243
|
+
</FormRoot>
|
|
1244
|
+
</DialogPopup>
|
|
1245
|
+
</DialogPortal>
|
|
1246
|
+
</DialogRoot>
|
|
1247
|
+
</>
|
|
1248
|
+
)
|
|
1249
|
+
}
|
|
1250
|
+
```
|
|
1251
|
+
|
|
1252
|
+
## Resources
|
|
1253
|
+
|
|
1254
|
+
- [Base UI Combobox Documentation](https://base-ui.com/react/components/combobox)
|
|
1255
|
+
- [API Reference](https://base-ui.com/react/components/combobox#api-reference)
|