197 lines
6.8 KiB
TypeScript
197 lines
6.8 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
|
import { ChevronDown, X, Check } from "lucide-react";
|
|
|
|
interface Option {
|
|
label: string;
|
|
value: string | number;
|
|
}
|
|
|
|
interface CustomMultiSelectProps {
|
|
label?: string;
|
|
options: Option[];
|
|
value?: (string | number)[];
|
|
onChange?: (value: (string | number)[]) => void;
|
|
placeholder?: string;
|
|
leftIcon?: React.ReactNode;
|
|
disabled?: boolean;
|
|
required?: boolean;
|
|
className?: string;
|
|
error?: string;
|
|
}
|
|
|
|
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|
label,
|
|
options,
|
|
value = [],
|
|
onChange,
|
|
placeholder = "Select options...",
|
|
leftIcon,
|
|
disabled,
|
|
required,
|
|
className = "",
|
|
error,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsOpen(false);
|
|
setSearchTerm("");
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
const handleSelect = (optionValue: string | number) => {
|
|
if (disabled) return;
|
|
const newValue = value.includes(optionValue)
|
|
? value.filter((v) => v !== optionValue)
|
|
: [...value, optionValue];
|
|
onChange?.(newValue);
|
|
};
|
|
|
|
const removeValue = (e: React.MouseEvent, optionValue: string | number) => {
|
|
e.stopPropagation();
|
|
if (disabled) return;
|
|
onChange?.(value.filter((v) => v !== optionValue));
|
|
};
|
|
|
|
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
|
|
|
return (
|
|
<div className="w-full flex flex-col gap-1.5" ref={containerRef}>
|
|
{label && (
|
|
<label className="text-sm font-semibold text-(--text-primary)">
|
|
{label}
|
|
{required && <span className="text-red-500 ml-1">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
<div className="relative w-full">
|
|
<div
|
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
className={`
|
|
w-full rounded-lg
|
|
bg-(--background) text-(--text-primary) text-sm
|
|
border ${isOpen
|
|
? "border-blue-600 ring-2 ring-blue-600/20"
|
|
: error ? "border-red-500" : "border-(--card-border)"
|
|
}
|
|
px-3 py-0 h-[46px]
|
|
outline-none
|
|
transition-all duration-200
|
|
hover:border-blue-500
|
|
cursor-pointer
|
|
flex items-center gap-2
|
|
overflow-hidden
|
|
${disabled
|
|
? "bg-(--background-secondary) text-(--text-secondary) cursor-not-allowed border-(--card-border)"
|
|
: ""
|
|
}
|
|
${leftIcon ? "pl-10" : ""}
|
|
pr-10
|
|
${className}
|
|
`}
|
|
>
|
|
{leftIcon && (
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none">
|
|
{leftIcon}
|
|
</span>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 overflow-hidden w-full h-full">
|
|
{selectedOptions.length === 0 ? (
|
|
<span className="text-(--text-secondary)/50 text-nowrap">{placeholder}</span>
|
|
) : selectedOptions.length === 1 ? (
|
|
<span
|
|
key={selectedOptions[0].value}
|
|
className="flex items-center gap-1 px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs font-medium border border-blue-100 dark:border-blue-800 min-w-0"
|
|
>
|
|
<span className="truncate">{selectedOptions[0].label}</span>
|
|
<X
|
|
size={14}
|
|
className="cursor-pointer hover:text-blue-900 dark:hover:text-blue-200 shrink-0"
|
|
onClick={(e) => removeValue(e, selectedOptions[0].value)}
|
|
/>
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs font-medium border border-blue-100 dark:border-blue-800">
|
|
{selectedOptions.length} Selected
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none">
|
|
<ChevronDown
|
|
size={16}
|
|
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""
|
|
}`}
|
|
/>
|
|
</span>
|
|
</div>
|
|
|
|
{isOpen && !disabled && (
|
|
<div className="absolute z-50 w-full mt-1 bg-(--card-bg) border border-(--card-border) rounded-lg shadow-lg max-h-60 overflow-hidden flex flex-col py-1">
|
|
<div className="px-2 py-1 border-b border-(--card-border)">
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
className="w-full px-2 py-1 text-sm bg-transparent outline-none text-(--text-primary) placeholder-(--text-secondary)/50"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
value={searchTerm}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto max-h-48">
|
|
{options
|
|
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-(--text-secondary)">
|
|
No options available
|
|
</div>
|
|
) : (
|
|
options
|
|
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
.map((option) => {
|
|
const isSelected = value.includes(option.value);
|
|
return (
|
|
<div
|
|
key={option.value}
|
|
onClick={() => handleSelect(option.value)}
|
|
className={`
|
|
px-3 py-2 text-sm cursor-pointer flex items-center justify-between
|
|
${isSelected
|
|
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
|
|
: "text-(--text-primary) hover:bg-(--table-row-hover)"
|
|
}
|
|
`}
|
|
>
|
|
{option.label}
|
|
{isSelected && <Check size={16} />}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CustomMultiSelect;
|