-
Notifications
You must be signed in to change notification settings - Fork 5.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Multi select ? #66
Comments
Unfortunately no. The |
π Would love to have a Multi Select |
@its-monotype Listbox from HeadlessUI (same creators as TailwindCSS) has support for multi-select |
Hey @Flo-Slv, I also wanted a multi-select, one of my colleague suggested to use a dropdown-menu with checkboxes. It worked well for my use-case. Checked it out here. |
Hey ! It can be a workaround until they implement a native solution ! Thanks ! |
https://github.com/colepeters/multiselect What about this? |
I'd love see a multi-select with labels (instead of saying something like "4 items selected"), which is incredibly valuable for adding "tags" to things - a fairly common use case Here's the one I'm currently using (not based on tailwind-css) Here's a version using tailwind: https://demo-react-tailwindcss-select.vercel.app/ |
There is a headless multi-select combobox in Base UI ( ![]() https://mui.com/material-ui/react-autocomplete/#customized-hook and small: ![]() https://bundlephobia.com/package/@mui/base@5.0.0-beta.4 maybe to consider as a base to style on top of, it could be a temporary solution for radix-ui/primitives#1342. |
In case anyone else comes to this issue looking for a solution, @mxkaske just dropped a mutli-select component built with cmdk and shadcn components. Demo here: https://craft.mxkaske.dev/post/fancy-multi-select Source here: https://github.com/mxkaske/mxkaske.dev/blob/main/components/craft/fancy-multi-select.tsx |
This isn't accessible |
@zachrip you can drop an issue in the repo here: https://github.com/mxkaske/mxkaske.dev/issues |
I tweaked Headless UI Listbox component to achieve the desired UI. Here is the example code: import { Listbox, Transition } from '@headlessui/react';
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
export default function MultiSelect() {
const [selected, setSelected] = React.useState(['None']);
const [options, setOptions] = React.useState<string[]>([]);
React.useEffect(() => {
setOptions(['None', 'Apple', 'Orange', 'Banana', 'Grapes']);
}, []);
return (
<Listbox
value={selected}
onChange={setSelected}
multiple>
<div className='relative'>
<Listbox.Button className='flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50'>
<span className='block truncate'> {selected.map(option => option).join(', ')}</span>
<CaretSortIcon className='h-4 w-4 opacity-50' />
</Listbox.Button>
<Transition
as={React.Fragment}
leave='transition ease-in duration-100'
leaveFrom='opacity-100'
leaveTo='opacity-0'>
<Listbox.Options className='absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-popover py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm'>
{options.map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className='relative cursor-default select-none py-1.5 pl-10 pr-4 text-sm rounded-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
value={option}>
{({ selected }) => (
<>
{option}
{selected ? (
<span className='absolute inset-y-0 right-2 flex items-center pl-3'>
<CheckIcon className='h-4 w-4' />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
} Hope this works for you. Cheers! |
Hi all, I have created component, I hope somebody will find it helpful: import * as React from 'react'
import { cn } from "@/lib/utils"
import { Check, X, ChevronsUpDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Badge } from "@/components/ui/badge";
export type OptionType = {
label: string;
value: string;
}
interface MultiSelectProps {
options: OptionType[];
selected: string[];
onChange: React.Dispatch<React.SetStateAction<string[]>>;
className?: string;
}
function MultiSelect({ options, selected, onChange, className, ...props }: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const handleUnselect = (item: string) => {
onChange(selected.filter((i) => i !== item))
}
return (
<Popover open={open} onOpenChange={setOpen} {...props}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={`w-full justify-between ${selected.length > 1 ? "h-full" : "h-10"}`}
onClick={() => setOpen(!open)}
>
<div className="flex gap-1 flex-wrap">
{selected.map((item) => (
<Badge
variant="secondary"
key={item}
className="mr-1 mb-1"
onClick={() => handleUnselect(item)}
>
{item}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(item);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(item)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
))}
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command className={className}>
<CommandInput placeholder="Search ..." />
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup className='max-h-64 overflow-auto'>
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => {
onChange(
selected.includes(option.value)
? selected.filter((item) => item !== option.value)
: [...selected, option.value]
)
setOpen(true)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selected.includes(option.value) ?
"opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)
}
export { MultiSelect } Use it like standalone component : import * as React from 'react'
import { MultiSelect } from from "@/components/ui/multi-select"
function Demo() {
const [selected, setSelected] = useState<string[]>([]);
return (
<MultiSelect
options={[
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
{
value: "wordpress",
label: "WordPress",
},
{
value: "express.js",
label: "Express.js",
},
]}
selected={selected}
onChange={setSelected}
className="w-[560px]"
/>
)
} or part of React Hook Form: <FormField
control={form.control}
name="industry"
render={({ field }) => (
<FormItem>
<FormLabel>Select Frameworks</FormLabel>
<MultiSelect
selected={field.value}
options={[
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
{
value: "wordpress",
label: "WordPress",
},
{
value: "express.js",
label: "Express.js",
}
]}
{...field}
className="sm:w-[510px]"
/>
<FormMessage />
</FormItem>
)}
/> |
You can create a PR for this to be supported officially? |
thank you, this is great. also wondering did anyone successfully make a form collect inputs correctly? |
Is there any component that has multi-select except the dropdown? @shadcn |
I created a multi input/select component but for tags that the user inputs rather than using a pre-defined list of options https://gist.github.com/enesien/03ba5340f628c6c812b306da5fedd1a4 |
I am getting an error through the selected item, I am using react hook form
|
@johnLamberts PR is still in progress, so until this is done, copy code from here, fix some imports and let me know does it work. |
I am still getting the same error, I have already check the code.
|
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { MultiSelect } from "@/components/ui/multi-select" const AuthorsSchema = z.array(
z.record(
z.string().trim()
)
) const form = useForm<z.infer<typeof AuthorsSchema>>({
resolver: zodResolver(AuthorsSchema),
defaultValues: {
authors: [],
},
}); const onHandleSubmit = (values: z.infer<typeof AuthorsSchema>) => {
console.log({ values })
};
const authorsData = [
{
value: "author1",
label: "Author 1",
}, {
value: "author2",
label: "Author 2",
},
{
value: "author3",
label: "Author 3",
},
{
value: "author4",
label: "Author 4",
}
] <Form {...form}>
<form
onSubmit={form.handleSubmit(onHandleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="authors"
render={({ field: { ...field } }) => (
<FormItem className="mb-5">
<FormLabel>Author</FormLabel>
<MultiSelect
selected={field.value}
options={authorsData}
{...field} />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Continue
</Button>
</form>
</Form> |
dinogit Evango None of the components worked in my Next.js application, it renders normally on the screen but when clicked it opens an Error in both: After a bit of searching I discovered that the problem is in <"CommandItem">, this code renders perfectly, but is not functional: <CommandGroup className="h-full overflow-auto">
{selectables.map((framework) => {
return (
<div key={framework.value}>
{framework.label}
</div>
// <CommandItem
// key={framework.value}
// onMouseDown={(e) => {
// e.preventDefault();
// e.stopPropagation();
// }}
// onSelect={(value) => {
// setInputValue("")
// setSelected(prev => [...prev, framework])
// }}
// className={"cursor-pointer"}
// >
// {framework.label}
// </CommandItem>
);
})}
</CommandGroup>
any suggestion?π
resolved in this [issues](https://github.com/shadcn-ui/ui/issues/2944) |
You need to check the version of the dependencies u have for cmdk as they had a major release, also check if you are importing the components from the right npm package shadcn abstraction instead of directly from radix, it's a really common mistake |
@sersavan Why have you made the Popover component a controlled one. I've noticed an issue with your deployment that clicking on the trigger-button when the dropdown is opened, causes the dropdown to close and then immediately reopen. The cause to that is the But since the trigger-button itself toggles the dropdown state, we see the dropdown being opened again. To solve this removing the |
@mshahzebraza |
@Marco-Antonio-Rodrigues you have to wrap 'CommandItem' inside 'CommandList' to make it work in latest version.
|
@shadcn , will we expect to see this component in the next releases? I'm using this MultiSelect component from Tremor, but I would keep my components consistent using only Shadcn UI. |
Thanks a lot for multi select! One addition that would be great is the ability to turn off searching via an https://shadcnui-expansions.typeart.cc/docs/multiple-selector Edit: Ah... nvm! I can kinda achieve this by passing the following props: inputProps={{
inputMode: 'none',
className: 'caret-transparent', // tailwindcss class, could also use 'style' attribute
}} |
Hello Guys this code multiple select for shadcn-vue with vue3 composition API
// Data
// Method
Summary The provided code is a Vue 3 Composition API component that implements a custom multiple select input using a popover for the dropdown. The component displays selected items as badges that can be removed. Users can search and select options from the dropdown. The component uses several custom UI components and utility functions to manage its state and display. |
So many variations and progressive updates for the variations. Can we compile a list of links to latest versions/news of the main ones? UPDATE: I did it. Please let me know about any updates and I'll do my best to keep this updated π (last updated 7/28/24) My choice:
Code updated (v1): handling linting errors (no other changes)"use client";
// source: shadcnui-expansions (multiple selector) (https://shadcnui-expansions.typeart.cc/docs/multiple-selector)
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";
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;
}
type GroupOption = Record<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[]>;
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;
}
export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(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<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ 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<
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,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const mouseOn = React.useRef<boolean>(false);
const [isLoading, setIsLoading] = React.useState(false);
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);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current!, // Note: added '!' myself (vs type assertion)
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<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]!); // Note: added '!' myself
}
}
}
// 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: 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
{...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="relative flex flex-wrap gap-1">
{selected.map((option) => {
console.log("(selected) option", option);
return (
<Badge
key={option.value}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"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 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-3 w-3 text-muted-foreground hover:text-foreground" />
</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));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
"absolute right-0 h-6 w-6 p-0",
(hideClearAllButton ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
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; Code updated (v2): handles string value type (store selected values only, rather than full Options objects) (value = string, selected values array = string[])// source: shadcnui-expansions (multiple selector) (https://shadcnui-expansions.typeart.cc/docs/multiple-selector)
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 "~/helpers";
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;
}
type GroupOption = Record<string, Option[]>;
interface MultipleSelectorProps {
value?: string[];
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[]>;
onChange?: (values: string[]) => 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;
}
export interface MultipleSelectorRef {
selectedValue: string[];
input: HTMLInputElement;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(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: string[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.includes(val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: string[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.includes(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<typeof CommandPrimitive.Empty>
>(({ 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<
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,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const mouseOn = React.useRef<boolean>(false);
const [isLoading, setIsLoading] = React.useState(false);
const [selected, setSelected] = React.useState<string[]>(value ?? []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current!, // Note: added '!' myself (vs type assertion)
focus: () => inputRef.current?.focus(),
}),
[selected],
);
const handleUnselect = React.useCallback(
(value: string) => {
const newOptions = selected.filter((s) => s !== 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 lastSelectValue = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (
!arrayDefaultOptions.find(
(option) => option.value === lastSelectValue,
)?.fixed
) {
handleUnselect(selected[selected.length - 1]!); // Note: added '!' myself
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected, arrayDefaultOptions],
);
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, [inputValue]) ||
selected.includes(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];
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
{...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="relative flex flex-wrap gap-1">
{selected.map((value) => {
const option = arrayDefaultOptions.find(
(option) => option.value === value,
);
return (
<Badge
key={value}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"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 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(value);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(value)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</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([]);
onChange?.([]);
}}
className={cn(
"absolute right-0 h-6 w-6 p-0",
(hideClearAllButton ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled ||
selected.length < 1 ||
selected.filter(
(s) =>
arrayDefaultOptions.find((option) => option.value === 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.value];
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; Code updated (v3): handles generic value type (store selected values only, rather than full Options objects) (value = T, selected values array = T[])// source: shadcnui-expansions (multiple selector) (https://shadcnui-expansions.typeart.cc/docs/multiple-selector)
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 "~/helpers";
export interface Option<T> {
value: T;
label: string;
disable?: boolean;
fixed?: boolean;
[key: string]: T | string | boolean | undefined;
}
type GroupOption<T> = Record<string, Option<T>[]>;
interface MultipleSelectorProps<T> {
value?: T[];
defaultOptions?: Option<T>[];
options?: Option<T>[];
placeholder?: string;
loadingIndicator?: React.ReactNode;
emptyIndicator?: React.ReactNode;
delay?: number;
triggerSearchOnFocus?: boolean;
onSearch?: (value: string) => Promise<Option<T>[]>;
onChange?: (options: T[]) => void;
maxSelected?: number;
onMaxSelected?: (maxLimit: number) => void;
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
groupBy?: string;
className?: string;
badgeClassName?: string;
selectFirstItem?: boolean;
creatable?: boolean;
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>;
hideClearAllButton?: boolean;
}
export interface MultipleSelectorRef<T> {
selectedValue: T[];
input: HTMLInputElement;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption<T>(
options: Option<T>[],
groupBy?: string,
): GroupOption<T> {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}
const groupOption: GroupOption<T> = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption<T>(
groupOption: GroupOption<T>,
picked: T[],
): GroupOption<T> {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption<T>;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.includes(val.value));
}
return cloneOption;
}
function isOptionsExist<T>(
groupOption: GroupOption<T>,
targetOption: Option<T>[],
): boolean {
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<typeof CommandPrimitive.Empty>
>(({ 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<
MultipleSelectorRef<unknown>,
MultipleSelectorProps<unknown>
>(
(
{
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,
}: MultipleSelectorProps<unknown>,
ref: React.Ref<MultipleSelectorRef<unknown>>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const mouseOn = React.useRef<boolean>(false);
const [isLoading, setIsLoading] = React.useState(false);
const [selected, setSelected] = React.useState<unknown[]>(value ?? []);
const [options, setOptions] = React.useState<GroupOption<unknown>>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current!, // Note: added '!' myself (vs type assertion)
focus: () => inputRef.current?.focus(),
}),
[selected],
);
const handleUnselect = React.useCallback(
(value: unknown) => {
const newOptions = selected.filter((s) => s !== 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 lastSelectValue = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (
!arrayDefaultOptions.find(
(option) => option.value === lastSelectValue,
)?.fixed
) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected, arrayDefaultOptions],
);
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 === 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];
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<unknown>>(
() => 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
{...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="relative flex flex-wrap gap-1">
{selected.map((value) => {
const option = arrayDefaultOptions.find(
(option) => option.value === value,
);
return (
<Badge
key={value as string}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"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 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(value);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(value)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</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) =>
arrayDefaultOptions.find((o) => o.value === s)?.fixed,
),
);
onChange?.(
selected.filter(
(s) =>
arrayDefaultOptions.find((o) => o.value === s)?.fixed,
),
);
}}
className={cn(
"absolute right-0 h-6 w-6 p-0",
(hideClearAllButton ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled ||
selected.length < 1 ||
selected.filter(
(s) =>
arrayDefaultOptions.find((o) => o.value === 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 as string}
value={option.value as string}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, option.value];
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; Code updated (v4): added optional hideOptionOnSelect prop (default was to hide the option when selected; when not present or false, checkmark is displayed on selected options, similar to native shadcn Select component; also made "x" similar in size/color to shadcn Select chevron)import { Command as CommandPrimitive, useCommandState } from "cmdk";
import { X, Check } 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 "~/helpers";
export interface Option<T> {
value: T;
label: string;
disable?: boolean;
fixed?: boolean;
[key: string]: T | string | boolean | undefined;
}
type GroupOption<T> = Record<string, Option<T>[]>;
interface MultipleSelectorProps<T> {
value?: T[];
defaultOptions?: Option<T>[];
options?: Option<T>[];
placeholder?: string;
loadingIndicator?: React.ReactNode;
emptyIndicator?: React.ReactNode;
delay?: number;
triggerSearchOnFocus?: boolean;
onSearch?: (value: string) => Promise<Option<T>[]>;
onChange?: (options: T[]) => void;
maxSelected?: number;
onMaxSelected?: (maxLimit: number) => void;
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
groupBy?: string;
className?: string;
badgeClassName?: string;
selectFirstItem?: boolean;
creatable?: boolean;
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>;
hideClearAllButton?: boolean;
hideOptionOnSelect?: boolean;
}
export interface MultipleSelectorRef<T> {
selectedValue: T[];
input: HTMLInputElement;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption<T>(
options: Option<T>[],
groupBy?: string,
): GroupOption<T> {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}
const groupOption: GroupOption<T> = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption<T>(
groupOption: GroupOption<T>,
picked: T[],
): GroupOption<T> {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption<T>;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.includes(val.value));
}
return cloneOption;
}
function isOptionsExist<T>(
groupOption: GroupOption<T>,
targetOption: Option<T>[],
): boolean {
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<typeof CommandPrimitive.Empty>
>(({ 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<
MultipleSelectorRef<unknown>,
MultipleSelectorProps<unknown>
>(
(
{
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,
hideOptionOnSelect,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps<unknown>,
ref: React.Ref<MultipleSelectorRef<unknown>>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const mouseOn = React.useRef<boolean>(false);
const [isLoading, setIsLoading] = React.useState(false);
const [selected, setSelected] = React.useState<unknown[]>(value ?? []);
const [options, setOptions] = React.useState<GroupOption<unknown>>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current!, // Note: added '!' myself (vs type assertion)
focus: () => inputRef.current?.focus(),
}),
[selected],
);
const handleUnselect = React.useCallback(
(value: unknown) => {
const newOptions = selected.filter((s) => s !== 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 lastSelectValue = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (
!arrayDefaultOptions.find(
(option) => option.value === lastSelectValue,
)?.fixed
) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected, arrayDefaultOptions],
);
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 === 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];
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<unknown>>(
() =>
hideOptionOnSelect ? removePickedOption(options, selected) : options,
[options, selected, hideOptionOnSelect],
);
/** 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
{...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="relative flex flex-wrap gap-1">
{selected.map((value) => {
const option =
arrayDefaultOptions.find(
(option) => option.value === value,
) || arrayOptions?.find((option) => option.value === value);
return (
<Badge
key={value as string}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"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 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(value);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(value)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</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) =>
arrayDefaultOptions.find((o) => o.value === s)?.fixed,
),
);
onChange?.(
selected.filter(
(s) =>
arrayDefaultOptions.find((o) => o.value === s)?.fixed,
),
);
}}
className={cn(
"absolute -right-2 h-6 w-6 p-0",
(hideClearAllButton ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled ||
selected.length < 1 ||
selected.filter(
(s) =>
arrayDefaultOptions.find((o) => o.value === s)?.fixed,
).length === selected.length) &&
"hidden",
)}
>
<X className="pointer h-4 w-4 opacity-50 hover:opacity-80" />
</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) => {
const isSelected = selected.includes(option.value);
return (
<CommandItem
key={option.value as string}
value={option.value as string}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
let newOptions;
if (isSelected) {
newOptions = selected.filter(
(s) => s !== option.value,
);
} else {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
newOptions = [...selected, option.value];
}
setInputValue("");
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
"cursor-pointer",
option.disable &&
"cursor-default text-muted-foreground",
)}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{isSelected && <Check className="h-4 w-4" />}
</span>
<span className="pl-6">{option.label}</span>
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
MultipleSelector.displayName = "MultipleSelector";
export default MultipleSelector; Code updated (v5): added ChevronDown when no options have yet been selected (similar appearance to shadcn Select); also added default placeholder "Select..."import { Command as CommandPrimitive, useCommandState } from "cmdk";
import { X, Check, ChevronDown } 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 "~/helpers";
export interface Option<T> {
value: T;
label: string;
disable?: boolean;
fixed?: boolean;
[key: string]: T | string | boolean | undefined;
}
type GroupOption<T> = Record<string, Option<T>[]>;
interface MultipleSelectorProps<T> {
value?: T[];
defaultOptions?: Option<T>[];
options?: Option<T>[];
placeholder?: string;
loadingIndicator?: React.ReactNode;
emptyIndicator?: React.ReactNode;
delay?: number;
triggerSearchOnFocus?: boolean;
onSearch?: (value: string) => Promise<Option<T>[]>;
onChange?: (options: T[]) => void;
maxSelected?: number;
onMaxSelected?: (maxLimit: number) => void;
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
groupBy?: string;
className?: string;
badgeClassName?: string;
selectFirstItem?: boolean;
creatable?: boolean;
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>;
hideClearAllButton?: boolean;
hideOptionOnSelect?: boolean;
}
export interface MultipleSelectorRef<T> {
selectedValue: T[];
input: HTMLInputElement;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption<T>(
options: Option<T>[],
groupBy?: string,
): GroupOption<T> {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}
const groupOption: GroupOption<T> = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption<T>(
groupOption: GroupOption<T>,
picked: T[],
): GroupOption<T> {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption<T>;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.includes(val.value));
}
return cloneOption;
}
function isOptionsExist<T>(
groupOption: GroupOption<T>,
targetOption: Option<T>[],
): boolean {
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<typeof CommandPrimitive.Empty>
>(({ 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<
MultipleSelectorRef<unknown>,
MultipleSelectorProps<unknown>
>(
(
{
value,
onChange,
placeholder = "Select...",
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,
hideOptionOnSelect,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps<unknown>,
ref: React.Ref<MultipleSelectorRef<unknown>>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const mouseOn = React.useRef<boolean>(false);
const [isLoading, setIsLoading] = React.useState(false);
const [selected, setSelected] = React.useState<unknown[]>(value ?? []);
const [options, setOptions] = React.useState<GroupOption<unknown>>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current!, // Note: added '!' myself (vs type assertion)
focus: () => inputRef.current?.focus(),
}),
[selected],
);
const handleUnselect = React.useCallback(
(value: unknown) => {
const newOptions = selected.filter((s) => s !== 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 lastSelectValue = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (
!arrayDefaultOptions.find(
(option) => option.value === lastSelectValue,
)?.fixed
) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected, arrayDefaultOptions],
);
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 === 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];
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<unknown>>(
() =>
hideOptionOnSelect ? removePickedOption(options, selected) : options,
[options, selected, hideOptionOnSelect],
);
/** 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
{...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="relative flex flex-wrap gap-1">
{selected.map((value) => {
const option =
arrayDefaultOptions.find((option) => option.value === value) ??
arrayOptions?.find((option) => option.value === value);
return (
<Badge
key={value as string}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"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 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(value);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(value)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</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",
// "flex-1 bg-transparent outline-none placeholder:text-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) =>
arrayDefaultOptions.find((o) => o.value === s)?.fixed ??
arrayOptions?.find((o) => o.value === s)?.fixed,
),
);
onChange?.(
selected.filter(
(s) =>
arrayDefaultOptions.find((o) => o.value === s)?.fixed ??
arrayOptions?.find((o) => o.value === s)?.fixed,
),
);
}}
className={cn(
selected.length > 0
? "absolute -right-2 h-6 w-6 p-0" // X
: "absolute right-1 mt-2 h-6 w-6 p-0", // ChevronDown
)}
>
{selected.length > 0 ? (
<X
className={cn(
"pointer h-4 w-4 opacity-50 hover:opacity-80",
(hideClearAllButton ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled ||
selected.length < 1 ||
selected.filter(
(s) =>
arrayDefaultOptions.find((o) => o.value === s)
?.fixed ??
arrayOptions?.find((o) => o.value === s)?.fixed,
).length === selected.length) &&
"hidden",
)}
/>
) : (
<ChevronDown className="pointer h-4 w-4 opacity-30" />
)}
</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) => {
const isSelected = selected.includes(option.value);
return (
<CommandItem
key={option.value as string}
value={option.value as string}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
let newOptions;
if (isSelected) {
newOptions = selected.filter(
(s) => s !== option.value,
);
} else {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
newOptions = [...selected, option.value];
}
setInputValue("");
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
"cursor-pointer",
option.disable &&
"cursor-default text-muted-foreground",
)}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{isSelected && <Check className="h-4 w-4" />}
</span>
<span className="pl-6">{option.label}</span>
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
MultipleSelector.displayName = "MultipleSelector";
export default MultipleSelector; Usage example (v5)"use client";
import { useForm } from "react-hook-form";
import { type z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import MultipleSelector, { type Option } from "@/components/ui/multiple-selector";
import {
DEFAULT_VALUES,
SCHEMA_FORM_CREATE,
} from "./_config";
export function FormCreate({ onClose }: Props) {
// ...
const { data: products } = api.product.getAll.useQuery();
const form = useForm<z.infer<typeof SCHEMA_FORM_CREATE>>({
resolver: zodResolver(SCHEMA_FORM_CREATE),
defaultValues: DEFAULT_VALUES
});
// ...other logic (onSubmit, etc.)
// options example #1 (pass value type into Option type on a per-options basis)
const optionsProductIds: Option<string>[] = products
? products.map((product) => ({
label: product.name,
value: product.id, // string
}))
: [];
// options example #2
const optionsProductPrices: Option<number>[] = products
? products.map((product) => ({
label: product.price.toString(),
value: product.price, // number
}))
: [];
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
// ...other form fields
<FormField
control={form.control}
name="productIds"
render={({ field }) => (
<FormItem>
<FormLabel>Products (productIds)</FormLabel>
<FormControl>
<MultipleSelector
{...field}
options={optionsProductIds} // using 'options' instead of 'defaultOptions' because they have an async source and I'm not early returning a loading state while waiting for fetching to finish
hidePlaceholderWhenSelected
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
// ...other form fields, components (e.g. submit button)
</form>
</Form>
);
} Chronological order:
Vue x shadcn
other notable comments:
|
This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you. |
Wait, is there a dedicated multi select component from shadcn now? |
This was it for me. Using cmdk version 0.2.1 fixed my issues "cmdk": "^0.2.1", |
Can we please keep this open until a a multiselect has been release? |
Please do it like the Tremor multi-select ( horizontal scrolling ). |
Any update please? I don't want to use another external library just for a select with multiple values.. |
Looks like one among the following solutions will be the one adopted: |
@ilbertt thanks for the information, I don't think that these PR will be accepted quickly, in the meantime I will use this one: https://shadcnui-expansions.typeart.cc/docs/multiple-selector |
I am using this since the dawn of time and it works really well. You can alot of customization as well. |
Hi there βπΌ
thanks for this amazing components !
Is there a way to select multiple data with the Select component ?
Thanks !
The text was updated successfully, but these errors were encountered: