212 lines
6.6 KiB
TypeScript
212 lines
6.6 KiB
TypeScript
"use client";
|
|
import React, { useState, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { CheckIcon, ChevronDownIcon, XIcon } from "@/components/icons";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface MultiSelectOption {
|
|
value: string | number;
|
|
label: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface MultiSelectProps {
|
|
label: string;
|
|
placeholder?: string;
|
|
options: MultiSelectOption[];
|
|
value: (string | number)[];
|
|
onChange: (value: (string | number)[]) => void;
|
|
error?: string;
|
|
required?: boolean;
|
|
disabled?: boolean;
|
|
searchable?: boolean;
|
|
maxSelections?: number;
|
|
className?: string;
|
|
helpText?: string;
|
|
}
|
|
|
|
export const MultiSelect: React.FC<MultiSelectProps> = ({
|
|
label,
|
|
placeholder = "Select options...",
|
|
options,
|
|
value = [],
|
|
onChange,
|
|
error,
|
|
required = false,
|
|
disabled = false,
|
|
searchable = true,
|
|
maxSelections,
|
|
className = "",
|
|
helpText,
|
|
}) => {
|
|
const [open, setOpen] = useState(false);
|
|
const [searchValue, setSearchValue] = useState("");
|
|
|
|
const hasError = !!error;
|
|
|
|
const filteredOptions = useMemo(() => {
|
|
if (!searchable || !searchValue) return options;
|
|
|
|
return options.filter(option =>
|
|
option.label.toLowerCase().includes(searchValue.toLowerCase()) ||
|
|
(option.description && option.description.toLowerCase().includes(searchValue.toLowerCase()))
|
|
);
|
|
}, [options, searchValue, searchable]);
|
|
|
|
const selectedOptions = useMemo(() => {
|
|
return options.filter(option => value.includes(option.value));
|
|
}, [options, value]);
|
|
|
|
const handleSelect = (optionValue: string | number) => {
|
|
if (disabled) return;
|
|
|
|
const newValue = value.includes(optionValue)
|
|
? value.filter(v => v !== optionValue)
|
|
: maxSelections && value.length >= maxSelections
|
|
? value
|
|
: [...value, optionValue];
|
|
|
|
onChange(newValue);
|
|
};
|
|
|
|
const handleRemove = (optionValue: string | number) => {
|
|
if (disabled) return;
|
|
onChange(value.filter(v => v !== optionValue));
|
|
};
|
|
|
|
const handleClearAll = () => {
|
|
if (disabled) return;
|
|
onChange([]);
|
|
};
|
|
|
|
return (
|
|
<div className={`space-y-2 ${className}`}>
|
|
<Label className="text-sm font-medium">
|
|
{label}
|
|
{required && <span className="text-red-500 ml-1">*</span>}
|
|
</Label>
|
|
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className={`w-full justify-between ${hasError ? "border-red-500" : ""}`}
|
|
disabled={disabled}
|
|
>
|
|
<span className="truncate">
|
|
{selectedOptions.length === 0
|
|
? placeholder
|
|
: `${selectedOptions.length} selected`}
|
|
</span>
|
|
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent className="w-full p-0" align="start">
|
|
<Command>
|
|
{searchable && (
|
|
<CommandInput
|
|
placeholder="Search options..."
|
|
value={searchValue}
|
|
onValueChange={setSearchValue}
|
|
/>
|
|
)}
|
|
|
|
<CommandList>
|
|
<CommandEmpty>
|
|
{searchable && searchValue ? "No options found." : "No options available."}
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{filteredOptions.map((option) => (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value.toString()}
|
|
onSelect={() => handleSelect(option.value)}
|
|
className="flex items-center justify-between"
|
|
>
|
|
<div className="flex items-center">
|
|
<div
|
|
className={cn(
|
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
|
value.includes(option.value)
|
|
? "bg-primary text-primary-foreground"
|
|
: "opacity-50 [&_svg]:invisible"
|
|
)}
|
|
>
|
|
<CheckIcon className="h-3 w-3" />
|
|
</div>
|
|
<div>
|
|
<div className="font-medium">{option.label}</div>
|
|
{option.description && (
|
|
<div className="text-xs text-gray-500">{option.description}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Selected Items Display */}
|
|
{selectedOptions.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-600">Selected:</span>
|
|
{!disabled && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClearAll}
|
|
className="text-xs text-red-600 hover:text-red-700"
|
|
>
|
|
Clear All
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedOptions.map((option) => (
|
|
<Badge
|
|
key={option.value}
|
|
className="flex items-center gap-1 pr-1"
|
|
>
|
|
<span className="truncate max-w-[200px]">{option.label}</span>
|
|
{!disabled && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(option.value)}
|
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{helpText && (
|
|
<p className="text-xs text-gray-500">{helpText}</p>
|
|
)}
|
|
|
|
{hasError && (
|
|
<p className="text-red-500 text-xs">{error}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|