@xyhp915/slack-base-ui 0.0.5 → 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.5",
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
+
@@ -10,7 +10,7 @@ import React, {
10
10
  import { X } from 'lucide-react'
11
11
  import { Button } from './Button'
12
12
 
13
- export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
13
+ export type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
14
14
 
15
15
  export interface DialogProps {
16
16
  open?: boolean;
@@ -29,7 +29,7 @@ export const Dialog = ({
29
29
  title,
30
30
  description,
31
31
  children,
32
- size = 'md',
32
+ size = 'xl',
33
33
  showCloseButton = true,
34
34
  className,
35
35
  }: DialogProps) => {
@@ -38,6 +38,9 @@ export const Dialog = ({
38
38
  md: 'max-w-md',
39
39
  lg: 'max-w-lg',
40
40
  xl: 'max-w-xl',
41
+ '2xl': 'max-w-2xl',
42
+ '3xl': 'max-w-3xl',
43
+ '4xl': 'max-w-4xl',
41
44
  }
42
45
 
43
46
  return (
@@ -242,27 +245,27 @@ export const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ childr
242
245
  }, [])
243
246
 
244
247
  const value = useMemo<UseDialogReturn>(
245
- () => ({ show, confirm, alert }),
246
- [show, confirm, alert],
248
+ () => ({ show, confirm, alert }),
249
+ [show, confirm, alert],
247
250
  )
248
251
 
249
252
  return (
250
- <DialogImperativeContext.Provider value={value}>
251
- {children}
252
- {dialogs.map(dialog => (
253
- <ImperativeDialogItem
254
- key={dialog.id}
255
- dialog={dialog}
256
- onRemove={() => removeDialog(dialog.id)}
257
- />
258
- ))}
259
- </DialogImperativeContext.Provider>
253
+ <DialogImperativeContext.Provider value={value}>
254
+ {children}
255
+ {dialogs.map(dialog => (
256
+ <ImperativeDialogItem
257
+ key={dialog.id}
258
+ dialog={dialog}
259
+ onRemove={() => removeDialog(dialog.id)}
260
+ />
261
+ ))}
262
+ </DialogImperativeContext.Provider>
260
263
  )
261
264
  }
262
265
 
263
266
  DialogProvider.displayName = 'DialogProvider'
264
267
 
265
- function ImperativeDialogItem({
268
+ function ImperativeDialogItem ({
266
269
  dialog,
267
270
  onRemove,
268
271
  }: {
@@ -272,13 +275,13 @@ function ImperativeDialogItem({
272
275
  const [open, setOpen] = useState(true)
273
276
 
274
277
  const handleClose = useCallback(
275
- (result: boolean) => {
276
- setOpen(false)
277
- dialog.resolve(result)
278
- // Allow close animation to finish before unmounting
279
- setTimeout(onRemove, 300)
280
- },
281
- [dialog, onRemove],
278
+ (result: boolean) => {
279
+ setOpen(false)
280
+ dialog.resolve(result)
281
+ // Allow close animation to finish before unmounting
282
+ setTimeout(onRemove, 300)
283
+ },
284
+ [dialog, onRemove],
282
285
  )
283
286
 
284
287
  const commonProps = {
@@ -291,55 +294,55 @@ function ImperativeDialogItem({
291
294
 
292
295
  if (dialog.type === 'show') {
293
296
  return (
294
- <Dialog
295
- {...commonProps}
296
- showCloseButton={dialog.showCloseButton ?? true}
297
- onOpenChange={o => !o && handleClose(false)}
298
- >
299
- {dialog.content}
300
- </Dialog>
297
+ <Dialog
298
+ {...commonProps}
299
+ showCloseButton={dialog.showCloseButton ?? true}
300
+ onOpenChange={o => !o && handleClose(false)}
301
+ >
302
+ {dialog.content}
303
+ </Dialog>
301
304
  )
302
305
  }
303
306
 
304
307
  if (dialog.type === 'confirm') {
305
308
  return (
309
+ <Dialog
310
+ {...commonProps}
311
+ size={dialog.size ?? 'sm'}
312
+ showCloseButton={false}
313
+ onOpenChange={o => !o && handleClose(false)}
314
+ >
315
+ {dialog.content}
316
+ <DialogFooter>
317
+ <Button variant="secondary" onClick={() => handleClose(false)}>
318
+ {dialog.cancelLabel ?? 'Cancel'}
319
+ </Button>
320
+ <Button
321
+ variant={dialog.confirmVariant ?? 'primary'}
322
+ onClick={() => handleClose(true)}
323
+ >
324
+ {dialog.confirmLabel ?? 'Confirm'}
325
+ </Button>
326
+ </DialogFooter>
327
+ </Dialog>
328
+ )
329
+ }
330
+
331
+ // alert
332
+ return (
306
333
  <Dialog
307
- {...commonProps}
308
- size={dialog.size ?? 'sm'}
309
- showCloseButton={false}
310
- onOpenChange={o => !o && handleClose(false)}
334
+ {...commonProps}
335
+ size={dialog.size ?? 'sm'}
336
+ showCloseButton={false}
337
+ onOpenChange={o => !o && handleClose(true)}
311
338
  >
312
339
  {dialog.content}
313
340
  <DialogFooter>
314
- <Button variant="secondary" onClick={() => handleClose(false)}>
315
- {dialog.cancelLabel ?? 'Cancel'}
316
- </Button>
317
- <Button
318
- variant={dialog.confirmVariant ?? 'primary'}
319
- onClick={() => handleClose(true)}
320
- >
321
- {dialog.confirmLabel ?? 'Confirm'}
341
+ <Button variant="primary" onClick={() => handleClose(true)}>
342
+ {dialog.confirmLabel ?? 'OK'}
322
343
  </Button>
323
344
  </DialogFooter>
324
345
  </Dialog>
325
- )
326
- }
327
-
328
- // alert
329
- return (
330
- <Dialog
331
- {...commonProps}
332
- size={dialog.size ?? 'sm'}
333
- showCloseButton={false}
334
- onOpenChange={o => !o && handleClose(true)}
335
- >
336
- {dialog.content}
337
- <DialogFooter>
338
- <Button variant="primary" onClick={() => handleClose(true)}>
339
- {dialog.confirmLabel ?? 'OK'}
340
- </Button>
341
- </DialogFooter>
342
- </Dialog>
343
346
  )
344
347
  }
345
348
 
@@ -363,7 +366,7 @@ function ImperativeDialogItem({
363
366
  * await alert({ title: 'Error', description: 'Something went wrong.' })
364
367
  * ```
365
368
  */
366
- export function useDialog(): UseDialogReturn {
369
+ export function useDialog (): UseDialogReturn {
367
370
  const ctx = useContext(DialogImperativeContext)
368
371
  if (!ctx) throw new Error('useDialog must be used within <DialogProvider>')
369
372
  return ctx