Spaces:
Runtime error
Runtime error
| "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 ( | |
| <div | |
| ref={forwardedRef} | |
| className={cn("py-6 text-center text-sm", className)} | |
| cmdk-empty="" | |
| role="presentation" | |
| {...props} | |
| /> | |
| ); | |
| }); | |
| 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 <input /> 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 = ( | |
| <CommandItem | |
| value={inputValue} | |
| className="cursor-pointer" | |
| onMouseDown={(e) => { | |
| 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}"`} | |
| </CommandItem> | |
| ); | |
| // 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 ( | |
| <CommandItem value="-" disabled> | |
| {emptyIndicator} | |
| </CommandItem> | |
| ); | |
| } | |
| return <CommandEmpty>{emptyIndicator}</CommandEmpty>; | |
| }, [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 ( | |
| <Command | |
| {...commandProps} | |
| onKeyDown={(e) => { | |
| 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()} | |
| > | |
| <div | |
| className={cn( | |
| "min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2", | |
| { | |
| "px-3 py-2": selected.length !== 0, | |
| "cursor-text": !disabled && selected.length !== 0, | |
| }, | |
| className | |
| )} | |
| onClick={() => { | |
| if (disabled) return; | |
| inputRef.current?.focus(); | |
| }} | |
| > | |
| <div className="flex flex-wrap gap-3"> | |
| {selected.map((option) => { | |
| return ( | |
| <Badge | |
| key={option.value} | |
| className={cn( | |
| "data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground bg-purple-500 p-2", | |
| "data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground", | |
| badgeClassName | |
| )} | |
| data-fixed={option.fixed} | |
| data-disabled={disabled || undefined} | |
| > | |
| {option.label} | |
| <button | |
| className={cn( | |
| "ml-1 rounded-full text-white outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2", | |
| (disabled || option.fixed) && "hidden" | |
| )} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") { | |
| handleUnselect(option); | |
| } | |
| }} | |
| onMouseDown={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }} | |
| onClick={() => handleUnselect(option)} | |
| > | |
| <X className="h-4 w-4 text-white hover:text-purple-500" /> | |
| </button> | |
| </Badge> | |
| ); | |
| })} | |
| {/* Avoid having the "Search" Icon */} | |
| <CommandPrimitive.Input | |
| {...inputProps} | |
| ref={inputRef} | |
| value={inputValue} | |
| disabled={disabled} | |
| onValueChange={(value) => { | |
| 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 | |
| )} | |
| /> | |
| <button | |
| type="button" | |
| onClick={() => setSelected(selected.filter((s) => s.fixed))} | |
| className={cn( | |
| (hideClearAllButton || | |
| disabled || | |
| selected.length < 1 || | |
| selected.filter((s) => s.fixed).length === selected.length) && | |
| "hidden" | |
| )} | |
| > | |
| <X /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="relative"> | |
| {open && ( | |
| <CommandList | |
| className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in" | |
| onMouseLeave={() => { | |
| mouseOn.current = false; | |
| }} | |
| onMouseEnter={() => { | |
| mouseOn.current = true; | |
| }} | |
| onMouseUp={() => { | |
| inputRef.current?.focus(); | |
| }} | |
| > | |
| {isLoading ? ( | |
| <>{loadingIndicator}</> | |
| ) : ( | |
| <> | |
| {EmptyItem()} | |
| {CreatableItem()} | |
| {!selectFirstItem && ( | |
| <CommandItem value="-" className="hidden" /> | |
| )} | |
| {Object.entries(selectables).map(([key, dropdowns]) => ( | |
| <CommandGroup | |
| key={key} | |
| heading={key} | |
| className="h-full overflow-auto" | |
| > | |
| <> | |
| {dropdowns.map((option) => { | |
| return ( | |
| <CommandItem | |
| key={option.value} | |
| value={option.value} | |
| disabled={option.disable} | |
| onMouseDown={(e) => { | |
| 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} | |
| </CommandItem> | |
| ); | |
| })} | |
| </> | |
| </CommandGroup> | |
| ))} | |
| </> | |
| )} | |
| </CommandList> | |
| )} | |
| </div> | |
| </Command> | |
| ); | |
| } | |
| ); | |
| 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; | |
| } | |