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.
@@ -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
+ }
package/utils/cn.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }