Skip to content
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

Open
Yolo390 opened this issue Feb 8, 2023 · 102 comments
Open

Multi select ? #66

Yolo390 opened this issue Feb 8, 2023 · 102 comments

Comments

@Yolo390
Copy link

Yolo390 commented Feb 8, 2023

Hi there βœ‹πŸΌ

thanks for this amazing components !

Is there a way to select multiple data with the Select component ?

Thanks !

@shadcn
Copy link
Collaborator

shadcn commented Feb 8, 2023

Unfortunately no. The <Select /> element does not support multi-select. There's an issue on the Radix UI repo I'm following too: radix-ui/primitives#1342

@jackblackCH
Copy link

πŸ‘ Would love to have a Multi Select

@franciscohanna92
Copy link

@its-monotype Listbox from HeadlessUI (same creators as TailwindCSS) has support for multi-select

@99sdawkhar
Copy link

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.

@Yolo390
Copy link
Author

Yolo390 commented Feb 20, 2023

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 !

@hiql
Copy link

hiql commented Mar 24, 2023

https://github.com/colepeters/multiselect

What about this?

@evangow
Copy link

evangow commented Jun 2, 2023

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)
https://react.semantic-ui.com/modules/dropdown/

Here's a version using tailwind: https://demo-react-tailwindcss-select.vercel.app/
Github repo here: https://github.com/onesine/react-tailwindcss-select
^The downside to this one is that it doesn't look like it handles aria support / keyboard navigation

@oliviertassinari
Copy link

oliviertassinari commented Jun 6, 2023

There is a headless multi-select combobox in Base UI (import X from @mui/base/useAutocomplete) which is supposed to be feature-rich:

Screenshot 2023-06-06 at 14 19 53

https://mui.com/material-ui/react-autocomplete/#customized-hook

and small:

Screenshot 2023-06-06 at 14 17 34

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.

@evangow
Copy link

evangow commented Jun 12, 2023

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

@zachrip
Copy link

zachrip commented Jun 14, 2023

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

@evangow
Copy link

evangow commented Jun 14, 2023

@zachrip you can drop an issue in the repo here: https://github.com/mxkaske/mxkaske.dev/issues

@console-logs
Copy link

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!

@dinogit
Copy link

dinogit commented Sep 13, 2023

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>
    )}
 />

@DarkAbhi
Copy link

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?

@zmzlois
Copy link

zmzlois commented Sep 21, 2023

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>
    )}
 />

thank you, this is great. also wondering did anyone successfully make a form collect inputs correctly?

@Syammed2429
Copy link

Unfortunately no. The <Select /> element does not support multi-select. There's an issue on the Radix UI repo I'm following too: radix-ui/primitives#1342

Is there any component that has multi-select except the dropdown? @shadcn

@enesien
Copy link

enesien commented Oct 13, 2023

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

@johnLamberts
Copy link

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>
    )}
 />

I am getting an error through the selected item, I am using react hook form

<FormField control={form.control} name="authors" render={({ field: { ...field } }) => ( <FormItem className="mb-5"> <FormLabel>Author</FormLabel> <MultiSelect selected={field.value} options={authorsData} {...field} /> </FormItem> )} />
it says that, selected is not iterable, I already check the onSelect method from CommandItem but I can't find any solution

@dinogit
Copy link

dinogit commented Oct 23, 2023

@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.

@johnLamberts
Copy link

johnLamberts commented Oct 23, 2023

@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 * as React from "react";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
} from "@/shared/components/ui/command";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/shared/components/ui/popover";
import { cn } from "@/shared/lib/utils";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useState } from "react";

// export type OptionType = {
//   id: string;
//   value: string;
// };

export type OptionType = Record<"id" | "value", string>;

interface MultiSelectProps {
  options: Record<"id" | "value", string>[];
  selected: Record<"id" | "value", string>[];
  onChange: React.Dispatch<
    React.SetStateAction<Record<"id" | "value", string>[]>
  >;
  className?: string;
}

const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>(
  ({ options, selected, onChange, className, ...props }, ref) => {
    const [open, setOpen] = useState(false);

    const handleUnselect = (item: Record<"id" | "value", string>) => {
      onChange(selected.filter((i) => i.id !== item.id));
    };

    return (
      <Popover open={open} onOpenChange={setOpen} {...props}>
        <PopoverTrigger asChild>
          <Button
            ref={ref}
            role="combobox"
            variant="outline"
            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.id}
                  className="mr-1 mb-1"
                  onClick={() => handleUnselect(item)}
                >
                  {item.value}
                  <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="">
          <Command className={className}>
            <CommandInput placeholder="Search..." />
            <CommandEmpty>No item found.</CommandEmpty>
            <CommandGroup className="h-32 overflow-auto">
              {options.map((option) => (
                <CommandItem
                  key={option.id}
                  onSelect={() => {
                    console.log(option.value);
                    console.log(selected);
                    onChange(
                      selected?.some(
                        (item: Record<"id" | "value", string>) =>
                          item.id === option.id
                      )
                        ? selected.filter((item) => item.id !== option.id)
                        : [...selected, option]
                    );
                    setOpen(true);
                  }}
                >
                  <Check
                    className={cn(
                      "mr-2 h-4 w-4",
                      selected?.some((item) => item.id === option.id)
                        ? "opacity-100"
                        : "opacity-0"
                    )}
                  />
                  {option.value}
                </CommandItem>
              ))}
            </CommandGroup>
          </Command>
        </PopoverContent>
      </Popover>
    );
  }
);

MultiSelect.displayName = "MultiSelect";
export { MultiSelect };
``


`

btw, it the `selected` always returned me undefined, and I already check my forms well

@dinogit
Copy link

dinogit commented Oct 23, 2023

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>

@Marco-Antonio-Rodrigues
Copy link

Marco-Antonio-Rodrigues commented May 6, 2024

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:
TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))

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)

@joaopedrodcf
Copy link

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

@mshahzebraza
Copy link

@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 onInteractOutside handler which is trying to close the popover whenever any element outside the dropdown is clicked. The issue is that, click on the trigger-button is also regarded as an outside click and hence it closes the dropdown in the onInteractOutside handler.

But since the trigger-button itself toggles the dropdown state, we see the dropdown being opened again.

To solve this removing the onInteractOutside handler is a fine solution. I've noticed that none of the logic related to the making the popover a controlled component is needed since all of it is handled by the popover component itself as expected

@sersavan
Copy link

@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 onInteractOutside handler which is trying to close the popover whenever any element outside the dropdown is clicked. The issue is that, click on the trigger-button is also regarded as an outside click and hence it closes the dropdown in the onInteractOutside handler.

But since the trigger-button itself toggles the dropdown state, we see the dropdown being opened again.

To solve this removing the onInteractOutside handler is a fine solution. I've noticed that none of the logic related to the making the popover a controlled component is needed since all of it is handled by the popover component itself as expected

@mshahzebraza
You're right about the behavior of the Popover component and the issue with the onInteractOutside handler. I made the component controlled to avoid the Popover closing when clicking within the component area again. However, I appreciate your suggestion and will consider revisiting this approach to see if there's a better solution.

@mani444
Copy link

mani444 commented May 24, 2024

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: TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))

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)

@Marco-Antonio-Rodrigues you have to wrap 'CommandItem' inside 'CommandList' to make it work in latest version.

  <CommandGroup>
                  <CommandList>
                  {options.map((option) => (
                    <CommandItem
                      key={option.label}
                      value={option.label}
                      onSelect={(currentValue) => {
                        if (onChange) {
                          if (mode === 'multiple' && Array.isArray(selected)) {
                            onChange(
                              selected.includes(option.value)
                                ? selected.filter(
                                    (item) => item !== option.value
                                  )
                                : [...selected, option.value]
                            );
                          } else {
                            onChange(option.value);
                          }
                          setOpen(false);
                        }
                      }}
                    >
                      <Check
                        className={cn(
                          'mr-2 h-4 w-4',
                          selected.includes(option.value)
                            ? 'opacity-100'
                            : 'opacity-0'
                        )}
                      />
                      {option.label}
                    </CommandItem>
                  ))}
                  </CommandList>
                </CommandGroup>

@alamenai
Copy link

alamenai commented May 27, 2024

@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.

@majames
Copy link

majames commented Jun 22, 2024

Thanks a lot for multi select! One addition that would be great is the ability to turn off searching via an isSearchable prop (similar to react-select). Although i'm not sure how easy that is to add given that it uses a cmdk <Input /> under the hood

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
}}

@shadcn shadcn added the Stale label Jul 7, 2024
@Jaychandmourya
Copy link

Hello Guys this code multiple select for shadcn-vue with vue3 composition API

<script setup lang="ts">
import { ref } from 'vue'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
  • ref from Vue is used to create reactive references.
  • UI components Button, Badge, Popover, PopoverContent, PopoverTrigger, Command, CommandEmpty, CommandGroup,
    CommandInput, CommandItem, and CommandList are imported from the project structure.
  • cn is a utility function likely used for conditional class names.

// Data

const selectedValue = ref<any>('')
const openMultipleSelect = ref<boolean>(false);
const optionMultipleSelect = ref([
  { label: "Vue3", value: "vue3"},
  { label: "Angular", value: "angular"},
  { label: "React" , value: "react" }
])
  • selectedValue: Stores selected values for the multiple select component.

  • openMultipleSelect: Manages the state of whether the popover is open.

  • optionMultipleSelect: List of selectable options.

// Method

const isSelected = (value: string): boolean => {
  return selectedValue.value?.includes(value);
};

const handleUnselected = (item: string): void => {
  selectedValue.value = selectedValue.value.filter(i => i !== item);
};

const handleOptionSelected = (value: string): void => {
  if (isSelected(value)) {
    selectedValue.value = selectedValue.value.filter(item => item !== value);
  } else {
    selectedValue.value = [...selectedValue.value, value];
  }
  openMultipleSelect.value = true;
};
  • isSelected: Checks if a value is in the selectedValue.
  • handleUnselected: Removes an item from the selectedValue.
  • handleOptionSelected: Adds or removes a value from the selectedValue based on its current state.
<template>
  <div class="grid grid-cols-1 gap-1.5">
    <Label class="text-sm font-medium leading-none mb-0.5">Multiple select</Label>
    <Popover>
      <PopoverTrigger
        as-child
        :class="
          cn(
            'bg-input  text-foreground font-inter hover:bg-input ',
            !selectedValue &&
              'text-inactive-action font-inter hover:text-inactive-action'
          )
        "
      >
        <Button
          variant="secondary"
          size="sm"
          role="combobox"
          class="w-full justify-between"
          :aria-expanded="openMultipleSelect"
        >
          <div class="flex gap-1 flex-wrap">
            <Badge
              v-for="item in selectedValue"
              :key="item"
              variant="rounded"
              color="black"
              class="mr-1"
              @click="handleUnselected(item)"
            >
              {{ item }}
              <button
                class="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
                @keydown.enter.prevent="handleUnselected(item)"
                @mousedown.prevent.stop
                @click.stop="handleUnselected(item)"
              >
                <ph-x-circle class="h-4 w-4" />
              </button>
            </Badge>
          </div>
          <ph-caret-down class="h-4 w-4" />
        </Button>
      </PopoverTrigger>
      <PopoverContent class="w-[570px] p-0">
        <Command>
          <CommandInput placeholder="Search ..." />
          <CommandEmpty>No framework found.</CommandEmpty>
          <CommandList>
            <CommandGroup class="max-h-64 overflow-auto">
              <CommandItem
                v-for="option in optionMultipleSelect"
                :key="option.value"
                :value="option.value"
                @select="
                  handleOptionSelected(option.value)
                "
              >
                <ph-check
                  class="mr-2 h-4 w-4"
                  :class="{
                    'opacity-100': isSelected(option.value),
                    'opacity-0': !isSelected(option.value),
                  }"
                />
                {{ option.label }}
              </CommandItem>
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  </div>
</template>
  • Label: Describes the multiple select input.
  • Popover: A UI component that wraps around the trigger and content for the dropdown.
  • PopoverTrigger: This is the button that, when clicked, opens the popover.
  • Uses the cn function to apply conditional class names.
  • Button: Acts as the main trigger for the popover. Shows the selected items and a down caret icon.
  • Badge: Represents each selected item. Includes a close button to remove the item from the selection.
  • PopoverContent: Contains the content of the dropdown.
  • Command: A component that includes an input for filtering options.
  • CommandInput: Input for searching through the options.
  • CommandEmpty: Message displayed when no options match the search query.
  • CommandList: Contains the list of selectable options.
  • CommandGroup: Wraps around the list of options.
  • CommandItem: Each option in the dropdown. Shows a checkmark if the item is selected.

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.

@shadcn shadcn removed the Stale label Jul 10, 2024
@shadcn shadcn added the Stale label Jul 26, 2024
@aaron-mota
Copy link

aaron-mota commented Jul 27, 2024

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:

  • after looking briefly at demos, ui/ux/dx, and available features, I decided to go with @hsuanyi-chou multiple selector
  • just started using it, but I'm enjoying it so far in my NextJS app with react-hook-form forms
  • had some linting errors (e.g. prefer nullish coalescing operator, etc.), so made minor adjustments to handle those
  • made updates to store the Option object's value field value only, rather than the entire Option object, when selecting options. This is a more standard approach, at least for what I've personally seen and used before, and is consistent with the shadcn Select component. It also makes it easier to align with schemas, etc. (string version & generics version code updates below)
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;
Screenshot (v5)

CleanShot 2024-07-28 at 08 38 26

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:

@shadcn shadcn removed the Stale label Jul 27, 2024
@shadcn shadcn added the Stale label Aug 12, 2024
@shadcn
Copy link
Collaborator

shadcn commented Aug 20, 2024

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.

@shadcn shadcn closed this as completed Aug 20, 2024
@mickdewald
Copy link

Wait, is there a dedicated multi select component from shadcn now?
Otherwise we can leave this issue opened.

@hughgardiner
Copy link

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

This was it for me. Using cmdk version 0.2.1 fixed my issues

    "cmdk": "^0.2.1",

@joselvelez
Copy link

Can we please keep this open until a a multiselect has been release?

@shadcn shadcn reopened this Nov 26, 2024
@alamenai
Copy link

Please do it like the Tremor multi-select ( horizontal scrolling ).

@sovetski
Copy link

sovetski commented Dec 9, 2024

Any update please? I don't want to use another external library just for a select with multiple values..

@ilbertt
Copy link

ilbertt commented Dec 17, 2024

Looks like one among the following solutions will be the one adopted:

@sovetski
Copy link

@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

@Salman9000
Copy link

@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.
image

@Yashraj950
Copy link

esit-pro pushed a commit to esit-pro/ui that referenced this issue Jan 14, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests