@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
|
@@ -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={
|
|
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>
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
<
|
|
17
|
+
<DropdownProvider>
|
|
18
|
+
<App />
|
|
19
|
+
</DropdownProvider>
|
|
17
20
|
</ImperativePopoverProvider>
|
|
18
21
|
</DialogProvider>
|
|
19
22
|
</ToastProvider>
|