PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | resources/js/components/ui/searchable-select.tsx +137 -56 3.0.4 → 3.0.17 View file →
@@ -1,5 +1,6 @@
1 -import React, { useState, useRef, useEffect } from "react";
1 +import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
2 +import { createPortal } from "react-dom";
2 3 import { ChevronDown, X, Search } from "lucide-react";
3 4 import { __ } from "../../lib/i18n";
4 5 import { Input } from "./input";
5 6
@@ -36,8 +37,17 @@
36 37 const [isOpen, setIsOpen] = useState(false);
37 38 const [searchTerm, setSearchTerm] = useState("");
38 39 const containerRef = useRef<HTMLDivElement>(null);
39 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);
40 50
41 51 // Filter options based on search term (search by both label and value)
42 52 const filteredOptions = options.filter(
43 53 (option) =>
@@ -47,18 +57,26 @@
47 57
48 58 // Get selected option
49 59 const selectedOption = options.find((opt) => opt.value === value);
50 60
51 - // Close dropdown when clicking outside
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".
52 65 useEffect(() => {
53 66 const handleClickOutside = (event: MouseEvent) => {
54 - if (
55 - containerRef.current &&
56 - !containerRef.current.contains(event.target as Node)
57 - ) {
58 - setIsOpen(false);
59 - setSearchTerm("");
67 + const target = event.target as Node;
68 + if (containerRef.current && containerRef.current.contains(target)) {
69 + return;
60 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("");
61 79 };
62 80
63 81 if (isOpen) {
64 82 document.addEventListener("mousedown", handleClickOutside);
@@ -72,8 +90,35 @@
72 90 document.removeEventListener("mousedown", handleClickOutside);
73 91 };
74 92 }, [isOpen]);
75 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 +
76 121 const handleSelect = (optionValue: string) => {
77 122 onChange(optionValue);
78 123 setIsOpen(false);
79 124 setSearchTerm("");
@@ -108,9 +153,20 @@
108 153 }
109 154 >
110 155 {selectedOption ? (
111 156 <div className="flex items-center justify-between w-full">
112 - <span>{selectedOption.label}</span>
157 + <span className="flex items-center gap-2 min-w-0">
158 + {selectedOption.icon && (
159 + <img
160 + src={selectedOption.icon}
161 + alt=""
162 + width={22}
163 + height={16}
164 + className="flex-shrink-0 rounded-[2px] object-cover ring-1 ring-black/10"
165 + />
166 + )}
167 + <span className="truncate">{selectedOption.label}</span>
168 + </span>
113 169 {showValueId &&
114 170 selectedOption.value &&
115 171 selectedOption.value !== "" && (
116 172 <span className="text-xs text-gray-500 dark:text-gray-400 ml-2 font-medium">
@@ -139,57 +195,82 @@
139 195 </div>
140 196 </div>
141 197 </div>
142 198
143 - {isOpen && (
144 - <div className="absolute z-[9999] w-full mt-1 bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-visible">
145 - {/* Search Input */}
146 - <div className="p-2 border-b border-gray-200 dark:border-gray-700">
147 - <div className="relative">
148 - <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
149 - <Input
150 - ref={searchInputRef}
151 - type="text"
152 - placeholder={searchPlaceholder}
153 - value={searchTerm}
154 - onChange={(e) => setSearchTerm(e.target.value)}
155 - className="pl-8 h-8"
156 - onClick={(e) => e.stopPropagation()}
157 - />
199 + {isOpen &&
200 + menuRect &&
201 + createPortal(
202 + <div
203 + id="yatra-searchable-select-portal"
204 + style={{
205 + position: "fixed",
206 + top: menuRect.top,
207 + left: menuRect.left,
208 + width: menuRect.width,
209 + zIndex: 999999,
210 + }}
211 + 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"
212 + >
213 + {/* Search Input */}
214 + <div className="p-2 border-b border-gray-200 dark:border-gray-700">
215 + <div className="relative">
216 + <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
217 + <Input
218 + ref={searchInputRef}
219 + type="text"
220 + placeholder={searchPlaceholder}
221 + value={searchTerm}
222 + onChange={(e) => setSearchTerm(e.target.value)}
223 + className="pl-8 h-8"
224 + onClick={(e) => e.stopPropagation()}
225 + />
226 + </div>
158 227 </div>
159 - </div>
160 228
161 - {/* Options List */}
162 - <div className="max-h-48 overflow-y-auto overflow-x-visible">
163 - {filteredOptions.length === 0 ? (
164 - <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 text-center">
165 - {__("No options found", "yatra")}
166 - </div>
167 - ) : (
168 - filteredOptions.map((option) => (
169 - <button
170 - key={option.value}
171 - type="button"
172 - onClick={() => handleSelect(option.value)}
173 - className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
174 - value === option.value
175 - ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
176 - : "text-gray-900 dark:text-white"
177 - }`}
178 - >
179 - <div className="flex items-center justify-between">
180 - <span>{option.label}</span>
181 - {showValueId && option.value && option.value !== "" && (
182 - <span className="text-xs text-gray-500 dark:text-gray-400 ml-2 font-medium">
183 - ID: {option.value}
229 + {/* Options List */}
230 + <div className="max-h-48 overflow-y-auto overflow-x-visible">
231 + {filteredOptions.length === 0 ? (
232 + <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 text-center">
233 + {__("No options found", "yatra")}
234 + </div>
235 + ) : (
236 + filteredOptions.map((option) => (
237 + <button
238 + key={option.value}
239 + type="button"
240 + onClick={() => handleSelect(option.value)}
241 + className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
242 + value === option.value
243 + ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
244 + : "text-gray-900 dark:text-white"
245 + }`}
246 + >
247 + <div className="flex items-center justify-between">
248 + <span className="flex items-center gap-2 min-w-0">
249 + {option.icon && (
250 + <img
251 + src={option.icon}
252 + alt=""
253 + loading="lazy"
254 + width={22}
255 + height={16}
256 + className="flex-shrink-0 rounded-[2px] object-cover ring-1 ring-black/10"
257 + />
258 + )}
259 + <span className="truncate">{option.label}</span>
184 260 </span>
185 - )}
186 - </div>
187 - </button>
188 - ))
189 - )}
190 - </div>
191 - </div>
192 - )}
261 + {showValueId && option.value && option.value !== "" && (
262 + <span className="text-xs text-gray-500 dark:text-gray-400 ml-2 font-medium">
263 + ID: {option.value}
264 + </span>
265 + )}
266 + </div>
267 + </button>
268 + ))
269 + )}
270 + </div>
271 + </div>,
272 + document.body,
273 + )}
193 274 </div>
194 275 );
195 276 };