@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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/package.json +110 -0
  4. package/src/animations/AnimatedBackground.tsx +645 -0
  5. package/src/animations/index.ts +2 -0
  6. package/src/blocks/ArticleCard.tsx +94 -0
  7. package/src/blocks/ArticleList.tsx +95 -0
  8. package/src/blocks/CTASection.tsx +136 -0
  9. package/src/blocks/FeatureSection.tsx +104 -0
  10. package/src/blocks/Hero.tsx +102 -0
  11. package/src/blocks/NewsletterSection.tsx +119 -0
  12. package/src/blocks/StatsSection.tsx +103 -0
  13. package/src/blocks/SuperHero.tsx +328 -0
  14. package/src/blocks/TestimonialSection.tsx +122 -0
  15. package/src/blocks/index.ts +9 -0
  16. package/src/components/README.md +2018 -0
  17. package/src/components/breadcrumb-navigation.tsx +127 -0
  18. package/src/components/breadcrumb.tsx +132 -0
  19. package/src/components/button-download.tsx +275 -0
  20. package/src/components/dropdown-menu.tsx +219 -0
  21. package/src/components/index.ts +86 -0
  22. package/src/components/markdown/MarkdownMessage.tsx +338 -0
  23. package/src/components/markdown/index.ts +5 -0
  24. package/src/components/menubar.tsx +274 -0
  25. package/src/components/multi-select-pro/async.tsx +608 -0
  26. package/src/components/multi-select-pro/helpers.tsx +84 -0
  27. package/src/components/multi-select-pro/index.tsx +622 -0
  28. package/src/components/navigation-menu.tsx +153 -0
  29. package/src/components/pagination-static.tsx +348 -0
  30. package/src/components/pagination.tsx +138 -0
  31. package/src/components/phone-input.tsx +276 -0
  32. package/src/components/sidebar.tsx +866 -0
  33. package/src/components/sonner.tsx +31 -0
  34. package/src/components/ssr-pagination.tsx +237 -0
  35. package/src/hooks/index.ts +19 -0
  36. package/src/hooks/useCfgRouter.ts +153 -0
  37. package/src/hooks/useLocalStorage.ts +221 -0
  38. package/src/hooks/useQueryParams.ts +73 -0
  39. package/src/hooks/useSessionStorage.ts +188 -0
  40. package/src/hooks/useTheme.ts +57 -0
  41. package/src/index.ts +24 -0
  42. package/src/lib/index.ts +2 -0
  43. package/src/styles/index.css +2 -0
  44. package/src/theme/ForceTheme.tsx +115 -0
  45. package/src/theme/ThemeProvider.tsx +82 -0
  46. package/src/theme/ThemeToggle.tsx +52 -0
  47. package/src/theme/index.ts +3 -0
  48. package/src/tools/JsonForm/JsonSchemaForm.tsx +199 -0
  49. package/src/tools/JsonForm/examples/BotConfigExample.tsx +245 -0
  50. package/src/tools/JsonForm/examples/RealBotConfigExample.tsx +157 -0
  51. package/src/tools/JsonForm/index.ts +46 -0
  52. package/src/tools/JsonForm/templates/ArrayFieldItemTemplate.tsx +46 -0
  53. package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +73 -0
  54. package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +106 -0
  55. package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +34 -0
  56. package/src/tools/JsonForm/templates/FieldTemplate.tsx +61 -0
  57. package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +43 -0
  58. package/src/tools/JsonForm/templates/index.ts +12 -0
  59. package/src/tools/JsonForm/types.ts +83 -0
  60. package/src/tools/JsonForm/utils.ts +212 -0
  61. package/src/tools/JsonForm/widgets/CheckboxWidget.tsx +36 -0
  62. package/src/tools/JsonForm/widgets/NumberWidget.tsx +88 -0
  63. package/src/tools/JsonForm/widgets/SelectWidget.tsx +100 -0
  64. package/src/tools/JsonForm/widgets/SwitchWidget.tsx +34 -0
  65. package/src/tools/JsonForm/widgets/TextWidget.tsx +95 -0
  66. package/src/tools/JsonForm/widgets/index.ts +12 -0
  67. package/src/tools/JsonTree/index.tsx +252 -0
  68. package/src/tools/LottiePlayer/LottiePlayer.client.tsx +212 -0
  69. package/src/tools/LottiePlayer/index.tsx +54 -0
  70. package/src/tools/LottiePlayer/types.ts +108 -0
  71. package/src/tools/LottiePlayer/useLottie.ts +163 -0
  72. package/src/tools/Mermaid/Mermaid.client.tsx +341 -0
  73. package/src/tools/Mermaid/index.tsx +40 -0
  74. package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +144 -0
  75. package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +255 -0
  76. package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +123 -0
  77. package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +98 -0
  78. package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +164 -0
  79. package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +253 -0
  80. package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +169 -0
  81. package/src/tools/OpenapiViewer/components/VersionSelector.tsx +64 -0
  82. package/src/tools/OpenapiViewer/components/index.ts +14 -0
  83. package/src/tools/OpenapiViewer/constants.ts +39 -0
  84. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +338 -0
  85. package/src/tools/OpenapiViewer/hooks/index.ts +8 -0
  86. package/src/tools/OpenapiViewer/hooks/useMobile.ts +10 -0
  87. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +203 -0
  88. package/src/tools/OpenapiViewer/index.tsx +36 -0
  89. package/src/tools/OpenapiViewer/types.ts +152 -0
  90. package/src/tools/OpenapiViewer/utils/apiKeyManager.ts +149 -0
  91. package/src/tools/OpenapiViewer/utils/formatters.ts +71 -0
  92. package/src/tools/OpenapiViewer/utils/index.ts +9 -0
  93. package/src/tools/OpenapiViewer/utils/versionManager.ts +161 -0
  94. package/src/tools/PrettyCode/PrettyCode.client.tsx +217 -0
  95. package/src/tools/PrettyCode/index.tsx +43 -0
  96. package/src/tools/VideoPlayer/README.md +239 -0
  97. package/src/tools/VideoPlayer/VideoControls.tsx +138 -0
  98. package/src/tools/VideoPlayer/VideoPlayer.tsx +230 -0
  99. package/src/tools/VideoPlayer/index.ts +9 -0
  100. package/src/tools/VideoPlayer/types.ts +62 -0
  101. 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
+ }