@xyhp915/slack-base-ui 0.0.6 → 0.0.8
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/libs/Combobox.d.ts +77 -0
- package/libs/Combobox.d.ts.map +1 -0
- package/libs/Dropdown.d.ts +177 -0
- package/libs/Dropdown.d.ts.map +1 -0
- package/libs/index.d.ts +4 -0
- package/libs/index.d.ts.map +1 -1
- package/libs/index.js +9363 -7095
- package/package.json +1 -1
- package/src/components/Combobox.tsx +441 -0
- package/src/components/Dropdown.tsx +520 -0
- package/src/components/Select.tsx +30 -4
- package/src/components/index.ts +38 -0
- package/src/main.tsx +4 -1
- package/src/pages/ComponentShowcase.tsx +324 -0
package/package.json
CHANGED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Combobox as BaseCombobox } from '@base-ui/react'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { Check, ChevronDown, X } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface ComboboxOption {
|
|
9
|
+
value: string
|
|
10
|
+
label: string
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ComboboxGroup {
|
|
15
|
+
label: string
|
|
16
|
+
options: ComboboxOption[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ComboboxProps {
|
|
20
|
+
/** Flat option list */
|
|
21
|
+
options?: ComboboxOption[]
|
|
22
|
+
/** Grouped option list (mutually exclusive with `options`) */
|
|
23
|
+
groups?: ComboboxGroup[]
|
|
24
|
+
/** Controlled selected value */
|
|
25
|
+
value?: ComboboxOption | null
|
|
26
|
+
/** Initial selected value (uncontrolled) */
|
|
27
|
+
defaultValue?: ComboboxOption | null
|
|
28
|
+
/** Callback when the selected value changes */
|
|
29
|
+
onValueChange?: (value: ComboboxOption | null) => void
|
|
30
|
+
/** Placeholder text shown in the input */
|
|
31
|
+
placeholder?: string
|
|
32
|
+
/** Whether the combobox is disabled */
|
|
33
|
+
disabled?: boolean
|
|
34
|
+
/** Whether a value is required */
|
|
35
|
+
required?: boolean
|
|
36
|
+
/** Label shown above the input */
|
|
37
|
+
label?: string
|
|
38
|
+
/** Error message shown below the input */
|
|
39
|
+
error?: string
|
|
40
|
+
/** Whether the combobox takes full width */
|
|
41
|
+
fullWidth?: boolean
|
|
42
|
+
/** Whether the user can clear the selected value */
|
|
43
|
+
clearable?: boolean
|
|
44
|
+
/** No-results message */
|
|
45
|
+
emptyMessage?: string
|
|
46
|
+
/** Additional CSS class on the outermost container */
|
|
47
|
+
className?: string
|
|
48
|
+
/** HTML id forwarded to the input */
|
|
49
|
+
id?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ComboboxMultipleProps {
|
|
53
|
+
/** Flat option list */
|
|
54
|
+
options?: ComboboxOption[]
|
|
55
|
+
/** Grouped option list */
|
|
56
|
+
groups?: ComboboxGroup[]
|
|
57
|
+
/** Controlled selected values */
|
|
58
|
+
value?: ComboboxOption[]
|
|
59
|
+
/** Initial selected values (uncontrolled) */
|
|
60
|
+
defaultValue?: ComboboxOption[]
|
|
61
|
+
/** Callback when selected values change */
|
|
62
|
+
onValueChange?: (value: ComboboxOption[]) => void
|
|
63
|
+
/** Placeholder text shown in the input */
|
|
64
|
+
placeholder?: string
|
|
65
|
+
/** Whether the combobox is disabled */
|
|
66
|
+
disabled?: boolean
|
|
67
|
+
/** Whether a value is required */
|
|
68
|
+
required?: boolean
|
|
69
|
+
/** Label shown above the input */
|
|
70
|
+
label?: string
|
|
71
|
+
/** Error message shown below the input */
|
|
72
|
+
error?: string
|
|
73
|
+
/** Whether the combobox takes full width */
|
|
74
|
+
fullWidth?: boolean
|
|
75
|
+
/** Whether the user can clear all selected values */
|
|
76
|
+
clearable?: boolean
|
|
77
|
+
/** No-results message */
|
|
78
|
+
emptyMessage?: string
|
|
79
|
+
/** Additional CSS class on the outermost container */
|
|
80
|
+
className?: string
|
|
81
|
+
/** HTML id forwarded to the input */
|
|
82
|
+
id?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Shared styles ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const inputGroupStyles = clsx(
|
|
88
|
+
'inline-flex w-full items-center rounded-md border',
|
|
89
|
+
'bg-(--bg-primary) text-(--text-primary)',
|
|
90
|
+
'transition-[border-color,box-shadow] outline-none',
|
|
91
|
+
'has-[:focus]:border-(--accent)/70',
|
|
92
|
+
'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const inputStyles = clsx(
|
|
96
|
+
'flex-1 min-w-0 appearance-none bg-transparent text-[14px] leading-[1.4]',
|
|
97
|
+
'px-3 py-2 text-(--text-primary) placeholder-(--text-muted)',
|
|
98
|
+
'outline-none disabled:cursor-not-allowed',
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const popupStyles = clsx(
|
|
102
|
+
'min-w-[var(--anchor-width)] overflow-auto rounded-lg border border-(--border-light) bg-(--bg-primary)',
|
|
103
|
+
'py-1 shadow-lg',
|
|
104
|
+
'origin-[var(--transform-origin)]',
|
|
105
|
+
'transition-[transform,scale,opacity]',
|
|
106
|
+
'data-[starting-style]:scale-95 data-[starting-style]:opacity-0',
|
|
107
|
+
'data-[ending-style]:scale-95 data-[ending-style]:opacity-0',
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const itemStyles = clsx(
|
|
111
|
+
'relative flex cursor-default select-none items-center rounded px-3 py-1.5 text-[14px] text-(--text-primary) mx-1',
|
|
112
|
+
'data-[highlighted]:bg-(--bg-hover) data-[highlighted]:outline-none',
|
|
113
|
+
'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// ── Single-select Combobox ─────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(
|
|
119
|
+
(
|
|
120
|
+
{
|
|
121
|
+
options,
|
|
122
|
+
groups,
|
|
123
|
+
value,
|
|
124
|
+
defaultValue,
|
|
125
|
+
onValueChange,
|
|
126
|
+
placeholder = 'Search…',
|
|
127
|
+
disabled,
|
|
128
|
+
required,
|
|
129
|
+
label,
|
|
130
|
+
error,
|
|
131
|
+
fullWidth,
|
|
132
|
+
clearable = true,
|
|
133
|
+
emptyMessage = 'No results found',
|
|
134
|
+
className,
|
|
135
|
+
id,
|
|
136
|
+
},
|
|
137
|
+
ref,
|
|
138
|
+
) => {
|
|
139
|
+
const generatedId = React.useId()
|
|
140
|
+
const inputId = id ?? generatedId
|
|
141
|
+
|
|
142
|
+
// Build the flat items array that Base UI needs for filtering
|
|
143
|
+
const items = React.useMemo<ComboboxOption[]>(() => {
|
|
144
|
+
if (groups) return groups.flatMap((g) => g.options)
|
|
145
|
+
return options ?? []
|
|
146
|
+
}, [options, groups])
|
|
147
|
+
|
|
148
|
+
// Build grouped items when `groups` is provided
|
|
149
|
+
const groupedItems = React.useMemo(() => {
|
|
150
|
+
if (!groups) return undefined
|
|
151
|
+
return groups.map((g) => ({
|
|
152
|
+
value: g.label,
|
|
153
|
+
items: g.options,
|
|
154
|
+
}))
|
|
155
|
+
}, [groups])
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full', className)}>
|
|
159
|
+
{label && (
|
|
160
|
+
<label htmlFor={inputId} className="text-[14px] font-semibold text-(--text-primary)">
|
|
161
|
+
{label}
|
|
162
|
+
{required && <span className="ml-0.5 text-(--danger)">*</span>}
|
|
163
|
+
</label>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<BaseCombobox.Root
|
|
167
|
+
value={value}
|
|
168
|
+
defaultValue={defaultValue}
|
|
169
|
+
onValueChange={
|
|
170
|
+
onValueChange
|
|
171
|
+
? (v: ComboboxOption | null) => onValueChange(v)
|
|
172
|
+
: undefined
|
|
173
|
+
}
|
|
174
|
+
items={groupedItems ?? items}
|
|
175
|
+
disabled={disabled}
|
|
176
|
+
required={required}
|
|
177
|
+
>
|
|
178
|
+
{/* Input row — wraps input + action buttons */}
|
|
179
|
+
<div
|
|
180
|
+
className={clsx(
|
|
181
|
+
inputGroupStyles,
|
|
182
|
+
error
|
|
183
|
+
? 'border-(--danger) has-[:focus]:border-(--danger) has-[:focus]:shadow-[0_0_0_2px_var(--danger)]'
|
|
184
|
+
: 'border-(--border-light) hover:border-(--text-primary)/30',
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
<BaseCombobox.Input
|
|
188
|
+
ref={ref}
|
|
189
|
+
id={inputId}
|
|
190
|
+
placeholder={placeholder}
|
|
191
|
+
className={inputStyles}
|
|
192
|
+
/>
|
|
193
|
+
<div className="flex items-center pr-2 gap-0.5 shrink-0">
|
|
194
|
+
{clearable && (
|
|
195
|
+
<BaseCombobox.Clear
|
|
196
|
+
aria-label="Clear"
|
|
197
|
+
className={clsx(
|
|
198
|
+
'p-0.5 rounded text-(--text-muted) hover:text-(--text-primary)',
|
|
199
|
+
'transition-colors cursor-pointer',
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
<X size={14} />
|
|
203
|
+
</BaseCombobox.Clear>
|
|
204
|
+
)}
|
|
205
|
+
<BaseCombobox.Trigger
|
|
206
|
+
aria-label="Open"
|
|
207
|
+
className="p-0.5 rounded text-(--text-muted) hover:text-(--text-primary) transition-colors cursor-pointer"
|
|
208
|
+
>
|
|
209
|
+
<ChevronDown size={16} />
|
|
210
|
+
</BaseCombobox.Trigger>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<BaseCombobox.Portal>
|
|
215
|
+
<BaseCombobox.Positioner sideOffset={4} className="z-50">
|
|
216
|
+
<BaseCombobox.Popup className={popupStyles}>
|
|
217
|
+
<BaseCombobox.Empty className="px-3 py-2 text-[13px] text-(--text-muted)">
|
|
218
|
+
{emptyMessage}
|
|
219
|
+
</BaseCombobox.Empty>
|
|
220
|
+
|
|
221
|
+
<BaseCombobox.List className="outline-none max-h-60 overflow-y-auto">
|
|
222
|
+
{groups
|
|
223
|
+
? (group: { value: string; items: ComboboxOption[] }) => (
|
|
224
|
+
<BaseCombobox.Group key={group.value}>
|
|
225
|
+
<BaseCombobox.GroupLabel className="px-3 py-1 text-[12px] font-semibold text-(--text-muted) uppercase tracking-wider">
|
|
226
|
+
{group.value}
|
|
227
|
+
</BaseCombobox.GroupLabel>
|
|
228
|
+
{group.items.map((opt) => (
|
|
229
|
+
<ComboboxItem key={opt.value} option={opt} />
|
|
230
|
+
))}
|
|
231
|
+
</BaseCombobox.Group>
|
|
232
|
+
)
|
|
233
|
+
: (item: ComboboxOption) => (
|
|
234
|
+
<ComboboxItem key={item.value} option={item} />
|
|
235
|
+
)}
|
|
236
|
+
</BaseCombobox.List>
|
|
237
|
+
</BaseCombobox.Popup>
|
|
238
|
+
</BaseCombobox.Positioner>
|
|
239
|
+
</BaseCombobox.Portal>
|
|
240
|
+
</BaseCombobox.Root>
|
|
241
|
+
|
|
242
|
+
{error && (
|
|
243
|
+
<span className="flex items-center gap-1 text-[12px] font-medium leading-tight text-(--danger)">
|
|
244
|
+
⚠️ {error}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
Combobox.displayName = 'Combobox'
|
|
253
|
+
|
|
254
|
+
// ── Multiple-select Combobox ──────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export const ComboboxMultiple = React.forwardRef<HTMLInputElement, ComboboxMultipleProps>(
|
|
257
|
+
(
|
|
258
|
+
{
|
|
259
|
+
options,
|
|
260
|
+
groups,
|
|
261
|
+
value: valueProp,
|
|
262
|
+
defaultValue,
|
|
263
|
+
onValueChange,
|
|
264
|
+
placeholder = 'Search…',
|
|
265
|
+
disabled,
|
|
266
|
+
required,
|
|
267
|
+
label,
|
|
268
|
+
error,
|
|
269
|
+
fullWidth,
|
|
270
|
+
clearable = true,
|
|
271
|
+
emptyMessage = 'No results found',
|
|
272
|
+
className,
|
|
273
|
+
id,
|
|
274
|
+
},
|
|
275
|
+
ref,
|
|
276
|
+
) => {
|
|
277
|
+
const generatedId = React.useId()
|
|
278
|
+
const inputId = id ?? generatedId
|
|
279
|
+
|
|
280
|
+
// Track selected values internally so we can render chips correctly
|
|
281
|
+
const [internalValue, setInternalValue] = React.useState<ComboboxOption[]>(
|
|
282
|
+
defaultValue ?? [],
|
|
283
|
+
)
|
|
284
|
+
const selectedValues = valueProp ?? internalValue
|
|
285
|
+
|
|
286
|
+
const handleValueChange = React.useCallback(
|
|
287
|
+
(v: ComboboxOption[]) => {
|
|
288
|
+
if (valueProp === undefined) {
|
|
289
|
+
setInternalValue(v)
|
|
290
|
+
}
|
|
291
|
+
onValueChange?.(v)
|
|
292
|
+
},
|
|
293
|
+
[valueProp, onValueChange],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const items = React.useMemo<ComboboxOption[]>(() => {
|
|
297
|
+
if (groups) return groups.flatMap((g) => g.options)
|
|
298
|
+
return options ?? []
|
|
299
|
+
}, [options, groups])
|
|
300
|
+
|
|
301
|
+
const groupedItems = React.useMemo(() => {
|
|
302
|
+
if (!groups) return undefined
|
|
303
|
+
return groups.map((g) => ({
|
|
304
|
+
value: g.label,
|
|
305
|
+
items: g.options,
|
|
306
|
+
}))
|
|
307
|
+
}, [groups])
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full', className)}>
|
|
311
|
+
{label && (
|
|
312
|
+
<label htmlFor={inputId} className="text-[14px] font-semibold text-(--text-primary)">
|
|
313
|
+
{label}
|
|
314
|
+
{required && <span className="ml-0.5 text-(--danger)">*</span>}
|
|
315
|
+
</label>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
<BaseCombobox.Root
|
|
319
|
+
multiple
|
|
320
|
+
value={selectedValues}
|
|
321
|
+
onValueChange={handleValueChange}
|
|
322
|
+
items={groupedItems ?? items}
|
|
323
|
+
disabled={disabled}
|
|
324
|
+
required={required}
|
|
325
|
+
>
|
|
326
|
+
{/* Input row with chips */}
|
|
327
|
+
<div
|
|
328
|
+
className={clsx(
|
|
329
|
+
inputGroupStyles,
|
|
330
|
+
'flex-wrap gap-1 py-1',
|
|
331
|
+
error
|
|
332
|
+
? 'border-(--danger) has-[:focus]:border-(--danger) has-[:focus]:shadow-[0_0_0_2px_var(--danger)]'
|
|
333
|
+
: 'border-(--border-light) hover:border-(--text-primary)/30',
|
|
334
|
+
)}
|
|
335
|
+
>
|
|
336
|
+
{selectedValues.length > 0 && (
|
|
337
|
+
<BaseCombobox.Chips className="flex flex-wrap gap-1 pl-2">
|
|
338
|
+
{selectedValues.map((opt) => (
|
|
339
|
+
<BaseCombobox.Chip
|
|
340
|
+
key={opt.value}
|
|
341
|
+
className={clsx(
|
|
342
|
+
'inline-flex items-center gap-1 rounded-md px-2 py-0.5',
|
|
343
|
+
'bg-(--bg-hover) text-(--text-primary) text-[12px] font-medium',
|
|
344
|
+
'border border-(--border-light)',
|
|
345
|
+
)}
|
|
346
|
+
>
|
|
347
|
+
{opt.label}
|
|
348
|
+
<BaseCombobox.ChipRemove
|
|
349
|
+
className="rounded-sm p-0.5 hover:bg-(--bg-secondary) text-(--text-muted) hover:text-(--text-primary) transition-colors cursor-pointer"
|
|
350
|
+
aria-label={`Remove ${opt.label}`}
|
|
351
|
+
>
|
|
352
|
+
<X size={12} />
|
|
353
|
+
</BaseCombobox.ChipRemove>
|
|
354
|
+
</BaseCombobox.Chip>
|
|
355
|
+
))}
|
|
356
|
+
</BaseCombobox.Chips>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
<BaseCombobox.Input
|
|
360
|
+
ref={ref}
|
|
361
|
+
id={inputId}
|
|
362
|
+
placeholder={placeholder}
|
|
363
|
+
className={clsx(inputStyles, 'py-1.5')}
|
|
364
|
+
/>
|
|
365
|
+
|
|
366
|
+
<div className="flex items-center pr-2 gap-0.5 shrink-0">
|
|
367
|
+
{clearable && (
|
|
368
|
+
<BaseCombobox.Clear
|
|
369
|
+
aria-label="Clear all"
|
|
370
|
+
className={clsx(
|
|
371
|
+
'p-0.5 rounded text-(--text-muted) hover:text-(--text-primary)',
|
|
372
|
+
'transition-colors cursor-pointer',
|
|
373
|
+
)}
|
|
374
|
+
>
|
|
375
|
+
<X size={14} />
|
|
376
|
+
</BaseCombobox.Clear>
|
|
377
|
+
)}
|
|
378
|
+
<BaseCombobox.Trigger
|
|
379
|
+
aria-label="Open"
|
|
380
|
+
className="p-0.5 rounded text-(--text-muted) hover:text-(--text-primary) transition-colors cursor-pointer"
|
|
381
|
+
>
|
|
382
|
+
<ChevronDown size={16} />
|
|
383
|
+
</BaseCombobox.Trigger>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<BaseCombobox.Portal>
|
|
388
|
+
<BaseCombobox.Positioner sideOffset={4} className="z-50">
|
|
389
|
+
<BaseCombobox.Popup className={popupStyles}>
|
|
390
|
+
<BaseCombobox.Empty className="px-3 py-2 text-[13px] text-(--text-muted)">
|
|
391
|
+
{emptyMessage}
|
|
392
|
+
</BaseCombobox.Empty>
|
|
393
|
+
|
|
394
|
+
<BaseCombobox.List className="outline-none max-h-60 overflow-y-auto">
|
|
395
|
+
{groups
|
|
396
|
+
? (group: { value: string; items: ComboboxOption[] }) => (
|
|
397
|
+
<BaseCombobox.Group key={group.value}>
|
|
398
|
+
<BaseCombobox.GroupLabel className="px-3 py-1 text-[12px] font-semibold text-(--text-muted) uppercase tracking-wider">
|
|
399
|
+
{group.value}
|
|
400
|
+
</BaseCombobox.GroupLabel>
|
|
401
|
+
{group.items.map((opt) => (
|
|
402
|
+
<ComboboxItem key={opt.value} option={opt} />
|
|
403
|
+
))}
|
|
404
|
+
</BaseCombobox.Group>
|
|
405
|
+
)
|
|
406
|
+
: (item: ComboboxOption) => (
|
|
407
|
+
<ComboboxItem key={item.value} option={item} />
|
|
408
|
+
)}
|
|
409
|
+
</BaseCombobox.List>
|
|
410
|
+
</BaseCombobox.Popup>
|
|
411
|
+
</BaseCombobox.Positioner>
|
|
412
|
+
</BaseCombobox.Portal>
|
|
413
|
+
</BaseCombobox.Root>
|
|
414
|
+
|
|
415
|
+
{error && (
|
|
416
|
+
<span className="flex items-center gap-1 text-[12px] font-medium leading-tight text-(--danger)">
|
|
417
|
+
⚠️ {error}
|
|
418
|
+
</span>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
)
|
|
422
|
+
},
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
ComboboxMultiple.displayName = 'ComboboxMultiple'
|
|
426
|
+
|
|
427
|
+
// ── Internal helpers ──────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/** Renders a single selectable option inside the popup list. */
|
|
430
|
+
function ComboboxItem({ option }: { option: ComboboxOption }) {
|
|
431
|
+
return (
|
|
432
|
+
<BaseCombobox.Item value={option} disabled={option.disabled} className={itemStyles}>
|
|
433
|
+
<BaseCombobox.ItemIndicator className="absolute right-3 flex items-center text-(--accent)">
|
|
434
|
+
<Check size={14} />
|
|
435
|
+
</BaseCombobox.ItemIndicator>
|
|
436
|
+
<span className="flex-1">{option.label}</span>
|
|
437
|
+
</BaseCombobox.Item>
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|