"use client"; import { Command as CommandPrimitive, useCommandState } from "cmdk"; import { ChevronDownIcon, X } from "lucide-react"; import * as React from "react"; import { createPortal } from "react-dom"; import { forwardRef, useEffect } from "react"; import { cn } from "../../utils/cn"; import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; export interface Option { value: string; label: string; /** Label to display in the badge when selected. Falls back to label if not provided. */ selectedLabel?: string; disable?: boolean; /** fixed option that can't be removed. */ fixed?: boolean; /** Group the options by providing key. */ [key: string]: unknown; } interface GroupOption { [key: string]: Option[]; } export 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; /** 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" >; /** hide the clear all button. */ hideClearAllButton?: boolean; /** Custom render function for options in the dropdown */ renderOption?: (option: Option) => React.ReactNode; /** Maximum number of badges to display before showing "+X more" */ maxDisplayCount?: number; /** Render dropdown in a portal to escape overflow containers (e.g., dialogs) */ usePortal?: boolean; } 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 = {}; options.forEach((option) => { 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>( ({ 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, onSearchSync, loadingIndicator, emptyIndicator, maxSelected = Number.MAX_SAFE_INTEGER, onMaxSelected, hidePlaceholderWhenSelected, disabled, groupBy, className, badgeClassName, selectFirstItem = true, creatable = false, triggerSearchOnFocus = false, commandProps, inputProps, hideClearAllButton = false, renderOption, maxDisplayCount, usePortal = false, }: MultipleSelectorProps, ref: React.Ref, ) => { const inputRef = React.useRef(null); const containerRef = 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); const [dropdownPosition, setDropdownPosition] = React.useState({ top: 0, left: 0, width: 0 }); 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 (lastSelectOption && !lastSelectOption.fixed) { handleUnselect(lastSelectOption); } } } 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 (!arrayOptions || onSearch) { return; } const newOption = transToGroupOption(arrayOptions || [], groupBy); if (JSON.stringify(newOption) !== JSON.stringify(options)) { setOptions(newOption); } }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); useEffect(() => { const doSearchSync = () => { const res = onSearchSync?.(debouncedSearchTerm); setOptions(transToGroupOption(res || [], groupBy)); }; const exec = async () => { if (!onSearchSync || !open) return; if (triggerSearchOnFocus) { doSearchSync(); } // Always call doSearchSync when search term changes (including when cleared to empty string) doSearchSync(); }; void exec(); }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearchSync]); useEffect(() => { if (!usePortal || !open || !containerRef.current) return; const updatePosition = () => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setDropdownPosition({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX, width: rect.width, }); } }; updatePosition(); const handleScroll = () => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); if (rect.bottom < 0 || rect.top > window.innerHeight) { setOpen(false); } else { updatePosition(); } } }; window.addEventListener("scroll", handleScroll, true); window.addEventListener("resize", updatePosition); return () => { window.removeEventListener("scroll", handleScroll, true); window.removeEventListener("resize", updatePosition); }; }, [usePortal, open]); 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(); }, [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}"`} ); if (!onSearch && inputValue.length > 0) { return Item; } if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { return Item; } return undefined; }; const EmptyItem = React.useCallback(() => { if (!emptyIndicator) return undefined; 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]); 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; }; } return undefined; }, [creatable, commandProps?.filter]); // Determine which badges to display const displayedBadges = maxDisplayCount ? selected.slice(0, maxDisplayCount) : selected; const hiddenCount = maxDisplayCount ? Math.max(0, selected.length - maxDisplayCount) : 0; return ( { handleKeyDown(e); commandProps?.onKeyDown?.(e); }} className={cn("h-auto overflow-visible bg-transparent!", commandProps?.className)} shouldFilter={commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch} filter={commandFilter()} >
{ if (disabled) return; inputRef?.current?.focus(); }} >
{displayedBadges.map((option) => { return ( {option.selectedLabel ?? option.label} ); })} {hiddenCount > 0 && ( {`+${hiddenCount} more`} )} { setInputValue(value); inputProps?.onValueChange?.(value); }} onBlur={(event) => { if (!onScrollbar) { setOpen(false); } inputProps?.onBlur?.(event); }} onFocus={(event) => { setOpen(true); inputProps?.onFocus?.(event); }} placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? "" : placeholder} className={cn( "flex-1 self-baseline bg-transparent outline-none placeholder:text-muted-foreground text-foreground caret-foreground", { "w-full": hidePlaceholderWhenSelected, "ml-1": selected.length !== 0, }, inputProps?.className, )} />
= 1 && "hidden")} />
{!usePortal && (
{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 bg-transparent hover:bg-accent data-selected:bg-transparent data-selected:hover:bg-accent", option.disable && "cursor-default text-muted-foreground", )} > {renderOption ? renderOption(option) : option.label} ); })} ))} )} )}
)} {usePortal && open && typeof window !== "undefined" && createPortal( { 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 bg-transparent hover:bg-accent data-selected:bg-transparent data-selected:hover:bg-accent", option.disable && "cursor-default text-muted-foreground", )} > {renderOption ? renderOption(option) : option.label} ); })} ))} )} , document.body, )}
); }, ); MultipleSelector.displayName = "MultipleSelector"; export { MultipleSelector };