Files
aeroresolve_frontend/src/components/custom/CustomMultiSelect.tsx
T

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;