pejay-ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/bin/cli.js +379 -0
- package/package.json +52 -0
- package/registry.json +350 -0
- package/templates/button/Button.tsx +156 -0
- package/templates/button/index.ts +2 -0
- package/templates/button/tooltip.tsx +124 -0
- package/templates/form/amount-input.tsx +252 -0
- package/templates/form/checkbox-group.tsx +235 -0
- package/templates/form/checkbox.tsx +148 -0
- package/templates/form/date-picker.tsx +647 -0
- package/templates/form/date-range-picker.tsx +1039 -0
- package/templates/form/email-input.tsx +55 -0
- package/templates/form/file-input.tsx +380 -0
- package/templates/form/index.ts +22 -0
- package/templates/form/input.tsx +255 -0
- package/templates/form/number-input.tsx +186 -0
- package/templates/form/password-input.tsx +233 -0
- package/templates/form/phone-input.tsx +82 -0
- package/templates/form/radio-group.tsx +191 -0
- package/templates/form/radio.tsx +157 -0
- package/templates/form/range-slider.tsx +210 -0
- package/templates/form/switch.tsx +134 -0
- package/templates/form/textarea.tsx +253 -0
- package/templates/form/time-picker.tsx +435 -0
- package/templates/form/time-range-picker.tsx +526 -0
- package/templates/form/url-input.tsx +81 -0
- package/templates/select-dropdown/index.ts +4 -0
- package/templates/select-dropdown/multiselect-input.tsx +687 -0
- package/templates/select-dropdown/select-input.tsx +565 -0
- package/utils/cn.ts +6 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useFloating,
|
|
4
|
+
autoUpdate,
|
|
5
|
+
flip,
|
|
6
|
+
shift,
|
|
7
|
+
size,
|
|
8
|
+
offset,
|
|
9
|
+
FloatingPortal,
|
|
10
|
+
} from "@floating-ui/react";
|
|
11
|
+
import { ChevronDown, Check, X, Search, Loader2 } from "lucide-react";
|
|
12
|
+
import { cn } from "@/utils/cn";
|
|
13
|
+
import { Tooltip } from "../button/tooltip";
|
|
14
|
+
import { type SelectOption } from "./select-input";
|
|
15
|
+
|
|
16
|
+
function getNextSelectableIndex(
|
|
17
|
+
currentIndex: number,
|
|
18
|
+
direction: "up" | "down",
|
|
19
|
+
list: SelectOption[],
|
|
20
|
+
) {
|
|
21
|
+
if (list.length === 0) return -1;
|
|
22
|
+
let nextIndex = currentIndex;
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < list.length; i++) {
|
|
25
|
+
if (direction === "down") {
|
|
26
|
+
nextIndex = nextIndex < list.length - 1 ? nextIndex + 1 : 0;
|
|
27
|
+
} else {
|
|
28
|
+
nextIndex = nextIndex > 0 ? nextIndex - 1 : list.length - 1;
|
|
29
|
+
}
|
|
30
|
+
if (!list[nextIndex]?.isHeader) {
|
|
31
|
+
return nextIndex;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return currentIndex;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MultiSelectInputProps {
|
|
38
|
+
/** Array of option objects: { id, label, key } */
|
|
39
|
+
options: SelectOption[];
|
|
40
|
+
/** Controlled array of selected keys */
|
|
41
|
+
value?: string[];
|
|
42
|
+
/** Callback triggered when the selection changes */
|
|
43
|
+
onChange?: (keys: string[], selectedOptions: SelectOption[]) => void;
|
|
44
|
+
/** Optional placeholder if no options are selected */
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
/** Optional icon rendered on the left of the button trigger */
|
|
47
|
+
prefixIcon?: React.ReactNode;
|
|
48
|
+
/** Display mode: 'count' (e.g. "3 selected") or 'chips' (individual tags) */
|
|
49
|
+
displayMode?: "count" | "chips";
|
|
50
|
+
/** Custom class name for the wrapper */
|
|
51
|
+
className?: string;
|
|
52
|
+
/** Custom width for the button trigger (e.g. 'w-64', 'w-full') */
|
|
53
|
+
width?: string;
|
|
54
|
+
/** Disabled state */
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
/** Loading state */
|
|
57
|
+
loading?: boolean;
|
|
58
|
+
/** Show full selected value in a tooltip on hover */
|
|
59
|
+
showTooltip?: boolean;
|
|
60
|
+
/** Enable filtering search functionality */
|
|
61
|
+
searchable?: boolean;
|
|
62
|
+
/** Position of the search input: 'trigger' (direct typing in trigger) or 'dropdown' (input inside overlay) */
|
|
63
|
+
searchPosition?: "trigger" | "dropdown";
|
|
64
|
+
/** Error state message or boolean flag */
|
|
65
|
+
error?: string | boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A helper component to conditionally display a tooltip only if the wrapped child is truncated
|
|
69
|
+
function TruncatedTooltip({
|
|
70
|
+
content,
|
|
71
|
+
children,
|
|
72
|
+
enabled,
|
|
73
|
+
}: {
|
|
74
|
+
content: string;
|
|
75
|
+
children: React.ReactElement<any>;
|
|
76
|
+
enabled: boolean;
|
|
77
|
+
}) {
|
|
78
|
+
const [isTruncated, setIsTruncated] = useState(false);
|
|
79
|
+
const ref = useRef<HTMLElement>(null);
|
|
80
|
+
|
|
81
|
+
const checkTruncation = () => {
|
|
82
|
+
if (!enabled || !ref.current) return;
|
|
83
|
+
const el = ref.current;
|
|
84
|
+
setIsTruncated(el.scrollWidth > el.clientWidth);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const trigger = React.cloneElement(children, {
|
|
88
|
+
ref,
|
|
89
|
+
onMouseEnter: (e: React.MouseEvent) => {
|
|
90
|
+
checkTruncation();
|
|
91
|
+
children.props.onMouseEnter?.(e);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Tooltip content={content} disabled={!enabled || !isTruncated} fullWidth>
|
|
97
|
+
{trigger}
|
|
98
|
+
</Tooltip>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function MultiSelectInput({
|
|
103
|
+
options = [],
|
|
104
|
+
value: controlledValue,
|
|
105
|
+
onChange,
|
|
106
|
+
placeholder = "Select...",
|
|
107
|
+
prefixIcon,
|
|
108
|
+
displayMode = "count",
|
|
109
|
+
className,
|
|
110
|
+
width = "w-full",
|
|
111
|
+
disabled = false,
|
|
112
|
+
loading = false,
|
|
113
|
+
showTooltip = false,
|
|
114
|
+
searchable = false,
|
|
115
|
+
searchPosition = "trigger",
|
|
116
|
+
error,
|
|
117
|
+
}: MultiSelectInputProps) {
|
|
118
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
119
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
120
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
121
|
+
|
|
122
|
+
// Uncontrolled fallback state
|
|
123
|
+
const [localValue, setLocalValue] = useState<string[]>([]);
|
|
124
|
+
|
|
125
|
+
const activeValue =
|
|
126
|
+
controlledValue !== undefined ? controlledValue : localValue;
|
|
127
|
+
|
|
128
|
+
// Selected option objects
|
|
129
|
+
const selectedOptions = options.filter(opt => activeValue.includes(opt.key));
|
|
130
|
+
|
|
131
|
+
// Filter options based on search query, keeping headers only if their group contains matches
|
|
132
|
+
const filteredOptions = React.useMemo(() => {
|
|
133
|
+
if (!searchQuery) return options;
|
|
134
|
+
|
|
135
|
+
const result: SelectOption[] = [];
|
|
136
|
+
let currentHeader: SelectOption | null = null;
|
|
137
|
+
let hasItemsInCurrentGroup = false;
|
|
138
|
+
|
|
139
|
+
for (const opt of options) {
|
|
140
|
+
if (opt.isHeader) {
|
|
141
|
+
currentHeader = opt;
|
|
142
|
+
hasItemsInCurrentGroup = false;
|
|
143
|
+
} else {
|
|
144
|
+
const matches = opt.label
|
|
145
|
+
.toLowerCase()
|
|
146
|
+
.includes(searchQuery.toLowerCase());
|
|
147
|
+
if (matches) {
|
|
148
|
+
if (currentHeader && !hasItemsInCurrentGroup) {
|
|
149
|
+
result.push(currentHeader);
|
|
150
|
+
hasItemsInCurrentGroup = true;
|
|
151
|
+
}
|
|
152
|
+
result.push(opt);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}, [options, searchQuery]);
|
|
158
|
+
|
|
159
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
160
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
161
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
162
|
+
const dropdownInputRef = useRef<HTMLInputElement>(null);
|
|
163
|
+
|
|
164
|
+
// 1. Setup Floating UI
|
|
165
|
+
const { refs, floatingStyles } = useFloating({
|
|
166
|
+
open: isOpen,
|
|
167
|
+
onOpenChange: open => {
|
|
168
|
+
setIsOpen(open);
|
|
169
|
+
if (!open) {
|
|
170
|
+
setSearchQuery("");
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
placement: "bottom-start",
|
|
174
|
+
whileElementsMounted: autoUpdate,
|
|
175
|
+
middleware: [
|
|
176
|
+
offset(4),
|
|
177
|
+
flip({ padding: 8 }),
|
|
178
|
+
shift({ padding: 8 }),
|
|
179
|
+
size({
|
|
180
|
+
apply({ rects, elements }) {
|
|
181
|
+
Object.assign(elements.floating.style, {
|
|
182
|
+
width: `${rects.reference.width}px`,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
padding: 8,
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Highlight first selected item or index 0 on open/search
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (isOpen) {
|
|
193
|
+
if (searchQuery !== "") {
|
|
194
|
+
const firstIdx = filteredOptions.findIndex(opt => !opt.isHeader);
|
|
195
|
+
setHighlightedIndex(firstIdx >= 0 ? firstIdx : 0);
|
|
196
|
+
} else if (activeValue.length > 0) {
|
|
197
|
+
const lastSelectedKey = activeValue[activeValue.length - 1];
|
|
198
|
+
const lastSelectedIdx = filteredOptions.findIndex(
|
|
199
|
+
opt => opt.key === lastSelectedKey && !opt.isHeader,
|
|
200
|
+
);
|
|
201
|
+
if (lastSelectedIdx >= 0) {
|
|
202
|
+
setHighlightedIndex(lastSelectedIdx);
|
|
203
|
+
} else {
|
|
204
|
+
const firstIdx = filteredOptions.findIndex(opt => !opt.isHeader);
|
|
205
|
+
setHighlightedIndex(firstIdx >= 0 ? firstIdx : 0);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
const firstIdx = filteredOptions.findIndex(opt => !opt.isHeader);
|
|
209
|
+
setHighlightedIndex(firstIdx >= 0 ? firstIdx : 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Auto-focus search input inside dropdown if searchPosition is 'dropdown'
|
|
213
|
+
if (searchable && searchPosition === "dropdown") {
|
|
214
|
+
setTimeout(() => dropdownInputRef.current?.focus(), 50);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}, [
|
|
218
|
+
isOpen,
|
|
219
|
+
searchQuery,
|
|
220
|
+
activeValue,
|
|
221
|
+
filteredOptions,
|
|
222
|
+
searchable,
|
|
223
|
+
searchPosition,
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
// Scroll highlighted item into view
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (!isOpen || highlightedIndex < 0 || !listRef.current) return;
|
|
229
|
+
const itemEl = listRef.current.children[highlightedIndex] as HTMLElement;
|
|
230
|
+
if (itemEl) {
|
|
231
|
+
itemEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
232
|
+
}
|
|
233
|
+
}, [highlightedIndex, isOpen]);
|
|
234
|
+
|
|
235
|
+
// Click outside to close dropdown
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
238
|
+
const target = e.target as Node;
|
|
239
|
+
if (
|
|
240
|
+
containerRef.current?.contains(target) ||
|
|
241
|
+
refs.floating.current?.contains(target)
|
|
242
|
+
) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
setIsOpen(false);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
249
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
250
|
+
}, [refs.floating]);
|
|
251
|
+
|
|
252
|
+
const toggleOption = (opt: SelectOption) => {
|
|
253
|
+
let nextValue: string[];
|
|
254
|
+
if (activeValue.includes(opt.key)) {
|
|
255
|
+
nextValue = activeValue.filter(val => val !== opt.key);
|
|
256
|
+
} else {
|
|
257
|
+
nextValue = [...activeValue, opt.key];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (controlledValue === undefined) {
|
|
261
|
+
setLocalValue(nextValue);
|
|
262
|
+
}
|
|
263
|
+
const nextOptions = options.filter(o => nextValue.includes(o.key));
|
|
264
|
+
onChange?.(nextValue, nextOptions);
|
|
265
|
+
// Clear search on selection to be user friendly
|
|
266
|
+
setSearchQuery("");
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const removeOptionKey = (keyToRemove: string, e?: React.MouseEvent) => {
|
|
270
|
+
e?.stopPropagation();
|
|
271
|
+
const nextValue = activeValue.filter(val => val !== keyToRemove);
|
|
272
|
+
if (controlledValue === undefined) {
|
|
273
|
+
setLocalValue(nextValue);
|
|
274
|
+
}
|
|
275
|
+
const nextOptions = options.filter(o => nextValue.includes(o.key));
|
|
276
|
+
onChange?.(nextValue, nextOptions);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const typeBufferRef = useRef("");
|
|
280
|
+
const typeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
281
|
+
|
|
282
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
283
|
+
if (disabled || loading) return;
|
|
284
|
+
|
|
285
|
+
if (!isOpen) {
|
|
286
|
+
if (
|
|
287
|
+
e.key === "Enter" ||
|
|
288
|
+
e.key === " " ||
|
|
289
|
+
e.key === "ArrowDown" ||
|
|
290
|
+
e.key === "ArrowUp"
|
|
291
|
+
) {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
setIsOpen(true);
|
|
294
|
+
if (searchable && searchPosition === "trigger") {
|
|
295
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Typeahead matching: only enabled when NOT searching
|
|
302
|
+
if (!searchable && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
typeBufferRef.current += e.key.toLowerCase();
|
|
305
|
+
|
|
306
|
+
const matchIndex = filteredOptions.findIndex(opt =>
|
|
307
|
+
opt.label.toLowerCase().startsWith(typeBufferRef.current),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (matchIndex !== -1) {
|
|
311
|
+
setHighlightedIndex(matchIndex);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (typeTimeoutRef.current) {
|
|
315
|
+
clearTimeout(typeTimeoutRef.current);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
typeTimeoutRef.current = setTimeout(() => {
|
|
319
|
+
typeBufferRef.current = "";
|
|
320
|
+
}, 500);
|
|
321
|
+
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
switch (e.key) {
|
|
326
|
+
case "ArrowDown":
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
setHighlightedIndex(prev =>
|
|
329
|
+
getNextSelectableIndex(prev, "down", filteredOptions),
|
|
330
|
+
);
|
|
331
|
+
break;
|
|
332
|
+
case "ArrowUp":
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
setHighlightedIndex(prev =>
|
|
335
|
+
getNextSelectableIndex(prev, "up", filteredOptions),
|
|
336
|
+
);
|
|
337
|
+
break;
|
|
338
|
+
case "Enter":
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
if (
|
|
341
|
+
highlightedIndex >= 0 &&
|
|
342
|
+
highlightedIndex < filteredOptions.length
|
|
343
|
+
) {
|
|
344
|
+
toggleOption(filteredOptions[highlightedIndex]);
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
case " ":
|
|
348
|
+
// Only toggle option on Space when not actively typing inside the search input
|
|
349
|
+
if (
|
|
350
|
+
!searchable ||
|
|
351
|
+
(searchable &&
|
|
352
|
+
e.target !== inputRef.current &&
|
|
353
|
+
e.target !== dropdownInputRef.current)
|
|
354
|
+
) {
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
if (
|
|
357
|
+
highlightedIndex >= 0 &&
|
|
358
|
+
highlightedIndex < filteredOptions.length
|
|
359
|
+
) {
|
|
360
|
+
toggleOption(filteredOptions[highlightedIndex]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
case "Backspace":
|
|
365
|
+
// Delete last selected chip if searchQuery is empty
|
|
366
|
+
if (
|
|
367
|
+
displayMode === "chips" &&
|
|
368
|
+
activeValue.length > 0 &&
|
|
369
|
+
searchQuery === ""
|
|
370
|
+
) {
|
|
371
|
+
e.preventDefault();
|
|
372
|
+
removeOptionKey(activeValue[activeValue.length - 1]);
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
case "Escape":
|
|
376
|
+
case "Tab":
|
|
377
|
+
setIsOpen(false);
|
|
378
|
+
break;
|
|
379
|
+
default:
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const isTriggerSearchActive = searchable && searchPosition === "trigger";
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div
|
|
388
|
+
ref={containerRef}
|
|
389
|
+
className={cn("relative inline-block", width, className)}
|
|
390
|
+
>
|
|
391
|
+
<div
|
|
392
|
+
ref={refs.setReference}
|
|
393
|
+
onClick={() => {
|
|
394
|
+
if (!disabled && !loading) {
|
|
395
|
+
setIsOpen(prev => !prev);
|
|
396
|
+
if (!isOpen && isTriggerSearchActive) {
|
|
397
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}}
|
|
401
|
+
onKeyDown={handleKeyDown}
|
|
402
|
+
tabIndex={disabled || loading || isTriggerSearchActive ? -1 : 0}
|
|
403
|
+
className={cn(
|
|
404
|
+
"flex items-center justify-between rounded-lg border-[1.5px] border-black text-sm select-none cursor-pointer outline-none transition-all duration-150 min-h-[36px] w-full",
|
|
405
|
+
"bg-white text-black hover:border-gray-800 focus-within:border-sky-500 focus-within:ring-4 focus-within:ring-sky-500/10",
|
|
406
|
+
error &&
|
|
407
|
+
"border-red-500 focus-within:border-red-500 focus-within:ring-red-500/10",
|
|
408
|
+
(disabled || loading) &&
|
|
409
|
+
"opacity-50 pointer-events-none cursor-not-allowed border-gray-300",
|
|
410
|
+
)}
|
|
411
|
+
>
|
|
412
|
+
{/* Left Prefix Icon Area */}
|
|
413
|
+
{prefixIcon &&
|
|
414
|
+
!(
|
|
415
|
+
displayMode === "chips" &&
|
|
416
|
+
selectedOptions.length > 0 &&
|
|
417
|
+
!loading
|
|
418
|
+
) && (
|
|
419
|
+
<div className="flex items-center pl-3 text-black shrink-0 self-stretch min-h-[36px]">
|
|
420
|
+
{prefixIcon}
|
|
421
|
+
</div>
|
|
422
|
+
)}
|
|
423
|
+
|
|
424
|
+
{/* Central Content Area */}
|
|
425
|
+
<div
|
|
426
|
+
className={cn(
|
|
427
|
+
"flex min-w-0 flex-1 px-3 py-1.5 items-center",
|
|
428
|
+
!loading && displayMode === "chips" && selectedOptions.length > 0
|
|
429
|
+
? "flex-col gap-1.5 items-start"
|
|
430
|
+
: "flex-row gap-2",
|
|
431
|
+
)}
|
|
432
|
+
>
|
|
433
|
+
{loading ? (
|
|
434
|
+
<span className="text-black/50 animate-pulse flex-1 text-left text-sm font-medium">
|
|
435
|
+
Loading...
|
|
436
|
+
</span>
|
|
437
|
+
) : (
|
|
438
|
+
<>
|
|
439
|
+
{/* Header container for icon & count */}
|
|
440
|
+
{selectedOptions.length > 0 && displayMode === "chips" && (
|
|
441
|
+
<div className="flex items-center justify-between w-full select-none text-black">
|
|
442
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
443
|
+
{prefixIcon}
|
|
444
|
+
<span className="text-black/50 text-xs font-semibold">
|
|
445
|
+
selected ({selectedOptions.length})
|
|
446
|
+
</span>
|
|
447
|
+
</div>
|
|
448
|
+
<button
|
|
449
|
+
onClick={e => {
|
|
450
|
+
e.stopPropagation();
|
|
451
|
+
if (controlledValue === undefined) {
|
|
452
|
+
setLocalValue([]);
|
|
453
|
+
}
|
|
454
|
+
onChange?.([], []);
|
|
455
|
+
setSearchQuery("");
|
|
456
|
+
}}
|
|
457
|
+
className="shrink-0 text-[10px] font-semibold text-black bg-black/10 rounded px-1.5 py-0.5 outline-none transition-all duration-150 active:scale-95 cursor-pointer"
|
|
458
|
+
tabIndex={-1}
|
|
459
|
+
>
|
|
460
|
+
Clear Selection
|
|
461
|
+
</button>
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
|
|
465
|
+
{/* Chips Display Mode */}
|
|
466
|
+
{displayMode === "chips" && selectedOptions.length > 0 ? (
|
|
467
|
+
<div
|
|
468
|
+
className="flex flex-wrap gap-1 flex-1 w-full items-center max-h-[82px] overflow-y-auto pr-1"
|
|
469
|
+
onClick={e => {
|
|
470
|
+
if (isOpen) {
|
|
471
|
+
e.stopPropagation();
|
|
472
|
+
}
|
|
473
|
+
}}
|
|
474
|
+
>
|
|
475
|
+
{selectedOptions.map(opt => (
|
|
476
|
+
<div
|
|
477
|
+
key={opt.key}
|
|
478
|
+
className="flex items-center justify-between gap-1 w-[120px] bg-gray-155 bg-sky-500/10 text-sky-500 px-2 py-0.5 rounded-md text-xs shrink-0 font-medium"
|
|
479
|
+
>
|
|
480
|
+
<TruncatedTooltip
|
|
481
|
+
content={opt.label}
|
|
482
|
+
enabled={showTooltip}
|
|
483
|
+
>
|
|
484
|
+
<span className="truncate block text-left flex-1 min-w-0 pr-1">
|
|
485
|
+
{opt.label}
|
|
486
|
+
</span>
|
|
487
|
+
</TruncatedTooltip>
|
|
488
|
+
<button
|
|
489
|
+
onClick={e => removeOptionKey(opt.key, e)}
|
|
490
|
+
className="text-sky-500 hover:text-red-500 shrink-0 outline-none cursor-pointer"
|
|
491
|
+
tabIndex={-1}
|
|
492
|
+
>
|
|
493
|
+
<X size={10} />
|
|
494
|
+
</button>
|
|
495
|
+
</div>
|
|
496
|
+
))}
|
|
497
|
+
|
|
498
|
+
{isTriggerSearchActive && isOpen && (
|
|
499
|
+
<input
|
|
500
|
+
ref={inputRef}
|
|
501
|
+
type="text"
|
|
502
|
+
className="bg-transparent border-none text-black placeholder:text-gray-400 outline-none text-left p-0 text-xs flex-1 min-w-[60px] h-5 font-medium"
|
|
503
|
+
placeholder={
|
|
504
|
+
selectedOptions.length === 0
|
|
505
|
+
? options.length === 0
|
|
506
|
+
? "No options available"
|
|
507
|
+
: placeholder
|
|
508
|
+
: ""
|
|
509
|
+
}
|
|
510
|
+
value={searchQuery}
|
|
511
|
+
onChange={e => {
|
|
512
|
+
setSearchQuery(e.target.value);
|
|
513
|
+
setIsOpen(true);
|
|
514
|
+
}}
|
|
515
|
+
onClick={e => {
|
|
516
|
+
e.stopPropagation();
|
|
517
|
+
setIsOpen(true);
|
|
518
|
+
}}
|
|
519
|
+
/>
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
) : (
|
|
523
|
+
<>
|
|
524
|
+
{isTriggerSearchActive ? (
|
|
525
|
+
<input
|
|
526
|
+
ref={inputRef}
|
|
527
|
+
type="text"
|
|
528
|
+
className="w-full bg-transparent border-none text-black placeholder:text-gray-400 outline-none text-left p-0 text-sm font-medium"
|
|
529
|
+
placeholder={
|
|
530
|
+
selectedOptions.length > 0
|
|
531
|
+
? `${selectedOptions.length} selected`
|
|
532
|
+
: options.length === 0
|
|
533
|
+
? "No options available"
|
|
534
|
+
: placeholder
|
|
535
|
+
}
|
|
536
|
+
value={
|
|
537
|
+
isOpen
|
|
538
|
+
? searchQuery
|
|
539
|
+
: selectedOptions.length > 0
|
|
540
|
+
? `${selectedOptions.length} selected`
|
|
541
|
+
: ""
|
|
542
|
+
}
|
|
543
|
+
onChange={e => {
|
|
544
|
+
setSearchQuery(e.target.value);
|
|
545
|
+
setIsOpen(true);
|
|
546
|
+
}}
|
|
547
|
+
onClick={e => {
|
|
548
|
+
e.stopPropagation();
|
|
549
|
+
setIsOpen(true);
|
|
550
|
+
}}
|
|
551
|
+
/>
|
|
552
|
+
) : (
|
|
553
|
+
<TruncatedTooltip
|
|
554
|
+
content={selectedOptions.map(o => o.label).join(", ")}
|
|
555
|
+
enabled={showTooltip && selectedOptions.length > 0}
|
|
556
|
+
>
|
|
557
|
+
<span className="truncate text-black flex-1 text-left font-medium">
|
|
558
|
+
{selectedOptions.length > 0
|
|
559
|
+
? `${selectedOptions.length} selected`
|
|
560
|
+
: options.length === 0
|
|
561
|
+
? "No options available"
|
|
562
|
+
: placeholder}
|
|
563
|
+
</span>
|
|
564
|
+
</TruncatedTooltip>
|
|
565
|
+
)}
|
|
566
|
+
</>
|
|
567
|
+
)}
|
|
568
|
+
</>
|
|
569
|
+
)}
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
{/* Right Caret Icon Area */}
|
|
573
|
+
<div className="flex items-center pr-3 pl-1 text-black shrink-0 self-stretch min-h-[36px]">
|
|
574
|
+
{loading ? (
|
|
575
|
+
<Loader2 size={16} className="animate-spin text-sky-500" />
|
|
576
|
+
) : (
|
|
577
|
+
<ChevronDown
|
|
578
|
+
size={16}
|
|
579
|
+
className={cn(
|
|
580
|
+
"transition-transform duration-200",
|
|
581
|
+
isOpen && "rotate-180",
|
|
582
|
+
)}
|
|
583
|
+
/>
|
|
584
|
+
)}
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
|
|
588
|
+
{/* Portal Dropdown Menu */}
|
|
589
|
+
{isOpen && (
|
|
590
|
+
<FloatingPortal>
|
|
591
|
+
<div
|
|
592
|
+
ref={refs.setFloating}
|
|
593
|
+
style={floatingStyles}
|
|
594
|
+
onKeyDown={handleKeyDown}
|
|
595
|
+
className="z-[9999] overflow-hidden rounded-lg border-[1.5px] border-black bg-white p-1 shadow-2xl flex flex-col"
|
|
596
|
+
>
|
|
597
|
+
{/* Search Position: Dropdown layout input header */}
|
|
598
|
+
{searchable && searchPosition === "dropdown" && (
|
|
599
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-gray-200 mb-1 select-none">
|
|
600
|
+
<Search size={14} className="text-black shrink-0" />
|
|
601
|
+
<input
|
|
602
|
+
ref={dropdownInputRef}
|
|
603
|
+
type="text"
|
|
604
|
+
placeholder="Search options..."
|
|
605
|
+
className="w-full bg-transparent border-none text-xs text-black placeholder:text-gray-400 outline-none p-0 flex-1 font-medium"
|
|
606
|
+
value={searchQuery}
|
|
607
|
+
onChange={e => setSearchQuery(e.target.value)}
|
|
608
|
+
onClick={e => e.stopPropagation()}
|
|
609
|
+
/>
|
|
610
|
+
{searchQuery && (
|
|
611
|
+
<button
|
|
612
|
+
onClick={e => {
|
|
613
|
+
e.stopPropagation();
|
|
614
|
+
setSearchQuery("");
|
|
615
|
+
dropdownInputRef.current?.focus();
|
|
616
|
+
}}
|
|
617
|
+
className="text-gray-500 hover:text-black p-0.5 rounded outline-none shrink-0"
|
|
618
|
+
>
|
|
619
|
+
<X size={12} />
|
|
620
|
+
</button>
|
|
621
|
+
)}
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
|
|
625
|
+
<div
|
|
626
|
+
ref={listRef}
|
|
627
|
+
className="max-h-60 overflow-y-auto flex flex-col gap-0.5 no-scrollbar"
|
|
628
|
+
>
|
|
629
|
+
{options.length === 0 ? (
|
|
630
|
+
<div className="px-3 py-2 text-gray-500 text-xs text-left select-none">
|
|
631
|
+
No options available
|
|
632
|
+
</div>
|
|
633
|
+
) : filteredOptions.length === 0 ? (
|
|
634
|
+
<div className="px-3 py-2 text-gray-500 text-xs text-left select-none">
|
|
635
|
+
No results found
|
|
636
|
+
</div>
|
|
637
|
+
) : (
|
|
638
|
+
filteredOptions.map((item, index) => {
|
|
639
|
+
if (item.isHeader) {
|
|
640
|
+
return (
|
|
641
|
+
<div
|
|
642
|
+
key={item.id}
|
|
643
|
+
className="px-3 py-1.5 text-[10px] font-bold text-black tracking-wider uppercase text-left select-none pointer-events-none mt-1 border-t border-gray-200/50 first:mt-0 first:border-none"
|
|
644
|
+
>
|
|
645
|
+
{item.label}
|
|
646
|
+
</div>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const isSelected = activeValue.includes(item.key);
|
|
651
|
+
const isHighlighted = index === highlightedIndex;
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<div
|
|
655
|
+
key={item.id}
|
|
656
|
+
onClick={() => toggleOption(item)}
|
|
657
|
+
className={cn(
|
|
658
|
+
"flex items-center justify-between gap-3 px-3 py-2 rounded-md text-sm select-none cursor-pointer transition-colors duration-75",
|
|
659
|
+
isSelected &&
|
|
660
|
+
"text-sky-600 font-semibold bg-sky-500/10",
|
|
661
|
+
isHighlighted &&
|
|
662
|
+
!isSelected &&
|
|
663
|
+
"bg-black/10 text-black",
|
|
664
|
+
!isHighlighted &&
|
|
665
|
+
!isSelected &&
|
|
666
|
+
"text-black hover:bg-gray-50",
|
|
667
|
+
)}
|
|
668
|
+
>
|
|
669
|
+
<span className="flex-1 whitespace-normal break-words text-left">
|
|
670
|
+
{item.label}
|
|
671
|
+
</span>
|
|
672
|
+
|
|
673
|
+
{/* Check icon for selected value */}
|
|
674
|
+
{isSelected && (
|
|
675
|
+
<Check size={14} className="text-sky-600 shrink-0" />
|
|
676
|
+
)}
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
})
|
|
680
|
+
)}
|
|
681
|
+
</div>
|
|
682
|
+
</div>
|
|
683
|
+
</FloatingPortal>
|
|
684
|
+
)}
|
|
685
|
+
</div>
|
|
686
|
+
);
|
|
687
|
+
}
|