import React, { useState, useRef, useEffect, useLayoutEffect } from "react"; import { createPortal } from "react-dom"; import { ChevronDown, X, Search } from "lucide-react"; import { __ } from "../../lib/i18n"; import { Input } from "./input"; export interface SearchableSelectOption { value: string; label: string; icon?: string; } export interface SearchableSelectProps { value: string; onChange: (value: string) => void; options: SearchableSelectOption[]; placeholder?: string; searchPlaceholder?: string; className?: string; error?: boolean; required?: boolean; disabled?: boolean; showValueId?: boolean; // Show "ID: value" next to label } export const SearchableSelect: React.FC = ({ value, onChange, options, placeholder = __("Select an option...", "yatra"), searchPlaceholder = __("Search...", "yatra"), className = "", error = false, disabled = false, showValueId = true, }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const containerRef = useRef(null); const searchInputRef = useRef(null); // Dropdown is portaled to document.body so it escapes parent stacking // contexts (each itinerary entry row is `position: relative` with its // own z-index — without portaling, later sibling rows would render // their content ABOVE this dropdown panel regardless of z-index). const [menuRect, setMenuRect] = useState<{ top: number; left: number; width: number; } | null>(null); // Filter options based on search term (search by both label and value) const filteredOptions = options.filter( (option) => option.label.toLowerCase().includes(searchTerm.toLowerCase()) || option.value.toLowerCase().includes(searchTerm.toLowerCase()), ); // Get selected option const selectedOption = options.find((opt) => opt.value === value); // Close dropdown when clicking outside. Because the menu is portaled // to document.body, we check membership against BOTH the trigger // container and the portal node — a click on the menu itself must // not count as "outside". useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; if (containerRef.current && containerRef.current.contains(target)) { return; } const portalRoot = document.getElementById( "yatra-searchable-select-portal", ); if (portalRoot && portalRoot.contains(target)) { return; } setIsOpen(false); setSearchTerm(""); }; if (isOpen) { document.addEventListener("mousedown", handleClickOutside); // Focus search input when dropdown opens setTimeout(() => { searchInputRef.current?.focus(); }, 100); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen]); // Measure trigger so the portaled menu can position under it. We // re-measure on open, on scroll, and on resize so the menu tracks // the trigger when the page scrolls under it. useLayoutEffect(() => { if (!isOpen) { setMenuRect(null); return; } const measure = () => { const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); setMenuRect({ top: rect.bottom + 4, left: rect.left, width: rect.width, }); }; measure(); window.addEventListener("scroll", measure, true); window.addEventListener("resize", measure); return () => { window.removeEventListener("scroll", measure, true); window.removeEventListener("resize", measure); }; }, [isOpen]); const handleSelect = (optionValue: string) => { onChange(optionValue); setIsOpen(false); setSearchTerm(""); }; const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); onChange(""); setSearchTerm(""); }; return (
!disabled && setIsOpen(!isOpen)} className={`flex-1 flex items-center justify-between text-left ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`} > {selectedOption ? (
{selectedOption.label} {showValueId && selectedOption.value && selectedOption.value !== "" && ( ID: {selectedOption.value} )}
) : ( placeholder )}
{value && !disabled && ( )}
{isOpen && menuRect && createPortal(
{/* Search Input */}
setSearchTerm(e.target.value)} className="pl-8 h-8" onClick={(e) => e.stopPropagation()} />
{/* Options List */}
{filteredOptions.length === 0 ? (
{__("No options found", "yatra")}
) : ( filteredOptions.map((option) => ( )) )}
, document.body, )}
); };