kontenhumas-fe/components/form/common/MultiSelect.tsx

212 lines
6.6 KiB
TypeScript
Raw Normal View History

2025-10-02 05:04:42 +00:00
"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>
);
};