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