@exotic-holidays/ui 0.1.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.
Files changed (38) hide show
  1. package/README.md +46 -0
  2. package/package.json +42 -0
  3. package/src/base-components/Accordion.tsx +561 -0
  4. package/src/base-components/Badge.tsx +191 -0
  5. package/src/base-components/Button.tsx +331 -0
  6. package/src/base-components/ButtonGroup.tsx +149 -0
  7. package/src/base-components/Card.tsx +250 -0
  8. package/src/base-components/Checkbox.tsx +49 -0
  9. package/src/base-components/ChipInput.tsx +208 -0
  10. package/src/base-components/CommonButton.tsx +33 -0
  11. package/src/base-components/DataTable.tsx +82 -0
  12. package/src/base-components/Divider.tsx +82 -0
  13. package/src/base-components/Dropdown.tsx +85 -0
  14. package/src/base-components/EmptyState.tsx +18 -0
  15. package/src/base-components/FilterPopover.tsx +50 -0
  16. package/src/base-components/Input.tsx +60 -0
  17. package/src/base-components/Modal.tsx +107 -0
  18. package/src/base-components/OtpVerificationModal.tsx +251 -0
  19. package/src/base-components/Pagination.tsx +51 -0
  20. package/src/base-components/PhoneInput.tsx +142 -0
  21. package/src/base-components/PopConfirm.tsx +350 -0
  22. package/src/base-components/SearchPopover.tsx +70 -0
  23. package/src/base-components/SearchableSelect.tsx +734 -0
  24. package/src/base-components/Select.tsx +49 -0
  25. package/src/base-components/Table.tsx +78 -0
  26. package/src/base-components/Textarea.tsx +45 -0
  27. package/src/base-components/ThemeProvider.tsx +92 -0
  28. package/src/base-components/Toaster.tsx +198 -0
  29. package/src/base-components/index.ts +32 -0
  30. package/src/components/DashboardLayout.tsx +326 -0
  31. package/src/components/ListPage.tsx +140 -0
  32. package/src/components/QuickAccess.tsx +118 -0
  33. package/src/components/UserMenu.tsx +138 -0
  34. package/src/helpers/bem.ts +13 -0
  35. package/src/helpers/cn.ts +9 -0
  36. package/src/index.ts +16 -0
  37. package/src/theme.css +285 -0
  38. package/tsconfig.json +11 -0
@@ -0,0 +1,734 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
4
+ import ReactDOM from 'react-dom';
5
+ import { cn } from '../helpers/cn';
6
+ import { ChevronDown, X, Search, Loader2 } from 'lucide-react';
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────────
9
+
10
+ export interface SearchableSelectOptionConfig<T = Record<string, unknown>> {
11
+ /** Key on the option object to use as the display label (default: 'name') */
12
+ label: string;
13
+ /** Key on the option object to use as the value (default: 'id') */
14
+ value: string;
15
+ /** Keys to search/filter on (default: [label]) */
16
+ keysToSearch?: string[];
17
+ /** Custom label render function */
18
+ labelFn?: (item: T) => string;
19
+ /** Custom option render function (for dropdown items) */
20
+ renderOption?: (item: T) => React.ReactNode;
21
+ }
22
+
23
+ export interface CreatableConfig<T = Record<string, unknown>> {
24
+ /** When to show the add-new button: true/'always' = always, 'with-content' = only when search has text */
25
+ visible: boolean | 'always' | 'with-content';
26
+ /** Position of the add-new button relative to the options list */
27
+ position: 'top' | 'bottom';
28
+ /** Action to execute when add-new is clicked. Return the new option object to auto-select it, or null/undefined to skip. */
29
+ action: (searchValue: string, setIsOpen: (v: boolean) => void) => Promise<T | null | undefined> | T | null | undefined;
30
+ /** Custom render for the add-new button area */
31
+ content?: (props: { searchValue: string; setDropdownOpen: (v: boolean) => void }) => React.ReactNode;
32
+ }
33
+
34
+ export interface SearchableSelectProps<T = Record<string, unknown>> {
35
+ /** Options array (for sync mode) */
36
+ options?: T[];
37
+ /** Callback fires on value change. Single: (value, selectedObj). Multi: (values[], selectedObjs[]) */
38
+ onChange: (value: string | number | (string | number)[], selected: T | T[]) => void;
39
+ /** Placeholder text */
40
+ placeholder?: string;
41
+ /** Enable multi-select */
42
+ multiple?: boolean;
43
+ /** Default selected value(s). Single: an option object. Multi: an array of option objects. */
44
+ defaultValue?: T | T[];
45
+ /** Option config — keys for label/value, search keys, custom render */
46
+ option?: SearchableSelectOptionConfig<T>;
47
+ /** Creatable config — add-new button */
48
+ creatable?: CreatableConfig<T>;
49
+ /** Additional className on the outer wrapper */
50
+ className?: string;
51
+ /** Enable search filtering (default: true) */
52
+ isSearchable?: boolean;
53
+ /** Show clear button when a value is selected */
54
+ allowClear?: boolean;
55
+ /** Disabled state */
56
+ disabled?: boolean;
57
+ /** Label text above the select */
58
+ label?: string;
59
+ /** Error message below the select */
60
+ error?: string;
61
+ /** Show required indicator on the label */
62
+ isRequired?: boolean;
63
+ /** Background surface level */
64
+ surface?: 0 | 1;
65
+ }
66
+
67
+ export interface AsyncSearchableSelectProps<T = Record<string, unknown>> extends Omit<SearchableSelectProps<T>, 'options'> {
68
+ /** Async loader — receives (searchString, pageNumber) and should return a promise resolving to an array of options */
69
+ loadOptions: (search: string, page: number) => Promise<T[]>;
70
+ }
71
+
72
+ // ─── Defaults ────────────────────────────────────────────────────────────────
73
+
74
+ const DEFAULT_OPTION_CONFIG: SearchableSelectOptionConfig = {
75
+ label: 'name',
76
+ value: 'id',
77
+ };
78
+
79
+ // ─── Exported Select Wrappers ────────────────────────────────────────────────
80
+
81
+ export const SearchableSelect = <T extends Record<string, unknown>>({
82
+ options = [],
83
+ ...props
84
+ }: SearchableSelectProps<T>) => {
85
+ return <SelectCore<T> async={false} options={options} loadOptions={async () => []} {...props} />;
86
+ };
87
+
88
+ export const AsyncSearchableSelect = <T extends Record<string, unknown>>({
89
+ loadOptions,
90
+ ...props
91
+ }: AsyncSearchableSelectProps<T>) => {
92
+ return <SelectCore<T> async options={[]} loadOptions={loadOptions} {...props} />;
93
+ };
94
+
95
+ // ─── Core Component ──────────────────────────────────────────────────────────
96
+
97
+ interface SelectCoreProps<T> extends SearchableSelectProps<T> {
98
+ async: boolean;
99
+ loadOptions: (search: string, page: number) => Promise<T[]>;
100
+ }
101
+
102
+ function SelectCore<T extends Record<string, unknown>>({
103
+ async: isAsync,
104
+ options = [],
105
+ loadOptions,
106
+ onChange,
107
+ placeholder = 'Select...',
108
+ multiple = false,
109
+ defaultValue,
110
+ option: optionConfig,
111
+ creatable,
112
+ className,
113
+ isSearchable = true,
114
+ allowClear = false,
115
+ disabled = false,
116
+ label,
117
+ error,
118
+ isRequired,
119
+ surface = 0,
120
+ }: SelectCoreProps<T>) {
121
+ const opt = { ...DEFAULT_OPTION_CONFIG, ...optionConfig } as SearchableSelectOptionConfig<T>;
122
+
123
+ // ── State ────────────────────────────────────────────────────────────────
124
+ const [isOpen, setIsOpen] = useState(false);
125
+ const [value, setValue] = useState<string | number | (string | number)[]>(() =>
126
+ multiple ? [] : ''
127
+ );
128
+ const [selectedValue, setSelectedValue] = useState<T | T[]>(() =>
129
+ multiple ? [] : ({} as T)
130
+ );
131
+ const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
132
+ const [searchValue, setSearchValue] = useState('');
133
+
134
+ // Sync-mode data
135
+ const [selectData, setSelectData] = useState<T[]>([]);
136
+ const [masterData, setMasterData] = useState<T[]>([]);
137
+
138
+ // Async-mode data
139
+ const [asyncSelectData, setAsyncSelectData] = useState<T[]>([]);
140
+ const [page, setPage] = useState(1);
141
+ const [isSearching, setIsSearching] = useState(false);
142
+ const [isOptionLoading, setIsOptionLoading] = useState(false);
143
+ const [gotAllData, setGotAllData] = useState(false);
144
+
145
+ // Refs
146
+ const containerRef = useRef<HTMLDivElement>(null);
147
+ const controlRef = useRef<HTMLDivElement>(null);
148
+ const searchInputRef = useRef<HTMLInputElement>(null);
149
+ const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
150
+ const highlightedRef = useRef<HTMLLIElement>(null);
151
+ const dropdownRef = useRef<HTMLDivElement>(null);
152
+
153
+ // Dropdown position (for portal rendering)
154
+ const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number; flipToTop: boolean }>({ top: 0, left: 0, width: 0, flipToTop: false });
155
+
156
+ // ── Computed helpers ─────────────────────────────────────────────────────
157
+ const displayData: T[] = isAsync ? asyncSelectData : selectData;
158
+
159
+ const getLabel = useCallback(
160
+ (item: T): string => {
161
+ if (opt.labelFn) return opt.labelFn(item);
162
+ return String(item[opt.label] ?? '');
163
+ },
164
+ [opt]
165
+ );
166
+
167
+ const hasValue = multiple
168
+ ? Array.isArray(value) && (value as (string | number)[]).length > 0
169
+ : value !== '' && value !== 0;
170
+
171
+ // ── Calculate dropdown position (auto-flip when clipped at bottom) ───────
172
+ const DROPDOWN_ESTIMATED_HEIGHT = 300; // search bar + max-height options + padding
173
+ const GAP = 6;
174
+
175
+ const updateDropdownPosition = useCallback(() => {
176
+ if (!controlRef.current) return;
177
+ const rect = controlRef.current.getBoundingClientRect();
178
+ const spaceBelow = window.innerHeight - rect.bottom;
179
+ const spaceAbove = rect.top;
180
+ const openBelow = spaceBelow >= DROPDOWN_ESTIMATED_HEIGHT || spaceBelow >= spaceAbove;
181
+
182
+ setDropdownPos({
183
+ top: openBelow ? rect.bottom + GAP : rect.top - GAP,
184
+ left: rect.left,
185
+ width: rect.width,
186
+ flipToTop: !openBelow,
187
+ });
188
+ }, []);
189
+
190
+ // ── Scroll highlighted into view ─────────────────────────────────────────
191
+ useEffect(() => {
192
+ highlightedRef.current?.scrollIntoView({ block: 'nearest' });
193
+ }, [highlightedIndex]);
194
+
195
+ // ── Sync options → master data ───────────────────────────────────────────
196
+ useEffect(() => {
197
+ if (options.length > 0) {
198
+ setMasterData(options);
199
+ setSelectData(options);
200
+ }
201
+ }, [options]);
202
+
203
+ // ── Default value ────────────────────────────────────────────────────────
204
+ const defaultValString = JSON.stringify(defaultValue || '');
205
+ useEffect(() => {
206
+ if (!defaultValue) return;
207
+ if (multiple && Array.isArray(defaultValue) && defaultValue.length > 0) {
208
+ const vals = (defaultValue as T[]).map((v) => v[opt.value] as string | number);
209
+ setSelectedValue(defaultValue as T[]);
210
+ setValue(vals);
211
+ } else if (!multiple && !Array.isArray(defaultValue) && (defaultValue as T)[opt.value] !== undefined) {
212
+ setSelectedValue(defaultValue as T);
213
+ setValue((defaultValue as T)[opt.value] as string | number);
214
+ }
215
+ // eslint-disable-next-line react-hooks/exhaustive-deps
216
+ }, [multiple, opt.value, defaultValString]);
217
+
218
+ // ── On open → setup ──────────────────────────────────────────────────────
219
+ useEffect(() => {
220
+ if (!isOpen) return;
221
+
222
+ updateDropdownPosition();
223
+
224
+ // Defer focus so the portal dropdown is mounted first
225
+ requestAnimationFrame(() => {
226
+ searchInputRef.current?.focus();
227
+ });
228
+ setHighlightedIndex(null);
229
+ setSearchValue('');
230
+
231
+ if (isAsync) {
232
+ setPage(1);
233
+ setGotAllData(false);
234
+ setIsOptionLoading(true);
235
+ loadOptions('', 1).then((initial) => {
236
+ setIsOptionLoading(false);
237
+ setAsyncSelectData(initial);
238
+ if (initial.length > 0) setHighlightedIndex(0);
239
+ });
240
+ } else {
241
+ setSelectData(masterData);
242
+ if (masterData.length > 0) setHighlightedIndex(0);
243
+ }
244
+ // eslint-disable-next-line react-hooks/exhaustive-deps
245
+ }, [isOpen]);
246
+
247
+ // ── Reposition on scroll / resize while open ─────────────────────────────
248
+ useEffect(() => {
249
+ if (!isOpen) return;
250
+ const handleReposition = () => updateDropdownPosition();
251
+ window.addEventListener('scroll', handleReposition, true);
252
+ window.addEventListener('resize', handleReposition);
253
+ return () => {
254
+ window.removeEventListener('scroll', handleReposition, true);
255
+ window.removeEventListener('resize', handleReposition);
256
+ };
257
+ }, [isOpen, updateDropdownPosition]);
258
+
259
+ // ── Click outside → close ────────────────────────────────────────────────
260
+ useEffect(() => {
261
+ const handler = (e: MouseEvent) => {
262
+ const target = e.target as Node;
263
+ if (
264
+ containerRef.current && !containerRef.current.contains(target) &&
265
+ dropdownRef.current && !dropdownRef.current.contains(target)
266
+ ) {
267
+ setIsOpen(false);
268
+ setHighlightedIndex(null);
269
+ }
270
+ };
271
+ window.addEventListener('click', handler);
272
+ return () => window.removeEventListener('click', handler);
273
+ }, []);
274
+
275
+ // ── Search handler ───────────────────────────────────────────────────────
276
+ const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
277
+ const val = e.target.value;
278
+ setSearchValue(val);
279
+
280
+ if (isAsync) {
281
+ if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
282
+ searchTimeoutRef.current = setTimeout(() => {
283
+ setIsSearching(true);
284
+ setPage(1);
285
+ setGotAllData(false);
286
+ loadOptions(val, 1).then((results) => {
287
+ setAsyncSelectData(results);
288
+ setHighlightedIndex(results.length > 0 ? 0 : null);
289
+ setIsSearching(false);
290
+ });
291
+ }, 300);
292
+ } else {
293
+ filterSyncData(val);
294
+ }
295
+ };
296
+
297
+ const filterSyncData = (search: string) => {
298
+ const s = search.toLowerCase();
299
+ if (s === '') {
300
+ setSelectData(masterData);
301
+ setHighlightedIndex(masterData.length > 0 ? 0 : null);
302
+ return;
303
+ }
304
+ const keys = opt.keysToSearch ?? [opt.label];
305
+ const filtered = options.filter((item) =>
306
+ keys.some((key) => {
307
+ try {
308
+ const v = item[key];
309
+ const str = typeof v === 'string' ? v : String(v);
310
+ return str.toLowerCase().includes(s);
311
+ } catch {
312
+ return false;
313
+ }
314
+ })
315
+ );
316
+ setSelectData(filtered);
317
+ setHighlightedIndex(filtered.length > 0 ? 0 : null);
318
+ };
319
+
320
+ // ── Option click ─────────────────────────────────────────────────────────
321
+ const handleOptionClick = (e: React.MouseEvent, item: T) => {
322
+ e.stopPropagation();
323
+ const itemValue = item[opt.value] as string | number;
324
+
325
+ if (multiple) {
326
+ const curValues = value as (string | number)[];
327
+ const curSelected = selectedValue as T[];
328
+ if (curValues.includes(itemValue)) {
329
+ const newVals = curValues.filter((v) => v !== itemValue);
330
+ const newSel = curSelected.filter((s) => s[opt.value] !== itemValue);
331
+ setValue(newVals);
332
+ setSelectedValue(newSel);
333
+ onChange(newVals, newSel);
334
+ } else {
335
+ const newVals = [...curValues, itemValue];
336
+ const newSel = [...curSelected, item];
337
+ setValue(newVals);
338
+ setSelectedValue(newSel);
339
+ onChange(newVals, newSel);
340
+ }
341
+ } else {
342
+ setValue(itemValue);
343
+ setSelectedValue(item);
344
+ onChange(itemValue, item);
345
+ setIsOpen(false);
346
+ }
347
+ };
348
+
349
+ // ── Remove a multi tag ───────────────────────────────────────────────────
350
+ const removeTag = (e: React.MouseEvent, id: string | number) => {
351
+ e.stopPropagation();
352
+ const newVals = (value as (string | number)[]).filter((v) => v !== id);
353
+ const newSel = (selectedValue as T[]).filter((s) => s[opt.value] !== id);
354
+ setValue(newVals);
355
+ setSelectedValue(newSel);
356
+ onChange(newVals, newSel);
357
+ };
358
+
359
+ // ── Clear all ────────────────────────────────────────────────────────────
360
+ const handleClear = (e: React.MouseEvent) => {
361
+ e.stopPropagation();
362
+ if (multiple) {
363
+ setValue([]);
364
+ setSelectedValue([]);
365
+ onChange([], []);
366
+ } else {
367
+ setValue('');
368
+ setSelectedValue({} as T);
369
+ onChange('', {} as T);
370
+ }
371
+ };
372
+
373
+ // ── Keyboard (dropdown) ──────────────────────────────────────────────────
374
+ const handleDropdownKeyDown = (e: React.KeyboardEvent) => {
375
+ const data = displayData;
376
+ switch (e.key) {
377
+ case 'ArrowDown':
378
+ e.preventDefault();
379
+ if (highlightedIndex === null) setHighlightedIndex(0);
380
+ else if (highlightedIndex < data.length - 1) setHighlightedIndex(highlightedIndex + 1);
381
+ break;
382
+ case 'ArrowUp':
383
+ e.preventDefault();
384
+ if (highlightedIndex !== null && highlightedIndex > 0) setHighlightedIndex(highlightedIndex - 1);
385
+ break;
386
+ case 'Escape':
387
+ setIsOpen(false);
388
+ break;
389
+ case 'Enter':
390
+ e.preventDefault();
391
+ if (highlightedIndex !== null && data[highlightedIndex]) {
392
+ handleOptionClick(e as unknown as React.MouseEvent, data[highlightedIndex]);
393
+ }
394
+ break;
395
+ }
396
+ };
397
+
398
+ // ── Keyboard (container – open select) ───────────────────────────────────
399
+ const handleContainerKeyDown = (e: React.KeyboardEvent) => {
400
+ if (disabled) return;
401
+ if (['Enter', ' ', 'ArrowDown'].includes(e.key)) {
402
+ e.preventDefault();
403
+ if (!isOpen) setIsOpen(true);
404
+ }
405
+ };
406
+
407
+ // ── Async scroll pagination ──────────────────────────────────────────────
408
+ const handleScroll = (e: React.UIEvent<HTMLUListElement>) => {
409
+ if (!isAsync || gotAllData || isSearching || isOptionLoading) return;
410
+ const t = e.currentTarget;
411
+ if (t.clientHeight + t.scrollTop >= t.scrollHeight - 1) {
412
+ setIsOptionLoading(true);
413
+ const nextPage = page + 1;
414
+ setPage(nextPage);
415
+ loadOptions(searchValue, nextPage).then((newItems) => {
416
+ if (newItems.length === 0) setGotAllData(true);
417
+ setAsyncSelectData((prev) => [...prev, ...newItems]);
418
+ setIsOptionLoading(false);
419
+ });
420
+ }
421
+ };
422
+
423
+ // ── Creatable action ─────────────────────────────────────────────────────
424
+ const handleAddNew = async (e: React.MouseEvent) => {
425
+ e.stopPropagation();
426
+ if (!creatable) return;
427
+ const newData = await creatable.action(searchValue, setIsOpen);
428
+ if (!newData) return;
429
+ const newVal = newData[opt.value] as string | number;
430
+
431
+ if (multiple) {
432
+ const curVals = value as (string | number)[];
433
+ if (!curVals.includes(newVal)) {
434
+ const newVals = [...curVals, newVal];
435
+ const newSel = [...(selectedValue as T[]), newData];
436
+ setValue(newVals);
437
+ setSelectedValue(newSel);
438
+ onChange(newVals, newSel);
439
+ }
440
+ } else {
441
+ if (value !== newVal) {
442
+ setValue(newVal);
443
+ setSelectedValue(newData);
444
+ onChange(newVal, newData);
445
+ }
446
+ }
447
+ setIsOpen(false);
448
+ };
449
+
450
+ // ── Creatable visibility helper ──────────────────────────────────────────
451
+ const isCreatableVisible = (() => {
452
+ if (!creatable) return false;
453
+ const vis = creatable.visible;
454
+ if (vis === true || vis === 'always') return true;
455
+ if (vis === 'with-content' && searchValue !== '') return true;
456
+ return false;
457
+ })();
458
+
459
+ // ── Check if an option value is selected ─────────────────────────────────
460
+ const isSelected = (item: T): boolean => {
461
+ const itemVal = item[opt.value] as string | number;
462
+ if (multiple) return (value as (string | number)[]).includes(itemVal);
463
+ return value === itemVal;
464
+ };
465
+
466
+ // ── Dropdown content (rendered via portal) ───────────────────────────────
467
+ const dropdownContent = isOpen ? (
468
+ <div
469
+ ref={dropdownRef}
470
+ className="fixed z-[9999] bg-surface-1 border border-border-subtle rounded-[10px] shadow-lg overflow-hidden"
471
+ style={{
472
+ ...(dropdownPos.flipToTop
473
+ ? { bottom: window.innerHeight - dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }
474
+ : { top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width }),
475
+ }}
476
+ onKeyDown={handleDropdownKeyDown}
477
+ >
478
+ {/* Search input */}
479
+ {isSearchable && (
480
+ <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border-subtle">
481
+ <Search size={15} className="text-foreground-disabled shrink-0" />
482
+ <input
483
+ ref={searchInputRef}
484
+ type="text"
485
+ className="w-full bg-white text-[13px] text-foreground-1 outline-none placeholder:text-foreground-disabled"
486
+ placeholder="Search..."
487
+ value={searchValue}
488
+ onChange={handleSearchChange}
489
+ onClick={(e) => e.stopPropagation()}
490
+ autoComplete="off"
491
+ spellCheck={false}
492
+ aria-label="Search options"
493
+ />
494
+ {isAsync && isSearching && (
495
+ <Loader2 size={15} className="text-primary animate-spin shrink-0" />
496
+ )}
497
+ </div>
498
+ )}
499
+
500
+ {/* Hidden focusable ref for non-searchable mode */}
501
+ {!isSearchable && <span ref={searchInputRef} tabIndex={-1} />}
502
+
503
+ {/* Add-new button (top) */}
504
+ {isCreatableVisible && creatable?.position === 'top' && (
505
+ <AddNewButton
506
+ creatable={creatable}
507
+ searchValue={searchValue}
508
+ onAdd={handleAddNew}
509
+ setIsOpen={setIsOpen}
510
+ />
511
+ )}
512
+
513
+ {/* Options list */}
514
+ <ul
515
+ className="max-h-[220px] overflow-y-auto py-1 custom-scrollbar"
516
+ role="listbox"
517
+ onScroll={handleScroll}
518
+ >
519
+ {displayData.length === 0 && !isOptionLoading && (
520
+ <li className="px-3.5 py-3 text-center text-[12.5px] text-foreground-disabled">
521
+ No options found
522
+ </li>
523
+ )}
524
+
525
+ {displayData.map((item, idx) => (
526
+ <li
527
+ key={idx}
528
+ ref={idx === highlightedIndex ? highlightedRef : null}
529
+ role="option"
530
+ aria-selected={isSelected(item)}
531
+ className={cn(
532
+ 'flex items-center gap-2 px-3.5 py-2 text-[13px] cursor-pointer transition-colors',
533
+ isSelected(item)
534
+ ? 'bg-primary/10 text-primary font-medium'
535
+ : 'text-foreground-1',
536
+ idx === highlightedIndex && 'bg-surface-hover',
537
+ isSelected(item) && idx === highlightedIndex && 'bg-primary/15'
538
+ )}
539
+ onClick={(e) => handleOptionClick(e, item)}
540
+ onMouseEnter={() => setHighlightedIndex(idx)}
541
+ >
542
+ {/* Checkbox indicator for multi */}
543
+ {multiple && (
544
+ <span
545
+ className={cn(
546
+ 'w-4 h-4 rounded border-[1.5px] flex items-center justify-center shrink-0 transition-colors',
547
+ isSelected(item)
548
+ ? 'bg-primary border-primary'
549
+ : 'border-border-subtle bg-surface-0'
550
+ )}
551
+ >
552
+ {isSelected(item) && (
553
+ <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
554
+ <path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
555
+ </svg>
556
+ )}
557
+ </span>
558
+ )}
559
+
560
+ {/* Option label */}
561
+ <span className="truncate">
562
+ {opt.renderOption
563
+ ? opt.renderOption(item)
564
+ : getLabel(item)}
565
+ </span>
566
+ </li>
567
+ ))}
568
+
569
+ {/* Async pagination loader */}
570
+ {isAsync && isOptionLoading && (
571
+ <li className="flex items-center justify-center py-3">
572
+ <Loader2 size={18} className="text-primary animate-spin" />
573
+ </li>
574
+ )}
575
+ </ul>
576
+
577
+ {/* Add-new button (bottom) */}
578
+ {isCreatableVisible && creatable?.position === 'bottom' && (
579
+ <AddNewButton
580
+ creatable={creatable}
581
+ searchValue={searchValue}
582
+ onAdd={handleAddNew}
583
+ setIsOpen={setIsOpen}
584
+ />
585
+ )}
586
+ </div>
587
+ ) : null;
588
+
589
+ // ── Render ───────────────────────────────────────────────────────────────
590
+ return (
591
+ <div className={cn('flex flex-col gap-1.5 w-full', className)}>
592
+ {/* Label */}
593
+ {label && (
594
+ <label className="text-[12px] font-normal text-foreground-subtle tracking-[0.3px] capitalize">
595
+ {label}
596
+ {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
597
+ </label>
598
+ )}
599
+
600
+ {/* Select container */}
601
+ <div
602
+ ref={containerRef}
603
+ tabIndex={disabled ? -1 : 0}
604
+ onClick={() => {
605
+ if (!disabled) setIsOpen((prev) => !prev);
606
+ }}
607
+ onKeyDown={handleContainerKeyDown}
608
+ className={cn(
609
+ 'relative',
610
+ disabled && 'opacity-50 pointer-events-none'
611
+ )}
612
+ >
613
+ {/* Control */}
614
+ <div
615
+ ref={controlRef}
616
+ className={cn(
617
+ 'w-full border-[1.5px] rounded-[10px] px-3.5 py-2.5 flex items-center gap-2 cursor-pointer transition-all min-h-[42px]',
618
+ isOpen
619
+ ? `border-primary bg-surface-1`
620
+ : `border-border-subtle bg-surface-1`,
621
+ error && 'border-danger-alt focus:border-danger-alt'
622
+ )}
623
+ >
624
+ {/* Value area */}
625
+ <div className="flex-1 flex items-center gap-1.5 flex-wrap min-w-0">
626
+ {multiple ? (
627
+ <>
628
+ {(selectedValue as T[]).length === 0 ? (
629
+ <span className="text-[13.5px] text-foreground-subtle select-none">{placeholder}</span>
630
+ ) : (
631
+ (selectedValue as T[]).map((item, i) => (
632
+ <span
633
+ key={i}
634
+ className="inline-flex items-center gap-1 bg-primary/10 text-primary text-[12px] font-semibold px-2.5 py-0.5 rounded-full"
635
+ >
636
+ <span className="truncate max-w-[120px]">{getLabel(item)}</span>
637
+ {!disabled && (
638
+ <button
639
+ type="button"
640
+ className="flex items-center justify-center hover:text-danger transition-colors cursor-pointer"
641
+ onClick={(e) => removeTag(e, item[opt.value] as string | number)}
642
+ aria-label={`Remove ${getLabel(item)}`}
643
+ >
644
+ <X size={12} />
645
+ </button>
646
+ )}
647
+ </span>
648
+ ))
649
+ )}
650
+ </>
651
+ ) : (
652
+ <>
653
+ {!hasValue ? (
654
+ <span className="text-[13.5px] text-foreground-subtle select-none">{placeholder}</span>
655
+ ) : (
656
+ <span className="text-[13.5px] text-foreground-1 truncate">
657
+ {getLabel(selectedValue as T)}
658
+ </span>
659
+ )}
660
+ </>
661
+ )}
662
+ </div>
663
+
664
+ {/* Indicators */}
665
+ <div className="flex items-center gap-1 shrink-0">
666
+ {allowClear && hasValue && !disabled && (
667
+ <>
668
+ <button
669
+ type="button"
670
+ className="flex items-center justify-center p-0.5 text-foreground-disabled hover:text-foreground-muted transition-colors cursor-pointer"
671
+ onClick={handleClear}
672
+ aria-label="Clear selection"
673
+ >
674
+ <X size={15} />
675
+ </button>
676
+ <span className="w-px h-4 bg-border-subtle" />
677
+ </>
678
+ )}
679
+ <div
680
+ className={cn(
681
+ 'flex items-center justify-center text-foreground-disabled transition-transform duration-200',
682
+ isOpen && 'rotate-180'
683
+ )}
684
+ >
685
+ <ChevronDown size={16} />
686
+ </div>
687
+ </div>
688
+ </div>
689
+ </div>
690
+
691
+ {/* Error message */}
692
+ {error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
693
+
694
+ {/* Dropdown rendered via portal to escape overflow containers */}
695
+ {typeof document !== 'undefined' &&
696
+ ReactDOM.createPortal(dropdownContent, document.body)}
697
+ </div>
698
+ );
699
+ }
700
+
701
+ // ─── Add New Button Sub-Component ────────────────────────────────────────────
702
+
703
+ interface AddNewButtonProps<T> {
704
+ creatable: CreatableConfig<T>;
705
+ searchValue: string;
706
+ onAdd: (e: React.MouseEvent) => void;
707
+ setIsOpen: (v: boolean) => void;
708
+ }
709
+
710
+ function AddNewButton<T>({ creatable, searchValue, onAdd, setIsOpen }: AddNewButtonProps<T>) {
711
+ // Custom content renderer
712
+ if (creatable.content) {
713
+ return (
714
+ <div className="border-y border-border-subtle" onClick={onAdd}>
715
+ {creatable.content({ searchValue, setDropdownOpen: setIsOpen })}
716
+ </div>
717
+ );
718
+ }
719
+
720
+ return (
721
+ <div
722
+ className="flex items-center gap-2 px-3.5 py-2.5 border-y border-border-subtle text-primary hover:bg-surface-hover transition-colors cursor-pointer"
723
+ onClick={onAdd}
724
+ >
725
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" width="16" height="16" className="shrink-0">
726
+ <path d="M12 5V19M5 12H19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
727
+ </svg>
728
+ <span className="text-[13px] font-medium">
729
+ Add {searchValue === '' ? 'new' : ''}
730
+ {searchValue !== '' && <span className="font-semibold ml-1">{searchValue}</span>}
731
+ </span>
732
+ </div>
733
+ );
734
+ }