import React, { useState, useRef, useEffect } from "react"; import { CaretDownIcon, XIcon, CheckIcon } from "@phosphor-icons/react"; import DropdownPortal from "./DropdownPortal"; interface Option { label: string; value: string | number; } interface CustomSearchableDropdownProps extends Omit, 'onChange' | 'size'> { label?: string; options: Option[]; value?: string | number; onChange?: (value: string) => void; placeholder?: string; leftIcon?: React.ReactNode; error?: string; size?: "sm" | "md" | "lg"; } const CustomSearchableDropdown = React.forwardRef< HTMLDivElement, CustomSearchableDropdownProps >( ( { label, options, value, onChange, placeholder = "Select...", disabled, className = "", required, leftIcon, size = "md", ...props }, ref ) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const dropdownRef = useRef(null); const inputRef = useRef(null); const panelRef = useRef(null); const sizeClasses = { sm: "py-1 text-sm", md: "py-1.5 text-sm", lg: "py-2.5 text-base", }; // Sync search term with selected value on mount or value update useEffect(() => { const selectedOption = options.find((opt) => String(opt.value) === String(value)); if (selectedOption) { setSearchTerm(selectedOption.label); } else { setSearchTerm(""); } }, [value, options]); const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchTerm.toLowerCase()) ); 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); // Revert to selected value label if closing without selection const selectedOption = options.find((opt) => String(opt.value) === String(value)); if (selectedOption) { setSearchTerm(selectedOption.label); } else if (!value) { setSearchTerm(""); } } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [value, options]); useEffect(() => { if (!isOpen) { setHighlightedIndex(0); } }, [isOpen]); const handleInputChange = (e: React.ChangeEvent) => { setSearchTerm(e.target.value); setIsOpen(true); if (e.target.value === "") { onChange?.(""); // Clear selection if input is cleared } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!isOpen && e.key !== "Tab") { setIsOpen(true); } switch (e.key) { case "ArrowDown": e.preventDefault(); setHighlightedIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : prev ); break; case "ArrowUp": e.preventDefault(); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev)); break; case "Enter": e.preventDefault(); if (isOpen && filteredOptions[highlightedIndex]) { handleSelect(filteredOptions[highlightedIndex]); } break; case "Escape": e.preventDefault(); setIsOpen(false); break; case "Tab": setIsOpen(false); break; } }; const handleSelect = (option: Option) => { onChange?.(String(option.value)); setSearchTerm(option.label); setIsOpen(false); }; const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); onChange?.(""); setSearchTerm(""); inputRef.current?.focus(); }; const handleFocus = () => { if (!disabled) setIsOpen(true); }; return (
{label && ( )}
{leftIcon && ( {leftIcon} )}
{!value && ( )} {searchTerm && !disabled && ( )}
{/* Dropdown Menu */} {filteredOptions.length === 0 ? (
No results found
) : ( filteredOptions.map((option, index) => { const isSelected = String(option.value) === String(value); return ( ); }) )}
{props.error &&

{props.error}

}
); } ); CustomSearchableDropdown.displayName = "CustomSearchableDropdown"; export default CustomSearchableDropdown;