PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.11
Yatra – Travel Booking & Tour Operator Software v3.0.11
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 / multi-select.tsx

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

210 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useRef, useEffect } from "react";
2 import { ChevronDown, X, Search, Check } from "lucide-react";
3 import { __ } from "../../lib/i18n";
4 import { Input } from "./input";
5
6 export interface MultiSelectOption {
7 value: string | number;
8 label: string;
9 }
10
11 export interface MultiSelectProps {
12 value: (string | number)[];
13 onChange: (value: (string | number)[]) => void;
14 options: MultiSelectOption[];
15 placeholder?: string;
16 searchPlaceholder?: string;
17 className?: string;
18 error?: boolean;
19 disabled?: boolean;
20 }
21
22 export const MultiSelect: React.FC<MultiSelectProps> = ({
23 value,
24 onChange,
25 options,
26 placeholder = __("Select options...", "yatra"),
27 searchPlaceholder = __("Search...", "yatra"),
28 className = "",
29 error = false,
30 disabled = false,
31 }) => {
32 const [isOpen, setIsOpen] = useState(false);
33 const [searchTerm, setSearchTerm] = useState("");
34 const containerRef = useRef<HTMLDivElement>(null);
35 const searchInputRef = useRef<HTMLInputElement>(null);
36
37 // Filter options based on search term
38 const filteredOptions = options.filter((option) =>
39 option.label.toLowerCase().includes(searchTerm.toLowerCase()),
40 );
41
42 // Get selected options
43 const selectedOptions = options.filter((opt) =>
44 value.some((v) => String(v) === String(opt.value)),
45 );
46
47 // Close dropdown when clicking outside
48 useEffect(() => {
49 const handleClickOutside = (event: MouseEvent) => {
50 if (
51 containerRef.current &&
52 !containerRef.current.contains(event.target as Node)
53 ) {
54 setIsOpen(false);
55 setSearchTerm("");
56 }
57 };
58
59 if (isOpen) {
60 document.addEventListener("mousedown", handleClickOutside);
61 setTimeout(() => {
62 searchInputRef.current?.focus();
63 }, 100);
64 }
65
66 return () => {
67 document.removeEventListener("mousedown", handleClickOutside);
68 };
69 }, [isOpen]);
70
71 const handleToggle = (optionValue: string | number) => {
72 const isSelected = value.some((v) => String(v) === String(optionValue));
73 if (isSelected) {
74 onChange(value.filter((v) => String(v) !== String(optionValue)));
75 } else {
76 onChange([...value, optionValue]);
77 }
78 };
79
80 const handleRemove = (optionValue: string | number, e: React.MouseEvent) => {
81 e.stopPropagation();
82 onChange(value.filter((v) => String(v) !== String(optionValue)));
83 };
84
85 const handleClearAll = (e: React.MouseEvent) => {
86 e.stopPropagation();
87 onChange([]);
88 };
89
90 return (
91 <div
92 ref={containerRef}
93 className={`relative ${className}`}
94 style={{ zIndex: isOpen ? 9999 : "auto" }}
95 >
96 <div
97 onClick={() => !disabled && setIsOpen(!isOpen)}
98 className={`flex min-h-11 w-full rounded-md border-2 ${
99 error ? "border-red-500" : "border-gray-300 dark:border-gray-600"
100 } bg-white dark:bg-gray-800 px-3 py-2 text-base ring-offset-white cursor-pointer 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 ${
101 disabled ? "opacity-50 cursor-not-allowed" : ""
102 }`}
103 >
104 <div className="flex-1 flex flex-wrap gap-1.5 items-center">
105 {selectedOptions.length > 0 ? (
106 selectedOptions.map((option) => (
107 <span
108 key={option.value}
109 className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300 text-sm"
110 >
111 {option.label}
112 {!disabled && (
113 <button
114 type="button"
115 onClick={(e) => handleRemove(option.value, e)}
116 className="hover:bg-blue-200 dark:hover:bg-blue-800 rounded-full p-0.5"
117 >
118 <X className="w-3 h-3" />
119 </button>
120 )}
121 </span>
122 ))
123 ) : (
124 <span className="text-gray-500 dark:text-gray-400">
125 {placeholder}
126 </span>
127 )}
128 </div>
129 <div className="flex items-center gap-1 ml-2">
130 {value.length > 0 && !disabled && (
131 <button
132 type="button"
133 onClick={handleClearAll}
134 className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
135 aria-label={__("Clear all", "yatra")}
136 >
137 <X className="w-4 h-4 text-gray-400" />
138 </button>
139 )}
140 <ChevronDown
141 className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? "rotate-180" : ""}`}
142 />
143 </div>
144 </div>
145
146 {isOpen && (
147 <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-72 overflow-visible">
148 {/* Search Input */}
149 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
150 <div className="relative">
151 <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
152 <Input
153 ref={searchInputRef}
154 type="text"
155 placeholder={searchPlaceholder}
156 value={searchTerm}
157 onChange={(e) => setSearchTerm(e.target.value)}
158 className="pl-8 h-9"
159 onClick={(e) => e.stopPropagation()}
160 />
161 </div>
162 </div>
163
164 {/* Options List */}
165 <div className="max-h-52 overflow-y-auto">
166 {filteredOptions.length === 0 ? (
167 <div className="px-3 py-3 text-sm text-gray-500 dark:text-gray-400 text-center">
168 {__("No options found", "yatra")}
169 </div>
170 ) : (
171 filteredOptions.map((option) => {
172 const isSelected = value.some(
173 (v) => String(v) === String(option.value),
174 );
175 return (
176 <button
177 key={option.value}
178 type="button"
179 onClick={() => handleToggle(option.value)}
180 className={`w-full px-3 py-2.5 text-left text-sm flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
181 isSelected
182 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400"
183 : "text-gray-900 dark:text-white"
184 }`}
185 >
186 <span>{option.label}</span>
187 {isSelected && (
188 <Check className="w-4 h-4 text-blue-600 dark:text-blue-400" />
189 )}
190 </button>
191 );
192 })
193 )}
194 </div>
195
196 {/* Selected count */}
197 {value.length > 0 && (
198 <div className="px-3 py-2 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
199 {value.length}{" "}
200 {value.length === 1
201 ? __("selected", "yatra")
202 : __("selected", "yatra")}
203 </div>
204 )}
205 </div>
206 )}
207 </div>
208 );
209 };
210