PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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.16, at resources/js/components/ui/searchable-select.tsx

277 lines 9.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 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>
169 {showValueId &&
170 selectedOption.value &&
171 selectedOption.value !== "" && (
172 <span className="text-xs text-gray-500 dark:text-gray-400 ml-2 font-medium">
173 ID: {selectedOption.value}
174 </span>
175 )}
176 </div>
177 ) : (
178 placeholder
179 )}
180 </span>
181 <div className="flex items-center gap-1">
182 {value && !disabled && (
183 <button
184 type="button"
185 onClick={handleClear}
186 className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
187 aria-label={__("Clear selection", "yatra")}
188 >
189 <X className="w-4 h-4 text-gray-400" />
190 </button>
191 )}
192 <ChevronDown
193 className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? "rotate-180" : ""}`}
194 />
195 </div>
196 </div>
197 </div>
198
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>
227 </div>
228
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>
260 </span>
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 )}
274 </div>
275 );
276 };
277