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