@djangocfg/ui-core 2.1.89 → 2.1.91

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