'use client'; import * as React from 'react'; import { useEffect } from 'react'; import { Command as CommandPrimitive, useCommandState } from 'cmdk'; import { XIcon } from 'lucide-react'; import { useDebounce } from '../lib/use-debounce'; import { cn } from '../lib/utils'; import { Command, CommandGroup, CommandItem, CommandList } from './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 MultiSelectProps { 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; } export interface MultiSelectRef { selectedValue: Option[]; input: HTMLInputElement; focus: () => void; reset: () => void; } 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; } const CommandEmpty = ({ className, ...props }: React.ComponentProps) => { const render = useCommandState((state) => state.filtered.count === 0); if (!render) return null; return (
); }; CommandEmpty.displayName = 'CommandEmpty'; const MultiSelect = ({ 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, }: MultiSelectProps) => { 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); 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.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 (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)); }; // eslint-disable-next-line @typescript-eslint/require-await 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 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}
); })} {/* Avoid having the "Search" Icon */} { setInputValue(value); inputProps?.onValueChange?.(value); }} onBlur={(event) => { if (!onScrollbar) { setOpen(false); } inputProps?.onBlur?.(event); }} onFocus={(event) => { setOpen(true); if (triggerSearchOnFocus) { void onSearch?.(debouncedSearchTerm); } inputProps?.onFocus?.(event); }} placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder} className={cn( 'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-none disabled:cursor-not-allowed', { 'w-full': hidePlaceholderWhenSelected, 'px-3 py-2': 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', option.disable && 'pointer-events-none cursor-not-allowed opacity-50', )} > {option.label} ); })} ))} )} )}
); }; MultiSelect.displayName = 'MultiSelect'; export { MultiSelect };