Multi Selector

Multiple Selector using shadcn/ui components

Thanks to Sersavan

Selected Frameworks:

  • nextjs
  • svelte

Usages

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

Props

PropTypeDefaultDescription
options{ label: string; value: string; icon?: React.ComponentType; disable?: boolean; }[][]An array of option objects displayed in the multi-select. Each object has a label, value, optional icon, and optional disable flag.
onValueChange(value: string[]) => voidundefinedCallback triggered when the selected values change. Receives an array of selected values.
defaultValuestring[][]The default selected values when the component mounts.
placeholderstring"Select options"Text displayed when no values are selected.
animationnumber0Duration of animation effects (in seconds) for visual feedback like bouncing badges.
maxCountnumber3Maximum number of items to display before summarizing the rest.
modalPopoverbooleanfalseEnables modal behavior for the popover, disabling interaction with outside elements and enhancing accessibility for screen readers.
asChildbooleanfalseRenders the multi-select as a child element of another component.
classNamestringundefinedAdditional CSS classes for custom styling of the multi-select component.
popoverClassstringundefinedAdditional CSS classes for custom styling of the popover content.
showallbooleanfalseOption to display all items without truncating the list, regardless of the maxCount setting.
refReact.RefObject<HTMLButtonElement>undefinedA React ref object for the root element of the multi-select component, allowing for external access to the component’s DOM node.