PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / ui / searchable-select.tsx

searchable-select.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.8, at resources/js/components/ui/searchable-select.tsx

254 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
2 import { createPortal } from "react-dom";
3 import { ChevronDown, X, Search } from "lucide-react";
4 import { __ } from "../../lib/i18n";
5 import { Input } from "./input";
6
7 export interface SearchableSelectOption {
8 value: string;
9 label: string;
10 icon?: string;
11 }
12
13 export interface SearchableSelectProps {
14 value: string;
15 onChange: (value: string) => void;
16 options: SearchableSelectOption[];
17 placeholder?: string;
18 searchPlaceholder?: string;
19 className?: string;
20 error?: boolean;
21 required?: boolean;
22 disabled?: boolean;
23 showValueId?: boolean; // Show "ID: value" next to label
24 }
25
26 export const SearchableSelect: React.FC<SearchableSelectProps> = ({
27 value,
28 onChange,
29 options,
30 placeholder = __("Select an option...", "yatra"),
31 searchPlaceholder = __("Search...", "yatra"),
32 className = "",
33 error = false,
34 disabled = false,
35 showValueId = true,
36 }) => {
37 const [isOpen, setIsOpen] = useState(false);
38 const [searchTerm, setSearchTerm] = useState("");
39 const containerRef = useRef<HTMLDivElement>(null);
40 const searchInputRef = useRef<HTMLInputElement>(null);
41 // Dropdown is portaled to document.body so it escapes parent stacking
42 // contexts (each itinerary entry row is `position: relative` with its
43 // own z-index — without portaling, later sibling rows would render
44 // their content ABOVE this dropdown panel regardless of z-index).
45 const [menuRect, setMenuRect] = useState<{
46 top: number;
47 left: number;
48 width: number;
49 } | null>(null);
50
51 // Filter options based on search term (search by both label and value)
52 const filteredOptions = options.filter(
53 (option) =>
54 option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
55 option.value.toLowerCase().includes(searchTerm.toLowerCase()),
56 );
57
58 // Get selected option
59 const selectedOption = options.find((opt) => opt.value === value);
60
61 // Close dropdown when clicking outside. Because the menu is portaled
62 // to document.body, we check membership against BOTH the trigger
63 // container and the portal node — a click on the menu itself must
64 // not count as "outside".
65 useEffect(() => {
66 const handleClickOutside = (event: MouseEvent) => {
67 const target = event.target as Node;
68 if (containerRef.current && containerRef.current.contains(target)) {
69 return;
70 }
71 const portalRoot = document.getElementById(
72 "yatra-searchable-select-portal",
73 );
74 if (portalRoot && portalRoot.contains(target)) {
75 return;
76 }
77 setIsOpen(false);
78 setSearchTerm("");
79 };
80
81 if (isOpen) {
82 document.addEventListener("mousedown", handleClickOutside);
83 // Focus search input when dropdown opens
84 setTimeout(() => {
85 searchInputRef.current?.focus();
86 }, 100);
87 }
88
89 return () => {
90 document.removeEventListener("mousedown", handleClickOutside);
91 };
92 }, [isOpen]);
93
94 // Measure trigger so the portaled menu can position under it. We
95 // re-measure on open, on scroll, and on resize so the menu tracks
96 // the trigger when the page scrolls under it.
97 useLayoutEffect(() => {
98 if (!isOpen) {
99 setMenuRect(null);
100 return;
101 }
102 const measure = () => {
103 const el = containerRef.current;
104 if (!el) return;
105 const rect = el.getBoundingClientRect();
106 setMenuRect({
107 top: rect.bottom + 4,
108 left: rect.left,
109 width: rect.width,
110 });
111 };
112 measure();
113 window.addEventListener("scroll", measure, true);
114 window.addEventListener("resize", measure);
115 return () => {
116 window.removeEventListener("scroll", measure, true);
117 window.removeEventListener("resize", measure);
118 };
119 }, [isOpen]);
120
121 const handleSelect = (optionValue: string) => {
122 onChange(optionValue);
123 setIsOpen(false);
124 setSearchTerm("");
125 };
126
127 const handleClear = (e: React.MouseEvent) => {
128 e.stopPropagation();
129 onChange("");
130 setSearchTerm("");
131 };
132
133 return (
134 <div
135 ref={containerRef}
136 className={`relative ${className}`}
137 style={{ zIndex: isOpen ? 9999 : "auto" }}
138 >
139 <div
140 className={`flex h-11 w-full rounded-md border-2 ${
141 error ? "border-red-500" : "border-gray-300 dark:border-gray-600"
142 } bg-white dark:bg-gray-800 px-4 py-2.5 text-base ring-offset-white focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:ring-offset-gray-900 dark:focus-within:ring-blue-400 transition-colors`}
143 >
144 <div
145 onClick={() => !disabled && setIsOpen(!isOpen)}
146 className={`flex-1 flex items-center justify-between text-left ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
147 >
148 <span
149 className={
150 selectedOption
151 ? "text-gray-900 dark:text-white"
152 : "text-gray-500 dark:text-gray-400"
153 }
154 >
155 {selectedOption ? (
156 <div className="flex items-center justify-between w-full">
157 <span>{selectedOption.label}</span>
158 {showValueId &&
159 selectedOption.value &&
160 selectedOption.value !== "" && (
161 <span className="text-xs text-gray-500 dark:text-gray-400 ml-2 font-medium">
162 ID: {selectedOption.value}
163 </span>
164 )}
165 </div>
166 ) : (
167 placeholder
168 )}
169 </span>
170 <div className="flex items-center gap-1">
171 {value && !disabled && (
172 <button
173 type="button"
174 onClick={handleClear}
175 className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
176 aria-label={__("Clear selection", "yatra")}
177 >
178 <X className="w-4 h-4 text-gray-400" />
179 </button>
180 )}
181 <ChevronDown
182 className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? "rotate-180" : ""}`}
183 />
184 </div>
185 </div>
186 </div>
187
188 {isOpen &&
189 menuRect &&
190 createPortal(
191 <div
192 id="yatra-searchable-select-portal"
193 style={{
194 position: "fixed",
195 top: menuRect.top,
196 left: menuRect.left,
197 width: menuRect.width,
198 zIndex: 999999,
199 }}
200 className="bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-visible"
201 >
202 {/* Search Input */}
203 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
204 <div className="relative">
205 <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
206 <Input
207 ref={searchInputRef}
208 type="text"
209 placeholder={searchPlaceholder}
210 value={searchTerm}
211 onChange={(e) => setSearchTerm(e.target.value)}
212 className="pl-8 h-8"
213 onClick={(e) => e.stopPropagation()}
214 />
215 </div>
216 </div>
217
218 {/* Options List */}
219 <div className="max-h-48 overflow-y-auto overflow-x-visible">
220 {filteredOptions.length === 0 ? (
221 <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 text-center">
222 {__("No options found", "yatra")}
223 </div>
224 ) : (
225 filteredOptions.map((option) => (
226 <button
227 key={option.value}
228 type="button"
229 onClick={() => handleSelect(option.value)}
230 className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
231 value === option.value
232 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
233 : "text-gray-900 dark:text-white"
234 }`}
235 >
236 <div className="flex items-center justify-between">
237 <span>{option.label}</span>
238 {showValueId && option.value && option.value !== "" && (
239 <span className="text-xs text-gray-500 dark:text-gray-400 ml-2 font-medium">
240 ID: {option.value}
241 </span>
242 )}
243 </div>
244 </button>
245 ))
246 )}
247 </div>
248 </div>,
249 document.body,
250 )}
251 </div>
252 );
253 };
254