tekivex-ui 2.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.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/index.cjs +70 -0
  4. package/dist/index.d.ts +89 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +11002 -0
  7. package/dist/src/a11y/index.d.ts +24 -0
  8. package/dist/src/a11y/index.d.ts.map +1 -0
  9. package/dist/src/components/TkxAccordion.d.ts +32 -0
  10. package/dist/src/components/TkxAccordion.d.ts.map +1 -0
  11. package/dist/src/components/TkxAlert.d.ts +14 -0
  12. package/dist/src/components/TkxAlert.d.ts.map +1 -0
  13. package/dist/src/components/TkxAvatar.d.ts +14 -0
  14. package/dist/src/components/TkxAvatar.d.ts.map +1 -0
  15. package/dist/src/components/TkxBadge.d.ts +12 -0
  16. package/dist/src/components/TkxBadge.d.ts.map +1 -0
  17. package/dist/src/components/TkxButton.d.ts +17 -0
  18. package/dist/src/components/TkxButton.d.ts.map +1 -0
  19. package/dist/src/components/TkxCard.d.ts +20 -0
  20. package/dist/src/components/TkxCard.d.ts.map +1 -0
  21. package/dist/src/components/TkxCarousel.d.ts +32 -0
  22. package/dist/src/components/TkxCarousel.d.ts.map +1 -0
  23. package/dist/src/components/TkxChat.d.ts +32 -0
  24. package/dist/src/components/TkxChat.d.ts.map +1 -0
  25. package/dist/src/components/TkxCheckbox.d.ts +13 -0
  26. package/dist/src/components/TkxCheckbox.d.ts.map +1 -0
  27. package/dist/src/components/TkxClock.d.ts +23 -0
  28. package/dist/src/components/TkxClock.d.ts.map +1 -0
  29. package/dist/src/components/TkxColorPicker.d.ts +18 -0
  30. package/dist/src/components/TkxColorPicker.d.ts.map +1 -0
  31. package/dist/src/components/TkxCommand.d.ts +34 -0
  32. package/dist/src/components/TkxCommand.d.ts.map +1 -0
  33. package/dist/src/components/TkxDatePicker.d.ts +47 -0
  34. package/dist/src/components/TkxDatePicker.d.ts.map +1 -0
  35. package/dist/src/components/TkxDivider.d.ts +8 -0
  36. package/dist/src/components/TkxDivider.d.ts.map +1 -0
  37. package/dist/src/components/TkxDrawer.d.ts +16 -0
  38. package/dist/src/components/TkxDrawer.d.ts.map +1 -0
  39. package/dist/src/components/TkxFileUpload.d.ts +16 -0
  40. package/dist/src/components/TkxFileUpload.d.ts.map +1 -0
  41. package/dist/src/components/TkxIcon.d.ts +16 -0
  42. package/dist/src/components/TkxIcon.d.ts.map +1 -0
  43. package/dist/src/components/TkxImage.d.ts +16 -0
  44. package/dist/src/components/TkxImage.d.ts.map +1 -0
  45. package/dist/src/components/TkxInput.d.ts +13 -0
  46. package/dist/src/components/TkxInput.d.ts.map +1 -0
  47. package/dist/src/components/TkxLogo.d.ts +21 -0
  48. package/dist/src/components/TkxLogo.d.ts.map +1 -0
  49. package/dist/src/components/TkxMenu.d.ts +64 -0
  50. package/dist/src/components/TkxMenu.d.ts.map +1 -0
  51. package/dist/src/components/TkxModal.d.ts +14 -0
  52. package/dist/src/components/TkxModal.d.ts.map +1 -0
  53. package/dist/src/components/TkxNumberInput.d.ts +32 -0
  54. package/dist/src/components/TkxNumberInput.d.ts.map +1 -0
  55. package/dist/src/components/TkxOTP.d.ts +24 -0
  56. package/dist/src/components/TkxOTP.d.ts.map +1 -0
  57. package/dist/src/components/TkxPagination.d.ts +19 -0
  58. package/dist/src/components/TkxPagination.d.ts.map +1 -0
  59. package/dist/src/components/TkxProgress.d.ts +13 -0
  60. package/dist/src/components/TkxProgress.d.ts.map +1 -0
  61. package/dist/src/components/TkxRadio.d.ts +22 -0
  62. package/dist/src/components/TkxRadio.d.ts.map +1 -0
  63. package/dist/src/components/TkxRating.d.ts +18 -0
  64. package/dist/src/components/TkxRating.d.ts.map +1 -0
  65. package/dist/src/components/TkxSelect.d.ts +37 -0
  66. package/dist/src/components/TkxSelect.d.ts.map +1 -0
  67. package/dist/src/components/TkxSkeleton.d.ts +12 -0
  68. package/dist/src/components/TkxSkeleton.d.ts.map +1 -0
  69. package/dist/src/components/TkxSlider.d.ts +33 -0
  70. package/dist/src/components/TkxSlider.d.ts.map +1 -0
  71. package/dist/src/components/TkxStepper.d.ts +31 -0
  72. package/dist/src/components/TkxStepper.d.ts.map +1 -0
  73. package/dist/src/components/TkxTable.d.ts +23 -0
  74. package/dist/src/components/TkxTable.d.ts.map +1 -0
  75. package/dist/src/components/TkxTabs.d.ts +32 -0
  76. package/dist/src/components/TkxTabs.d.ts.map +1 -0
  77. package/dist/src/components/TkxTag.d.ts +30 -0
  78. package/dist/src/components/TkxTag.d.ts.map +1 -0
  79. package/dist/src/components/TkxTimeline.d.ts +20 -0
  80. package/dist/src/components/TkxTimeline.d.ts.map +1 -0
  81. package/dist/src/components/TkxToast.d.ts +23 -0
  82. package/dist/src/components/TkxToast.d.ts.map +1 -0
  83. package/dist/src/components/TkxToggle.d.ts +11 -0
  84. package/dist/src/components/TkxToggle.d.ts.map +1 -0
  85. package/dist/src/components/TkxTooltip.d.ts +10 -0
  86. package/dist/src/components/TkxTooltip.d.ts.map +1 -0
  87. package/dist/src/components/TkxVideoPlayer.d.ts +43 -0
  88. package/dist/src/components/TkxVideoPlayer.d.ts.map +1 -0
  89. package/dist/src/components/index.d.ts +41 -0
  90. package/dist/src/components/index.d.ts.map +1 -0
  91. package/dist/src/engine/css.d.ts +24 -0
  92. package/dist/src/engine/css.d.ts.map +1 -0
  93. package/dist/src/engine/index.d.ts +10 -0
  94. package/dist/src/engine/index.d.ts.map +1 -0
  95. package/dist/src/engine/quantum.d.ts +47 -0
  96. package/dist/src/engine/quantum.d.ts.map +1 -0
  97. package/dist/src/engine/security.d.ts +54 -0
  98. package/dist/src/engine/security.d.ts.map +1 -0
  99. package/dist/src/engine/tkx.d.ts +29 -0
  100. package/dist/src/engine/tkx.d.ts.map +1 -0
  101. package/dist/src/engine/wcag.d.ts +34 -0
  102. package/dist/src/engine/wcag.d.ts.map +1 -0
  103. package/dist/src/hooks/index.d.ts +8 -0
  104. package/dist/src/hooks/index.d.ts.map +1 -0
  105. package/dist/src/themes/index.d.ts +26 -0
  106. package/dist/src/themes/index.d.ts.map +1 -0
  107. package/dist/style.css +1 -0
  108. package/dist/tests/TkxButton.test.d.ts +2 -0
  109. package/dist/tests/TkxButton.test.d.ts.map +1 -0
  110. package/dist/tests/a11y/components.test.d.ts +2 -0
  111. package/dist/tests/a11y/components.test.d.ts.map +1 -0
  112. package/dist/tests/quantum.test.d.ts +2 -0
  113. package/dist/tests/quantum.test.d.ts.map +1 -0
  114. package/dist/tests/security.test.d.ts +2 -0
  115. package/dist/tests/security.test.d.ts.map +1 -0
  116. package/dist/tests/setup.d.ts +2 -0
  117. package/dist/tests/setup.d.ts.map +1 -0
  118. package/dist/tests/tkx.test.d.ts +2 -0
  119. package/dist/tests/tkx.test.d.ts.map +1 -0
  120. package/dist/tests/wcag.test.d.ts +2 -0
  121. package/dist/tests/wcag.test.d.ts.map +1 -0
  122. package/package.json +78 -0
  123. package/src/a11y/index.tsx +87 -0
  124. package/src/components/TkxAccordion.tsx +588 -0
  125. package/src/components/TkxAlert.tsx +91 -0
  126. package/src/components/TkxAvatar.tsx +101 -0
  127. package/src/components/TkxBadge.tsx +73 -0
  128. package/src/components/TkxButton.tsx +153 -0
  129. package/src/components/TkxCard.tsx +92 -0
  130. package/src/components/TkxCarousel.tsx +552 -0
  131. package/src/components/TkxChat.tsx +354 -0
  132. package/src/components/TkxCheckbox.tsx +218 -0
  133. package/src/components/TkxClock.tsx +601 -0
  134. package/src/components/TkxColorPicker.tsx +633 -0
  135. package/src/components/TkxCommand.tsx +644 -0
  136. package/src/components/TkxDatePicker.tsx +1567 -0
  137. package/src/components/TkxDivider.tsx +49 -0
  138. package/src/components/TkxDrawer.tsx +234 -0
  139. package/src/components/TkxFileUpload.tsx +371 -0
  140. package/src/components/TkxIcon.tsx +683 -0
  141. package/src/components/TkxImage.tsx +277 -0
  142. package/src/components/TkxInput.tsx +101 -0
  143. package/src/components/TkxLogo.tsx +297 -0
  144. package/src/components/TkxMenu.tsx +1042 -0
  145. package/src/components/TkxModal.tsx +119 -0
  146. package/src/components/TkxNumberInput.tsx +408 -0
  147. package/src/components/TkxOTP.tsx +295 -0
  148. package/src/components/TkxPagination.tsx +388 -0
  149. package/src/components/TkxProgress.tsx +114 -0
  150. package/src/components/TkxRadio.tsx +284 -0
  151. package/src/components/TkxRating.tsx +286 -0
  152. package/src/components/TkxSelect.tsx +923 -0
  153. package/src/components/TkxSkeleton.tsx +105 -0
  154. package/src/components/TkxSlider.tsx +492 -0
  155. package/src/components/TkxStepper.tsx +485 -0
  156. package/src/components/TkxTable.tsx +148 -0
  157. package/src/components/TkxTabs.tsx +124 -0
  158. package/src/components/TkxTag.tsx +319 -0
  159. package/src/components/TkxTimeline.tsx +303 -0
  160. package/src/components/TkxToast.tsx +284 -0
  161. package/src/components/TkxToggle.tsx +93 -0
  162. package/src/components/TkxTooltip.tsx +69 -0
  163. package/src/components/TkxVideoPlayer.tsx +1013 -0
  164. package/src/components/index.ts +40 -0
  165. package/src/engine/css.ts +126 -0
  166. package/src/engine/index.ts +10 -0
  167. package/src/engine/quantum.ts +207 -0
  168. package/src/engine/security.ts +217 -0
  169. package/src/engine/tkx.ts +874 -0
  170. package/src/engine/wcag.ts +275 -0
  171. package/src/hooks/index.ts +120 -0
  172. package/src/styles/global.css +167 -0
  173. package/src/themes/index.ts +111 -0
@@ -0,0 +1,923 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useCallback,
6
+ useId,
7
+ type ReactNode,
8
+ type CSSProperties,
9
+ type KeyboardEvent,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ import { useTheme } from '../themes';
13
+ import { sanitizeString } from '../engine/security';
14
+ import { tkx, cx } from '../engine/tkx';
15
+
16
+ // ── Types ──────────────────────────────────────────────────────────────────────
17
+
18
+ export type SelectSize = 'sm' | 'md' | 'lg';
19
+
20
+ export interface SelectOption {
21
+ value: string;
22
+ label: string;
23
+ group?: string;
24
+ disabled?: boolean;
25
+ icon?: ReactNode;
26
+ description?: string;
27
+ }
28
+
29
+ export interface TkxSelectProps {
30
+ options: SelectOption[];
31
+ value?: string | string[];
32
+ defaultValue?: string | string[];
33
+ placeholder?: string;
34
+ size?: SelectSize;
35
+ isDisabled?: boolean;
36
+ isInvalid?: boolean;
37
+ label?: string;
38
+ hint?: string;
39
+ errorMessage?: string;
40
+ multiple?: boolean;
41
+ searchable?: boolean;
42
+ clearable?: boolean;
43
+ isLoading?: boolean;
44
+ onChange?: (value: string | string[]) => void;
45
+ renderOption?: (option: SelectOption, isSelected: boolean) => ReactNode;
46
+ maxMenuHeight?: number;
47
+ id?: string;
48
+ className?: string;
49
+ style?: CSSProperties;
50
+ }
51
+
52
+ // ── Helpers ────────────────────────────────────────────────────────────────────
53
+
54
+ const SIZE_MAP: Record<SelectSize, { py: string; px: string; fontSize: string; iconSize: number; tagPy: string; tagPx: string }> = {
55
+ sm: { py: '6px', px: '10px', fontSize: '13px', iconSize: 14, tagPy: '1px', tagPx: '6px' },
56
+ md: { py: '9px', px: '12px', fontSize: '14px', iconSize: 16, tagPy: '2px', tagPx: '8px' },
57
+ lg: { py: '12px', px: '14px', fontSize: '15px', iconSize: 18, tagPy: '3px', tagPx: '10px' },
58
+ };
59
+
60
+ function toArray(v: string | string[] | undefined): string[] {
61
+ if (v === undefined) return [];
62
+ return Array.isArray(v) ? v : [v];
63
+ }
64
+
65
+ // ── Portal Dropdown positioning ────────────────────────────────────────────────
66
+
67
+ interface DropdownRect {
68
+ top: number;
69
+ left: number;
70
+ width: number;
71
+ placement: 'below' | 'above';
72
+ }
73
+
74
+ function calcDropdownRect(
75
+ triggerEl: HTMLElement,
76
+ dropdownHeight: number,
77
+ ): DropdownRect {
78
+ const rect = triggerEl.getBoundingClientRect();
79
+ const viewportH = window.innerHeight;
80
+ const gap = 6;
81
+ const spaceBelow = viewportH - rect.bottom - gap;
82
+ const spaceAbove = rect.top - gap;
83
+ const placement: 'below' | 'above' =
84
+ spaceBelow >= Math.min(dropdownHeight, 200) || spaceBelow >= spaceAbove
85
+ ? 'below'
86
+ : 'above';
87
+
88
+ return {
89
+ top:
90
+ placement === 'below'
91
+ ? rect.bottom + window.scrollY + gap
92
+ : rect.top + window.scrollY - gap - Math.min(dropdownHeight, 280),
93
+ left: rect.left + window.scrollX,
94
+ width: rect.width,
95
+ placement,
96
+ };
97
+ }
98
+
99
+ // ── Spinner ────────────────────────────────────────────────────────────────────
100
+
101
+ function Spinner({ size, color }: { size: number; color: string }) {
102
+ return (
103
+ <svg
104
+ width={size}
105
+ height={size}
106
+ viewBox="0 0 24 24"
107
+ fill="none"
108
+ stroke={color}
109
+ strokeWidth="2.5"
110
+ strokeLinecap="round"
111
+ aria-hidden="true"
112
+ style={{
113
+ animation: 'tkx-spin 0.7s linear infinite',
114
+ }}
115
+ >
116
+ <style>{`@keyframes tkx-spin{to{transform:rotate(360deg)}}`}</style>
117
+ <path d="M12 2a10 10 0 0 1 10 10" />
118
+ </svg>
119
+ );
120
+ }
121
+
122
+ // ── Main Component ─────────────────────────────────────────────────────────────
123
+
124
+ export function TkxSelect({
125
+ options,
126
+ value: valueProp,
127
+ defaultValue,
128
+ placeholder = 'Select an option…',
129
+ size = 'md',
130
+ isDisabled = false,
131
+ isInvalid = false,
132
+ label,
133
+ hint,
134
+ errorMessage,
135
+ multiple = false,
136
+ searchable = false,
137
+ clearable = false,
138
+ isLoading = false,
139
+ onChange,
140
+ renderOption,
141
+ maxMenuHeight = 280,
142
+ id: idProp,
143
+ className,
144
+ style,
145
+ }: TkxSelectProps) {
146
+ const theme = useTheme();
147
+ const autoId = useId();
148
+ const id = idProp ?? autoId;
149
+ const listboxId = `${id}-listbox`;
150
+ const hintId = `${id}-hint`;
151
+ const errorId = `${id}-error`;
152
+
153
+ const isControlled = valueProp !== undefined;
154
+ const [internalValue, setInternalValue] = useState<string[]>(
155
+ toArray(defaultValue),
156
+ );
157
+
158
+ const selectedValues = isControlled ? toArray(valueProp) : internalValue;
159
+
160
+ const [isOpen, setIsOpen] = useState(false);
161
+ const [search, setSearch] = useState('');
162
+ const [activeIndex, setActiveIndex] = useState(-1);
163
+ const [dropdownRect, setDropdownRect] = useState<DropdownRect | null>(null);
164
+ const [typeahead, setTypeahead] = useState('');
165
+ const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
166
+
167
+ const triggerRef = useRef<HTMLButtonElement>(null);
168
+ const searchInputRef = useRef<HTMLInputElement>(null);
169
+ const dropdownRef = useRef<HTMLDivElement>(null);
170
+ const listRef = useRef<HTMLDivElement>(null);
171
+
172
+ const sz = SIZE_MAP[size];
173
+ const hasError = isInvalid || !!errorMessage;
174
+
175
+ // ── Filtered + grouped options ────────────────────────────────────────────
176
+
177
+ const filteredOptions = searchable && search
178
+ ? options.filter((o) =>
179
+ o.label.toLowerCase().includes(search.toLowerCase()),
180
+ )
181
+ : options;
182
+
183
+ const flatEnabled = filteredOptions.filter((o) => !o.disabled);
184
+
185
+ const groups = filteredOptions.reduce<Record<string, SelectOption[]>>(
186
+ (acc, opt) => {
187
+ const g = opt.group ?? '';
188
+ if (!acc[g]) acc[g] = [];
189
+ acc[g].push(opt);
190
+ return acc;
191
+ },
192
+ {},
193
+ );
194
+
195
+ // ── Commit value ──────────────────────────────────────────────────────────
196
+
197
+ const commitValue = useCallback(
198
+ (val: string) => {
199
+ let next: string[];
200
+ if (multiple) {
201
+ next = selectedValues.includes(val)
202
+ ? selectedValues.filter((v) => v !== val)
203
+ : [...selectedValues, val];
204
+ } else {
205
+ next = [val];
206
+ }
207
+
208
+ if (!isControlled) setInternalValue(next);
209
+ onChange?.(multiple ? next : next[0] ?? '');
210
+
211
+ if (!multiple) {
212
+ setIsOpen(false);
213
+ setSearch('');
214
+ setActiveIndex(-1);
215
+ triggerRef.current?.focus();
216
+ }
217
+ },
218
+ [isControlled, multiple, onChange, selectedValues],
219
+ );
220
+
221
+ const removeTag = useCallback(
222
+ (val: string, e: React.MouseEvent) => {
223
+ e.stopPropagation();
224
+ const next = selectedValues.filter((v) => v !== val);
225
+ if (!isControlled) setInternalValue(next);
226
+ onChange?.(multiple ? next : next[0] ?? '');
227
+ },
228
+ [isControlled, multiple, onChange, selectedValues],
229
+ );
230
+
231
+ const clearAll = useCallback(
232
+ (e: React.MouseEvent) => {
233
+ e.stopPropagation();
234
+ if (!isControlled) setInternalValue([]);
235
+ onChange?.(multiple ? [] : '');
236
+ },
237
+ [isControlled, multiple, onChange],
238
+ );
239
+
240
+ // ── Positioning ───────────────────────────────────────────────────────────
241
+
242
+ const updatePosition = useCallback(() => {
243
+ if (!triggerRef.current || !isOpen) return;
244
+ setDropdownRect(calcDropdownRect(triggerRef.current, maxMenuHeight));
245
+ }, [isOpen, maxMenuHeight]);
246
+
247
+ useEffect(() => {
248
+ if (!isOpen) return;
249
+ updatePosition();
250
+ window.addEventListener('scroll', updatePosition, true);
251
+ window.addEventListener('resize', updatePosition);
252
+ return () => {
253
+ window.removeEventListener('scroll', updatePosition, true);
254
+ window.removeEventListener('resize', updatePosition);
255
+ };
256
+ }, [isOpen, updatePosition]);
257
+
258
+ // ── Outside click ─────────────────────────────────────────────────────────
259
+
260
+ useEffect(() => {
261
+ if (!isOpen) return;
262
+ const handler = (e: PointerEvent) => {
263
+ const target = e.target as Node;
264
+ if (
265
+ triggerRef.current?.contains(target) ||
266
+ dropdownRef.current?.contains(target)
267
+ )
268
+ return;
269
+ setIsOpen(false);
270
+ setSearch('');
271
+ };
272
+ document.addEventListener('pointerdown', handler);
273
+ return () => document.removeEventListener('pointerdown', handler);
274
+ }, [isOpen]);
275
+
276
+ // ── Focus search when open ────────────────────────────────────────────────
277
+
278
+ useEffect(() => {
279
+ if (isOpen && searchable) {
280
+ setTimeout(() => searchInputRef.current?.focus(), 0);
281
+ }
282
+ }, [isOpen, searchable]);
283
+
284
+ // ── Scroll active item into view ──────────────────────────────────────────
285
+
286
+ useEffect(() => {
287
+ if (!isOpen || activeIndex < 0) return;
288
+ const el = listRef.current?.querySelector(
289
+ `[data-idx="${activeIndex}"]`,
290
+ ) as HTMLElement | null;
291
+ el?.scrollIntoView({ block: 'nearest' });
292
+ }, [activeIndex, isOpen]);
293
+
294
+ // ── Keyboard: trigger button ───────────────────────────────────────────────
295
+
296
+ const handleTriggerKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
297
+ switch (e.key) {
298
+ case 'Enter':
299
+ case ' ':
300
+ e.preventDefault();
301
+ if (!isOpen) {
302
+ openDropdown();
303
+ } else if (activeIndex >= 0 && flatEnabled[activeIndex]) {
304
+ commitValue(flatEnabled[activeIndex].value);
305
+ }
306
+ break;
307
+ case 'Escape':
308
+ e.preventDefault();
309
+ setIsOpen(false);
310
+ setSearch('');
311
+ break;
312
+ case 'ArrowDown':
313
+ e.preventDefault();
314
+ if (!isOpen) openDropdown();
315
+ else setActiveIndex((i) => Math.min(i + 1, flatEnabled.length - 1));
316
+ break;
317
+ case 'ArrowUp':
318
+ e.preventDefault();
319
+ if (!isOpen) openDropdown();
320
+ else setActiveIndex((i) => Math.max(i - 1, 0));
321
+ break;
322
+ case 'Home':
323
+ e.preventDefault();
324
+ if (isOpen) setActiveIndex(0);
325
+ break;
326
+ case 'End':
327
+ e.preventDefault();
328
+ if (isOpen) setActiveIndex(flatEnabled.length - 1);
329
+ break;
330
+ default:
331
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
332
+ if (!isOpen) openDropdown();
333
+ handleTypeahead(e.key);
334
+ }
335
+ }
336
+ };
337
+
338
+ // ── Keyboard: search input ─────────────────────────────────────────────────
339
+
340
+ const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
341
+ switch (e.key) {
342
+ case 'Escape':
343
+ e.preventDefault();
344
+ setIsOpen(false);
345
+ setSearch('');
346
+ triggerRef.current?.focus();
347
+ break;
348
+ case 'ArrowDown':
349
+ e.preventDefault();
350
+ setActiveIndex((i) => Math.min(i + 1, flatEnabled.length - 1));
351
+ break;
352
+ case 'ArrowUp':
353
+ e.preventDefault();
354
+ setActiveIndex((i) => Math.max(i - 1, 0));
355
+ break;
356
+ case 'Home':
357
+ e.preventDefault();
358
+ setActiveIndex(0);
359
+ break;
360
+ case 'End':
361
+ e.preventDefault();
362
+ setActiveIndex(flatEnabled.length - 1);
363
+ break;
364
+ case 'Enter':
365
+ e.preventDefault();
366
+ if (activeIndex >= 0 && flatEnabled[activeIndex]) {
367
+ commitValue(flatEnabled[activeIndex].value);
368
+ }
369
+ break;
370
+ }
371
+ };
372
+
373
+ // ── Typeahead ─────────────────────────────────────────────────────────────
374
+
375
+ function handleTypeahead(char: string) {
376
+ if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current);
377
+ const next = typeahead + char.toLowerCase();
378
+ setTypeahead(next);
379
+
380
+ const idx = flatEnabled.findIndex((o) =>
381
+ o.label.toLowerCase().startsWith(next),
382
+ );
383
+ if (idx >= 0) setActiveIndex(idx);
384
+
385
+ typeaheadTimer.current = setTimeout(() => setTypeahead(''), 800);
386
+ }
387
+
388
+ function openDropdown() {
389
+ if (isDisabled || isLoading) return;
390
+ setIsOpen(true);
391
+ const startIdx = flatEnabled.findIndex((o) =>
392
+ selectedValues.includes(o.value),
393
+ );
394
+ setActiveIndex(startIdx >= 0 ? startIdx : 0);
395
+ }
396
+
397
+ // ── Render helpers ────────────────────────────────────────────────────────
398
+
399
+ const borderColor = hasError
400
+ ? theme.danger
401
+ : isOpen
402
+ ? theme.primary
403
+ : theme.border;
404
+
405
+ const activeOptionId =
406
+ activeIndex >= 0 && flatEnabled[activeIndex]
407
+ ? `${id}-opt-${flatEnabled[activeIndex].value}`
408
+ : undefined;
409
+
410
+ const describedBy =
411
+ [hint && hintId, hasError && errorId].filter(Boolean).join(' ') ||
412
+ undefined;
413
+
414
+ // Tags displayed in trigger when multiple
415
+ const selectedOptionObjects = selectedValues
416
+ .map((v) => options.find((o) => o.value === v))
417
+ .filter(Boolean) as SelectOption[];
418
+
419
+ // ── Dropdown element (portalled) ──────────────────────────────────────────
420
+
421
+ const dropdownEl = isOpen && dropdownRect
422
+ ? createPortal(
423
+ <div
424
+ ref={dropdownRef}
425
+ role="listbox"
426
+ id={listboxId}
427
+ aria-label={label ? sanitizeString(label) : 'Options'}
428
+ aria-multiselectable={multiple}
429
+ style={{
430
+ position: 'absolute',
431
+ top: dropdownRect.top,
432
+ left: dropdownRect.left,
433
+ width: dropdownRect.width,
434
+ zIndex: 9999,
435
+ backgroundColor: theme.surface,
436
+ border: `1.5px solid ${theme.border}`,
437
+ borderRadius: 10,
438
+ boxShadow: `0 8px 32px rgba(0,0,0,0.28), 0 2px 8px rgba(0,0,0,0.14)`,
439
+ overflow: 'hidden',
440
+ minWidth: dropdownRect.width,
441
+ maxHeight: maxMenuHeight,
442
+ display: 'flex',
443
+ flexDirection: 'column',
444
+ }}
445
+ >
446
+ {/* Search */}
447
+ {searchable && (
448
+ <div
449
+ style={{
450
+ padding: '8px 10px',
451
+ borderBottom: `1px solid ${theme.border}`,
452
+ flexShrink: 0,
453
+ display: 'flex',
454
+ alignItems: 'center',
455
+ gap: 6,
456
+ }}
457
+ >
458
+ <svg
459
+ width="14"
460
+ height="14"
461
+ viewBox="0 0 24 24"
462
+ fill="none"
463
+ stroke={theme.textMuted}
464
+ strokeWidth="2"
465
+ aria-hidden="true"
466
+ style={{ flexShrink: 0 }}
467
+ >
468
+ <circle cx="11" cy="11" r="8" />
469
+ <path d="M21 21l-4.35-4.35" />
470
+ </svg>
471
+ <input
472
+ ref={searchInputRef}
473
+ type="text"
474
+ value={search}
475
+ onChange={(e) => {
476
+ setSearch(e.target.value);
477
+ setActiveIndex(0);
478
+ }}
479
+ onKeyDown={handleSearchKeyDown}
480
+ placeholder="Search…"
481
+ aria-label="Search options"
482
+ style={{
483
+ border: 'none',
484
+ outline: 'none',
485
+ background: 'transparent',
486
+ color: theme.text,
487
+ fontSize: sz.fontSize,
488
+ fontFamily: 'inherit',
489
+ width: '100%',
490
+ }}
491
+ />
492
+ </div>
493
+ )}
494
+
495
+ {/* Options list */}
496
+ <div
497
+ ref={listRef}
498
+ style={{
499
+ overflowY: 'auto',
500
+ flexGrow: 1,
501
+ maxHeight: maxMenuHeight - (searchable ? 48 : 0),
502
+ }}
503
+ >
504
+ {Object.entries(groups).map(([group, groupOpts]) => (
505
+ <div key={group}>
506
+ {group && (
507
+ <div
508
+ aria-hidden="true"
509
+ style={{
510
+ padding: '8px 12px 4px',
511
+ fontSize: '11px',
512
+ fontWeight: 700,
513
+ letterSpacing: '0.08em',
514
+ textTransform: 'uppercase',
515
+ color: theme.textMuted,
516
+ fontFamily: 'inherit',
517
+ position: 'sticky',
518
+ top: 0,
519
+ backgroundColor: theme.surface,
520
+ zIndex: 1,
521
+ borderBottom: `1px solid ${theme.border}`,
522
+ }}
523
+ >
524
+ {sanitizeString(group)}
525
+ </div>
526
+ )}
527
+ {groupOpts.map((opt) => {
528
+ const flatIdx = flatEnabled.indexOf(opt);
529
+ const isActive = flatIdx === activeIndex;
530
+ const isSelected = selectedValues.includes(opt.value);
531
+
532
+ const optionContent = renderOption ? (
533
+ renderOption(opt, isSelected)
534
+ ) : (
535
+ <span
536
+ style={{
537
+ display: 'flex',
538
+ flexDirection: 'column',
539
+ flex: 1,
540
+ minWidth: 0,
541
+ }}
542
+ >
543
+ <span
544
+ style={{
545
+ display: 'flex',
546
+ alignItems: 'center',
547
+ gap: 8,
548
+ }}
549
+ >
550
+ {opt.icon && (
551
+ <span style={{ flexShrink: 0, display: 'flex' }}>
552
+ {opt.icon}
553
+ </span>
554
+ )}
555
+ <span
556
+ style={{
557
+ overflow: 'hidden',
558
+ textOverflow: 'ellipsis',
559
+ whiteSpace: 'nowrap',
560
+ }}
561
+ >
562
+ {sanitizeString(opt.label)}
563
+ </span>
564
+ </span>
565
+ {opt.description && (
566
+ <span
567
+ style={{
568
+ fontSize: '12px',
569
+ color: theme.textMuted,
570
+ marginTop: 1,
571
+ overflow: 'hidden',
572
+ textOverflow: 'ellipsis',
573
+ whiteSpace: 'nowrap',
574
+ }}
575
+ >
576
+ {sanitizeString(opt.description)}
577
+ </span>
578
+ )}
579
+ </span>
580
+ );
581
+
582
+ return (
583
+ <div
584
+ key={opt.value}
585
+ id={`${id}-opt-${opt.value}`}
586
+ data-idx={flatIdx >= 0 ? flatIdx : undefined}
587
+ role="option"
588
+ aria-selected={isSelected}
589
+ aria-disabled={opt.disabled || undefined}
590
+ onClick={() => !opt.disabled && commitValue(opt.value)}
591
+ onMouseEnter={() =>
592
+ !opt.disabled && flatIdx >= 0 && setActiveIndex(flatIdx)
593
+ }
594
+ style={{
595
+ padding: `${sz.py} ${sz.px}`,
596
+ fontSize: sz.fontSize,
597
+ fontFamily: 'inherit',
598
+ color: opt.disabled ? theme.textMuted : theme.text,
599
+ backgroundColor: isActive
600
+ ? `${theme.primary}22`
601
+ : isSelected
602
+ ? `${theme.primary}12`
603
+ : 'transparent',
604
+ cursor: opt.disabled ? 'not-allowed' : 'pointer',
605
+ display: 'flex',
606
+ alignItems: 'center',
607
+ justifyContent: 'space-between',
608
+ gap: 8,
609
+ transition: 'background-color 80ms',
610
+ opacity: opt.disabled ? 0.5 : 1,
611
+ userSelect: 'none',
612
+ }}
613
+ >
614
+ {optionContent}
615
+ {isSelected && !renderOption && (
616
+ <svg
617
+ width="14"
618
+ height="14"
619
+ viewBox="0 0 24 24"
620
+ fill="none"
621
+ stroke={theme.primary}
622
+ strokeWidth="2.5"
623
+ aria-hidden="true"
624
+ style={{ flexShrink: 0 }}
625
+ >
626
+ <path d="M20 6L9 17l-5-5" />
627
+ </svg>
628
+ )}
629
+ </div>
630
+ );
631
+ })}
632
+ </div>
633
+ ))}
634
+
635
+ {filteredOptions.length === 0 && (
636
+ <div
637
+ style={{
638
+ padding: `${sz.py} ${sz.px}`,
639
+ fontSize: sz.fontSize,
640
+ fontFamily: 'inherit',
641
+ color: theme.textMuted,
642
+ textAlign: 'center',
643
+ }}
644
+ >
645
+ {isLoading ? 'Loading…' : 'No options found'}
646
+ </div>
647
+ )}
648
+ </div>
649
+ </div>,
650
+ document.body,
651
+ )
652
+ : null;
653
+
654
+ // ── Render ─────────────────────────────────────────────────────────────────
655
+
656
+ const hasClearable =
657
+ clearable && selectedValues.length > 0 && !isDisabled && !isLoading;
658
+
659
+ return (
660
+ <div
661
+ className={cx(tkx('flex flex-col gap-1 w-full'), className)}
662
+ style={style}
663
+ >
664
+ {label && (
665
+ <label
666
+ htmlFor={id}
667
+ style={{
668
+ fontSize: '14px',
669
+ fontWeight: 500,
670
+ fontFamily: 'inherit',
671
+ color: theme.text,
672
+ userSelect: 'none',
673
+ }}
674
+ >
675
+ {sanitizeString(label)}
676
+ </label>
677
+ )}
678
+
679
+ <div style={{ position: 'relative', width: '100%', opacity: isDisabled ? 0.55 : 1 }}>
680
+ <button
681
+ ref={triggerRef}
682
+ id={id}
683
+ type="button"
684
+ role="combobox"
685
+ aria-haspopup="listbox"
686
+ aria-expanded={isOpen}
687
+ aria-controls={listboxId}
688
+ aria-activedescendant={activeOptionId}
689
+ aria-invalid={hasError || undefined}
690
+ aria-describedby={describedBy}
691
+ aria-multiselectable={multiple}
692
+ disabled={isDisabled}
693
+ onClick={() => (isOpen ? setIsOpen(false) : openDropdown())}
694
+ onKeyDown={handleTriggerKeyDown}
695
+ style={{
696
+ width: '100%',
697
+ display: 'flex',
698
+ alignItems: 'center',
699
+ justifyContent: 'space-between',
700
+ gap: 8,
701
+ padding: multiple && selectedValues.length > 0
702
+ ? `4px ${sz.px}`
703
+ : `${sz.py} ${sz.px}`,
704
+ fontSize: sz.fontSize,
705
+ fontFamily: 'inherit',
706
+ backgroundColor: theme.surface,
707
+ color: selectedValues.length > 0 ? theme.text : theme.textMuted,
708
+ border: `1.5px solid ${borderColor}`,
709
+ borderRadius: 8,
710
+ boxSizing: 'border-box',
711
+ transition: 'border-color 150ms',
712
+ textAlign: 'left',
713
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
714
+ outline: 'none',
715
+ minHeight: size === 'sm' ? 34 : size === 'lg' ? 50 : 42,
716
+ flexWrap: 'wrap',
717
+ }}
718
+ >
719
+ {/* Multi-select tags or single label */}
720
+ <span
721
+ style={{
722
+ display: 'flex',
723
+ alignItems: 'center',
724
+ flexWrap: 'wrap',
725
+ gap: 4,
726
+ flex: 1,
727
+ minWidth: 0,
728
+ }}
729
+ >
730
+ {multiple && selectedOptionObjects.length > 0
731
+ ? selectedOptionObjects.map((opt) => (
732
+ <span
733
+ key={opt.value}
734
+ style={{
735
+ display: 'inline-flex',
736
+ alignItems: 'center',
737
+ gap: 4,
738
+ padding: `${sz.tagPy} ${sz.tagPx}`,
739
+ fontSize: `calc(${sz.fontSize} - 1px)`,
740
+ fontFamily: 'inherit',
741
+ backgroundColor: `${theme.primary}22`,
742
+ color: theme.primary,
743
+ borderRadius: 6,
744
+ border: `1px solid ${theme.primary}44`,
745
+ lineHeight: 1.4,
746
+ maxWidth: 150,
747
+ overflow: 'hidden',
748
+ textOverflow: 'ellipsis',
749
+ whiteSpace: 'nowrap',
750
+ }}
751
+ >
752
+ <span
753
+ style={{
754
+ overflow: 'hidden',
755
+ textOverflow: 'ellipsis',
756
+ whiteSpace: 'nowrap',
757
+ maxWidth: 110,
758
+ }}
759
+ >
760
+ {sanitizeString(opt.label)}
761
+ </span>
762
+ <span
763
+ role="button"
764
+ aria-label={`Remove ${sanitizeString(opt.label)}`}
765
+ tabIndex={-1}
766
+ onClick={(e) => removeTag(opt.value, e)}
767
+ style={{
768
+ cursor: 'pointer',
769
+ flexShrink: 0,
770
+ display: 'flex',
771
+ alignItems: 'center',
772
+ opacity: 0.8,
773
+ }}
774
+ >
775
+ <svg
776
+ width="10"
777
+ height="10"
778
+ viewBox="0 0 24 24"
779
+ fill="none"
780
+ stroke="currentColor"
781
+ strokeWidth="2.5"
782
+ aria-hidden="true"
783
+ >
784
+ <path d="M18 6L6 18M6 6l12 12" />
785
+ </svg>
786
+ </span>
787
+ </span>
788
+ ))
789
+ : !multiple && selectedValues.length > 0
790
+ ? (() => {
791
+ const sel = options.find((o) => o.value === selectedValues[0]);
792
+ return (
793
+ <span
794
+ style={{
795
+ overflow: 'hidden',
796
+ textOverflow: 'ellipsis',
797
+ whiteSpace: 'nowrap',
798
+ display: 'flex',
799
+ alignItems: 'center',
800
+ gap: 6,
801
+ }}
802
+ >
803
+ {sel?.icon && (
804
+ <span style={{ flexShrink: 0, display: 'flex' }}>
805
+ {sel.icon}
806
+ </span>
807
+ )}
808
+ {sel ? sanitizeString(sel.label) : ''}
809
+ </span>
810
+ );
811
+ })()
812
+ : (
813
+ <span style={{ opacity: 0.6 }}>
814
+ {sanitizeString(placeholder)}
815
+ </span>
816
+ )}
817
+ </span>
818
+
819
+ {/* Right icons: clear + loading + chevron */}
820
+ <span
821
+ style={{
822
+ display: 'flex',
823
+ alignItems: 'center',
824
+ gap: 4,
825
+ flexShrink: 0,
826
+ color: theme.textMuted,
827
+ }}
828
+ >
829
+ {isLoading && <Spinner size={sz.iconSize} color={theme.primary} />}
830
+ {hasClearable && !isLoading && (
831
+ <span
832
+ role="button"
833
+ aria-label="Clear selection"
834
+ tabIndex={-1}
835
+ onClick={clearAll}
836
+ style={{
837
+ cursor: 'pointer',
838
+ display: 'flex',
839
+ alignItems: 'center',
840
+ lineHeight: 1,
841
+ opacity: 0.7,
842
+ transition: 'opacity 120ms',
843
+ }}
844
+ onMouseEnter={(e) =>
845
+ ((e.currentTarget as HTMLElement).style.opacity = '1')
846
+ }
847
+ onMouseLeave={(e) =>
848
+ ((e.currentTarget as HTMLElement).style.opacity = '0.7')
849
+ }
850
+ >
851
+ <svg
852
+ width={sz.iconSize}
853
+ height={sz.iconSize}
854
+ viewBox="0 0 24 24"
855
+ fill="none"
856
+ stroke="currentColor"
857
+ strokeWidth="2"
858
+ aria-hidden="true"
859
+ >
860
+ <path d="M18 6L6 18M6 6l12 12" />
861
+ </svg>
862
+ </span>
863
+ )}
864
+ <svg
865
+ width={sz.iconSize}
866
+ height={sz.iconSize}
867
+ viewBox="0 0 24 24"
868
+ fill="none"
869
+ stroke="currentColor"
870
+ strokeWidth="2"
871
+ aria-hidden="true"
872
+ style={{
873
+ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
874
+ transition: 'transform 150ms',
875
+ flexShrink: 0,
876
+ }}
877
+ >
878
+ <path d="M6 9l6 6 6-6" />
879
+ </svg>
880
+ </span>
881
+ </button>
882
+ </div>
883
+
884
+ {hint && !hasError && (
885
+ <span
886
+ id={hintId}
887
+ style={{ fontSize: '12px', color: theme.textMuted, fontFamily: 'inherit' }}
888
+ >
889
+ {sanitizeString(hint)}
890
+ </span>
891
+ )}
892
+ {hasError && errorMessage && (
893
+ <span
894
+ id={errorId}
895
+ role="alert"
896
+ style={{
897
+ fontSize: '12px',
898
+ color: theme.danger,
899
+ display: 'flex',
900
+ alignItems: 'center',
901
+ gap: 4,
902
+ fontFamily: 'inherit',
903
+ }}
904
+ >
905
+ <svg
906
+ width="12"
907
+ height="12"
908
+ viewBox="0 0 24 24"
909
+ fill="currentColor"
910
+ aria-hidden="true"
911
+ >
912
+ <path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
913
+ </svg>
914
+ {sanitizeString(errorMessage)}
915
+ </span>
916
+ )}
917
+
918
+ {dropdownEl}
919
+ </div>
920
+ );
921
+ }
922
+
923
+ TkxSelect.displayName = 'TkxSelect';