"use client"; import { Command as CommandPrimitive, useCommandState } from "cmdk"; import { X } from "lucide-react"; import * as React from "react"; import { forwardRef, useEffect } from "react"; import { Badge } from "@/components/ui/badge"; import { Command, CommandGroup, CommandItem, CommandList, } from "@/components/ui/command"; import { cn } from "@/lib/utils"; const CommandEmpty = forwardRef(({ className, ...props }, forwardedRef) => { const render = useCommandState((state) => state.filtered.count === 0); if (!render) return null; return (
); }); CommandEmpty.displayName = "CommandEmpty"; const MultipleSelector = React.forwardRef( ( { value, onChange, placeholder, defaultOptions: arrayDefaultOptions = [], options: arrayOptions, delay, onSearch, loadingIndicator, emptyIndicator, maxSelected = Number.MAX_SAFE_INTEGER, onMaxSelected, hidePlaceholderWhenSelected, disabled, groupBy, className, badgeClassName, selectFirstItem = true, creatable = false, triggerSearchOnFocus = false, commandProps, inputProps, hideClearAllButton = false, }, ref ) => { const inputRef = React.useRef(null); const [open, setOpen] = React.useState(false); const mouseOn = React.useRef(false); const [isLoading, setIsLoading] = React.useState(false); const [selected, setSelected] = React.useState(value || []); const [options, setOptions] = React.useState( transToGroupOption(arrayDefaultOptions, groupBy) ); const [inputValue, setInputValue] = React.useState(""); const debouncedSearchTerm = useDebounce(inputValue, delay || 500); React.useImperativeHandle( ref, () => ({ selectedValue: [...selected], input: inputRef.current, focus: () => inputRef.current?.focus(), }), [selected] ); const handleUnselect = React.useCallback( (option) => { const newOptions = selected.filter((s) => s.value !== option.value); setSelected(newOptions); onChange?.(newOptions); }, [onChange, selected] ); const handleKeyDown = React.useCallback( (e) => { const input = inputRef.current; if (input) { if (e.key === "Delete" || e.key === "Backspace") { if (input.value === "" && selected.length > 0) { const lastSelectOption = selected[selected.length - 1]; // If last item is fixed, we should not remove it. if (!lastSelectOption.fixed) { handleUnselect(selected[selected.length - 1]); } } } // This is not a default behavior of the field if (e.key === "Escape") { input.blur(); } } }, [handleUnselect, selected] ); useEffect(() => { if (value) { setSelected(value); } }, [value]); useEffect(() => { /** If `onSearch` is provided, do not trigger options updated. */ if (!arrayOptions || onSearch) { return; } const newOption = transToGroupOption(arrayOptions || [], groupBy); if (JSON.stringify(newOption) !== JSON.stringify(options)) { setOptions(newOption); } }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); useEffect(() => { const doSearch = async () => { setIsLoading(true); const res = await onSearch?.(debouncedSearchTerm); setOptions(transToGroupOption(res || [], groupBy)); setIsLoading(false); }; const exec = async () => { if (!onSearch || !open) return; if (triggerSearchOnFocus) { await doSearch(); } if (debouncedSearchTerm) { await doSearch(); } }; void exec(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); const CreatableItem = () => { if (!creatable) return undefined; if ( isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || selected.find((s) => s.value === inputValue) ) { return undefined; } const Item = ( { e.preventDefault(); e.stopPropagation(); }} onSelect={(value) => { if (selected.length >= maxSelected) { onMaxSelected?.(selected.length); return; } setInputValue(""); const newOptions = [...selected, { value, label: value }]; setSelected(newOptions); onChange?.(newOptions); }} > {`Create "${inputValue}"`} ); // For normal creatable if (!onSearch && inputValue.length > 0) { return Item; } // For async search creatable. avoid showing creatable item before loading at first. if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { return Item; } return undefined; }; const EmptyItem = React.useCallback(() => { if (!emptyIndicator) return undefined; // For async search that showing emptyIndicator if (onSearch && !creatable && Object.keys(options).length === 0) { return ( {emptyIndicator} ); } return {emptyIndicator}; }, [creatable, emptyIndicator, onSearch, options]); const selectables = React.useMemo( () => removePickedOption(options, selected), [options, selected] ); /** Avoid Creatable Selector freezing or lagging when paste a long string. */ const commandFilter = React.useCallback(() => { if (commandProps?.filter) { return commandProps.filter; } if (creatable) { return (value, search) => { return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; }; } // Using default filter in `cmdk`. We don't have to provide it. return undefined; }, [creatable, commandProps?.filter]); return ( { handleKeyDown(e); commandProps?.onKeyDown?.(e); }} className={cn( "h-auto overflow-visible bg-transparent", commandProps?.className )} shouldFilter={ commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch } // When onSearch is provided, we don't want to filter the options. You can still override it. filter={commandFilter()} >
{ if (disabled) return; inputRef.current?.focus(); }} >
{selected.map((option) => { return ( {option.label} ); })} {/* Avoid having the "Search" Icon */} { setInputValue(value); inputProps?.onValueChange?.(value); }} onBlur={(event) => { if (mouseOn.current === false) { setOpen(false); } inputProps?.onBlur?.(event); }} onFocus={(event) => { setOpen(true); triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); inputProps?.onFocus?.(event); }} placeholder={ hidePlaceholderWhenSelected && selected.length !== 0 ? "" : placeholder } className={cn( "flex-1 bg-transparent outline-none placeholder:text-muted-foreground", { "w-full": hidePlaceholderWhenSelected, "px-3 py-2": selected.length === 0, "ml-1": selected.length !== 0, }, inputProps?.className )} />
{open && ( { mouseOn.current = false; }} onMouseEnter={() => { mouseOn.current = true; }} onMouseUp={() => { inputRef.current?.focus(); }} > {isLoading ? ( <>{loadingIndicator} ) : ( <> {EmptyItem()} {CreatableItem()} {!selectFirstItem && ( )} {Object.entries(selectables).map(([key, dropdowns]) => ( <> {dropdowns.map((option) => { return ( { e.preventDefault(); e.stopPropagation(); }} onSelect={() => { if (selected.length >= maxSelected) { onMaxSelected?.(selected.length); return; } setInputValue(""); const newOptions = [...selected, option]; setSelected(newOptions); onChange?.(newOptions); }} className={cn( "cursor-pointer", option.disable && "cursor-default text-muted-foreground" )} > {option.label} ); })} ))} )} )}
); } ); MultipleSelector.displayName = "MultipleSelector"; export default MultipleSelector; export function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = React.useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay || 500); return () => { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; } function transToGroupOption(options, groupBy) { if (options.length === 0) { return {}; } if (!groupBy) { return { "": options, }; } const groupOption = {}; options.forEach((option) => { const key = option[groupBy] || ""; if (!groupOption[key]) { groupOption[key] = []; } groupOption[key].push(option); }); return groupOption; } function removePickedOption(groupOption, picked) { const cloneOption = JSON.parse(JSON.stringify(groupOption)); for (const [key, value] of Object.entries(cloneOption)) { cloneOption[key] = value.filter( (val) => !picked.find((p) => p.value === val.value) ); } return cloneOption; } function isOptionsExist(groupOption, targetOption) { for (const [, value] of Object.entries(groupOption)) { if ( value.some((option) => targetOption.find((p) => p.value === option.value)) ) { return true; } } return false; }