@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.
@@ -0,0 +1,520 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useState,
7
+ } from 'react'
8
+ import { Menu as BaseMenu } from '@base-ui/react'
9
+ import clsx from 'clsx'
10
+ import { ChevronRight } from 'lucide-react'
11
+
12
+ // ─── Shared Types ──────────────────────────────────────────────────────────────
13
+
14
+ export interface DropdownOption {
15
+ value: string
16
+ label: string
17
+ /** Optional icon rendered before the label */
18
+ icon?: React.ReactNode
19
+ /** Show shortcut hint on the right */
20
+ shortcut?: string
21
+ disabled?: boolean
22
+ /** Renders in red to indicate destructive action */
23
+ destructive?: boolean
24
+ }
25
+
26
+ export interface DropdownGroup {
27
+ /** Group header label */
28
+ label: string
29
+ options: DropdownOption[]
30
+ }
31
+
32
+ // ─── Declarative API ───────────────────────────────────────────────────────────
33
+
34
+ export interface DropdownProps {
35
+ children: React.ReactNode
36
+ open?: boolean
37
+ defaultOpen?: boolean
38
+ onOpenChange?: (open: boolean) => void
39
+ /** Called when the user selects an option */
40
+ onSelect?: (value: string) => void
41
+ }
42
+
43
+ export interface DropdownTriggerProps {
44
+ children?: React.ReactNode
45
+ className?: string
46
+ render?: React.ReactElement
47
+ }
48
+
49
+ export interface DropdownContentProps {
50
+ children: React.ReactNode
51
+ className?: string
52
+ side?: 'top' | 'right' | 'bottom' | 'left'
53
+ align?: 'start' | 'center' | 'end'
54
+ sideOffset?: number
55
+ alignOffset?: number
56
+ }
57
+
58
+ export interface DropdownItemProps {
59
+ value: string
60
+ children: React.ReactNode
61
+ icon?: React.ReactNode
62
+ shortcut?: string
63
+ className?: string
64
+ disabled?: boolean
65
+ destructive?: boolean
66
+ onSelect?: (value: string) => void
67
+ }
68
+
69
+ export interface DropdownLabelProps {
70
+ children: React.ReactNode
71
+ className?: string
72
+ }
73
+
74
+ export interface DropdownSeparatorProps {
75
+ className?: string
76
+ }
77
+
78
+ export interface DropdownGroupProps {
79
+ label: string
80
+ children: React.ReactNode
81
+ className?: string
82
+ }
83
+
84
+ // Internal context so DropdownItem can call parent onSelect
85
+ const DropdownSelectContext = createContext<((value: string) => void) | undefined>(undefined)
86
+
87
+ export const Dropdown: React.FC<DropdownProps> = ({
88
+ children,
89
+ open,
90
+ defaultOpen,
91
+ onOpenChange,
92
+ onSelect,
93
+ }) => {
94
+ return (
95
+ <DropdownSelectContext.Provider value={onSelect}>
96
+ <BaseMenu.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
97
+ {children}
98
+ </BaseMenu.Root>
99
+ </DropdownSelectContext.Provider>
100
+ )
101
+ }
102
+ Dropdown.displayName = 'Dropdown'
103
+
104
+ export const DropdownTrigger = React.forwardRef<HTMLButtonElement, DropdownTriggerProps>(
105
+ ({ children, className, render }, ref) => {
106
+ if (render) {
107
+ return (
108
+ <span className="contents">
109
+ <BaseMenu.Trigger ref={ref} className={clsx('outline-none', className)} render={render}/>
110
+ </span>
111
+ )
112
+ }
113
+ return (
114
+ <span className="contents">
115
+ <BaseMenu.Trigger ref={ref} className={clsx('outline-none', className)}>
116
+ {children}
117
+ </BaseMenu.Trigger>
118
+ </span>
119
+ )
120
+ },
121
+ )
122
+ DropdownTrigger.displayName = 'DropdownTrigger'
123
+
124
+ export const DropdownContent = React.forwardRef<HTMLDivElement, DropdownContentProps>(
125
+ (
126
+ {
127
+ children,
128
+ className,
129
+ side = 'bottom',
130
+ align = 'start',
131
+ sideOffset = 4,
132
+ alignOffset = 0,
133
+ },
134
+ ref,
135
+ ) => {
136
+ return (
137
+ <BaseMenu.Portal>
138
+ <BaseMenu.Positioner
139
+ side={side}
140
+ align={align}
141
+ sideOffset={sideOffset}
142
+ alignOffset={alignOffset}
143
+ >
144
+ <BaseMenu.Popup
145
+ ref={ref}
146
+ className={clsx(
147
+ 'z-50 min-w-40 max-w-72 rounded-md border border-(--border-light) bg-(--bg-primary) py-1 shadow-lg',
148
+ className,
149
+ )}
150
+ >
151
+ {children}
152
+ </BaseMenu.Popup>
153
+ </BaseMenu.Positioner>
154
+ </BaseMenu.Portal>
155
+ )
156
+ },
157
+ )
158
+ DropdownContent.displayName = 'DropdownContent'
159
+
160
+ export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
161
+ ({ value, children, icon, shortcut, className, disabled, destructive, onSelect }, ref) => {
162
+ const contextOnSelect = useContext(DropdownSelectContext)
163
+
164
+ const handleSelect = useCallback(() => {
165
+ onSelect?.(value)
166
+ contextOnSelect?.(value)
167
+ }, [value, onSelect, contextOnSelect])
168
+
169
+ return (
170
+ <BaseMenu.Item
171
+ ref={ref}
172
+ className={clsx(
173
+ 'relative flex items-center gap-2 px-3 py-1.5 text-[14px] outline-none cursor-pointer select-none',
174
+ 'text-(--text-primary)',
175
+ 'data-[highlighted]:bg-(--bg-hover)',
176
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
177
+ destructive &&
178
+ 'text-(--danger) data-[highlighted]:bg-red-50 dark:data-[highlighted]:bg-red-950/20',
179
+ className,
180
+ )}
181
+ disabled={disabled}
182
+ onSelect={handleSelect}
183
+ >
184
+ {icon && <span className="shrink-0 text-(--text-muted)">{icon}</span>}
185
+ <span className="flex-1">{children}</span>
186
+ {shortcut && (
187
+ <span className="ml-auto text-[12px] text-(--text-muted)">{shortcut}</span>
188
+ )}
189
+ </BaseMenu.Item>
190
+ )
191
+ },
192
+ )
193
+ DropdownItem.displayName = 'DropdownItem'
194
+
195
+ export const DropdownLabel: React.FC<DropdownLabelProps> = ({ children, className }) => (
196
+ <div
197
+ className={clsx(
198
+ 'px-3 py-1.5 text-[11px] font-semibold text-(--text-muted) uppercase tracking-wider',
199
+ className,
200
+ )}
201
+ >
202
+ {children}
203
+ </div>
204
+ )
205
+ DropdownLabel.displayName = 'DropdownLabel'
206
+
207
+ export const DropdownSeparator: React.FC<DropdownSeparatorProps> = ({ className }) => (
208
+ <BaseMenu.Separator className={clsx('my-1 h-px bg-(--border-light)', className)}/>
209
+ )
210
+ DropdownSeparator.displayName = 'DropdownSeparator'
211
+
212
+ export const DropdownGroup: React.FC<DropdownGroupProps> = ({ label, children, className }) => (
213
+ <BaseMenu.Group className={className}>
214
+ <BaseMenu.GroupLabel className="px-3 py-1 text-[11px] font-semibold text-(--text-muted) uppercase tracking-wider">
215
+ {label}
216
+ </BaseMenu.GroupLabel>
217
+ {children}
218
+ </BaseMenu.Group>
219
+ )
220
+ DropdownGroup.displayName = 'DropdownGroup'
221
+
222
+ /** Convenience: render a list of DropdownOption objects inside a DropdownContent */
223
+ export const DropdownOptionList: React.FC<{
224
+ options: DropdownOption[]
225
+ onSelect?: (value: string) => void
226
+ }> = ({ options, onSelect }) => (
227
+ <>
228
+ {options.map((opt) => (
229
+ <DropdownItem
230
+ key={opt.value}
231
+ value={opt.value}
232
+ icon={opt.icon}
233
+ shortcut={opt.shortcut}
234
+ disabled={opt.disabled}
235
+ destructive={opt.destructive}
236
+ onSelect={onSelect}
237
+ >
238
+ {opt.label}
239
+ </DropdownItem>
240
+ ))}
241
+ </>
242
+ )
243
+
244
+ /** Convenience: render a list of DropdownGroup objects inside a DropdownContent */
245
+ export const DropdownGroupList: React.FC<{
246
+ groups: DropdownGroup[]
247
+ onSelect?: (value: string) => void
248
+ }> = ({ groups, onSelect }) => (
249
+ <>
250
+ {groups.map((g, i) => (
251
+ <React.Fragment key={g.label}>
252
+ {i > 0 && <DropdownSeparator/>}
253
+ <DropdownGroup label={g.label}>
254
+ {g.options.map((opt) => (
255
+ <DropdownItem
256
+ key={opt.value}
257
+ value={opt.value}
258
+ icon={opt.icon}
259
+ shortcut={opt.shortcut}
260
+ disabled={opt.disabled}
261
+ destructive={opt.destructive}
262
+ onSelect={onSelect}
263
+ >
264
+ {opt.label}
265
+ </DropdownItem>
266
+ ))}
267
+ </DropdownGroup>
268
+ </React.Fragment>
269
+ ))}
270
+ </>
271
+ )
272
+
273
+ // Submenus (declarative only)
274
+ export interface DropdownSubProps {
275
+ children: React.ReactNode
276
+ open?: boolean
277
+ defaultOpen?: boolean
278
+ onOpenChange?: (open: boolean) => void
279
+ }
280
+
281
+ export interface DropdownSubTriggerProps {
282
+ children: React.ReactNode
283
+ className?: string
284
+ disabled?: boolean
285
+ }
286
+
287
+ export interface DropdownSubContentProps {
288
+ children: React.ReactNode
289
+ className?: string
290
+ }
291
+
292
+ export const DropdownSub: React.FC<DropdownSubProps> = ({
293
+ children,
294
+ open,
295
+ defaultOpen,
296
+ onOpenChange,
297
+ }) => (
298
+ <BaseMenu.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
299
+ {children}
300
+ </BaseMenu.Root>
301
+ )
302
+ DropdownSub.displayName = 'DropdownSub'
303
+
304
+ export const DropdownSubTrigger = React.forwardRef<HTMLButtonElement, DropdownSubTriggerProps>(
305
+ ({ children, className, disabled }, ref) => (
306
+ <BaseMenu.Trigger
307
+ ref={ref}
308
+ className={clsx(
309
+ 'relative flex w-full items-center justify-between gap-2 px-3 py-1.5 text-[14px] outline-none cursor-pointer select-none',
310
+ 'text-(--text-primary) data-[highlighted]:bg-(--bg-hover)',
311
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
312
+ className,
313
+ )}
314
+ disabled={disabled}
315
+ >
316
+ {children}
317
+ <ChevronRight className="ml-auto h-3.5 w-3.5 text-(--text-muted)"/>
318
+ </BaseMenu.Trigger>
319
+ ),
320
+ )
321
+ DropdownSubTrigger.displayName = 'DropdownSubTrigger'
322
+
323
+ export const DropdownSubContent = React.forwardRef<HTMLDivElement, DropdownSubContentProps>(
324
+ ({ children, className }, ref) => (
325
+ <BaseMenu.Portal>
326
+ <BaseMenu.Positioner side="right" align="start" sideOffset={8}>
327
+ <BaseMenu.Popup
328
+ ref={ref}
329
+ className={clsx(
330
+ 'z-50 min-w-40 max-w-72 rounded-md border border-(--border-light) bg-(--bg-primary) py-1 shadow-lg',
331
+ className,
332
+ )}
333
+ >
334
+ {children}
335
+ </BaseMenu.Popup>
336
+ </BaseMenu.Positioner>
337
+ </BaseMenu.Portal>
338
+ ),
339
+ )
340
+ DropdownSubContent.displayName = 'DropdownSubContent'
341
+
342
+ // ─── Imperative API ────────────────────────────────────────────────────────────
343
+
344
+ export interface DropdownShowOptions {
345
+ /** Flat list of options (use `groups` for grouped layout) */
346
+ options?: DropdownOption[]
347
+ /** Grouped options (renders group headers + separators) */
348
+ groups?: DropdownGroup[]
349
+ /** Called when the user picks an option */
350
+ onSelect?: (value: string) => void
351
+ side?: 'top' | 'right' | 'bottom' | 'left'
352
+ align?: 'start' | 'center' | 'end'
353
+ sideOffset?: number
354
+ alignOffset?: number
355
+ className?: string
356
+ }
357
+
358
+ export interface UseDropdownReturn {
359
+ /**
360
+ * Show the dropdown anchored to the given element.
361
+ * Replaces any currently open dropdown.
362
+ */
363
+ show: (anchor: HTMLElement | null, options: DropdownShowOptions) => void
364
+ /** Close the dropdown. */
365
+ hide: () => void
366
+ /**
367
+ * Toggle the dropdown for the given anchor.
368
+ * Closes if the same anchor is already open; otherwise opens on the new anchor.
369
+ */
370
+ toggle: (anchor: HTMLElement | null, options: DropdownShowOptions) => void
371
+ /** Whether the dropdown is currently open. */
372
+ isOpen: boolean
373
+ }
374
+
375
+ interface DropdownState {
376
+ open: boolean
377
+ anchor: HTMLElement | null
378
+ options: DropdownShowOptions
379
+ }
380
+
381
+ const DropdownContext = createContext<UseDropdownReturn | null>(null)
382
+
383
+ /**
384
+ * Provides the imperative dropdown API to all descendant components.
385
+ * Only one dropdown is shown at a time (singleton).
386
+ *
387
+ * @example
388
+ * ```tsx
389
+ * // main.tsx
390
+ * <DropdownProvider>
391
+ * <App />
392
+ * </DropdownProvider>
393
+ *
394
+ * // Any component
395
+ * const dropdown = useDropdown()
396
+ *
397
+ * <button onClick={e => dropdown.show(e.currentTarget, {
398
+ * options: [
399
+ * { value: 'edit', label: '编辑', icon: <Edit size={14} /> },
400
+ * { value: 'delete', label: '删除', destructive: true },
401
+ * ],
402
+ * onSelect: (value) => handleAction(value),
403
+ * })}>
404
+ * 操作
405
+ * </button>
406
+ * ```
407
+ */
408
+ export const DropdownProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
409
+ const [state, setState] = useState<DropdownState>({
410
+ open: false,
411
+ anchor: null,
412
+ options: {},
413
+ })
414
+
415
+ const show = useCallback((anchor: HTMLElement | null, options: DropdownShowOptions) => {
416
+ setState({ open: true, anchor, options })
417
+ }, [])
418
+
419
+ const hide = useCallback(() => {
420
+ setState((prev) => ({ ...prev, open: false }))
421
+ }, [])
422
+
423
+ const toggle = useCallback(
424
+ (anchor: HTMLElement | null, options: DropdownShowOptions) => {
425
+ setState((prev) => {
426
+ if (prev.open && prev.anchor === anchor) {
427
+ return { ...prev, open: false }
428
+ }
429
+ return { open: true, anchor, options }
430
+ })
431
+ },
432
+ [],
433
+ )
434
+
435
+ const value = useMemo<UseDropdownReturn>(
436
+ () => ({ show, hide, toggle, isOpen: state.open }),
437
+ [show, hide, toggle, state.open],
438
+ )
439
+
440
+ return (
441
+ <DropdownContext.Provider value={value}>
442
+ {children}
443
+ <ImperativeDropdownPortal state={state} onClose={hide}/>
444
+ </DropdownContext.Provider>
445
+ )
446
+ }
447
+ DropdownProvider.displayName = 'DropdownProvider'
448
+
449
+ function ImperativeDropdownPortal ({
450
+ state,
451
+ onClose,
452
+ }: {
453
+ state: DropdownState
454
+ onClose: () => void
455
+ }) {
456
+ if (!state.open || !state.anchor) return null
457
+
458
+ const { options } = state
459
+
460
+ const handleSelect = (value: string) => {
461
+ options.onSelect?.(value)
462
+ onClose()
463
+ }
464
+
465
+ return (
466
+ <BaseMenu.Root open={state.open} onOpenChange={(open) => !open && onClose()}>
467
+ <BaseMenu.Portal>
468
+ <BaseMenu.Positioner
469
+ anchor={state.anchor}
470
+ side={options.side ?? 'bottom'}
471
+ align={options.align ?? 'start'}
472
+ sideOffset={options.sideOffset ?? 4}
473
+ alignOffset={options.alignOffset ?? 0}
474
+ >
475
+ <BaseMenu.Popup
476
+ className={clsx(
477
+ 'z-50 min-w-40 max-w-72 rounded-md border border-(--border-light) bg-(--bg-primary) py-1 shadow-lg',
478
+ options.className,
479
+ )}
480
+ >
481
+ {options.groups ? (
482
+ <DropdownGroupList groups={options.groups} onSelect={handleSelect}/>
483
+ ) : (
484
+ <DropdownOptionList options={options.options ?? []} onSelect={handleSelect}/>
485
+ )}
486
+ </BaseMenu.Popup>
487
+ </BaseMenu.Positioner>
488
+ </BaseMenu.Portal>
489
+ </BaseMenu.Root>
490
+ )
491
+ }
492
+
493
+ /**
494
+ * Returns imperative methods for showing a dropdown anchored to any element.
495
+ *
496
+ * Must be used inside `<DropdownProvider>`.
497
+ *
498
+ * @example
499
+ * ```tsx
500
+ * const dropdown = useDropdown()
501
+ *
502
+ * // Show
503
+ * <button onClick={e => dropdown.show(e.currentTarget, {
504
+ * options: [{ value: 'copy', label: 'Copy' }, { value: 'paste', label: 'Paste' }],
505
+ * onSelect: (v) => console.log(v),
506
+ * })}>
507
+ * Actions
508
+ * </button>
509
+ *
510
+ * // Toggle (click again to close)
511
+ * <button onClick={e => dropdown.toggle(e.currentTarget, { options, onSelect })}>
512
+ * Toggle
513
+ * </button>
514
+ * ```
515
+ */
516
+ export function useDropdown (): UseDropdownReturn {
517
+ const ctx = useContext(DropdownContext)
518
+ if (!ctx) throw new Error('useDropdown must be used within <DropdownProvider>')
519
+ return ctx
520
+ }
@@ -61,6 +61,30 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
61
61
  ) => {
62
62
  const generatedId = React.useId()
63
63
  const triggerId = id ?? generatedId
64
+ const isControlled = value !== undefined
65
+ const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null)
66
+
67
+ const allOptions = React.useMemo(
68
+ () => (groups ? groups.flatMap((group) => group.options) : options ?? []),
69
+ [groups, options],
70
+ )
71
+
72
+ const selectedValue = isControlled ? value ?? null : internalValue
73
+ const selectedLabel = React.useMemo(
74
+ () => allOptions.find((opt) => opt.value === selectedValue)?.label,
75
+ [allOptions, selectedValue],
76
+ )
77
+
78
+ const handleValueChange = React.useCallback(
79
+ (nextValue: string | null) => {
80
+ if (!isControlled) {
81
+ setInternalValue(nextValue)
82
+ }
83
+
84
+ onValueChange?.(nextValue)
85
+ },
86
+ [isControlled, onValueChange],
87
+ )
64
88
 
65
89
  return (
66
90
  <div className={clsx('flex flex-col gap-1.5', fullWidth && 'w-full')}>
@@ -75,9 +99,9 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
75
99
  )}
76
100
 
77
101
  <BaseSelect.Root
78
- value={value}
79
- defaultValue={defaultValue}
80
- onValueChange={onValueChange}
102
+ value={isControlled ? value : undefined}
103
+ defaultValue={isControlled ? undefined : defaultValue}
104
+ onValueChange={handleValueChange}
81
105
  disabled={disabled}
82
106
  required={required}
83
107
  >
@@ -99,7 +123,9 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
99
123
  <BaseSelect.Value
100
124
  className="flex-1 text-left data-[placeholder]:text-(--text-muted)"
101
125
  placeholder={placeholder}
102
- />
126
+ >
127
+ {selectedLabel}
128
+ </BaseSelect.Value>
103
129
  <BaseSelect.Icon className="shrink-0 text-(--text-muted)">
104
130
  <ChevronDown size={14} />
105
131
  </BaseSelect.Icon>
@@ -184,3 +184,41 @@ export type { LoadingProps, LoadingVariant, LoadingSize } from './Loading'
184
184
  export { AutoComplete } from './AutoComplete'
185
185
  export type { AutoCompleteProps, AutoCompleteOption } from './AutoComplete'
186
186
 
187
+ // Combobox Components
188
+ export { Combobox, ComboboxMultiple } from './Combobox'
189
+ export type { ComboboxProps, ComboboxMultipleProps, ComboboxOption, ComboboxGroup } from './Combobox'
190
+
191
+ // Dropdown Components (declarative + imperative)
192
+ export {
193
+ Dropdown,
194
+ DropdownTrigger,
195
+ DropdownContent,
196
+ DropdownItem,
197
+ DropdownLabel,
198
+ DropdownSeparator,
199
+ DropdownGroup,
200
+ DropdownOptionList,
201
+ DropdownGroupList,
202
+ DropdownSub,
203
+ DropdownSubTrigger,
204
+ DropdownSubContent,
205
+ DropdownProvider,
206
+ useDropdown,
207
+ } from './Dropdown'
208
+ export type {
209
+ DropdownProps,
210
+ DropdownTriggerProps,
211
+ DropdownContentProps,
212
+ DropdownItemProps,
213
+ DropdownLabelProps,
214
+ DropdownSeparatorProps,
215
+ DropdownGroupProps,
216
+ DropdownSubProps,
217
+ DropdownSubTriggerProps,
218
+ DropdownSubContentProps,
219
+ DropdownOption,
220
+ DropdownGroup as DropdownGroupType,
221
+ DropdownShowOptions,
222
+ UseDropdownReturn,
223
+ } from './Dropdown'
224
+
package/src/main.tsx CHANGED
@@ -6,6 +6,7 @@ import { ThemeProvider } from './context/ThemeContext'
6
6
  import { ToastProvider } from './components'
7
7
  import { DialogProvider } from './components'
8
8
  import { ImperativePopoverProvider } from './components'
9
+ import { DropdownProvider } from './components'
9
10
 
10
11
  createRoot(document.getElementById('root')!).render(
11
12
  <StrictMode>
@@ -13,7 +14,9 @@ createRoot(document.getElementById('root')!).render(
13
14
  <ToastProvider>
14
15
  <DialogProvider>
15
16
  <ImperativePopoverProvider>
16
- <App />
17
+ <DropdownProvider>
18
+ <App />
19
+ </DropdownProvider>
17
20
  </ImperativePopoverProvider>
18
21
  </DialogProvider>
19
22
  </ToastProvider>