@xyhp915/slack-base-ui 0.0.6 → 0.0.7

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@xyhp915/slack-base-ui",
3
3
  "main": "libs/index.js",
4
4
  "types": "libs/index.d.ts",
5
- "version": "0.0.6",
5
+ "version": "0.0.7",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "dev": "vite",
@@ -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
+