/** biome-ignore-all lint/a11y/noStaticElementInteractions: temporary suppress until in active use */ /** biome-ignore-all lint/a11y/useKeyWithClickEvents: temporary suppress until in active use */ "use client"; import { Command as CommandPrimitive, useCommandState } from "cmdk"; import * as React from "react"; import { forwardRef, useEffect } from "react"; import { LuX } from "react-icons/lu"; import { cn } from "../lib/utils"; import { Badge } from "./ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command"; export interface Option { value: string; label?: string; 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; onChange?: (options: 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" >; } export interface MultipleSelectorRef { selectedValue: Option[]; input: HTMLInputElement; } // eslint-disable-next-line react-refresh/only-export-components 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 = {}; options.forEach((option) => { const key = (option[groupBy] as string) || ""; if (!groupOption[key]) { groupOption[key] = [option]; } else { 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, 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, }: MultipleSelectorProps, ref: React.Ref, ) => { const inputRef = React.useRef(null); const [open, setOpen] = React.useState(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 as HTMLInputElement, focus: () => inputRef.current?.focus(), }), [selected], ); 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?.fixed) { // biome-ignore lint/style/noNonNullAssertion: false positive handleUnselect(selected.at(-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); } }, [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, onSearch]); 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 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: 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 ?? option.value} ); })} {/* Avoid having the "Search" Icon */} { setInputValue(value); inputProps?.onValueChange?.(value); }} onKeyDown={(e) => { // Allow consumer to handle first inputProps?.onKeyDown?.( e as unknown as React.KeyboardEvent, ); if (e.defaultPrevented) return; if (e.key === "Enter") { const value = inputValue.trim(); if (value.length === 0) return; // If option already exists among available options, pick that; otherwise create const entries = Object.values(options).flat(); const existing = entries.find( (o) => o.value === value && !o.disable, ); // Prevent duplicates in the current selection if ( selected.some((s) => s.value === (existing?.value ?? value)) ) { e.preventDefault(); setInputValue(""); return; } if (selected.length >= maxSelected) { onMaxSelected?.(selected.length); return; } e.preventDefault(); setInputValue(""); const picked = existing ?? { value, label: value }; const newOptions = [...selected, picked]; setSelected(newOptions); onChange?.(newOptions); } }} onBlur={(event) => { setOpen(false); inputProps?.onBlur?.(event); }} onFocus={(event) => { setOpen(true); if (triggerSearchOnFocus && onSearch) { 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 && ( {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 ?? option.value} ); })} ))} )} )}
); }, ); MultipleSelector.displayName = "MultipleSelector"; export default MultipleSelector;