Fast, composable, fully-featured multiple selector for React.
1"use client";
2
3import { Command as CommandPrimitive, useCommandState } from "cmdk";
4import { X } from "lucide-react";
5import * as React from "react";
6import { forwardRef, useEffect } from "react";
7
8import { Badge } from "@/components/ui/badge";
9import {
10 Command,
11 CommandGroup,
12 CommandItem,
13 CommandList,
14} from "@/components/ui/command";
15import { cn } from "@/lib/utils";
16
17export interface Option {
18 value: string;
19 label: string;
20 disable?: boolean;
21 /** fixed option that can't be removed. */
22 fixed?: boolean;
23 /** Group the options by providing key. */
24 [key: string]: string | boolean | undefined;
25}
26interface GroupOption {
27 [key: string]: Option[];
28}
29
30interface MultiSelectProps {
31 value?: Option[];
32 defaultOptions?: Option[];
33 /** manually controlled options */
34 options?: Option[];
35 placeholder?: string;
36 /** Loading component. */
37 loadingIndicator?: React.ReactNode;
38 /** Empty component. */
39 emptyIndicator?: React.ReactNode;
40 /** Debounce time for async search. Only work with `onSearch`. */
41 delay?: number;
42 /**
43 * Only work with `onSearch` prop. Trigger search when `onFocus`.
44 * For example, when user click on the input, it will trigger the search to get initial options.
45 **/
46 triggerSearchOnFocus?: boolean;
47 /** async search */
48 onSearch?: (value: string) => Promise<Option[]>;
49 /**
50 * sync search. This search will not showing loadingIndicator.
51 * The rest props are the same as async search.
52 * i.e.: creatable, groupBy, delay.
53 **/
54 onSearchSync?: (value: string) => Option[];
55 onChange?: (options: Option[]) => void;
56 /** Limit the maximum number of selected options. */
57 maxSelected?: number;
58 /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
59 onMaxSelected?: (maxLimit: number) => void;
60 /** Hide the placeholder when there are options selected. */
61 hidePlaceholderWhenSelected?: boolean;
62 disabled?: boolean;
63 /** Group the options base on provided key. */
64 groupBy?: string;
65 className?: string;
66 badgeClassName?: string;
67 /**
68 * First item selected is a default behavior by cmdk. That is why the default is true.
69 * This is a workaround solution by add a dummy item.
70 *
71 * @reference: https://github.com/pacocoursey/cmdk/issues/171
72 */
73 selectFirstItem?: boolean;
74 /** Allow user to create option when there is no option matched. */
75 creatable?: boolean;
76 /** Props of `Command` */
77 commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
78 /** Props of `CommandInput` */
79 inputProps?: Omit<
80 React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
81 "value" | "placeholder" | "disabled"
82 >;
83 /** hide the clear all button. */
84 hideClearAllButton?: boolean;
85}
86
87export interface MultiSelectRef {
88 selectedValue: Option[];
89 input: HTMLInputElement;
90 focus: () => void;
91 reset: () => void;
92}
93
94export function useDebounce<T>(value: T, delay?: number): T {
95 const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
96
97 useEffect(() => {
98 const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
99
100 return () => {
101 clearTimeout(timer);
102 };
103 }, [value, delay]);
104
105 return debouncedValue;
106}
107
108function transToGroupOption(options: Option[], groupBy?: string) {
109 if (options.length === 0) {
110 return {};
111 }
112 if (!groupBy) {
113 return {
114 "": options,
115 };
116 }
117
118 const groupOption: GroupOption = {};
119 options.forEach((option) => {
120 const key = (option[groupBy] as string) || "";
121 if (!groupOption[key]) {
122 groupOption[key] = [];
123 }
124 groupOption[key].push(option);
125 });
126 return groupOption;
127}
128
129function removePickedOption(groupOption: GroupOption, picked: Option[]) {
130 const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
131
132 for (const [key, value] of Object.entries(cloneOption)) {
133 cloneOption[key] = value.filter(
134 (val) => !picked.find((p) => p.value === val.value)
135 );
136 }
137 return cloneOption;
138}
139
140function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
141 for (const [, value] of Object.entries(groupOption)) {
142 if (
143 value.some((option) => targetOption.find((p) => p.value === option.value))
144 ) {
145 return true;
146 }
147 }
148 return false;
149}
150
151/**
152 * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
153 * So we create one and copy the `Empty` implementation from `cmdk`.
154 *
155 * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
156 **/
157const CommandEmpty = forwardRef<
158 HTMLDivElement,
159 React.ComponentProps<typeof CommandPrimitive.Empty>
160>(({ className, ...props }, forwardedRef) => {
161 const render = useCommandState((state) => state.filtered.count === 0);
162
163 if (!render) return null;
164
165 return (
166 <div
167 ref={forwardedRef}
168 className={cn("py-6 text-center text-sm", className)}
169 cmdk-empty=""
170 role="presentation"
171 {...props}
172 />
173 );
174});
175
176CommandEmpty.displayName = "CommandEmpty";
177
178const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
179 (
180 {
181 value,
182 onChange,
183 placeholder,
184 defaultOptions: arrayDefaultOptions = [],
185 options: arrayOptions,
186 delay,
187 onSearch,
188 onSearchSync,
189 loadingIndicator,
190 emptyIndicator,
191 maxSelected = Number.MAX_SAFE_INTEGER,
192 onMaxSelected,
193 hidePlaceholderWhenSelected,
194 disabled,
195 groupBy,
196 className,
197 badgeClassName,
198 selectFirstItem = true,
199 creatable = false,
200 triggerSearchOnFocus = false,
201 commandProps,
202 inputProps,
203 hideClearAllButton = false,
204 }: MultiSelectProps,
205 ref: React.Ref<MultiSelectRef>
206 ) => {
207 const inputRef = React.useRef<HTMLInputElement>(null);
208 const [open, setOpen] = React.useState(false);
209 const [onScrollbar, setOnScrollbar] = React.useState(false);
210 const [isLoading, setIsLoading] = React.useState(false);
211 const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
212
213 const [selected, setSelected] = React.useState<Option[]>(value || []);
214 const [options, setOptions] = React.useState<GroupOption>(
215 transToGroupOption(arrayDefaultOptions, groupBy)
216 );
217 const [inputValue, setInputValue] = React.useState("");
218 const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
219
220 React.useImperativeHandle(
221 ref,
222 () => ({
223 selectedValue: [...selected],
224 input: inputRef.current as HTMLInputElement,
225 focus: () => inputRef?.current?.focus(),
226 reset: () => setSelected([]),
227 }),
228 [selected]
229 );
230
231 const handleClickOutside = (event: MouseEvent | TouchEvent) => {
232 if (
233 dropdownRef.current &&
234 !dropdownRef.current.contains(event.target as Node) &&
235 inputRef.current &&
236 !inputRef.current.contains(event.target as Node)
237 ) {
238 setOpen(false);
239 inputRef.current.blur();
240 }
241 };
242
243 const handleUnselect = React.useCallback(
244 (option: Option) => {
245 const newOptions = selected.filter((s) => s.value !== option.value);
246 setSelected(newOptions);
247 onChange?.(newOptions);
248 },
249 [onChange, selected]
250 );
251
252 const handleKeyDown = React.useCallback(
253 (e: React.KeyboardEvent<HTMLDivElement>) => {
254 const input = inputRef.current;
255 if (input) {
256 if (e.key === "Delete" || e.key === "Backspace") {
257 if (input.value === "" && selected.length > 0) {
258 const lastSelectOption = selected[selected.length - 1];
259 // If last item is fixed, we should not remove it.
260 if (!lastSelectOption.fixed) {
261 handleUnselect(selected[selected.length - 1]);
262 }
263 }
264 }
265 // This is not a default behavior of the <input /> field
266 if (e.key === "Escape") {
267 input.blur();
268 }
269 }
270 },
271 [handleUnselect, selected]
272 );
273
274 useEffect(() => {
275 if (open) {
276 document.addEventListener("mousedown", handleClickOutside);
277 document.addEventListener("touchend", handleClickOutside);
278 } else {
279 document.removeEventListener("mousedown", handleClickOutside);
280 document.removeEventListener("touchend", handleClickOutside);
281 }
282
283 return () => {
284 document.removeEventListener("mousedown", handleClickOutside);
285 document.removeEventListener("touchend", handleClickOutside);
286 };
287 }, [open]);
288
289 useEffect(() => {
290 if (value) {
291 setSelected(value);
292 }
293 }, [value]);
294
295 useEffect(() => {
296 /** If `onSearch` is provided, do not trigger options updated. */
297 if (!arrayOptions || onSearch) {
298 return;
299 }
300 const newOption = transToGroupOption(arrayOptions || [], groupBy);
301 if (JSON.stringify(newOption) !== JSON.stringify(options)) {
302 setOptions(newOption);
303 }
304 }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
305
306 useEffect(() => {
307 /** sync search */
308
309 const doSearchSync = () => {
310 const res = onSearchSync?.(debouncedSearchTerm);
311 setOptions(transToGroupOption(res || [], groupBy));
312 };
313
314 const exec = async () => {
315 if (!onSearchSync || !open) return;
316
317 if (triggerSearchOnFocus) {
318 doSearchSync();
319 }
320
321 if (debouncedSearchTerm) {
322 doSearchSync();
323 }
324 };
325
326 void exec();
327 // eslint-disable-next-line react-hooks/exhaustive-deps
328 }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
329
330 useEffect(() => {
331 /** async search */
332
333 const doSearch = async () => {
334 setIsLoading(true);
335 const res = await onSearch?.(debouncedSearchTerm);
336 setOptions(transToGroupOption(res || [], groupBy));
337 setIsLoading(false);
338 };
339
340 const exec = async () => {
341 if (!onSearch || !open) return;
342
343 if (triggerSearchOnFocus) {
344 await doSearch();
345 }
346
347 if (debouncedSearchTerm) {
348 await doSearch();
349 }
350 };
351
352 void exec();
353 // eslint-disable-next-line react-hooks/exhaustive-deps
354 }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
355
356 const CreatableItem = () => {
357 if (!creatable) return undefined;
358 if (
359 isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
360 selected.find((s) => s.value === inputValue)
361 ) {
362 return undefined;
363 }
364
365 const Item = (
366 <CommandItem
367 value={inputValue}
368 className="cursor-pointer"
369 onMouseDown={(e) => {
370 e.preventDefault();
371 e.stopPropagation();
372 }}
373 onSelect={(value: string) => {
374 if (selected.length >= maxSelected) {
375 onMaxSelected?.(selected.length);
376 return;
377 }
378 setInputValue("");
379 const newOptions = [...selected, { value, label: value }];
380 setSelected(newOptions);
381 onChange?.(newOptions);
382 }}
383 >
384 {`Create "${inputValue}"`}
385 </CommandItem>
386 );
387
388 // For normal creatable
389 if (!onSearch && inputValue.length > 0) {
390 return Item;
391 }
392
393 // For async search creatable. avoid showing creatable item before loading at first.
394 if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
395 return Item;
396 }
397
398 return undefined;
399 };
400
401 const EmptyItem = React.useCallback(() => {
402 if (!emptyIndicator) return undefined;
403
404 // For async search that showing emptyIndicator
405 if (onSearch && !creatable && Object.keys(options).length === 0) {
406 return (
407 <CommandItem value="-" disabled>
408 {emptyIndicator}
409 </CommandItem>
410 );
411 }
412
413 return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
414 }, [creatable, emptyIndicator, onSearch, options]);
415
416 const selectables = React.useMemo<GroupOption>(
417 () => removePickedOption(options, selected),
418 [options, selected]
419 );
420
421 /** Avoid Creatable Selector freezing or lagging when paste a long string. */
422 const commandFilter = React.useCallback(() => {
423 if (commandProps?.filter) {
424 return commandProps.filter;
425 }
426
427 if (creatable) {
428 return (value: string, search: string) => {
429 return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
430 };
431 }
432 // Using default filter in `cmdk`. We don't have to provide it.
433 return undefined;
434 }, [creatable, commandProps?.filter]);
435
436 return (
437 <Command
438 ref={dropdownRef}
439 {...commandProps}
440 onKeyDown={(e) => {
441 handleKeyDown(e);
442 commandProps?.onKeyDown?.(e);
443 }}
444 className={cn(
445 "h-auto overflow-visible bg-transparent",
446 commandProps?.className
447 )}
448 shouldFilter={
449 commandProps?.shouldFilter !== undefined
450 ? commandProps.shouldFilter
451 : !onSearch
452 } // When onSearch is provided, we don't want to filter the options. You can still override it.
453 filter={commandFilter()}
454 >
455 <div
456 className={cn(
457 "min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
458 {
459 "px-3 py-2": selected.length !== 0,
460 "cursor-text": !disabled && selected.length !== 0,
461 },
462 className
463 )}
464 onClick={() => {
465 if (disabled) return;
466 inputRef?.current?.focus();
467 }}
468 >
469 <div className="relative flex flex-wrap gap-1">
470 {selected.map((option) => {
471 return (
472 <Badge
473 key={option.value}
474 className={cn(
475 "data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
476 "data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
477 badgeClassName
478 )}
479 data-fixed={option.fixed}
480 data-disabled={disabled || undefined}
481 >
482 {option.label}
483 <button
484 className={cn(
485 "ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
486 (disabled || option.fixed) && "hidden"
487 )}
488 onKeyDown={(e) => {
489 if (e.key === "Enter") {
490 handleUnselect(option);
491 }
492 }}
493 onMouseDown={(e) => {
494 e.preventDefault();
495 e.stopPropagation();
496 }}
497 onClick={() => handleUnselect(option)}
498 >
499 <X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
500 </button>
501 </Badge>
502 );
503 })}
504 {/* Avoid having the "Search" Icon */}
505 <CommandPrimitive.Input
506 {...inputProps}
507 ref={inputRef}
508 value={inputValue}
509 disabled={disabled}
510 onValueChange={(value) => {
511 setInputValue(value);
512 inputProps?.onValueChange?.(value);
513 }}
514 onBlur={(event) => {
515 if (!onScrollbar) {
516 setOpen(false);
517 }
518 inputProps?.onBlur?.(event);
519 }}
520 onFocus={(event) => {
521 setOpen(true);
522 triggerSearchOnFocus && onSearch?.(debouncedSearchTerm);
523 inputProps?.onFocus?.(event);
524 }}
525 placeholder={
526 hidePlaceholderWhenSelected && selected.length !== 0
527 ? ""
528 : placeholder
529 }
530 className={cn(
531 "flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
532 {
533 "w-full": hidePlaceholderWhenSelected,
534 "px-3 py-2": selected.length === 0,
535 "ml-1": selected.length !== 0,
536 },
537 inputProps?.className
538 )}
539 />
540 <button
541 type="button"
542 onClick={() => {
543 setSelected(selected.filter((s) => s.fixed));
544 onChange?.(selected.filter((s) => s.fixed));
545 }}
546 className={cn(
547 "absolute right-0 h-6 w-6 p-0",
548 (hideClearAllButton ||
549 disabled ||
550 selected.length < 1 ||
551 selected.filter((s) => s.fixed).length === selected.length) &&
552 "hidden"
553 )}
554 >
555 <X />
556 </button>
557 </div>
558 </div>
559 <div className="relative">
560 {open && (
561 <CommandList
562 className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
563 onMouseLeave={() => {
564 setOnScrollbar(false);
565 }}
566 onMouseEnter={() => {
567 setOnScrollbar(true);
568 }}
569 onMouseUp={() => {
570 inputRef?.current?.focus();
571 }}
572 >
573 {isLoading ? (
574 <>{loadingIndicator}</>
575 ) : (
576 <>
577 {EmptyItem()}
578 {CreatableItem()}
579 {!selectFirstItem && (
580 <CommandItem value="-" className="hidden" />
581 )}
582 {Object.entries(selectables).map(([key, dropdowns]) => (
583 <CommandGroup
584 key={key}
585 heading={key}
586 className="h-full overflow-auto"
587 >
588 <>
589 {dropdowns.map((option) => {
590 return (
591 <CommandItem
592 key={option.value}
593 value={option.value}
594 disabled={option.disable}
595 onMouseDown={(e) => {
596 e.preventDefault();
597 e.stopPropagation();
598 }}
599 onSelect={() => {
600 if (selected.length >= maxSelected) {
601 onMaxSelected?.(selected.length);
602 return;
603 }
604 setInputValue("");
605 const newOptions = [...selected, option];
606 setSelected(newOptions);
607 onChange?.(newOptions);
608 }}
609 className={cn(
610 "cursor-pointer",
611 option.disable &&
612 "cursor-default text-muted-foreground"
613 )}
614 >
615 {option.label}
616 </CommandItem>
617 );
618 })}
619 </>
620 </CommandGroup>
621 ))}
622 </>
623 )}
624 </CommandList>
625 )}
626 </div>
627 </Command>
628 );
629 }
630);
631
632MultiSelect.displayName = "MultiSelect";
633export default MultiSelect;
634