@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.
- package/README.md +46 -0
- package/package.json +42 -0
- package/src/base-components/Accordion.tsx +561 -0
- package/src/base-components/Badge.tsx +191 -0
- package/src/base-components/Button.tsx +331 -0
- package/src/base-components/ButtonGroup.tsx +149 -0
- package/src/base-components/Card.tsx +250 -0
- package/src/base-components/Checkbox.tsx +49 -0
- package/src/base-components/ChipInput.tsx +208 -0
- package/src/base-components/CommonButton.tsx +33 -0
- package/src/base-components/DataTable.tsx +82 -0
- package/src/base-components/Divider.tsx +82 -0
- package/src/base-components/Dropdown.tsx +85 -0
- package/src/base-components/EmptyState.tsx +18 -0
- package/src/base-components/FilterPopover.tsx +50 -0
- package/src/base-components/Input.tsx +60 -0
- package/src/base-components/Modal.tsx +107 -0
- package/src/base-components/OtpVerificationModal.tsx +251 -0
- package/src/base-components/Pagination.tsx +51 -0
- package/src/base-components/PhoneInput.tsx +142 -0
- package/src/base-components/PopConfirm.tsx +350 -0
- package/src/base-components/SearchPopover.tsx +70 -0
- package/src/base-components/SearchableSelect.tsx +734 -0
- package/src/base-components/Select.tsx +49 -0
- package/src/base-components/Table.tsx +78 -0
- package/src/base-components/Textarea.tsx +45 -0
- package/src/base-components/ThemeProvider.tsx +92 -0
- package/src/base-components/Toaster.tsx +198 -0
- package/src/base-components/index.ts +32 -0
- package/src/components/DashboardLayout.tsx +326 -0
- package/src/components/ListPage.tsx +140 -0
- package/src/components/QuickAccess.tsx +118 -0
- package/src/components/UserMenu.tsx +138 -0
- package/src/helpers/bem.ts +13 -0
- package/src/helpers/cn.ts +9 -0
- package/src/index.ts +16 -0
- package/src/theme.css +285 -0
- 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
|
+
}
|