204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
|
import { CaretDownIcon, CheckIcon, XCircleIcon } from "@phosphor-icons/react";
|
|
import DropdownPortal from "./DropdownPortal";
|
|
|
|
interface Option {
|
|
label: string;
|
|
value: string | number;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface CustomMultiSelectProps {
|
|
label?: string;
|
|
options: Option[];
|
|
value?: (string | number)[];
|
|
onChange?: (value: (string | number)[]) => void;
|
|
placeholder?: string;
|
|
leftIcon?: React.ReactNode;
|
|
size?: "sm" | "md" | "lg";
|
|
disabled?: boolean;
|
|
required?: boolean;
|
|
className?: string;
|
|
error?: string;
|
|
}
|
|
|
|
const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProps>(
|
|
(
|
|
{
|
|
label,
|
|
options,
|
|
value = [],
|
|
onChange,
|
|
placeholder = "Select...",
|
|
disabled,
|
|
className = "",
|
|
required,
|
|
leftIcon,
|
|
error,
|
|
size = "md",
|
|
},
|
|
ref
|
|
) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
const sizeClasses = {
|
|
sm: "h-[36px] text-sm",
|
|
md: "h-[44px] text-sm",
|
|
lg: "h-[48px] text-base",
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
const target = event.target as Node;
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(target) &&
|
|
!(panelRef.current && panelRef.current.contains(target))
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (option: Option) => {
|
|
if (option.disabled) return;
|
|
const optionValueStr = String(option.value);
|
|
const isSelected = value.some(v => String(v) === optionValueStr);
|
|
let newValue = [...value];
|
|
if (isSelected) {
|
|
newValue = newValue.filter(v => String(v) !== optionValueStr);
|
|
} else {
|
|
newValue.push(option.value);
|
|
}
|
|
onChange?.(newValue);
|
|
};
|
|
|
|
const handleRemove = (e: React.MouseEvent, optionValue: string | number) => {
|
|
e.stopPropagation();
|
|
const optionValueStr = String(optionValue);
|
|
const newValue = value.filter(v => String(v) !== optionValueStr);
|
|
onChange?.(newValue);
|
|
};
|
|
|
|
const selectedOptions = options.filter(opt => value.some(v => String(v) === String(opt.value)));
|
|
|
|
return (
|
|
<div className="w-full flex flex-col gap-1.5" ref={ref}>
|
|
{label && (
|
|
<label className="text-sm font-medium text-gray-900">
|
|
{label}
|
|
{required && <span className="text-red-500 ml-1">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
<div className="relative w-full" ref={dropdownRef}>
|
|
<div
|
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
className={`
|
|
w-full rounded-lg
|
|
bg-white
|
|
border ${error ? 'border-red-500' : 'border-gray-300'}
|
|
${sizeClasses[size]}
|
|
px-3
|
|
outline-none
|
|
transition-all duration-200
|
|
flex items-center justify-between
|
|
${!disabled ? 'cursor-pointer hover:border-primary' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
|
|
${isOpen ? 'border-primary ring-2 ring-primary/20' : ''}
|
|
${leftIcon ? "pl-10" : ""}
|
|
pr-9
|
|
${className}
|
|
`}
|
|
>
|
|
{leftIcon && (
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
|
{leftIcon}
|
|
</span>
|
|
)}
|
|
|
|
<div className="flex-1 text-left font-medium tracking-[0.25px] flex items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-0.5">
|
|
{selectedOptions.length > 0 ? (
|
|
selectedOptions.map(opt => (
|
|
<span
|
|
key={opt.value}
|
|
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-[#F2F9F6] border border-[#D5E8DF] text-[#032D20] text-xs font-medium shrink-0 transition-all"
|
|
>
|
|
<span>{opt.label}</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => handleRemove(e, opt.value)}
|
|
className="inline-flex items-center justify-center text-[#7A998C] hover:text-red-500 transition-colors cursor-pointer shrink-0"
|
|
title={`Remove ${opt.label}`}
|
|
>
|
|
<XCircleIcon size={14} weight="fill" />
|
|
</button>
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-[#6C766D] truncate">{placeholder}</span>
|
|
)}
|
|
</div>
|
|
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none flex items-center">
|
|
<CaretDownIcon
|
|
size={16}
|
|
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
|
/>
|
|
</span>
|
|
</div>
|
|
|
|
{/* Dropdown Menu */}
|
|
<DropdownPortal
|
|
anchorRef={dropdownRef}
|
|
isOpen={isOpen && !disabled}
|
|
ref={panelRef}
|
|
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
|
>
|
|
{options.length === 0 ? (
|
|
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
|
) : (
|
|
options.map((option) => {
|
|
const isSelected = value.some(v => String(v) === String(option.value));
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
disabled={option.disabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleSelect(option);
|
|
}}
|
|
className={`
|
|
w-full text-left px-4 min-h-[44px] text-[15px]
|
|
transition-colors duration-150 flex items-center gap-2
|
|
${isSelected
|
|
? "bg-[#EAF7EE] text-[#1B9869] font-medium"
|
|
: option.disabled
|
|
? "text-gray-400 cursor-not-allowed"
|
|
: "text-[#0F172B] hover:bg-gray-50"
|
|
}
|
|
`}
|
|
>
|
|
{isSelected && <CheckIcon size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
|
|
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</DropdownPortal>
|
|
</div>
|
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
CustomMultiSelect.displayName = "CustomMultiSelect";
|
|
|
|
export default CustomMultiSelect;
|