/** * SPDX-License-Identifier: MIT * MIT License * Copyright (c) 2024 sersavan * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" import { type VariantProps, cva } from "class-variance-authority" import { CheckIcon, ChevronDown, WandSparkles, XIcon } from "lucide-react" import * as React from "react" /** * Variants for the multi-select component to handle different styles. * Uses class-variance-authority (cva) to define different styles based on "variant" prop. */ const multiSelectVariants = cva( "m-1 transition ease-in-out delay-150 hover:-translate-y-1 duration-300", { variants: { variant: { default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", secondary: "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", inverted: "inverted", }, }, defaultVariants: { variant: "default", }, }, ) /** * Props for MultiSelect component */ interface MultiSelectProps extends React.ButtonHTMLAttributes, VariantProps { /** * An array of option objects to be displayed in the multi-select component. * Each option object has a label, value, and an optional icon. */ options: { /** The text to display for the option. */ label: string /** The unique value associated with the option. */ value: string /** Optional icon component to display alongside the option. */ icon?: React.ComponentType<{ className?: string }> }[] /** * Callback function triggered when the selected values change. * Receives an array of the new selected values. */ onValueChange: (value: string[]) => void /** The default selected values when the component mounts. */ defaultValue?: string[] /** * Placeholder text to be displayed when no values are selected. * Optional, defaults to "Select options". */ placeholder?: string /** * Animation duration in seconds for the visual effects (e.g., bouncing badges). * Optional, defaults to 0 (no animation). */ animation?: number /** * Maximum number of items to display. Extra selected items will be summarized. * Optional, defaults to 3. */ maxCount?: number /** * The modality of the popover. When set to true, interaction with outside elements * will be disabled and only popover content will be visible to screen readers. * Optional, defaults to false. */ modalPopover?: boolean /** * If true, renders the multi-select component as a child of another component. * Optional, defaults to false. */ asChild?: boolean /** * Additional class names to apply custom styles to the multi-select component. * Optional, can be used to add custom styles. */ className?: string } export const MultiSelect = React.forwardRef( ( { options, onValueChange, variant, defaultValue = [], placeholder = "Select options", animation = 0, maxCount = 3, modalPopover = false, asChild = false, className, ...props }, ref, ) => { const [selectedValues, setSelectedValues] = React.useState(defaultValue) const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) const [isAnimating, setIsAnimating] = React.useState(false) const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { setIsPopoverOpen(true) } else if (event.key === "Backspace" && !event.currentTarget.value) { const newSelectedValues = [...selectedValues] newSelectedValues.pop() setSelectedValues(newSelectedValues) onValueChange(newSelectedValues) } } const toggleOption = (option: string) => { const newSelectedValues = selectedValues.includes(option) ? selectedValues.filter((value) => value !== option) : [...selectedValues, option] setSelectedValues(newSelectedValues) onValueChange(newSelectedValues) } const handleClear = () => { setSelectedValues([]) onValueChange([]) } const handleTogglePopover = () => { setIsPopoverOpen((prev) => !prev) } const clearExtraOptions = () => { const newSelectedValues = selectedValues.slice(0, maxCount) setSelectedValues(newSelectedValues) onValueChange(newSelectedValues) } const toggleAll = () => { if (selectedValues.length === options.length) { handleClear() } else { const allValues = options.map((option) => option.value) setSelectedValues(allValues) onValueChange(allValues) } } const stopWheelEventPropagation: React.WheelEventHandler = (e) => { e.stopPropagation() } const stopTouchMoveEventPropagation: React.TouchEventHandler = (e) => { e.stopPropagation() } return ( setIsPopoverOpen(false)} onWheel={stopWheelEventPropagation} onTouchMove={stopTouchMoveEventPropagation} > No results found.
(Select All)
{options.map((option) => { const isSelected = selectedValues.includes(option.value) return ( toggleOption(option.value)} className="cursor-pointer" >
{option.icon && ( )} {option.label}
) })}
{selectedValues.length > 0 && ( <> Clear )} setIsPopoverOpen(false)} className="flex-1 justify-center cursor-pointer max-w-full" > Close
{animation > 0 && selectedValues.length > 0 && ( setIsAnimating(!isAnimating)} /> )}
) }, ) MultiSelect.displayName = "MultiSelect"