@djangocfg/ui-nextjs 1.4.45
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/LICENSE +21 -0
- package/README.md +152 -0
- package/package.json +110 -0
- package/src/animations/AnimatedBackground.tsx +645 -0
- package/src/animations/index.ts +2 -0
- package/src/blocks/ArticleCard.tsx +94 -0
- package/src/blocks/ArticleList.tsx +95 -0
- package/src/blocks/CTASection.tsx +136 -0
- package/src/blocks/FeatureSection.tsx +104 -0
- package/src/blocks/Hero.tsx +102 -0
- package/src/blocks/NewsletterSection.tsx +119 -0
- package/src/blocks/StatsSection.tsx +103 -0
- package/src/blocks/SuperHero.tsx +328 -0
- package/src/blocks/TestimonialSection.tsx +122 -0
- package/src/blocks/index.ts +9 -0
- package/src/components/README.md +2018 -0
- package/src/components/breadcrumb-navigation.tsx +127 -0
- package/src/components/breadcrumb.tsx +132 -0
- package/src/components/button-download.tsx +275 -0
- package/src/components/dropdown-menu.tsx +219 -0
- package/src/components/index.ts +86 -0
- package/src/components/markdown/MarkdownMessage.tsx +338 -0
- package/src/components/markdown/index.ts +5 -0
- package/src/components/menubar.tsx +274 -0
- package/src/components/multi-select-pro/async.tsx +608 -0
- package/src/components/multi-select-pro/helpers.tsx +84 -0
- package/src/components/multi-select-pro/index.tsx +622 -0
- package/src/components/navigation-menu.tsx +153 -0
- package/src/components/pagination-static.tsx +348 -0
- package/src/components/pagination.tsx +138 -0
- package/src/components/phone-input.tsx +276 -0
- package/src/components/sidebar.tsx +866 -0
- package/src/components/sonner.tsx +31 -0
- package/src/components/ssr-pagination.tsx +237 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useCfgRouter.ts +153 -0
- package/src/hooks/useLocalStorage.ts +221 -0
- package/src/hooks/useQueryParams.ts +73 -0
- package/src/hooks/useSessionStorage.ts +188 -0
- package/src/hooks/useTheme.ts +57 -0
- package/src/index.ts +24 -0
- package/src/lib/index.ts +2 -0
- package/src/styles/index.css +2 -0
- package/src/theme/ForceTheme.tsx +115 -0
- package/src/theme/ThemeProvider.tsx +82 -0
- package/src/theme/ThemeToggle.tsx +52 -0
- package/src/theme/index.ts +3 -0
- package/src/tools/JsonForm/JsonSchemaForm.tsx +199 -0
- package/src/tools/JsonForm/examples/BotConfigExample.tsx +245 -0
- package/src/tools/JsonForm/examples/RealBotConfigExample.tsx +157 -0
- package/src/tools/JsonForm/index.ts +46 -0
- package/src/tools/JsonForm/templates/ArrayFieldItemTemplate.tsx +46 -0
- package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +73 -0
- package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +106 -0
- package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +34 -0
- package/src/tools/JsonForm/templates/FieldTemplate.tsx +61 -0
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +43 -0
- package/src/tools/JsonForm/templates/index.ts +12 -0
- package/src/tools/JsonForm/types.ts +83 -0
- package/src/tools/JsonForm/utils.ts +212 -0
- package/src/tools/JsonForm/widgets/CheckboxWidget.tsx +36 -0
- package/src/tools/JsonForm/widgets/NumberWidget.tsx +88 -0
- package/src/tools/JsonForm/widgets/SelectWidget.tsx +100 -0
- package/src/tools/JsonForm/widgets/SwitchWidget.tsx +34 -0
- package/src/tools/JsonForm/widgets/TextWidget.tsx +95 -0
- package/src/tools/JsonForm/widgets/index.ts +12 -0
- package/src/tools/JsonTree/index.tsx +252 -0
- package/src/tools/LottiePlayer/LottiePlayer.client.tsx +212 -0
- package/src/tools/LottiePlayer/index.tsx +54 -0
- package/src/tools/LottiePlayer/types.ts +108 -0
- package/src/tools/LottiePlayer/useLottie.ts +163 -0
- package/src/tools/Mermaid/Mermaid.client.tsx +341 -0
- package/src/tools/Mermaid/index.tsx +40 -0
- package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +144 -0
- package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +255 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +123 -0
- package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +98 -0
- package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +164 -0
- package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +253 -0
- package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +169 -0
- package/src/tools/OpenapiViewer/components/VersionSelector.tsx +64 -0
- package/src/tools/OpenapiViewer/components/index.ts +14 -0
- package/src/tools/OpenapiViewer/constants.ts +39 -0
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +338 -0
- package/src/tools/OpenapiViewer/hooks/index.ts +8 -0
- package/src/tools/OpenapiViewer/hooks/useMobile.ts +10 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +203 -0
- package/src/tools/OpenapiViewer/index.tsx +36 -0
- package/src/tools/OpenapiViewer/types.ts +152 -0
- package/src/tools/OpenapiViewer/utils/apiKeyManager.ts +149 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +71 -0
- package/src/tools/OpenapiViewer/utils/index.ts +9 -0
- package/src/tools/OpenapiViewer/utils/versionManager.ts +161 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +217 -0
- package/src/tools/PrettyCode/index.tsx +43 -0
- package/src/tools/VideoPlayer/README.md +239 -0
- package/src/tools/VideoPlayer/VideoControls.tsx +138 -0
- package/src/tools/VideoPlayer/VideoPlayer.tsx +230 -0
- package/src/tools/VideoPlayer/index.ts +9 -0
- package/src/tools/VideoPlayer/types.ts +62 -0
- package/src/tools/index.ts +43 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MultiSelectProAsync - Extended MultiSelectPro with async search support
|
|
3
|
+
*
|
|
4
|
+
* Based on MultiSelectPro with added features:
|
|
5
|
+
* - Async search through external API
|
|
6
|
+
* - Debounced search queries
|
|
7
|
+
* - Loading states
|
|
8
|
+
* - Controlled search value from parent
|
|
9
|
+
*
|
|
10
|
+
* All original MultiSelectPro features preserved:
|
|
11
|
+
* - Animations, variants, responsive, groups, etc.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
"use client"
|
|
15
|
+
|
|
16
|
+
import * as React from "react"
|
|
17
|
+
import { Check, ChevronsUpDown, X, XCircle, Loader2 } from "lucide-react"
|
|
18
|
+
import { cva } from "class-variance-authority"
|
|
19
|
+
import { cn } from "@djangocfg/ui-core/lib"
|
|
20
|
+
import {
|
|
21
|
+
Button,
|
|
22
|
+
Command,
|
|
23
|
+
CommandEmpty,
|
|
24
|
+
CommandGroup,
|
|
25
|
+
CommandInput,
|
|
26
|
+
CommandItem,
|
|
27
|
+
CommandList,
|
|
28
|
+
CommandSeparator,
|
|
29
|
+
Popover,
|
|
30
|
+
PopoverContent,
|
|
31
|
+
PopoverTrigger,
|
|
32
|
+
Badge,
|
|
33
|
+
Separator,
|
|
34
|
+
} from "@djangocfg/ui-core/components"
|
|
35
|
+
|
|
36
|
+
// ==================== TYPES ====================
|
|
37
|
+
|
|
38
|
+
export interface MultiSelectProAsyncOption {
|
|
39
|
+
label: string
|
|
40
|
+
value: string
|
|
41
|
+
description?: string // Optional subtitle/description text shown below label
|
|
42
|
+
icon?: React.ComponentType<{ className?: string }>
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
style?: {
|
|
45
|
+
badgeColor?: string
|
|
46
|
+
iconColor?: string
|
|
47
|
+
gradient?: string
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MultiSelectProAsyncGroup {
|
|
52
|
+
heading: string
|
|
53
|
+
options: MultiSelectProAsyncOption[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AnimationConfig {
|
|
57
|
+
badgeAnimation?: "bounce" | "pulse" | "wiggle" | "fade" | "slide" | "none"
|
|
58
|
+
popoverAnimation?: "scale" | "slide" | "fade" | "flip" | "none"
|
|
59
|
+
optionHoverAnimation?: "highlight" | "scale" | "glow" | "none"
|
|
60
|
+
duration?: number
|
|
61
|
+
delay?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MultiSelectProAsyncRef {
|
|
65
|
+
reset: () => void
|
|
66
|
+
getSelectedValues: () => string[]
|
|
67
|
+
setSelectedValues: (values: string[]) => void
|
|
68
|
+
clear: () => void
|
|
69
|
+
focus: () => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MultiSelectProAsyncProps {
|
|
73
|
+
// Options from API
|
|
74
|
+
options: MultiSelectProAsyncOption[] | MultiSelectProAsyncGroup[]
|
|
75
|
+
|
|
76
|
+
// Selection
|
|
77
|
+
onValueChange?: (value: string[]) => void
|
|
78
|
+
defaultValue?: string[]
|
|
79
|
+
|
|
80
|
+
// Async Search
|
|
81
|
+
searchValue: string
|
|
82
|
+
onSearchChange: (value: string) => void
|
|
83
|
+
isLoading?: boolean
|
|
84
|
+
|
|
85
|
+
// UI
|
|
86
|
+
placeholder?: string
|
|
87
|
+
searchPlaceholder?: string
|
|
88
|
+
emptyText?: string
|
|
89
|
+
loadingText?: string
|
|
90
|
+
variant?: "default" | "secondary" | "destructive" | "inverted"
|
|
91
|
+
animation?: number
|
|
92
|
+
animationConfig?: AnimationConfig
|
|
93
|
+
maxCount?: number
|
|
94
|
+
modalPopover?: boolean
|
|
95
|
+
className?: string
|
|
96
|
+
popoverClassName?: string
|
|
97
|
+
disabled?: boolean
|
|
98
|
+
|
|
99
|
+
// Behavior
|
|
100
|
+
hideSelectAll?: boolean
|
|
101
|
+
searchable?: boolean
|
|
102
|
+
closeOnSelect?: boolean
|
|
103
|
+
resetOnDefaultValueChange?: boolean
|
|
104
|
+
|
|
105
|
+
// Advanced
|
|
106
|
+
autoSize?: boolean
|
|
107
|
+
singleLine?: boolean
|
|
108
|
+
minWidth?: string
|
|
109
|
+
maxWidth?: string
|
|
110
|
+
deduplicateOptions?: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ==================== VARIANTS ====================
|
|
114
|
+
|
|
115
|
+
const multiSelectVariants = cva(
|
|
116
|
+
"w-full justify-between min-h-10 h-auto py-2",
|
|
117
|
+
{
|
|
118
|
+
variants: {
|
|
119
|
+
variant: {
|
|
120
|
+
default: "border-input bg-background hover:bg-accent/50",
|
|
121
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
122
|
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
123
|
+
inverted: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
defaultVariants: {
|
|
127
|
+
variant: "default",
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const badgeAnimations = {
|
|
133
|
+
bounce: "animate-bounce",
|
|
134
|
+
pulse: "animate-pulse",
|
|
135
|
+
wiggle: "animate-wiggle",
|
|
136
|
+
fade: "animate-fadeIn",
|
|
137
|
+
slide: "animate-slideIn",
|
|
138
|
+
none: "",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const popoverAnimations = {
|
|
142
|
+
scale: "data-[state=open]:animate-scaleIn data-[state=closed]:animate-scaleOut",
|
|
143
|
+
slide: "data-[state=open]:animate-slideDown data-[state=closed]:animate-slideUp",
|
|
144
|
+
fade: "data-[state=open]:animate-fadeIn data-[state=closed]:animate-fadeOut",
|
|
145
|
+
flip: "data-[state=open]:animate-flipIn data-[state=closed]:animate-flipOut",
|
|
146
|
+
none: "",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ==================== HELPERS ====================
|
|
150
|
+
|
|
151
|
+
function isGroupedOptions(options: MultiSelectProAsyncOption[] | MultiSelectProAsyncGroup[]): options is MultiSelectProAsyncGroup[] {
|
|
152
|
+
return options.length > 0 && 'heading' in options[0]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function flattenOptions(options: MultiSelectProAsyncOption[] | MultiSelectProAsyncGroup[]): MultiSelectProAsyncOption[] {
|
|
156
|
+
if (isGroupedOptions(options)) {
|
|
157
|
+
return options.flatMap((group) => group.options)
|
|
158
|
+
}
|
|
159
|
+
return options
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function deduplicateOptions(options: MultiSelectProAsyncOption[]): MultiSelectProAsyncOption[] {
|
|
163
|
+
const seen = new Set<string>()
|
|
164
|
+
return options.filter((option) => {
|
|
165
|
+
if (seen.has(option.value)) {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
seen.add(option.value)
|
|
169
|
+
return true
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ==================== COMPONENT ====================
|
|
174
|
+
|
|
175
|
+
export const MultiSelectProAsync = React.forwardRef<MultiSelectProAsyncRef, MultiSelectProAsyncProps>(
|
|
176
|
+
(
|
|
177
|
+
{
|
|
178
|
+
options,
|
|
179
|
+
onValueChange,
|
|
180
|
+
defaultValue = [],
|
|
181
|
+
searchValue,
|
|
182
|
+
onSearchChange,
|
|
183
|
+
isLoading = false,
|
|
184
|
+
placeholder = "Select options",
|
|
185
|
+
searchPlaceholder = "Search...",
|
|
186
|
+
emptyText = "No results found.",
|
|
187
|
+
loadingText = "Loading...",
|
|
188
|
+
variant = "default",
|
|
189
|
+
animation = 0,
|
|
190
|
+
animationConfig,
|
|
191
|
+
maxCount = 3,
|
|
192
|
+
modalPopover = false,
|
|
193
|
+
className,
|
|
194
|
+
popoverClassName,
|
|
195
|
+
disabled = false,
|
|
196
|
+
hideSelectAll = false,
|
|
197
|
+
searchable = true,
|
|
198
|
+
closeOnSelect = false,
|
|
199
|
+
resetOnDefaultValueChange = true,
|
|
200
|
+
autoSize = false,
|
|
201
|
+
singleLine = false,
|
|
202
|
+
minWidth,
|
|
203
|
+
maxWidth,
|
|
204
|
+
deduplicateOptions: shouldDeduplicate = false,
|
|
205
|
+
},
|
|
206
|
+
ref
|
|
207
|
+
) => {
|
|
208
|
+
const [open, setOpen] = React.useState(false)
|
|
209
|
+
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
|
|
210
|
+
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
211
|
+
const [announcements, setAnnouncements] = React.useState<string>("")
|
|
212
|
+
// Cache selected options to persist them when they disappear from current options
|
|
213
|
+
const [selectedOptionsCache, setSelectedOptionsCache] = React.useState<Map<string, MultiSelectProAsyncOption>>(new Map())
|
|
214
|
+
|
|
215
|
+
// Process options
|
|
216
|
+
const flatOptions = React.useMemo(() => {
|
|
217
|
+
const flat = flattenOptions(options)
|
|
218
|
+
return shouldDeduplicate ? deduplicateOptions(flat) : flat
|
|
219
|
+
}, [options, shouldDeduplicate])
|
|
220
|
+
|
|
221
|
+
// Update cache whenever new options appear
|
|
222
|
+
React.useEffect(() => {
|
|
223
|
+
setSelectedOptionsCache(prev => {
|
|
224
|
+
const updated = new Map(prev)
|
|
225
|
+
flatOptions.forEach(option => {
|
|
226
|
+
if (selectedValues.includes(option.value)) {
|
|
227
|
+
updated.set(option.value, option)
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
return updated
|
|
231
|
+
})
|
|
232
|
+
}, [flatOptions, selectedValues])
|
|
233
|
+
|
|
234
|
+
// Animation configuration
|
|
235
|
+
const animConfig = React.useMemo(
|
|
236
|
+
(): AnimationConfig => ({
|
|
237
|
+
badgeAnimation: animationConfig?.badgeAnimation || (animation > 0 ? "bounce" : "none"),
|
|
238
|
+
popoverAnimation: animationConfig?.popoverAnimation || "scale",
|
|
239
|
+
optionHoverAnimation: animationConfig?.optionHoverAnimation || "highlight",
|
|
240
|
+
duration: animationConfig?.duration || animation || 0.3,
|
|
241
|
+
delay: animationConfig?.delay || 0,
|
|
242
|
+
}),
|
|
243
|
+
[animation, animationConfig]
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
// Reset on defaultValue change
|
|
247
|
+
React.useEffect(() => {
|
|
248
|
+
if (resetOnDefaultValueChange) {
|
|
249
|
+
setSelectedValues(defaultValue)
|
|
250
|
+
}
|
|
251
|
+
}, [JSON.stringify(defaultValue), resetOnDefaultValueChange])
|
|
252
|
+
|
|
253
|
+
// Announce changes for screen readers
|
|
254
|
+
const announce = React.useCallback((message: string) => {
|
|
255
|
+
setAnnouncements(message)
|
|
256
|
+
setTimeout(() => setAnnouncements(""), 1000)
|
|
257
|
+
}, [])
|
|
258
|
+
|
|
259
|
+
// Toggle selection
|
|
260
|
+
const toggleOption = React.useCallback(
|
|
261
|
+
(value: string) => {
|
|
262
|
+
const isRemoving = selectedValues.includes(value)
|
|
263
|
+
const newValues = isRemoving
|
|
264
|
+
? selectedValues.filter((v) => v !== value)
|
|
265
|
+
: [...selectedValues, value]
|
|
266
|
+
|
|
267
|
+
setSelectedValues(newValues)
|
|
268
|
+
onValueChange?.(newValues)
|
|
269
|
+
|
|
270
|
+
const option = flatOptions.find((o) => o.value === value)
|
|
271
|
+
if (option) {
|
|
272
|
+
// Add to cache when selecting
|
|
273
|
+
if (!isRemoving) {
|
|
274
|
+
setSelectedOptionsCache(prev => new Map(prev).set(value, option))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
announce(
|
|
278
|
+
isRemoving
|
|
279
|
+
? `Removed ${option.label}`
|
|
280
|
+
: `Added ${option.label}`
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (closeOnSelect) {
|
|
285
|
+
setOpen(false)
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
[selectedValues, onValueChange, flatOptions, announce, closeOnSelect]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
// Select all
|
|
292
|
+
const handleSelectAll = React.useCallback(() => {
|
|
293
|
+
const allValues = flatOptions.filter((o) => !o.disabled).map((o) => o.value)
|
|
294
|
+
setSelectedValues(allValues)
|
|
295
|
+
onValueChange?.(allValues)
|
|
296
|
+
|
|
297
|
+
// Cache all selected options
|
|
298
|
+
setSelectedOptionsCache(prev => {
|
|
299
|
+
const updated = new Map(prev)
|
|
300
|
+
flatOptions.forEach(option => {
|
|
301
|
+
if (!option.disabled) {
|
|
302
|
+
updated.set(option.value, option)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
return updated
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
announce(`Selected all ${allValues.length} options`)
|
|
309
|
+
}, [flatOptions, onValueChange, announce])
|
|
310
|
+
|
|
311
|
+
// Clear all
|
|
312
|
+
const handleClearAll = React.useCallback(() => {
|
|
313
|
+
setSelectedValues([])
|
|
314
|
+
onValueChange?.([])
|
|
315
|
+
announce("Cleared all selections")
|
|
316
|
+
}, [onValueChange, announce])
|
|
317
|
+
|
|
318
|
+
// Imperative methods
|
|
319
|
+
React.useImperativeHandle(ref, () => ({
|
|
320
|
+
reset: () => {
|
|
321
|
+
setSelectedValues(defaultValue)
|
|
322
|
+
announce("Reset to default values")
|
|
323
|
+
},
|
|
324
|
+
getSelectedValues: () => selectedValues,
|
|
325
|
+
setSelectedValues: (values: string[]) => {
|
|
326
|
+
setSelectedValues(values)
|
|
327
|
+
onValueChange?.(values)
|
|
328
|
+
},
|
|
329
|
+
clear: handleClearAll,
|
|
330
|
+
focus: () => buttonRef.current?.focus(),
|
|
331
|
+
}))
|
|
332
|
+
|
|
333
|
+
// Selected options for display - use cache as fallback
|
|
334
|
+
const selectedOptions = React.useMemo(
|
|
335
|
+
() => selectedValues.map(value => {
|
|
336
|
+
// First try to find in current options
|
|
337
|
+
const option = flatOptions.find(o => o.value === value)
|
|
338
|
+
// If not found, fallback to cache
|
|
339
|
+
return option || selectedOptionsCache.get(value)
|
|
340
|
+
}).filter((option): option is MultiSelectProAsyncOption => option !== undefined),
|
|
341
|
+
[flatOptions, selectedValues, selectedOptionsCache]
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
// Render badge with custom styles
|
|
345
|
+
const renderBadge = (option: MultiSelectProAsyncOption, index: number) => {
|
|
346
|
+
const { style, icon: Icon } = option
|
|
347
|
+
const badgeStyle: React.CSSProperties = {}
|
|
348
|
+
|
|
349
|
+
if (style?.gradient) {
|
|
350
|
+
badgeStyle.background = style.gradient
|
|
351
|
+
badgeStyle.color = style.iconColor || "white"
|
|
352
|
+
} else if (style?.badgeColor) {
|
|
353
|
+
badgeStyle.backgroundColor = style.badgeColor
|
|
354
|
+
badgeStyle.color = style.iconColor || "white"
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const animationClass = animConfig.badgeAnimation
|
|
358
|
+
? badgeAnimations[animConfig.badgeAnimation]
|
|
359
|
+
: ""
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<Badge
|
|
363
|
+
key={option.value}
|
|
364
|
+
variant={variant === "default" ? "secondary" : "outline"}
|
|
365
|
+
className={cn(
|
|
366
|
+
"mr-1 mb-1 text-xs gap-1 flex items-center",
|
|
367
|
+
animationClass
|
|
368
|
+
)}
|
|
369
|
+
style={{
|
|
370
|
+
...badgeStyle,
|
|
371
|
+
animationDelay: `${(animConfig.delay || 0) * index}s`,
|
|
372
|
+
animationDuration: `${animConfig.duration}s`,
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
{Icon && <Icon className="h-3 w-3" />}
|
|
376
|
+
<span>{option.label}</span>
|
|
377
|
+
{!disabled && (
|
|
378
|
+
<button
|
|
379
|
+
className="ml-1 rounded-full hover:bg-muted-foreground/20 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
380
|
+
onClick={(e) => {
|
|
381
|
+
e.stopPropagation()
|
|
382
|
+
toggleOption(option.value)
|
|
383
|
+
}}
|
|
384
|
+
aria-label={`Remove ${option.label}`}
|
|
385
|
+
>
|
|
386
|
+
<X className="h-3 w-3" />
|
|
387
|
+
</button>
|
|
388
|
+
)}
|
|
389
|
+
</Badge>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Display value
|
|
394
|
+
const displayValue = React.useMemo(() => {
|
|
395
|
+
if (selectedOptions.length === 0) {
|
|
396
|
+
return <span className="text-muted-foreground">{placeholder}</span>
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const displayed = selectedOptions.slice(0, maxCount)
|
|
400
|
+
const remaining = selectedOptions.length - maxCount
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<div className={cn("flex gap-1", singleLine ? "flex-nowrap overflow-x-auto" : "flex-wrap")}>
|
|
404
|
+
{displayed.map((option, index) => renderBadge(option, index))}
|
|
405
|
+
{remaining > 0 && (
|
|
406
|
+
<Badge variant="outline" className="text-xs">
|
|
407
|
+
+{remaining} more
|
|
408
|
+
</Badge>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
)
|
|
412
|
+
}, [selectedOptions, maxCount, placeholder, singleLine, variant, disabled, animConfig])
|
|
413
|
+
|
|
414
|
+
// Render options
|
|
415
|
+
const renderOptions = () => {
|
|
416
|
+
if (isGroupedOptions(options)) {
|
|
417
|
+
return options.map((group, groupIndex) => (
|
|
418
|
+
<React.Fragment key={group.heading}>
|
|
419
|
+
{groupIndex > 0 && <CommandSeparator />}
|
|
420
|
+
<CommandGroup heading={group.heading}>
|
|
421
|
+
{group.options.map((option) => {
|
|
422
|
+
const isSelected = selectedValues.includes(option.value)
|
|
423
|
+
const Icon = option.icon
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<CommandItem
|
|
427
|
+
key={option.value}
|
|
428
|
+
value={option.value}
|
|
429
|
+
onSelect={() => !option.disabled && toggleOption(option.value)}
|
|
430
|
+
disabled={option.disabled}
|
|
431
|
+
className={cn(
|
|
432
|
+
"cursor-pointer",
|
|
433
|
+
option.disabled && "opacity-50 cursor-not-allowed"
|
|
434
|
+
)}
|
|
435
|
+
>
|
|
436
|
+
<Check
|
|
437
|
+
className={cn(
|
|
438
|
+
"mr-2 h-4 w-4 shrink-0",
|
|
439
|
+
isSelected ? "opacity-100" : "opacity-0"
|
|
440
|
+
)}
|
|
441
|
+
/>
|
|
442
|
+
{Icon && <Icon className="mr-2 h-4 w-4" />}
|
|
443
|
+
<div className="flex-1 flex flex-col gap-0.5">
|
|
444
|
+
<span>{option.label}</span>
|
|
445
|
+
{option.description && (
|
|
446
|
+
<span className="text-xs text-muted-foreground">{option.description}</span>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
</CommandItem>
|
|
450
|
+
)
|
|
451
|
+
})}
|
|
452
|
+
</CommandGroup>
|
|
453
|
+
</React.Fragment>
|
|
454
|
+
))
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<CommandGroup>
|
|
459
|
+
{(options as MultiSelectProAsyncOption[]).map((option) => {
|
|
460
|
+
const isSelected = selectedValues.includes(option.value)
|
|
461
|
+
const Icon = option.icon
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<CommandItem
|
|
465
|
+
key={option.value}
|
|
466
|
+
value={option.value}
|
|
467
|
+
onSelect={() => !option.disabled && toggleOption(option.value)}
|
|
468
|
+
disabled={option.disabled}
|
|
469
|
+
className={cn(
|
|
470
|
+
"cursor-pointer",
|
|
471
|
+
option.disabled && "opacity-50 cursor-not-allowed"
|
|
472
|
+
)}
|
|
473
|
+
>
|
|
474
|
+
<Check
|
|
475
|
+
className={cn(
|
|
476
|
+
"mr-2 h-4 w-4 shrink-0",
|
|
477
|
+
isSelected ? "opacity-100" : "opacity-0"
|
|
478
|
+
)}
|
|
479
|
+
/>
|
|
480
|
+
{Icon && <Icon className="mr-2 h-4 w-4" />}
|
|
481
|
+
<div className="flex-1 flex flex-col gap-0.5">
|
|
482
|
+
<span>{option.label}</span>
|
|
483
|
+
{option.description && (
|
|
484
|
+
<span className="text-xs text-muted-foreground">{option.description}</span>
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
487
|
+
</CommandItem>
|
|
488
|
+
)
|
|
489
|
+
})}
|
|
490
|
+
</CommandGroup>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const containerStyle: React.CSSProperties = {}
|
|
495
|
+
if (minWidth) containerStyle.minWidth = minWidth
|
|
496
|
+
if (maxWidth) containerStyle.maxWidth = maxWidth
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div style={containerStyle} className="relative">
|
|
500
|
+
{/* ARIA Live Region for announcements */}
|
|
501
|
+
<div
|
|
502
|
+
role="status"
|
|
503
|
+
aria-live="polite"
|
|
504
|
+
aria-atomic="true"
|
|
505
|
+
className="sr-only"
|
|
506
|
+
>
|
|
507
|
+
{announcements}
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
<Popover
|
|
511
|
+
open={open}
|
|
512
|
+
onOpenChange={(isOpen) => {
|
|
513
|
+
if (!disabled) {
|
|
514
|
+
setOpen(isOpen)
|
|
515
|
+
if (isOpen) {
|
|
516
|
+
announce(`Dropdown opened. ${flatOptions.length} options available`)
|
|
517
|
+
} else {
|
|
518
|
+
onSearchChange("") // Clear search when closing
|
|
519
|
+
announce("Dropdown closed")
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}}
|
|
523
|
+
modal={modalPopover}
|
|
524
|
+
>
|
|
525
|
+
<PopoverTrigger asChild>
|
|
526
|
+
<Button
|
|
527
|
+
ref={buttonRef}
|
|
528
|
+
variant="outline"
|
|
529
|
+
role="combobox"
|
|
530
|
+
aria-expanded={open}
|
|
531
|
+
aria-label={placeholder}
|
|
532
|
+
className={cn(multiSelectVariants({ variant }), className)}
|
|
533
|
+
disabled={disabled}
|
|
534
|
+
>
|
|
535
|
+
<div className={cn("flex-1 text-left", autoSize ? "" : "overflow-hidden")}>
|
|
536
|
+
{displayValue}
|
|
537
|
+
</div>
|
|
538
|
+
<div className="flex items-center gap-1 ml-2">
|
|
539
|
+
{selectedValues.length > 0 && !disabled && (
|
|
540
|
+
<button
|
|
541
|
+
onClick={(e) => {
|
|
542
|
+
e.stopPropagation()
|
|
543
|
+
handleClearAll()
|
|
544
|
+
}}
|
|
545
|
+
className="rounded-full hover:bg-muted-foreground/20 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
546
|
+
aria-label="Clear all selections"
|
|
547
|
+
>
|
|
548
|
+
<XCircle className="h-4 w-4 shrink-0 opacity-50" />
|
|
549
|
+
</button>
|
|
550
|
+
)}
|
|
551
|
+
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
552
|
+
</div>
|
|
553
|
+
</Button>
|
|
554
|
+
</PopoverTrigger>
|
|
555
|
+
<PopoverContent
|
|
556
|
+
className={cn(
|
|
557
|
+
"w-[var(--radix-popover-trigger-width)] p-0",
|
|
558
|
+
animConfig.popoverAnimation ? popoverAnimations[animConfig.popoverAnimation] : "",
|
|
559
|
+
popoverClassName
|
|
560
|
+
)}
|
|
561
|
+
align="start"
|
|
562
|
+
style={{
|
|
563
|
+
animationDuration: `${animConfig.duration}s`,
|
|
564
|
+
}}
|
|
565
|
+
>
|
|
566
|
+
<Command shouldFilter={false}>
|
|
567
|
+
{searchable && (
|
|
568
|
+
<CommandInput
|
|
569
|
+
placeholder={searchPlaceholder}
|
|
570
|
+
value={searchValue}
|
|
571
|
+
onValueChange={onSearchChange}
|
|
572
|
+
/>
|
|
573
|
+
)}
|
|
574
|
+
<CommandList>
|
|
575
|
+
{!hideSelectAll && !isGroupedOptions(options) && (
|
|
576
|
+
<>
|
|
577
|
+
<CommandGroup>
|
|
578
|
+
<CommandItem
|
|
579
|
+
onSelect={handleSelectAll}
|
|
580
|
+
className="cursor-pointer justify-center font-medium"
|
|
581
|
+
>
|
|
582
|
+
Select All
|
|
583
|
+
</CommandItem>
|
|
584
|
+
</CommandGroup>
|
|
585
|
+
<Separator />
|
|
586
|
+
</>
|
|
587
|
+
)}
|
|
588
|
+
|
|
589
|
+
{isLoading ? (
|
|
590
|
+
<div className="py-6 text-center text-sm">
|
|
591
|
+
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
|
|
592
|
+
{loadingText}
|
|
593
|
+
</div>
|
|
594
|
+
) : options.length === 0 ? (
|
|
595
|
+
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
596
|
+
) : (
|
|
597
|
+
renderOptions()
|
|
598
|
+
)}
|
|
599
|
+
</CommandList>
|
|
600
|
+
</Command>
|
|
601
|
+
</PopoverContent>
|
|
602
|
+
</Popover>
|
|
603
|
+
</div>
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
MultiSelectProAsync.displayName = "MultiSelectProAsync"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper utilities for MultiSelectPro components
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { MultiSelectProOption } from './index'
|
|
6
|
+
import type { MultiSelectProAsyncOption } from './async'
|
|
7
|
+
|
|
8
|
+
// ==================== TYPES ====================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generic option builder config
|
|
12
|
+
*/
|
|
13
|
+
export interface OptionBuilderConfig<T = any> {
|
|
14
|
+
/** Extract unique ID/value from item */
|
|
15
|
+
getValue: (item: T) => string
|
|
16
|
+
/** Extract main label text */
|
|
17
|
+
getLabel: (item: T) => string
|
|
18
|
+
/** Optional: Extract description/subtitle text */
|
|
19
|
+
getDescription?: (item: T) => string | undefined
|
|
20
|
+
/** Optional: Check if item is disabled */
|
|
21
|
+
isDisabled?: (item: T) => boolean
|
|
22
|
+
/** Optional: Custom icon component */
|
|
23
|
+
getIcon?: (item: T) => React.ComponentType<{ className?: string }> | undefined
|
|
24
|
+
/** Optional: Custom styling */
|
|
25
|
+
getStyle?: (item: T) => {
|
|
26
|
+
badgeColor?: string
|
|
27
|
+
iconColor?: string
|
|
28
|
+
gradient?: string
|
|
29
|
+
} | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ==================== HELPERS ====================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generic option builder for MultiSelectPro components
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* // Simple usage
|
|
40
|
+
* const options = items.map(createOption({
|
|
41
|
+
* getValue: (item) => item.id,
|
|
42
|
+
* getLabel: (item) => item.name,
|
|
43
|
+
* }))
|
|
44
|
+
*
|
|
45
|
+
* // With description
|
|
46
|
+
* const options = channels.map(createOption({
|
|
47
|
+
* getValue: (ch) => ch.id,
|
|
48
|
+
* getLabel: (ch) => ch.title,
|
|
49
|
+
* getDescription: (ch) => `📱 ${ch.phone}`,
|
|
50
|
+
* }))
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createOption<T>(
|
|
54
|
+
config: OptionBuilderConfig<T>
|
|
55
|
+
): (item: T) => MultiSelectProOption | MultiSelectProAsyncOption {
|
|
56
|
+
return (item: T) => ({
|
|
57
|
+
value: config.getValue(item),
|
|
58
|
+
label: config.getLabel(item),
|
|
59
|
+
description: config.getDescription?.(item),
|
|
60
|
+
disabled: config.isDisabled?.(item) ?? false,
|
|
61
|
+
icon: config.getIcon?.(item),
|
|
62
|
+
style: config.getStyle?.(item),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Batch create options from array
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```tsx
|
|
71
|
+
* const options = createOptions(channels, {
|
|
72
|
+
* getValue: (ch) => ch.id,
|
|
73
|
+
* getLabel: (ch) => ch.title,
|
|
74
|
+
* getDescription: (ch) => ch.phone,
|
|
75
|
+
* })
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function createOptions<T>(
|
|
79
|
+
items: T[],
|
|
80
|
+
config: OptionBuilderConfig<T>
|
|
81
|
+
): (MultiSelectProOption | MultiSelectProAsyncOption)[] {
|
|
82
|
+
const builder = createOption(config)
|
|
83
|
+
return items.map(builder)
|
|
84
|
+
}
|