Multi Selector
Multiple Selector using shadcn/ui components
Multiple Selector using shadcn/ui components
Thanks to Sersavan
1'use client';2import * as React from 'react';3import { CheckIcon, XCircle, ChevronDown, XIcon } from 'lucide-react';45import { cn } from '@/lib/utils';6import { Button } from '@/components/website/ui/button';7import {8Popover,9PopoverContent,10PopoverTrigger,11} from '@/components/website/ui/popover';12import {13Command,14CommandEmpty,15CommandGroup,16CommandInput,17CommandItem,18CommandList,19CommandSeparator,20} from '@/components/website/ui/command';2122/**23* Props for MultiSelect component24*/25interface MultiSelectProps26extends React.ButtonHTMLAttributes<HTMLButtonElement> {27/**28* An array of option objects to be displayed in the multi-select component.29* Each option object has a label, value, and an optional icon.30*/31options: {32/** The text to display for the option. */33label: string;34/** The unique value associated with the option. */35value: string;36/** Optional icon component to display alongside the option. */37icon?: React.ComponentType<{ className?: string }>;38disable?: boolean;39}[];4041/**42* Callback function triggered when the selected values change.43* Receives an array of the new selected values.44*/45onValueChange: (value: string[]) => void;4647/** The default selected values when the component mounts. */48defaultValue?: string[];4950/**51* Placeholder text to be displayed when no values are selected.52* Optional, defaults to "Select options".53*/54placeholder?: string;5556/**57* Animation duration in seconds for the visual effects (e.g., bouncing badges).58* Optional, defaults to 0 (no animation).59*/60animation?: number;6162/**63* Maximum number of items to display. Extra selected items will be summarized.64* Optional, defaults to 3.65*/66maxCount?: number;6768/**69* The modality of the popover. When set to true, interaction with outside elements70* will be disabled and only popover content will be visible to screen readers.71* Optional, defaults to false.72*/73modalPopover?: boolean;7475/**76* If true, renders the multi-select component as a child of another component.77* Optional, defaults to false.78*/79asChild?: boolean;8081/**82* Additional class names to apply custom styles to the multi-select component.83* Optional, can be used to add custom styles.84*/85className?: string;86popoverClass?: string;87showall?: boolean;88}8990export const MultiSelect = React.forwardRef<91HTMLButtonElement,92MultiSelectProps93>(94(95{96options,97onValueChange,98defaultValue = [],99placeholder = 'Select options',100animation = 0,101maxCount = 3,102modalPopover = false,103asChild = false,104className,105popoverClass,106showall = false,107...props108},109ref110) => {111const [selectedValues, setSelectedValues] =112React.useState<string[]>(defaultValue);113const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);114115const handleInputKeyDown = (116event: React.KeyboardEvent<HTMLInputElement>117) => {118if (event.key === 'Enter') {119setIsPopoverOpen(true);120} else if (event.key === 'Backspace' && !event.currentTarget.value) {121const newSelectedValues = [...selectedValues];122newSelectedValues.pop();123setSelectedValues(newSelectedValues);124onValueChange(newSelectedValues);125}126};127128const toggleOption = (option: string) => {129const newSelectedValues = selectedValues.includes(option)130? selectedValues.filter((value) => value !== option)131: [...selectedValues, option];132setSelectedValues(newSelectedValues);133onValueChange(newSelectedValues);134};135136const handleClear = () => {137setSelectedValues([]);138onValueChange([]);139};140141const handleTogglePopover = () => {142setIsPopoverOpen((prev) => !prev);143};144145const clearExtraOptions = () => {146const newSelectedValues = selectedValues.slice(0, maxCount);147setSelectedValues(newSelectedValues);148onValueChange(newSelectedValues);149};150const filteredOptions = options.filter((option) => !option.disable);151const toggleAll = () => {152if (selectedValues.length === filteredOptions.length) {153handleClear();154} else {155const allValues = filteredOptions.map((option) => option.value);156setSelectedValues(allValues);157onValueChange(allValues);158}159};160161return (162<Popover163open={isPopoverOpen}164onOpenChange={setIsPopoverOpen}165modal={modalPopover}166>167<PopoverTrigger asChild>168<Button169ref={ref}170{...props}171onClick={handleTogglePopover}172className={cn(173'flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-background hover:bg-background',174className175)}176>177{selectedValues.length > 0 ? (178<div className='flex justify-between items-center w-full'>179<div className='flex flex-wrap items-center gap-1 p-1'>180{(showall181? selectedValues182: selectedValues.slice(0, maxCount)183).map((value) => {184const option = options.find((o) => o.value === value);185const IconComponent = option?.icon;186return (187<div188key={value}189className={cn(190'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-primary text-primary-foreground '191)}192>193{IconComponent && (194<IconComponent className='h-4 w-4 mr-2' />195)}196{option?.label}197<XCircle198className='ml-2 h-4 w-4 cursor-pointer'199onClick={(event) => {200event.stopPropagation();201toggleOption(value);202}}203/>204</div>205);206})}207{!showall && selectedValues.length > maxCount && (208<div209className={cn(210'bg-primary-foreground inline-flex items-center border px-2 py-0.5 rounded-full text-foreground border-foreground/1 hover:bg-transparent'211)}212style={{ animationDuration: `${animation}s` }}213>214{`+ ${selectedValues.length - maxCount} more`}215<XCircle216className='ml-2 h-4 w-4 cursor-pointer'217onClick={(event) => {218event.stopPropagation();219clearExtraOptions();220}}221/>222</div>223)}224</div>225<div className='flex items-center justify-between'>226<XIcon227className='h-4 mx-2 cursor-pointer text-muted-foreground'228onClick={(event) => {229event.stopPropagation();230handleClear();231}}232/>233{/* <Separator234orientation="vertical"235className="flex min-h-6 h-full"236/> */}237<ChevronDown className='h-4 mx-2 cursor-pointer text-muted-foreground' />238</div>239</div>240) : (241<div className='flex items-center justify-between w-full mx-auto'>242<span className='text-sm text-muted-foreground mx-3'>243{placeholder}244</span>245<ChevronDown className='h-4 cursor-pointer text-muted-foreground mx-2' />246</div>247)}248</Button>249</PopoverTrigger>250<PopoverContent251className={cn('w-auto p-0', popoverClass)}252align='start'253onEscapeKeyDown={() => setIsPopoverOpen(false)}254>255<Command>256<CommandInput257placeholder='Search...'258onKeyDown={handleInputKeyDown}259/>260<CommandList>261<CommandEmpty>No results found.</CommandEmpty>262<CommandGroup>263<CommandItem264key='all'265onSelect={toggleAll}266className='cursor-pointer'267>268<div269className={cn(270'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',271selectedValues.length === filteredOptions.length272? 'bg-primary text-primary-foreground'273: 'opacity-50 [&_svg]:invisible'274)}275>276<CheckIcon className='h-4 w-4' />277</div>278<span>(Select All)</span>279</CommandItem>280{options.map((option) => {281const isSelected = selectedValues.includes(option.value);282const isDisabled = option.disable; // Check if option is disabled283284return (285<CommandItem286key={option.value}287onSelect={() => !isDisabled && toggleOption(option.value)}288className={cn(289'cursor-pointer',290isDisabled && 'opacity-50 cursor-not-allowed' // Disable styling291)}292>293<div294className={cn(295'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',296isSelected297? 'bg-primary text-primary-foreground'298: 'opacity-50 [&_svg]:invisible'299)}300>301{!isDisabled && <CheckIcon className='h-4 w-4' />}302</div>303{option.icon && (304<option.icon305className={cn(306'mr-2 h-4 w-4',307isDisabled ? 'text-muted-foreground' : ''308)}309/>310)}311<span>{option.label}</span>312</CommandItem>313);314})}315</CommandGroup>316<CommandSeparator />317<CommandGroup>318<div className='flex items-center justify-between'>319{selectedValues.length > 0 && (320<>321<CommandItem322onSelect={handleClear}323className='flex-1 justify-center cursor-pointer border-r'324>325Clear326</CommandItem>327</>328)}329<CommandItem330onSelect={() => setIsPopoverOpen(false)}331className='flex-1 justify-center cursor-pointer max-w-full'332>333Close334</CommandItem>335</div>336</CommandGroup>337</CommandList>338</Command>339</PopoverContent>340</Popover>341);342}343);344345MultiSelect.displayName = 'MultiSelect';
Prop | Type | Default | Description |
---|---|---|---|
options | { label: string; value: string; icon?: React.ComponentType; disable?: boolean; }[] | [] | An array of option objects displayed in the multi-select. Each object has a label , value , optional icon , and optional disable flag. |
onValueChange | (value: string[]) => void | undefined | Callback triggered when the selected values change. Receives an array of selected values. |
defaultValue | string[] | [] | The default selected values when the component mounts. |
placeholder | string | "Select options" | Text displayed when no values are selected. |
animation | number | 0 | Duration of animation effects (in seconds) for visual feedback like bouncing badges. |
maxCount | number | 3 | Maximum number of items to display before summarizing the rest. |
modalPopover | boolean | false | Enables modal behavior for the popover, disabling interaction with outside elements and enhancing accessibility for screen readers. |
asChild | boolean | false | Renders the multi-select as a child element of another component. |
className | string | undefined | Additional CSS classes for custom styling of the multi-select component. |
popoverClass | string | undefined | Additional CSS classes for custom styling of the popover content. |
showall | boolean | false | Option to display all items without truncating the list, regardless of the maxCount setting. |
ref | React.RefObject<HTMLButtonElement> | undefined | A React ref object for the root element of the multi-select component, allowing for external access to the component’s DOM node. |