mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 17:31:13 -05:00
144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
import * as React from "react";
|
|
import { useState, useMemo } from "react";
|
|
import { Check, ChevronsUpDown, X } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
|
|
export interface MultiSelectOption {
|
|
label: string;
|
|
value: string;
|
|
}
|
|
|
|
interface MultiSelectComboboxProps {
|
|
options: MultiSelectOption[];
|
|
value?: string[];
|
|
onValueChange: (values: string[]) => void;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
maxDisplay?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function MultiSelectCombobox({
|
|
options,
|
|
value = [],
|
|
onValueChange,
|
|
placeholder = "Select options...",
|
|
searchPlaceholder = "Search...",
|
|
emptyText = "No options found.",
|
|
maxDisplay = 2,
|
|
className,
|
|
}: MultiSelectComboboxProps) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const handleSelect = (selectedValue: string) => {
|
|
const newValues = value.includes(selectedValue)
|
|
? value.filter((v) => v !== selectedValue)
|
|
: [...value, selectedValue];
|
|
onValueChange(newValues);
|
|
};
|
|
|
|
const handleRemove = (valueToRemove: string) => {
|
|
onValueChange(value.filter((v) => v !== valueToRemove));
|
|
};
|
|
|
|
const displayText = useMemo(() => {
|
|
if (value.length === 0) return placeholder;
|
|
if (value.length <= maxDisplay) {
|
|
return value
|
|
.map((v) => options.find((o) => o.value === v)?.label)
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
}
|
|
return `${value.length} selected`;
|
|
}, [value, options, maxDisplay, placeholder]);
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className={cn("w-full justify-between", className)}
|
|
>
|
|
<span className="truncate">{displayText}</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-full p-0 z-50" align="start">
|
|
<Command>
|
|
<CommandInput placeholder={searchPlaceholder} />
|
|
<CommandList>
|
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
<CommandGroup>
|
|
{options.map((option) => (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value}
|
|
onSelect={() => handleSelect(option.value)}
|
|
>
|
|
<Checkbox
|
|
checked={value.includes(option.value)}
|
|
className="mr-2"
|
|
/>
|
|
<span>{option.label}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Selected Items Display */}
|
|
{value.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{value.map((v) => {
|
|
const option = options.find((o) => o.value === v);
|
|
return option ? (
|
|
<Badge key={v} variant="secondary" className="gap-1">
|
|
{option.label}
|
|
<X
|
|
className="w-3 h-3 cursor-pointer hover:text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemove(v);
|
|
}}
|
|
/>
|
|
</Badge>
|
|
) : null;
|
|
})}
|
|
{value.length > 1 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
onClick={() => onValueChange([])}
|
|
>
|
|
Clear all
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|