"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 { cn } from "../utils"; import { Badge } from "./badge"; import { Command, CommandGroup, CommandItem, CommandList } from "./command"; export interface Option { value: string; label: string; create?: boolean; disable?: boolean; /** fixed option that can't be removed. */ fixed?: boolean; /** Group the options by providing key. */ [key: string]: string | boolean | undefined; } interface GroupOption { [key: string]: Option[]; } interface MultipleSelectorProps { value?: Option[]; defaultOptions?: Option[]; /** manually controlled options */ options?: Option[]; placeholder?: string; /** Loading component. */ loadingIndicator?: React.ReactNode; /** Empty component. */ emptyIndicator?: React.ReactNode; /** Debounce time for async search. Only work with `onSearch`. */ delay?: number; /** * Only work with `onSearch` prop. Trigger search when `onFocus`. * For example, when user click on the input, it will trigger the search to get initial options. **/ triggerSearchOnFocus?: boolean; /** async search */ onSearch?: (value: string) => Promise; /** * sync search. This search will not showing loadingIndicator. * The rest props are the same as async search. * i.e.: creatable, groupBy, delay. **/ onSearchSync?: (value: string) => Option[]; onChange?: (options: Option[]) => void; onCreate?: (option: Option) => void; /** Limit the maximum number of selected options. */ maxSelected?: number; /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ onMaxSelected?: (maxLimit: number) => void; /** Hide the placeholder when there are options selected. */ hidePlaceholderWhenSelected?: boolean; disabled?: boolean; /** Group the options base on provided key. */ groupBy?: string; className?: string; badgeClassName?: string; /** * First item selected is a default behavior by cmdk. That is why the default is true. * This is a workaround solution by add a dummy item. * * @reference: https://github.com/pacocoursey/cmdk/issues/171 */ selectFirstItem?: boolean; /** Allow user to create option when there is no option matched. */ creatable?: boolean; /** Props of `Command` */ commandProps?: React.ComponentPropsWithoutRef; /** Props of `CommandInput` */ inputProps?: Omit< React.ComponentPropsWithoutRef, "value" | "placeholder" | "disabled" >; renderOption?: (option: Option) => React.ReactNode; } export interface MultipleSelectorRef { selectedValue: Option[]; input: HTMLInputElement; focus: () => void; reset: () => void; } export function useDebounce(value: T, delay?: number): T { const [debouncedValue, setDebouncedValue] = React.useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay || 500); return () => { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; } function transToGroupOption(options: Option[], groupBy?: string) { if (options.length === 0) { return {}; } if (!groupBy) { return { "": options, }; } const groupOption: GroupOption = {}; for (const option of options) { const key = (option[groupBy] as string) || ""; if (!groupOption[key]) { groupOption[key] = []; } groupOption[key].push(option); } return groupOption; } function removePickedOption(groupOption: GroupOption, picked: Option[]) { const cloneOption = JSON.parse(JSON.stringify(groupOption)) as 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: GroupOption, targetOption: Option[]) { for (const [, value] of Object.entries(groupOption)) { if ( value.some((option) => targetOption.find((p) => p.value === option.value)) ) { return true; } } return false; } /** * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. * So we create one and copy the `Empty` implementation from `cmdk`. * * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 **/ const CommandEmpty = forwardRef< HTMLDivElement, React.ComponentProps >(({ className, ...props }, forwardedRef) => { const render = useCommandState((state) => state.filtered.count === 0); if (!render) return null; return (
); }); CommandEmpty.displayName = "CommandEmpty"; const MultipleSelector = React.forwardRef< MultipleSelectorRef, MultipleSelectorProps >( ( { value, onChange, onCreate, placeholder, defaultOptions: arrayDefaultOptions = [], options: arrayOptions, delay, onSearch, onSearchSync, loadingIndicator, emptyIndicator, maxSelected = Number.MAX_SAFE_INTEGER, onMaxSelected, hidePlaceholderWhenSelected = true, disabled, groupBy, className, badgeClassName, selectFirstItem = true, creatable = false, triggerSearchOnFocus = false, commandProps, inputProps, renderOption, }: MultipleSelectorProps, ref: React.Ref, ) => { const inputRef = React.useRef(null); const [open, setOpen] = React.useState(false); const [onScrollbar, setOnScrollbar] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); const dropdownRef = React.useRef(null); // Added this 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 as HTMLInputElement, focus: () => inputRef?.current?.focus(), reset: () => setSelected([]), }), [selected], ); const handleClickOutside = (event: MouseEvent | TouchEvent) => { if ( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && inputRef.current && !inputRef.current.contains(event.target as Node) ) { setOpen(false); inputRef.current.blur(); } }; const handleUnselect = React.useCallback( (option: Option) => { const newOptions = selected.filter((s) => s.value !== option.value); setSelected(newOptions); onChange?.(newOptions); }, [onChange, selected], ); const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { 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 && !lastSelectOption.fixed) { handleUnselect(lastSelectOption); } } } // This is not a default behavior of the field if (e.key === "Escape") { input.blur(); } } }, [handleUnselect, selected], ); useEffect(() => { if (open) { document.addEventListener("mousedown", handleClickOutside); document.addEventListener("touchend", handleClickOutside); } else { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("touchend", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("touchend", handleClickOutside); }; }, [open]); 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(() => { /** sync search */ const doSearchSync = () => { const res = onSearchSync?.(debouncedSearchTerm); setOptions(transToGroupOption(res || [], groupBy)); }; const exec = async () => { if (!onSearchSync || !open) return; if (triggerSearchOnFocus) { doSearchSync(); } if (debouncedSearchTerm) { doSearchSync(); } }; void exec(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); useEffect(() => { /** async search */ 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: string) => { if (selected.length >= maxSelected) { onMaxSelected?.(selected.length); return; } setInputValue(""); const newOption = { value: inputValue, label: inputValue }; const newOptions = [...selected, newOption]; setSelected(newOptions); onChange?.(newOptions); onCreate?.(newOption); }} > {`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: string, search: string) => { 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 (!onScrollbar) { 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, "py-1": selected.length === 0, "ml-1": selected.length !== 0, }, inputProps?.className, )} />
{open && ( { setOnScrollbar(false); }} onMouseEnter={() => { setOnScrollbar(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 w-full", option.disable && "cursor-default text-muted-foreground", )} > {renderOption ? renderOption(option) : option.label} ); })} ))} )} )}
); }, ); MultipleSelector.displayName = "MultipleSelector"; export default MultipleSelector;