Files
saas_frontend/src/components/custom/CustomMultiSelect.tsx
T
2026-01-19 11:05:40 +05:30

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;