1
+ // src/components/multi-select.tsx
2
+
3
+ import * as React from "react" ;
4
+ import { cva , type VariantProps } from "class-variance-authority" ;
5
+ import {
6
+ CheckIcon ,
7
+ XCircle ,
8
+ ChevronDown ,
9
+ XIcon ,
10
+ WandSparkles ,
11
+ } from "lucide-react" ;
12
+
13
+ import { cn } from "@/lib/utils" ;
14
+ import { Separator } from "@/components/ui/separator" ;
15
+ import { Button } from "@/components/ui/button" ;
16
+ import { Badge } from "@/components/ui/badge" ;
17
+ import {
18
+ Popover ,
19
+ PopoverContent ,
20
+ PopoverTrigger ,
21
+ } from "@/components/ui/popover" ;
22
+ import {
23
+ Command ,
24
+ CommandEmpty ,
25
+ CommandGroup ,
26
+ CommandInput ,
27
+ CommandItem ,
28
+ CommandList ,
29
+ CommandSeparator ,
30
+ } from "@/components/ui/command" ;
31
+
32
+ /**
33
+ * Variants for the multi-select component to handle different styles.
34
+ * Uses class-variance-authority (cva) to define different styles based on "variant" prop.
35
+ */
36
+ const multiSelectVariants = cva (
37
+ "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300" ,
38
+ {
39
+ variants : {
40
+ variant : {
41
+ default :
42
+ "border-foreground/10 text-foreground bg-card hover:bg-card/80" ,
43
+ secondary :
44
+ "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80" ,
45
+ destructive :
46
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80" ,
47
+ inverted : "inverted" ,
48
+ } ,
49
+ } ,
50
+ defaultVariants : {
51
+ variant : "default" ,
52
+ } ,
53
+ }
54
+ ) ;
55
+
56
+ /**
57
+ * Props for MultiSelect component
58
+ */
59
+ interface MultiSelectProps
60
+ extends React . ButtonHTMLAttributes < HTMLButtonElement > ,
61
+ VariantProps < typeof multiSelectVariants > {
62
+ /**
63
+ * An array of option objects to be displayed in the multi-select component.
64
+ * Each option object has a label, value, and an optional icon.
65
+ */
66
+ options : {
67
+ /** The text to display for the option. */
68
+ label : string ;
69
+ /** The unique value associated with the option. */
70
+ value : string ;
71
+ /** Optional icon component to display alongside the option. */
72
+ icon ?: React . ComponentType < { className ?: string } > ;
73
+ } [ ] ;
74
+
75
+ /**
76
+ * Callback function triggered when the selected values change.
77
+ * Receives an array of the new selected values.
78
+ */
79
+ onValueChange : ( value : string [ ] ) => void ;
80
+
81
+ /** The default selected values when the component mounts. */
82
+ defaultValue ?: string [ ] ;
83
+
84
+ /**
85
+ * Placeholder text to be displayed when no values are selected.
86
+ * Optional, defaults to "Select options".
87
+ */
88
+ placeholder ?: string ;
89
+
90
+ /**
91
+ * Animation duration in seconds for the visual effects (e.g., bouncing badges).
92
+ * Optional, defaults to 0 (no animation).
93
+ */
94
+ animation ?: number ;
95
+
96
+ /**
97
+ * Maximum number of items to display. Extra selected items will be summarized.
98
+ * Optional, defaults to 3.
99
+ */
100
+ maxCount ?: number ;
101
+
102
+ /**
103
+ * The modality of the popover. When set to true, interaction with outside elements
104
+ * will be disabled and only popover content will be visible to screen readers.
105
+ * Optional, defaults to false.
106
+ */
107
+ modalPopover ?: boolean ;
108
+
109
+ /**
110
+ * If true, renders the multi-select component as a child of another component.
111
+ * Optional, defaults to false.
112
+ */
113
+ asChild ?: boolean ;
114
+
115
+ /**
116
+ * Additional class names to apply custom styles to the multi-select component.
117
+ * Optional, can be used to add custom styles.
118
+ */
119
+ className ?: string ;
120
+ }
121
+
122
+ export const MultiSelect = React . forwardRef <
123
+ HTMLButtonElement ,
124
+ MultiSelectProps
125
+ > (
126
+ (
127
+ {
128
+ options,
129
+ onValueChange,
130
+ variant,
131
+ defaultValue = [ ] ,
132
+ placeholder = "Select options" ,
133
+ animation = 0 ,
134
+ maxCount = 3 ,
135
+ modalPopover = false ,
136
+ asChild = false ,
137
+ className,
138
+ ...props
139
+ } ,
140
+ ref
141
+ ) => {
142
+ const [ selectedValues , setSelectedValues ] =
143
+ React . useState < string [ ] > ( defaultValue ) ;
144
+ const [ isPopoverOpen , setIsPopoverOpen ] = React . useState ( false ) ;
145
+ const [ isAnimating , setIsAnimating ] = React . useState ( false ) ;
146
+
147
+ const handleInputKeyDown = (
148
+ event : React . KeyboardEvent < HTMLInputElement >
149
+ ) => {
150
+ if ( event . key === "Enter" ) {
151
+ setIsPopoverOpen ( true ) ;
152
+ } else if ( event . key === "Backspace" && ! event . currentTarget . value ) {
153
+ const newSelectedValues = [ ...selectedValues ] ;
154
+ newSelectedValues . pop ( ) ;
155
+ setSelectedValues ( newSelectedValues ) ;
156
+ onValueChange ( newSelectedValues ) ;
157
+ }
158
+ } ;
159
+
160
+ const toggleOption = ( option : string ) => {
161
+ const newSelectedValues = selectedValues . includes ( option )
162
+ ? selectedValues . filter ( ( value ) => value !== option )
163
+ : [ ...selectedValues , option ] ;
164
+ setSelectedValues ( newSelectedValues ) ;
165
+ onValueChange ( newSelectedValues ) ;
166
+ } ;
167
+
168
+ const handleClear = ( ) => {
169
+ setSelectedValues ( [ ] ) ;
170
+ onValueChange ( [ ] ) ;
171
+ } ;
172
+
173
+ const handleTogglePopover = ( ) => {
174
+ setIsPopoverOpen ( ( prev ) => ! prev ) ;
175
+ } ;
176
+
177
+ const clearExtraOptions = ( ) => {
178
+ const newSelectedValues = selectedValues . slice ( 0 , maxCount ) ;
179
+ setSelectedValues ( newSelectedValues ) ;
180
+ onValueChange ( newSelectedValues ) ;
181
+ } ;
182
+
183
+ const toggleAll = ( ) => {
184
+ if ( selectedValues . length === options . length ) {
185
+ handleClear ( ) ;
186
+ } else {
187
+ const allValues = options . map ( ( option ) => option . value ) ;
188
+ setSelectedValues ( allValues ) ;
189
+ onValueChange ( allValues ) ;
190
+ }
191
+ } ;
192
+
193
+ return (
194
+ < Popover
195
+ open = { isPopoverOpen }
196
+ onOpenChange = { setIsPopoverOpen }
197
+ modal = { modalPopover }
198
+ >
199
+ < PopoverTrigger asChild >
200
+ < Button
201
+ ref = { ref }
202
+ { ...props }
203
+ onClick = { handleTogglePopover }
204
+ className = { cn (
205
+ "flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit" ,
206
+ className
207
+ ) }
208
+ >
209
+ { selectedValues . length > 0 ? (
210
+ < div className = "flex justify-between items-center w-full" >
211
+ < div className = "flex flex-wrap items-center" >
212
+ { selectedValues . slice ( 0 , maxCount ) . map ( ( value ) => {
213
+ const option = options . find ( ( o ) => o . value === value ) ;
214
+ const IconComponent = option ?. icon ;
215
+ return (
216
+ < Badge
217
+ key = { value }
218
+ className = { cn (
219
+ isAnimating ? "animate-bounce" : "" ,
220
+ multiSelectVariants ( { variant } )
221
+ ) }
222
+ style = { { animationDuration : `${ animation } s` } }
223
+ >
224
+ { IconComponent && (
225
+ < IconComponent className = "h-4 w-4 mr-2" />
226
+ ) }
227
+ { option ?. label }
228
+ < XCircle
229
+ className = "ml-2 h-4 w-4 cursor-pointer"
230
+ onClick = { ( event ) => {
231
+ event . stopPropagation ( ) ;
232
+ toggleOption ( value ) ;
233
+ } }
234
+ />
235
+ </ Badge >
236
+ ) ;
237
+ } ) }
238
+ { selectedValues . length > maxCount && (
239
+ < Badge
240
+ className = { cn (
241
+ "bg-transparent text-foreground border-foreground/1 hover:bg-transparent" ,
242
+ isAnimating ? "animate-bounce" : "" ,
243
+ multiSelectVariants ( { variant } )
244
+ ) }
245
+ style = { { animationDuration : `${ animation } s` } }
246
+ >
247
+ { `+ ${ selectedValues . length - maxCount } more` }
248
+ < XCircle
249
+ className = "ml-2 h-4 w-4 cursor-pointer"
250
+ onClick = { ( event ) => {
251
+ event . stopPropagation ( ) ;
252
+ clearExtraOptions ( ) ;
253
+ } }
254
+ />
255
+ </ Badge >
256
+ ) }
257
+ </ div >
258
+ < div className = "flex items-center justify-between" >
259
+ < XIcon
260
+ className = "h-4 mx-2 cursor-pointer text-muted-foreground"
261
+ onClick = { ( event ) => {
262
+ event . stopPropagation ( ) ;
263
+ handleClear ( ) ;
264
+ } }
265
+ />
266
+ < Separator
267
+ orientation = "vertical"
268
+ className = "flex min-h-6 h-full"
269
+ />
270
+ < ChevronDown className = "h-4 mx-2 cursor-pointer text-muted-foreground" />
271
+ </ div >
272
+ </ div >
273
+ ) : (
274
+ < div className = "flex items-center justify-between w-full mx-auto" >
275
+ < span className = "text-sm text-muted-foreground mx-3" >
276
+ { placeholder }
277
+ </ span >
278
+ < ChevronDown className = "h-4 cursor-pointer text-muted-foreground mx-2" />
279
+ </ div >
280
+ ) }
281
+ </ Button >
282
+ </ PopoverTrigger >
283
+ < PopoverContent
284
+ className = "w-auto p-0"
285
+ align = "start"
286
+ onEscapeKeyDown = { ( ) => setIsPopoverOpen ( false ) }
287
+ >
288
+ < Command >
289
+ < CommandInput
290
+ placeholder = "Search..."
291
+ onKeyDown = { handleInputKeyDown }
292
+ />
293
+ < CommandList >
294
+ < CommandEmpty > No results found.</ CommandEmpty >
295
+ < CommandGroup >
296
+ < CommandItem
297
+ key = "all"
298
+ onSelect = { toggleAll }
299
+ className = "cursor-pointer"
300
+ >
301
+ < div
302
+ className = { cn (
303
+ "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary" ,
304
+ selectedValues . length === options . length
305
+ ? "bg-primary text-primary-foreground"
306
+ : "opacity-50 [&_svg]:invisible"
307
+ ) }
308
+ >
309
+ < CheckIcon className = "h-4 w-4" />
310
+ </ div >
311
+ < span > (Select All)</ span >
312
+ </ CommandItem >
313
+ { options . map ( ( option ) => {
314
+ const isSelected = selectedValues . includes ( option . value ) ;
315
+ return (
316
+ < CommandItem
317
+ key = { option . value }
318
+ onSelect = { ( ) => toggleOption ( option . value ) }
319
+ className = "cursor-pointer"
320
+ >
321
+ < div
322
+ className = { cn (
323
+ "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary" ,
324
+ isSelected
325
+ ? "bg-primary text-primary-foreground"
326
+ : "opacity-50 [&_svg]:invisible"
327
+ ) }
328
+ >
329
+ < CheckIcon className = "h-4 w-4" />
330
+ </ div >
331
+ { option . icon && (
332
+ < option . icon className = "mr-2 h-4 w-4 text-muted-foreground" />
333
+ ) }
334
+ < span > { option . label } </ span >
335
+ </ CommandItem >
336
+ ) ;
337
+ } ) }
338
+ </ CommandGroup >
339
+ < CommandSeparator />
340
+ < CommandGroup >
341
+ < div className = "flex items-center justify-between" >
342
+ { selectedValues . length > 0 && (
343
+ < >
344
+ < CommandItem
345
+ onSelect = { handleClear }
346
+ className = "flex-1 justify-center cursor-pointer"
347
+ >
348
+ Clear
349
+ </ CommandItem >
350
+ < Separator
351
+ orientation = "vertical"
352
+ className = "flex min-h-6 h-full"
353
+ />
354
+ </ >
355
+ ) }
356
+ < CommandItem
357
+ onSelect = { ( ) => setIsPopoverOpen ( false ) }
358
+ className = "flex-1 justify-center cursor-pointer max-w-full"
359
+ >
360
+ Close
361
+ </ CommandItem >
362
+ </ div >
363
+ </ CommandGroup >
364
+ </ CommandList >
365
+ </ Command >
366
+ </ PopoverContent >
367
+ { animation > 0 && selectedValues . length > 0 && (
368
+ < WandSparkles
369
+ className = { cn (
370
+ "cursor-pointer my-2 text-foreground bg-background w-3 h-3" ,
371
+ isAnimating ? "" : "text-muted-foreground"
372
+ ) }
373
+ onClick = { ( ) => setIsAnimating ( ! isAnimating ) }
374
+ />
375
+ ) }
376
+ </ Popover >
377
+ ) ;
378
+ }
379
+ ) ;
380
+
381
+ MultiSelect . displayName = "MultiSelect" ;
0 commit comments