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,565 @@
|
|
|
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, Search, X, Loader2 } from "lucide-react";
|
|
12
|
+
import { cn } from "@/utils/cn";
|
|
13
|
+
import { Tooltip } from "../button/tooltip";
|
|
14
|
+
|
|
15
|
+
export interface SelectOption {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
key: string;
|
|
19
|
+
isHeader?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getNextSelectableIndex(
|
|
23
|
+
currentIndex: number,
|
|
24
|
+
direction: "up" | "down",
|
|
25
|
+
list: SelectOption[],
|
|
26
|
+
) {
|
|
27
|
+
if (list.length === 0) return -1;
|
|
28
|
+
let nextIndex = currentIndex;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < list.length; i++) {
|
|
31
|
+
if (direction === "down") {
|
|
32
|
+
nextIndex = nextIndex < list.length - 1 ? nextIndex + 1 : 0;
|
|
33
|
+
} else {
|
|
34
|
+
nextIndex = nextIndex > 0 ? nextIndex - 1 : list.length - 1;
|
|
35
|
+
}
|
|
36
|
+
if (!list[nextIndex]?.isHeader) {
|
|
37
|
+
return nextIndex;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return currentIndex;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SelectInputProps {
|
|
44
|
+
/** Array of option objects: { id, label, key } */
|
|
45
|
+
options: SelectOption[];
|
|
46
|
+
/** Controlled value (key). If undefined, component runs in uncontrolled mode */
|
|
47
|
+
value?: string;
|
|
48
|
+
/** Default selected key for uncontrolled mode */
|
|
49
|
+
defaultValue?: string;
|
|
50
|
+
/** Callback triggered when a new option is selected */
|
|
51
|
+
onChange?: (key: string, option: SelectOption) => void;
|
|
52
|
+
/** Optional placeholder if no option is selected */
|
|
53
|
+
placeholder?: string;
|
|
54
|
+
/** Optional icon rendered on the left of the select button trigger */
|
|
55
|
+
prefixIcon?: React.ReactNode;
|
|
56
|
+
/** Custom class name for the wrapper */
|
|
57
|
+
className?: string;
|
|
58
|
+
/** Custom width for the button (e.g. 'w-64', 'w-full') */
|
|
59
|
+
width?: string;
|
|
60
|
+
/** Disabled state */
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
/** Loading state */
|
|
63
|
+
loading?: boolean;
|
|
64
|
+
/** Show full selected value in a tooltip on hover */
|
|
65
|
+
showTooltip?: boolean;
|
|
66
|
+
/** Enable filtering search functionality */
|
|
67
|
+
searchable?: boolean;
|
|
68
|
+
/** Position of the search input: 'trigger' (direct typing in trigger) or 'dropdown' (input inside overlay) */
|
|
69
|
+
searchPosition?: "trigger" | "dropdown";
|
|
70
|
+
/** Error state message or boolean flag */
|
|
71
|
+
error?: string | boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A helper component to conditionally display a tooltip only if the wrapped child is truncated
|
|
75
|
+
function TruncatedTooltip({
|
|
76
|
+
content,
|
|
77
|
+
children,
|
|
78
|
+
enabled,
|
|
79
|
+
}: {
|
|
80
|
+
content: string;
|
|
81
|
+
children: React.ReactElement<any>;
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
}) {
|
|
84
|
+
const [isTruncated, setIsTruncated] = useState(false);
|
|
85
|
+
const ref = useRef<HTMLElement>(null);
|
|
86
|
+
|
|
87
|
+
const checkTruncation = () => {
|
|
88
|
+
if (!enabled || !ref.current) return;
|
|
89
|
+
const el = ref.current;
|
|
90
|
+
setIsTruncated(el.scrollWidth > el.clientWidth);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const trigger = React.cloneElement(children, {
|
|
94
|
+
ref,
|
|
95
|
+
onMouseEnter: (e: React.MouseEvent) => {
|
|
96
|
+
checkTruncation();
|
|
97
|
+
children.props.onMouseEnter?.(e);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Tooltip content={content} disabled={!enabled || !isTruncated} fullWidth>
|
|
103
|
+
{trigger}
|
|
104
|
+
</Tooltip>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function SelectInput({
|
|
109
|
+
options = [],
|
|
110
|
+
value: controlledValue,
|
|
111
|
+
defaultValue,
|
|
112
|
+
onChange,
|
|
113
|
+
placeholder = "Select...",
|
|
114
|
+
prefixIcon,
|
|
115
|
+
className,
|
|
116
|
+
width = "w-full",
|
|
117
|
+
disabled = false,
|
|
118
|
+
loading = false,
|
|
119
|
+
showTooltip = false,
|
|
120
|
+
searchable = false,
|
|
121
|
+
searchPosition = "trigger",
|
|
122
|
+
error,
|
|
123
|
+
}: SelectInputProps) {
|
|
124
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
125
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
126
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
127
|
+
|
|
128
|
+
// Uncontrolled fallback state: checks defaultValue, else defaults to empty string
|
|
129
|
+
const [localValue, setLocalValue] = useState<string>(() => {
|
|
130
|
+
return defaultValue !== undefined ? defaultValue : "";
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const activeValue =
|
|
134
|
+
controlledValue !== undefined ? controlledValue : localValue;
|
|
135
|
+
|
|
136
|
+
// Find currently selected option
|
|
137
|
+
const selectedOption = options.find(opt => opt.key === activeValue);
|
|
138
|
+
|
|
139
|
+
// Filter options based on search query, keeping headers only if their group contains matches
|
|
140
|
+
const filteredOptions = React.useMemo(() => {
|
|
141
|
+
if (!searchQuery) return options;
|
|
142
|
+
|
|
143
|
+
const result: SelectOption[] = [];
|
|
144
|
+
let currentHeader: SelectOption | null = null;
|
|
145
|
+
let hasItemsInCurrentGroup = false;
|
|
146
|
+
|
|
147
|
+
for (const opt of options) {
|
|
148
|
+
if (opt.isHeader) {
|
|
149
|
+
currentHeader = opt;
|
|
150
|
+
hasItemsInCurrentGroup = false;
|
|
151
|
+
} else {
|
|
152
|
+
const matches = opt.label
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.includes(searchQuery.toLowerCase());
|
|
155
|
+
if (matches) {
|
|
156
|
+
if (currentHeader && !hasItemsInCurrentGroup) {
|
|
157
|
+
result.push(currentHeader);
|
|
158
|
+
hasItemsInCurrentGroup = true;
|
|
159
|
+
}
|
|
160
|
+
result.push(opt);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}, [options, searchQuery]);
|
|
166
|
+
|
|
167
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
168
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
169
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
170
|
+
const dropdownInputRef = useRef<HTMLInputElement>(null);
|
|
171
|
+
|
|
172
|
+
// 1. Setup Floating UI
|
|
173
|
+
const { refs, floatingStyles } = useFloating({
|
|
174
|
+
open: isOpen,
|
|
175
|
+
onOpenChange: open => {
|
|
176
|
+
setIsOpen(open);
|
|
177
|
+
if (!open) {
|
|
178
|
+
setSearchQuery("");
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
placement: "bottom-start",
|
|
182
|
+
whileElementsMounted: autoUpdate,
|
|
183
|
+
middleware: [
|
|
184
|
+
offset(4),
|
|
185
|
+
flip({ padding: 8 }),
|
|
186
|
+
shift({ padding: 8 }),
|
|
187
|
+
size({
|
|
188
|
+
apply({ rects, elements }) {
|
|
189
|
+
Object.assign(elements.floating.style, {
|
|
190
|
+
width: `${rects.reference.width}px`,
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
padding: 8,
|
|
194
|
+
}),
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Highlight first option or selected option when dropdown opens
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (isOpen) {
|
|
201
|
+
if (searchQuery !== "") {
|
|
202
|
+
const firstIdx = filteredOptions.findIndex(opt => !opt.isHeader);
|
|
203
|
+
setHighlightedIndex(firstIdx >= 0 ? firstIdx : 0);
|
|
204
|
+
} else {
|
|
205
|
+
const selectedIdx = filteredOptions.findIndex(
|
|
206
|
+
opt => opt.key === activeValue && !opt.isHeader,
|
|
207
|
+
);
|
|
208
|
+
if (selectedIdx >= 0) {
|
|
209
|
+
setHighlightedIndex(selectedIdx);
|
|
210
|
+
} else {
|
|
211
|
+
const firstIdx = filteredOptions.findIndex(opt => !opt.isHeader);
|
|
212
|
+
setHighlightedIndex(firstIdx >= 0 ? firstIdx : 0);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Auto-focus search input inside dropdown if searchPosition is 'dropdown'
|
|
217
|
+
if (searchable && searchPosition === "dropdown") {
|
|
218
|
+
setTimeout(() => dropdownInputRef.current?.focus(), 50);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, [
|
|
222
|
+
isOpen,
|
|
223
|
+
searchQuery,
|
|
224
|
+
activeValue,
|
|
225
|
+
searchable,
|
|
226
|
+
searchPosition,
|
|
227
|
+
filteredOptions,
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
// Scroll highlighted item into view
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!isOpen || highlightedIndex < 0 || !listRef.current) return;
|
|
233
|
+
const itemsContainer = listRef.current;
|
|
234
|
+
// Account for dropdown search input layout if present
|
|
235
|
+
const itemEl = itemsContainer.children[highlightedIndex] as HTMLElement;
|
|
236
|
+
if (itemEl) {
|
|
237
|
+
itemEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
238
|
+
}
|
|
239
|
+
}, [highlightedIndex, isOpen]);
|
|
240
|
+
|
|
241
|
+
// Click outside to close dropdown
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
244
|
+
const target = e.target as Node;
|
|
245
|
+
if (
|
|
246
|
+
containerRef.current?.contains(target) ||
|
|
247
|
+
refs.floating.current?.contains(target)
|
|
248
|
+
) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
setIsOpen(false);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
255
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
256
|
+
}, [refs.floating]);
|
|
257
|
+
|
|
258
|
+
const selectOption = (opt: SelectOption) => {
|
|
259
|
+
if (controlledValue === undefined) {
|
|
260
|
+
setLocalValue(opt.key);
|
|
261
|
+
}
|
|
262
|
+
onChange?.(opt.key, opt);
|
|
263
|
+
setSearchQuery("");
|
|
264
|
+
setIsOpen(false);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const typeBufferRef = useRef("");
|
|
268
|
+
const typeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
269
|
+
|
|
270
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
271
|
+
if (disabled || loading) return;
|
|
272
|
+
|
|
273
|
+
if (!isOpen) {
|
|
274
|
+
if (
|
|
275
|
+
e.key === "Enter" ||
|
|
276
|
+
e.key === " " ||
|
|
277
|
+
e.key === "ArrowDown" ||
|
|
278
|
+
e.key === "ArrowUp"
|
|
279
|
+
) {
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
setIsOpen(true);
|
|
282
|
+
if (searchable && searchPosition === "trigger") {
|
|
283
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Typeahead matching: only enabled when NOT searching
|
|
290
|
+
if (!searchable && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
typeBufferRef.current += e.key.toLowerCase();
|
|
293
|
+
|
|
294
|
+
const matchIndex = filteredOptions.findIndex(opt =>
|
|
295
|
+
opt.label.toLowerCase().startsWith(typeBufferRef.current),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
if (matchIndex !== -1) {
|
|
299
|
+
setHighlightedIndex(matchIndex);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (typeTimeoutRef.current) {
|
|
303
|
+
clearTimeout(typeTimeoutRef.current);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
typeTimeoutRef.current = setTimeout(() => {
|
|
307
|
+
typeBufferRef.current = "";
|
|
308
|
+
}, 500);
|
|
309
|
+
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
switch (e.key) {
|
|
314
|
+
case "ArrowDown":
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
setHighlightedIndex(prev =>
|
|
317
|
+
getNextSelectableIndex(prev, "down", filteredOptions),
|
|
318
|
+
);
|
|
319
|
+
break;
|
|
320
|
+
case "ArrowUp":
|
|
321
|
+
e.preventDefault();
|
|
322
|
+
setHighlightedIndex(prev =>
|
|
323
|
+
getNextSelectableIndex(prev, "up", filteredOptions),
|
|
324
|
+
);
|
|
325
|
+
break;
|
|
326
|
+
case "Enter":
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
if (
|
|
329
|
+
highlightedIndex >= 0 &&
|
|
330
|
+
highlightedIndex < filteredOptions.length
|
|
331
|
+
) {
|
|
332
|
+
selectOption(filteredOptions[highlightedIndex]);
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
case "Spacebar":
|
|
336
|
+
case " ":
|
|
337
|
+
// Only trigger selection on space when NOT actively typing in search inputs
|
|
338
|
+
if (
|
|
339
|
+
!searchable ||
|
|
340
|
+
(searchable &&
|
|
341
|
+
e.target !== inputRef.current &&
|
|
342
|
+
e.target !== dropdownInputRef.current)
|
|
343
|
+
) {
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
if (
|
|
346
|
+
highlightedIndex >= 0 &&
|
|
347
|
+
highlightedIndex < filteredOptions.length
|
|
348
|
+
) {
|
|
349
|
+
selectOption(filteredOptions[highlightedIndex]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
case "Escape":
|
|
354
|
+
case "Tab":
|
|
355
|
+
setIsOpen(false);
|
|
356
|
+
break;
|
|
357
|
+
default:
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const isTriggerSearchActive = searchable && searchPosition === "trigger";
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div
|
|
366
|
+
ref={containerRef}
|
|
367
|
+
className={cn("relative inline-block", width, className)}
|
|
368
|
+
>
|
|
369
|
+
{/* Trigger Button */}
|
|
370
|
+
<div
|
|
371
|
+
ref={refs.setReference}
|
|
372
|
+
onClick={() => {
|
|
373
|
+
if (!disabled && !loading) {
|
|
374
|
+
setIsOpen(prev => !prev);
|
|
375
|
+
if (!isOpen && isTriggerSearchActive) {
|
|
376
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}}
|
|
380
|
+
onKeyDown={handleKeyDown}
|
|
381
|
+
tabIndex={disabled || loading || isTriggerSearchActive ? -1 : 0}
|
|
382
|
+
className={cn(
|
|
383
|
+
"flex items-center justify-between rounded-lg border-[1.5px] border-black text-sm select-none cursor-pointer outline-none transition-all duration-150 h-9 w-full",
|
|
384
|
+
"bg-white text-black hover:border-gray-800 focus-within:border-sky-500 focus-within:ring-4 focus-within:ring-sky-500/10",
|
|
385
|
+
error &&
|
|
386
|
+
"border-red-500 focus-within:border-red-500 focus-within:ring-red-500/10",
|
|
387
|
+
(disabled || loading) &&
|
|
388
|
+
"opacity-50 pointer-events-none cursor-not-allowed border-gray-300",
|
|
389
|
+
)}
|
|
390
|
+
>
|
|
391
|
+
{/* Left Prefix Icon Area */}
|
|
392
|
+
{prefixIcon && (
|
|
393
|
+
<div className="flex items-center pl-3 text-black shrink-0 h-full">
|
|
394
|
+
{prefixIcon}
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{/* Central Content Area */}
|
|
399
|
+
<div className="flex items-center min-w-0 flex-1 px-3 py-2 h-full">
|
|
400
|
+
{loading ? (
|
|
401
|
+
<span className="text-gray-400 animate-pulse flex-1 text-left min-w-0 text-sm font-medium">
|
|
402
|
+
Loading...
|
|
403
|
+
</span>
|
|
404
|
+
) : isTriggerSearchActive ? (
|
|
405
|
+
/* Search Trigger Variant: direct input */
|
|
406
|
+
<input
|
|
407
|
+
ref={inputRef}
|
|
408
|
+
type="text"
|
|
409
|
+
className="w-full bg-transparent border-none text-black placeholder:text-gray-400 outline-none text-left p-0 text-sm font-medium"
|
|
410
|
+
placeholder={
|
|
411
|
+
selectedOption
|
|
412
|
+
? selectedOption.label
|
|
413
|
+
: options.length === 0
|
|
414
|
+
? "No options available"
|
|
415
|
+
: placeholder
|
|
416
|
+
}
|
|
417
|
+
value={
|
|
418
|
+
isOpen
|
|
419
|
+
? searchQuery
|
|
420
|
+
: selectedOption
|
|
421
|
+
? selectedOption.label
|
|
422
|
+
: ""
|
|
423
|
+
}
|
|
424
|
+
onChange={e => {
|
|
425
|
+
setSearchQuery(e.target.value);
|
|
426
|
+
setIsOpen(true);
|
|
427
|
+
}}
|
|
428
|
+
onClick={e => {
|
|
429
|
+
e.stopPropagation();
|
|
430
|
+
setIsOpen(true);
|
|
431
|
+
}}
|
|
432
|
+
/>
|
|
433
|
+
) : (
|
|
434
|
+
/* Static Text Trigger Variant */
|
|
435
|
+
<TruncatedTooltip
|
|
436
|
+
content={selectedOption?.label || ""}
|
|
437
|
+
enabled={showTooltip && !!selectedOption}
|
|
438
|
+
>
|
|
439
|
+
<span className="truncate text-black flex-1 text-left min-w-0 font-medium">
|
|
440
|
+
{selectedOption
|
|
441
|
+
? selectedOption.label
|
|
442
|
+
: options.length === 0
|
|
443
|
+
? "No options available"
|
|
444
|
+
: placeholder}
|
|
445
|
+
</span>
|
|
446
|
+
</TruncatedTooltip>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
{/* Right Caret Icon Area */}
|
|
451
|
+
<div className="flex items-center pr-3 pl-1 text-black shrink-0 h-full">
|
|
452
|
+
{loading ? (
|
|
453
|
+
<Loader2 size={16} className="animate-spin text-sky-500" />
|
|
454
|
+
) : (
|
|
455
|
+
<ChevronDown
|
|
456
|
+
size={16}
|
|
457
|
+
className={cn(
|
|
458
|
+
"transition-transform duration-200",
|
|
459
|
+
isOpen && "rotate-180",
|
|
460
|
+
)}
|
|
461
|
+
/>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
{/* Portal Dropdown Menu */}
|
|
467
|
+
{isOpen && (
|
|
468
|
+
<FloatingPortal>
|
|
469
|
+
<div
|
|
470
|
+
ref={refs.setFloating}
|
|
471
|
+
style={floatingStyles}
|
|
472
|
+
onKeyDown={handleKeyDown}
|
|
473
|
+
className="z-[9999] overflow-hidden rounded-lg border-[1.5px] border-black bg-white p-1 shadow-2xl flex flex-col"
|
|
474
|
+
>
|
|
475
|
+
{/* Search Position: Dropdown layout input header */}
|
|
476
|
+
{searchable && searchPosition === "dropdown" && (
|
|
477
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-gray-200 mb-1 select-none">
|
|
478
|
+
<Search size={14} className="text-black shrink-0" />
|
|
479
|
+
<input
|
|
480
|
+
ref={dropdownInputRef}
|
|
481
|
+
type="text"
|
|
482
|
+
placeholder="Search options..."
|
|
483
|
+
className="w-full bg-transparent border-none text-xs text-black placeholder:text-gray-400 outline-none p-0 flex-1 font-medium"
|
|
484
|
+
value={searchQuery}
|
|
485
|
+
onChange={e => setSearchQuery(e.target.value)}
|
|
486
|
+
onClick={e => e.stopPropagation()}
|
|
487
|
+
/>
|
|
488
|
+
{searchQuery && (
|
|
489
|
+
<button
|
|
490
|
+
onClick={e => {
|
|
491
|
+
e.stopPropagation();
|
|
492
|
+
setSearchQuery("");
|
|
493
|
+
dropdownInputRef.current?.focus();
|
|
494
|
+
}}
|
|
495
|
+
className="text-black bg-black/10 p-1 cursor-pointer hover:text-red-500 hover:bg-red-500/10 rounded outline-none shrink-0"
|
|
496
|
+
>
|
|
497
|
+
<X size={12} />
|
|
498
|
+
</button>
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
|
|
503
|
+
{/* List options wrapper */}
|
|
504
|
+
<div
|
|
505
|
+
ref={listRef}
|
|
506
|
+
className="max-h-60 overflow-y-auto flex flex-col gap-0.5 no-scrollbar"
|
|
507
|
+
>
|
|
508
|
+
{options.length === 0 ? (
|
|
509
|
+
<div className="px-3 py-2 text-gray-500 text-xs text-left select-none">
|
|
510
|
+
No options available
|
|
511
|
+
</div>
|
|
512
|
+
) : filteredOptions.length === 0 ? (
|
|
513
|
+
<div className="px-3 py-2 text-gray-500 text-xs text-left select-none">
|
|
514
|
+
No results found
|
|
515
|
+
</div>
|
|
516
|
+
) : (
|
|
517
|
+
filteredOptions.map((item, index) => {
|
|
518
|
+
if (item.isHeader) {
|
|
519
|
+
return (
|
|
520
|
+
<div
|
|
521
|
+
key={item.id}
|
|
522
|
+
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"
|
|
523
|
+
>
|
|
524
|
+
{item.label}
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const isSelected = item.key === activeValue;
|
|
530
|
+
const isHighlighted = index === highlightedIndex;
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<div
|
|
534
|
+
key={item.id}
|
|
535
|
+
onClick={() => selectOption(item)}
|
|
536
|
+
className={cn(
|
|
537
|
+
"flex items-center justify-between gap-3 px-3 py-2 rounded-md text-sm select-none cursor-pointer transition-colors duration-75",
|
|
538
|
+
isSelected &&
|
|
539
|
+
"text-sky-600 font-semibold bg-sky-500/10",
|
|
540
|
+
isHighlighted &&
|
|
541
|
+
!isSelected &&
|
|
542
|
+
"bg-black/5 text-black",
|
|
543
|
+
!isHighlighted &&
|
|
544
|
+
!isSelected &&
|
|
545
|
+
"text-black hover:bg-black/5",
|
|
546
|
+
)}
|
|
547
|
+
>
|
|
548
|
+
<span className="flex-1 whitespace-normal break-words text-left">
|
|
549
|
+
{item.label}
|
|
550
|
+
</span>
|
|
551
|
+
|
|
552
|
+
{isSelected && (
|
|
553
|
+
<Check size={14} className="text-sky-600 shrink-0" />
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
})
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</FloatingPortal>
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
);
|
|
565
|
+
}
|