Spaces:
Running
Running
import * as React from 'react' | |
import { cva, type VariantProps } from 'class-variance-authority' | |
import { CheckIcon, XCircle, ChevronDown, XIcon, WandSparkles } from 'lucide-react' | |
import { cn } from '@/lib/utils' | |
import { Separator } from '@/components/ui/separator' | |
import { Button } from '@/components/ui/button' | |
import { Badge } from '@/components/ui/badge' | |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' | |
import { | |
Command, | |
CommandEmpty, | |
CommandGroup, | |
CommandInput, | |
CommandItem, | |
CommandList, | |
CommandSeparator, | |
} from '@/components/ui/command' | |
const multiSelectVariants = cva( | |
'm-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 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', | |
}, | |
} | |
) | |
interface MultiSelectProps | |
extends React.ButtonHTMLAttributes<HTMLButtonElement>, | |
VariantProps<typeof multiSelectVariants> { | |
options: { | |
label: string | |
value: string | |
icon?: React.ComponentType<{ className?: string }> | |
}[] | |
onValueChange: (value: string[]) => void | |
defaultValue: string[] | |
placeholder?: string | |
animation?: number | |
maxCount?: number | |
asChild?: boolean | |
className?: string | |
} | |
export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>( | |
( | |
{ | |
options, | |
onValueChange, | |
variant, | |
defaultValue = [], | |
placeholder = 'Select options', | |
animation = 0, | |
maxCount = 3, | |
asChild = false, | |
className, | |
...props | |
}, | |
ref | |
) => { | |
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue) | |
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) | |
const [isAnimating, setIsAnimating] = React.useState(false) | |
React.useEffect(() => { | |
if (JSON.stringify(selectedValues) !== JSON.stringify(defaultValue)) { | |
setSelectedValues(defaultValue) | |
} | |
}, [defaultValue, selectedValues]) | |
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | |
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 = (value: string) => { | |
const newSelectedValues = selectedValues.includes(value) | |
? selectedValues.filter((v) => v !== value) | |
: [...selectedValues, value] | |
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) | |
} | |
} | |
return ( | |
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> | |
<PopoverTrigger asChild> | |
<Button | |
ref={ref} | |
{...props} | |
onClick={handleTogglePopover} | |
className={cn( | |
'flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit', | |
className | |
)} | |
> | |
{selectedValues.length > 0 ? ( | |
<div className="flex justify-between items-center w-full"> | |
<div className="flex flex-wrap items-center"> | |
{selectedValues.slice(0, maxCount).map((value) => { | |
const option = options.find((o) => o.value === value) | |
const IconComponent = option?.icon | |
return ( | |
<Badge | |
key={value} | |
className={cn(isAnimating ? 'animate-bounce' : '', multiSelectVariants({ variant, className }))} | |
style={{ animationDuration: `${animation}s` }} | |
> | |
{IconComponent && <IconComponent className="h-4 w-4 mr-2" />} | |
{option?.label} | |
<XCircle | |
className="ml-2 h-4 w-4 cursor-pointer" | |
onClick={(event) => { | |
event.stopPropagation() | |
toggleOption(value) | |
}} | |
/> | |
</Badge> | |
) | |
})} | |
{selectedValues.length > maxCount && ( | |
<Badge | |
className={cn( | |
'bg-transparent text-foreground border-foreground/1 hover:bg-transparent', | |
isAnimating ? 'animate-bounce' : '', | |
multiSelectVariants({ variant, className }) | |
)} | |
style={{ animationDuration: `${animation}s` }} | |
> | |
{`+ ${selectedValues.length - maxCount} more`} | |
<XCircle | |
className="ml-2 h-4 w-4 cursor-pointer" | |
onClick={(event) => { | |
event.stopPropagation() | |
clearExtraOptions() | |
}} | |
/> | |
</Badge> | |
)} | |
</div> | |
<div className="flex items-center justify-between"> | |
<XIcon | |
className="h-4 mx-2 cursor-pointer text-muted-foreground" | |
onClick={(event) => { | |
event.stopPropagation() | |
handleClear() | |
}} | |
/> | |
<Separator orientation="vertical" className="flex min-h-6 h-full" /> | |
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" /> | |
</div> | |
</div> | |
) : ( | |
<div className="flex items-center justify-between w-full mx-auto"> | |
<span className="text-sm text-muted-foreground mx-3">{placeholder}</span> | |
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" /> | |
</div> | |
)} | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent className="w-auto p-0" align="start" onEscapeKeyDown={() => setIsPopoverOpen(false)}> | |
<Command> | |
<CommandInput placeholder="Search..." onKeyDown={handleInputKeyDown} /> | |
<CommandList> | |
<CommandEmpty>No results found.</CommandEmpty> | |
<CommandGroup> | |
<CommandItem key="all" onSelect={toggleAll} className="cursor-pointer"> | |
<div | |
className={cn( | |
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary', | |
selectedValues.length === options.length | |
? 'bg-primary text-primary-foreground' | |
: 'opacity-50 [&_svg]:invisible' | |
)} | |
> | |
<CheckIcon className="h-4 w-4" /> | |
</div> | |
<span>(Select All)</span> | |
</CommandItem> | |
{options.map((option) => { | |
const isSelected = selectedValues.includes(option.value) | |
return ( | |
<CommandItem | |
key={option.value} | |
onSelect={() => toggleOption(option.value)} | |
className="cursor-pointer" | |
> | |
<div | |
className={cn( | |
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary', | |
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible' | |
)} | |
> | |
<CheckIcon className="h-4 w-4" /> | |
</div> | |
{option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />} | |
<span>{option.label}</span> | |
</CommandItem> | |
) | |
})} | |
</CommandGroup> | |
<CommandSeparator /> | |
<CommandGroup> | |
<div className="flex items-center justify-between"> | |
{selectedValues.length > 0 && ( | |
<> | |
<CommandItem onSelect={handleClear} className="flex-1 justify-center cursor-pointer"> | |
Clear | |
</CommandItem> | |
<Separator orientation="vertical" className="flex min-h-6 h-full" /> | |
</> | |
)} | |
<CommandSeparator /> | |
<CommandItem | |
onSelect={() => setIsPopoverOpen(false)} | |
className="flex-1 justify-center cursor-pointer" | |
> | |
Close | |
</CommandItem> | |
</div> | |
</CommandGroup> | |
</CommandList> | |
</Command> | |
</PopoverContent> | |
{animation > 0 && selectedValues.length > 0 && ( | |
<WandSparkles | |
className={cn( | |
'cursor-pointer my-2 text-foreground bg-background w-3 h-3', | |
isAnimating ? '' : 'text-muted-foreground' | |
)} | |
onClick={() => setIsAnimating(!isAnimating)} | |
/> | |
)} | |
</Popover> | |
) | |
} | |
) | |
MultiSelect.displayName = 'MultiSelect' | |