Multiple Selector
Demo
Installation
Shadcn CLI
npx shadcn@latest add https://laviecn.vercel.app/r/multiple-selector.json
pnpm dlx shadcn@latest add https://laviecn.vercel.app/r/multiple-selector.json
yarn dlx shadcn@latest add https://laviecn.vercel.app/r/multiple-selector.json
bun x shadcn@latest add https://laviecn.vercel.app/r/multiple-selector.json
Manual
Copy and paste the following code into your project.
"use client"
import * as React from "react"
import { Command as CommandPrimitive, useCommandState } from "cmdk"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/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<Option[]>
/**
* 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<typeof Command>
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>
/** hide the clear all button. */
hideClearAllButton?: boolean
/** Size of the input. */
size?: "sm" | "md"
}
export interface MultipleSelectorRef {
selectedValue: Option[]
input: HTMLInputElement
focus: () => void
reset: () => void
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value)
React.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
}
const CommandEmpty = ({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) => {
const render = useCommandState((state) => state.filtered.count === 0)
if (!render) return null
return (
<div
className={cn("px-2 py-4 text-center text-sm", className)}
cmdk-empty=""
role="presentation"
{...props}
/>
)
}
CommandEmpty.displayName = "CommandEmpty"
const MultipleSelector = ({
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,
size = "md",
}: MultipleSelectorProps) => {
const inputRef = React.useRef<HTMLInputElement>(null)
const [open, setOpen] = React.useState(false)
const [onScrollbar, setOnScrollbar] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const dropdownRef = React.useRef<HTMLDivElement>(null) // Added this
const [selected, setSelected] = React.useState<Option[]>(value || [])
const [options, setOptions] = React.useState<GroupOption>(
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<HTMLDivElement>) => {
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]
)
React.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])
React.useEffect(() => {
if (value) {
setSelected(value)
}
}, [value])
React.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])
React.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])
React.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 = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
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}"`}
</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<GroupOption>(
() => 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 (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e)
commandProps?.onKeyDown?.(e)
}}
className={cn(
"dark:bg-input/30 h-auto overflow-visible",
{
"min-h-8": size === "sm",
"min-h-9": size === "md",
},
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(
"border-input has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 relative rounded-md border text-sm transition-[color,box-shadow] outline-none has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
{
"p-[3.08px]": selected.length !== 0,
"cursor-text": !disabled && selected.length !== 0,
},
!hideClearAllButton && "pe-9",
className
)}
onClick={() => {
if (disabled) return
inputRef?.current?.focus()
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<div
key={option.value}
className={cn(
"animate-fadeIn relative inline-flex items-center rounded-md border ps-2 pe-7 pl-2 transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-fixed:pe-2",
"text-muted-foreground hover:border-ring/50 cursor-default text-xs font-medium",
{
"h-6": size === "sm",
"h-7": size === "md",
},
badgeClassName
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className={cn(
"text-muted-foreground hover:text-foreground absolute -inset-y-px -end-px flex items-center justify-center p-0",
{
"size-6": size === "sm",
"size-7": size === "md",
}
)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(option)
}
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={() => handleUnselect(option)}
aria-label="Remove"
>
<XIcon size={14} aria-hidden="true" />
</button>
</div>
)
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value)
inputProps?.onValueChange?.(value)
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false)
}
inputProps?.onBlur?.(event)
}}
onFocus={(event) => {
setOpen(true)
if (triggerSearchOnFocus) {
onSearch?.(debouncedSearchTerm)
}
inputProps?.onFocus?.(event)
}}
placeholder={
hidePlaceholderWhenSelected && selected.length !== 0
? ""
: placeholder
}
className={cn(
"placeholder:text-muted-foreground flex-1 bg-transparent outline-hidden disabled:cursor-not-allowed",
{
"w-full": hidePlaceholderWhenSelected,
"px-3 py-2": selected.length === 0,
"ml-1": selected.length !== 0,
"max-h-[30.08px]": size === "sm",
"max-h-[34.08px]": size === "md",
},
inputProps?.className
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed))
onChange?.(selected.filter((s) => s.fixed))
}}
className={cn(
"text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute end-0 top-0 flex size-8 items-center justify-center rounded-md border border-transparent transition-[color,box-shadow] outline-none focus-visible:ring-[3px]",
{
"size-8": size === "sm",
"size-9": size === "md",
},
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
"hidden"
)}
aria-label="Clear all"
>
<XIcon size={16} aria-hidden="true" />
</button>
</div>
</div>
<div className="relative">
<div
className={cn(
"border-input absolute top-2 z-10 w-full overflow-hidden rounded-md border",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
!open && "hidden"
)}
data-state={open ? "open" : "closed"}
>
{open && (
<CommandList
className="bg-popover text-popover-foreground shadow-lg outline-hidden"
onMouseLeave={() => {
setOnScrollbar(false)
}}
onMouseEnter={() => {
setOnScrollbar(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 &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
>
{option.label}
</CommandItem>
)
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</div>
</Command>
)
}
MultipleSelector.displayName = "MultipleSelector"
export MultipleSelector
Update the import paths to match your project setup.
Props
MultipleSelectorProps
Prop | Type | Default |
---|---|---|
size? | "sm" | "md" | - |
hideClearAllButton? | boolean | - |
inputProps? | Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>, 'value' | 'placeholder' | 'disabled'> | - |
commandProps? | React.ComponentPropsWithoutRef<typeof Command> | - |
creatable? | boolean | - |
selectFirstItem? | boolean | - |
badgeClassName? | string | - |
className? | string | - |
groupBy? | string | - |
disabled? | boolean | - |
hidePlaceholderWhenSelected? | boolean | - |
onMaxSelected? | (maxLimit: number) => void | - |
maxSelected? | number | - |
onChange? | (options: Option[]) => void | - |
onSearchSync? | (value: string) => Option[] | - |
onSearch? | (value: string) => Promise<Option[]> | - |
triggerSearchOnFocus? | boolean | - |
delay? | number | - |
emptyIndicator? | React.ReactNode | - |
loadingIndicator? | React.ReactNode | - |
placeholder? | string | - |
options? | Option[] | - |
defaultOptions? | Option[] | - |
value? | Option[] | - |