tekivex-ui 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/index.cjs +12 -12
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2207 -2018
  5. package/dist/src/components/TkxAppBar.d.ts +12 -0
  6. package/dist/src/components/TkxAppBar.d.ts.map +1 -0
  7. package/dist/src/components/TkxAutocomplete.d.ts +25 -0
  8. package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
  9. package/dist/src/components/TkxBottomNav.d.ts +15 -0
  10. package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
  11. package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
  12. package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
  13. package/dist/src/components/TkxCard.d.ts.map +1 -1
  14. package/dist/src/components/TkxDataGrid.d.ts +33 -0
  15. package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
  16. package/dist/src/components/TkxMasonry.d.ts +12 -0
  17. package/dist/src/components/TkxMasonry.d.ts.map +1 -0
  18. package/dist/src/components/TkxModal.d.ts.map +1 -1
  19. package/dist/src/components/TkxPopover.d.ts +15 -0
  20. package/dist/src/components/TkxPopover.d.ts.map +1 -0
  21. package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
  22. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
  23. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  24. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  25. package/dist/src/components/TkxSnackbar.d.ts +16 -0
  26. package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
  27. package/dist/src/components/TkxSpeedDial.d.ts +18 -0
  28. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
  29. package/dist/src/components/TkxToolbar.d.ts +22 -0
  30. package/dist/src/components/TkxToolbar.d.ts.map +1 -0
  31. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  32. package/dist/src/components/TkxTransferList.d.ts +20 -0
  33. package/dist/src/components/TkxTransferList.d.ts.map +1 -0
  34. package/dist/src/components/TkxTreeView.d.ts +23 -0
  35. package/dist/src/components/TkxTreeView.d.ts.map +1 -0
  36. package/dist/src/components/index.d.ts +13 -0
  37. package/dist/src/components/index.d.ts.map +1 -1
  38. package/dist/src/i18n/I18nProvider.d.ts +10 -0
  39. package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
  40. package/dist/src/i18n/index.d.ts +50 -0
  41. package/dist/src/i18n/index.d.ts.map +1 -0
  42. package/package.json +2 -2
  43. package/src/components/TkxAppBar.tsx +260 -0
  44. package/src/components/TkxAutocomplete.tsx +453 -0
  45. package/src/components/TkxBottomNav.tsx +239 -0
  46. package/src/components/TkxBreadcrumb.tsx +322 -0
  47. package/src/components/TkxCard.tsx +3 -1
  48. package/src/components/TkxDataGrid.tsx +540 -0
  49. package/src/components/TkxMasonry.tsx +176 -0
  50. package/src/components/TkxModal.tsx +4 -3
  51. package/src/components/TkxPopover.tsx +295 -0
  52. package/src/components/TkxRichTextDisplay.tsx +394 -0
  53. package/src/components/TkxSelect.tsx +2 -0
  54. package/src/components/TkxSlider.tsx +1 -0
  55. package/src/components/TkxSnackbar.tsx +314 -0
  56. package/src/components/TkxSpeedDial.tsx +395 -0
  57. package/src/components/TkxToolbar.tsx +260 -0
  58. package/src/components/TkxTooltip.tsx +3 -2
  59. package/src/components/TkxTransferList.tsx +487 -0
  60. package/src/components/TkxTreeView.tsx +421 -0
  61. package/src/components/index.ts +13 -0
  62. package/src/hooks/index.ts +1 -1
  63. package/src/i18n/I18nProvider.tsx +36 -0
  64. package/src/i18n/index.ts +237 -0
  65. package/dist/tests/TkxButton.test.d.ts +0 -2
  66. package/dist/tests/TkxButton.test.d.ts.map +0 -1
  67. package/dist/tests/a11y/components.test.d.ts +0 -2
  68. package/dist/tests/a11y/components.test.d.ts.map +0 -1
  69. package/dist/tests/quantum.test.d.ts +0 -2
  70. package/dist/tests/quantum.test.d.ts.map +0 -1
  71. package/dist/tests/security.test.d.ts +0 -2
  72. package/dist/tests/security.test.d.ts.map +0 -1
  73. package/dist/tests/setup.d.ts +0 -2
  74. package/dist/tests/setup.d.ts.map +0 -1
  75. package/dist/tests/tkx.test.d.ts +0 -2
  76. package/dist/tests/tkx.test.d.ts.map +0 -1
  77. package/dist/tests/wcag.test.d.ts +0 -2
  78. package/dist/tests/wcag.test.d.ts.map +0 -1
@@ -0,0 +1,260 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useCallback,
5
+ useRef,
6
+ useId,
7
+ type ReactNode,
8
+ type CSSProperties,
9
+ } from 'react';
10
+ import { useTheme } from '../themes';
11
+ import { sanitizeString } from '../engine/security';
12
+ import { useReducedMotion, useEscapeKey } from '../hooks';
13
+ import { tkx } from '../engine/tkx';
14
+
15
+ // ── Types ─────────────────────────────────────────────────────────────────────
16
+
17
+ export interface TkxAppBarProps {
18
+ title?: ReactNode;
19
+ logo?: ReactNode;
20
+ actions?: ReactNode;
21
+ navigation?: ReactNode;
22
+ position?: 'fixed' | 'sticky' | 'static';
23
+ variant?: 'default' | 'transparent' | 'elevated';
24
+ color?: 'primary' | 'surface';
25
+ }
26
+
27
+ // ── Constants ─────────────────────────────────────────────────────────────────
28
+
29
+ const MOBILE_BREAKPOINT = 768;
30
+ const BAR_HEIGHT = 56;
31
+
32
+ // ── Hamburger icon ──────────────────────────────────────────────────────────
33
+
34
+ function HamburgerIcon({ open }: { open: boolean }) {
35
+ return (
36
+ <svg
37
+ width="24"
38
+ height="24"
39
+ viewBox="0 0 24 24"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth={2}
43
+ strokeLinecap="round"
44
+ aria-hidden="true"
45
+ >
46
+ {open ? (
47
+ <>
48
+ <line x1="18" y1="6" x2="6" y2="18" />
49
+ <line x1="6" y1="6" x2="18" y2="18" />
50
+ </>
51
+ ) : (
52
+ <>
53
+ <line x1="3" y1="6" x2="21" y2="6" />
54
+ <line x1="3" y1="12" x2="21" y2="12" />
55
+ <line x1="3" y1="18" x2="21" y2="18" />
56
+ </>
57
+ )}
58
+ </svg>
59
+ );
60
+ }
61
+
62
+ // ── Breakpoint hook ─────────────────────────────────────────────────────────
63
+
64
+ function useIsMobile(): boolean {
65
+ const [isMobile, setIsMobile] = useState(() => {
66
+ if (typeof window === 'undefined') return false;
67
+ return window.innerWidth < MOBILE_BREAKPOINT;
68
+ });
69
+
70
+ useEffect(() => {
71
+ if (typeof window === 'undefined') return;
72
+ const handler = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
73
+ window.addEventListener('resize', handler);
74
+ return () => window.removeEventListener('resize', handler);
75
+ }, []);
76
+
77
+ return isMobile;
78
+ }
79
+
80
+ // ── Component ─────────────────────────────────────────────────────────────────
81
+
82
+ export function TkxAppBar({
83
+ title,
84
+ logo,
85
+ actions,
86
+ navigation,
87
+ position = 'sticky',
88
+ variant = 'default',
89
+ color = 'surface',
90
+ }: TkxAppBarProps) {
91
+ const theme = useTheme();
92
+ const reduced = useReducedMotion();
93
+ const isMobile = useIsMobile();
94
+ const [menuOpen, setMenuOpen] = useState(false);
95
+ const menuRef = useRef<HTMLDivElement>(null);
96
+ const navId = useId();
97
+
98
+ // Close mobile menu on escape
99
+ useEscapeKey(() => setMenuOpen(false), menuOpen);
100
+
101
+ // Close on resize to desktop
102
+ useEffect(() => {
103
+ if (!isMobile && menuOpen) setMenuOpen(false);
104
+ }, [isMobile, menuOpen]);
105
+
106
+ // Close on outside click
107
+ useEffect(() => {
108
+ if (!menuOpen) return;
109
+ const handler = (e: PointerEvent) => {
110
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
111
+ setMenuOpen(false);
112
+ }
113
+ };
114
+ document.addEventListener('pointerdown', handler);
115
+ return () => document.removeEventListener('pointerdown', handler);
116
+ }, [menuOpen]);
117
+
118
+ const toggleMenu = useCallback(() => {
119
+ setMenuOpen((prev) => !prev);
120
+ }, []);
121
+
122
+ // ── Color resolution ────────────────────────────────────────────────────
123
+
124
+ const bgColor = color === 'primary' ? theme.primary : theme.surface;
125
+ const textColor = color === 'primary' ? theme.bg : theme.text;
126
+
127
+ // ── Variant styles ──────────────────────────────────────────────────────
128
+
129
+ const variantStyles: Record<string, CSSProperties> = {
130
+ default: {
131
+ backgroundColor: bgColor,
132
+ borderBottom: `1px solid ${theme.border}`,
133
+ },
134
+ transparent: {
135
+ backgroundColor: 'transparent',
136
+ borderBottom: 'none',
137
+ },
138
+ elevated: {
139
+ backgroundColor: bgColor,
140
+ boxShadow: `0 2px 8px ${theme.bg}66`,
141
+ borderBottom: 'none',
142
+ },
143
+ };
144
+
145
+ // ── Position styles ─────────────────────────────────────────────────────
146
+
147
+ const positionStyles: Record<string, CSSProperties> = {
148
+ fixed: { position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1100 },
149
+ sticky: { position: 'sticky', top: 0, zIndex: 1100 },
150
+ static: { position: 'static' },
151
+ };
152
+
153
+ const safeTitle = typeof title === 'string' ? sanitizeString(title) : title;
154
+
155
+ // ── Transition ──────────────────────────────────────────────────────────
156
+
157
+ const slideTransition = reduced
158
+ ? 'none'
159
+ : 'max-height 300ms cubic-bezier(0.4,0,0.2,1), opacity 300ms ease';
160
+
161
+ return (
162
+ <header
163
+ role="banner"
164
+ className={tkx('font-sans w-full')}
165
+ style={{
166
+ ...positionStyles[position],
167
+ ...variantStyles[variant],
168
+ color: textColor,
169
+ height: BAR_HEIGHT,
170
+ }}
171
+ >
172
+ <div
173
+ ref={menuRef}
174
+ className={tkx('flex items-center h-full px-4')}
175
+ style={{ maxWidth: 1440, margin: '0 auto' }}
176
+ >
177
+ {/* Logo area */}
178
+ {logo && (
179
+ <div
180
+ className={tkx('flex items-center shrink-0 mr-3')}
181
+ aria-hidden="true"
182
+ >
183
+ {logo}
184
+ </div>
185
+ )}
186
+
187
+ {/* Title */}
188
+ {safeTitle && (
189
+ <div
190
+ className={tkx('text-base font-semibold shrink-0 mr-4 leading-snug')}
191
+ style={{ color: textColor }}
192
+ >
193
+ {safeTitle}
194
+ </div>
195
+ )}
196
+
197
+ {/* Desktop navigation */}
198
+ {!isMobile && navigation && (
199
+ <nav
200
+ id={navId}
201
+ aria-label="Main navigation"
202
+ className={tkx('flex items-center flex-1 gap-1')}
203
+ >
204
+ {navigation}
205
+ </nav>
206
+ )}
207
+
208
+ {/* Spacer for mobile */}
209
+ {isMobile && <div className={tkx('flex-1')} />}
210
+
211
+ {/* Desktop actions */}
212
+ {actions && (
213
+ <div className={tkx('flex items-center gap-2 ml-auto shrink-0')}>
214
+ {actions}
215
+ </div>
216
+ )}
217
+
218
+ {/* Mobile hamburger */}
219
+ {isMobile && navigation && (
220
+ <button
221
+ onClick={toggleMenu}
222
+ aria-expanded={menuOpen}
223
+ aria-controls={navId}
224
+ aria-label={menuOpen ? 'Close navigation menu' : 'Open navigation menu'}
225
+ className={tkx(
226
+ 'bg-transparent border-none cursor-pointer p-2 ml-2 rounded flex items-center justify-center',
227
+ 'focus-visible:focus-ring',
228
+ )}
229
+ style={{ color: textColor }}
230
+ >
231
+ <HamburgerIcon open={menuOpen} />
232
+ </button>
233
+ )}
234
+ </div>
235
+
236
+ {/* Mobile dropdown menu */}
237
+ {isMobile && navigation && (
238
+ <nav
239
+ id={navId}
240
+ aria-label="Main navigation"
241
+ style={{
242
+ maxHeight: menuOpen ? 400 : 0,
243
+ opacity: menuOpen ? 1 : 0,
244
+ overflow: 'hidden',
245
+ transition: slideTransition,
246
+ backgroundColor: theme.surface,
247
+ borderBottom: menuOpen ? `1px solid ${theme.border}` : 'none',
248
+ }}
249
+ >
250
+ <div
251
+ className={tkx('flex flex-col gap-1 px-4 py-3')}
252
+ role="menu"
253
+ >
254
+ {navigation}
255
+ </div>
256
+ </nav>
257
+ )}
258
+ </header>
259
+ );
260
+ }
@@ -0,0 +1,453 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useCallback,
6
+ useId,
7
+ useMemo,
8
+ type ReactNode,
9
+ type CSSProperties,
10
+ type KeyboardEvent,
11
+ } from 'react';
12
+ import { createPortal } from 'react-dom';
13
+ import { useTheme } from '../themes';
14
+ import { sanitizeString } from '../engine/security';
15
+ import { useReducedMotion, useEscapeKey } from '../hooks';
16
+ import { tkx } from '../engine/tkx';
17
+
18
+ // ── Types ─────────────────────────────────────────────────────────────────────
19
+
20
+ export interface AutocompleteOption {
21
+ value: string;
22
+ label: string;
23
+ description?: string;
24
+ icon?: ReactNode;
25
+ disabled?: boolean;
26
+ }
27
+
28
+ export interface TkxAutocompleteProps {
29
+ options: AutocompleteOption[];
30
+ value?: string;
31
+ onChange?: (value: string) => void;
32
+ onInputChange?: (input: string) => void;
33
+ placeholder?: string;
34
+ label: string;
35
+ isLoading?: boolean;
36
+ emptyMessage?: string;
37
+ filterFn?: (option: AutocompleteOption, input: string) => boolean;
38
+ freeSolo?: boolean;
39
+ className?: string;
40
+ style?: CSSProperties;
41
+ }
42
+
43
+ // ── Default fuzzy filter ────────────────────────────────────────────────────
44
+
45
+ function defaultFilter(option: AutocompleteOption, input: string): boolean {
46
+ if (!input) return true;
47
+ const query = input.toLowerCase();
48
+ const label = option.label.toLowerCase();
49
+ // Fuzzy: check if all chars of query appear in order in label
50
+ let qi = 0;
51
+ for (let li = 0; li < label.length && qi < query.length; li++) {
52
+ if (label[li] === query[qi]) qi++;
53
+ }
54
+ return qi === query.length;
55
+ }
56
+
57
+ // ── Dropdown positioning ────────────────────────────────────────────────────
58
+
59
+ interface DropdownPos {
60
+ top: number;
61
+ left: number;
62
+ width: number;
63
+ placement: 'below' | 'above';
64
+ }
65
+
66
+ function calcDropdown(triggerEl: HTMLElement, maxHeight: number): DropdownPos {
67
+ const rect = triggerEl.getBoundingClientRect();
68
+ const gap = 4;
69
+ const spaceBelow = window.innerHeight - rect.bottom;
70
+ const spaceAbove = rect.top;
71
+
72
+ const placement = spaceBelow >= maxHeight + gap || spaceBelow >= spaceAbove ? 'below' : 'above';
73
+
74
+ return {
75
+ top:
76
+ placement === 'below'
77
+ ? rect.bottom + window.scrollY + gap
78
+ : rect.top + window.scrollY - maxHeight - gap,
79
+ left: rect.left + window.scrollX,
80
+ width: rect.width,
81
+ placement,
82
+ };
83
+ }
84
+
85
+ // ── Spinner ─────────────────────────────────────────────────────────────────
86
+
87
+ function Spinner({ color }: { color: string }) {
88
+ return (
89
+ <svg
90
+ width="16"
91
+ height="16"
92
+ viewBox="0 0 16 16"
93
+ fill="none"
94
+ aria-hidden="true"
95
+ style={{ animation: 'spin 0.8s linear infinite', flexShrink: 0 }}
96
+ >
97
+ <circle cx="8" cy="8" r="6" stroke={color} strokeWidth="2" opacity="0.3" />
98
+ <path
99
+ d="M14 8a6 6 0 00-6-6"
100
+ stroke={color}
101
+ strokeWidth="2"
102
+ strokeLinecap="round"
103
+ />
104
+ </svg>
105
+ );
106
+ }
107
+
108
+ // ── Component ───────────────────────────────────────────────────────────────
109
+
110
+ const MAX_DROPDOWN_HEIGHT = 280;
111
+
112
+ export function TkxAutocomplete({
113
+ options,
114
+ value,
115
+ onChange,
116
+ onInputChange,
117
+ placeholder = '',
118
+ label,
119
+ isLoading = false,
120
+ emptyMessage = 'No results found',
121
+ filterFn,
122
+ freeSolo = false,
123
+ className,
124
+ style,
125
+ }: TkxAutocompleteProps) {
126
+ const theme = useTheme();
127
+ const reducedMotion = useReducedMotion();
128
+ const inputId = useId();
129
+ const listboxId = useId();
130
+
131
+ const inputRef = useRef<HTMLInputElement>(null);
132
+ const wrapperRef = useRef<HTMLDivElement>(null);
133
+
134
+ const [inputValue, setInputValue] = useState('');
135
+ const [isOpen, setIsOpen] = useState(false);
136
+ const [activeIndex, setActiveIndex] = useState(-1);
137
+ const [dropdownPos, setDropdownPos] = useState<DropdownPos | null>(null);
138
+
139
+ // Sync input value from controlled value
140
+ useEffect(() => {
141
+ if (value !== undefined) {
142
+ const selectedOption = options.find((o) => o.value === value);
143
+ setInputValue(selectedOption ? selectedOption.label : value);
144
+ }
145
+ }, [value, options]);
146
+
147
+ const filter = filterFn ?? defaultFilter;
148
+
149
+ const filtered = useMemo(() => {
150
+ if (!isOpen) return [];
151
+ return options.filter((o) => filter(o, inputValue));
152
+ }, [options, inputValue, isOpen, filter]);
153
+
154
+ const safeLabel = sanitizeString(label);
155
+ const safeEmptyMessage = sanitizeString(emptyMessage);
156
+
157
+ // Position the dropdown
158
+ useEffect(() => {
159
+ if (!isOpen || !wrapperRef.current) return;
160
+ const update = () => {
161
+ if (!wrapperRef.current) return;
162
+ setDropdownPos(calcDropdown(wrapperRef.current, MAX_DROPDOWN_HEIGHT));
163
+ };
164
+ update();
165
+ window.addEventListener('scroll', update, true);
166
+ window.addEventListener('resize', update);
167
+ return () => {
168
+ window.removeEventListener('scroll', update, true);
169
+ window.removeEventListener('resize', update);
170
+ };
171
+ }, [isOpen]);
172
+
173
+ // Close on click outside
174
+ useEffect(() => {
175
+ if (!isOpen) return;
176
+ const handler = (e: PointerEvent) => {
177
+ const target = e.target as Node;
178
+ if (wrapperRef.current?.contains(target)) return;
179
+ // check portal dropdown
180
+ const dropdown = document.getElementById(listboxId);
181
+ if (dropdown?.contains(target)) return;
182
+ setIsOpen(false);
183
+ };
184
+ document.addEventListener('pointerdown', handler);
185
+ return () => document.removeEventListener('pointerdown', handler);
186
+ }, [isOpen, listboxId]);
187
+
188
+ useEscapeKey(() => setIsOpen(false), isOpen);
189
+
190
+ const selectOption = useCallback(
191
+ (option: AutocompleteOption) => {
192
+ if (option.disabled) return;
193
+ setInputValue(option.label);
194
+ onChange?.(option.value);
195
+ setIsOpen(false);
196
+ setActiveIndex(-1);
197
+ inputRef.current?.focus();
198
+ },
199
+ [onChange],
200
+ );
201
+
202
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
203
+ const val = e.target.value;
204
+ setInputValue(val);
205
+ onInputChange?.(val);
206
+ if (!isOpen) setIsOpen(true);
207
+ setActiveIndex(-1);
208
+ if (freeSolo) {
209
+ onChange?.(val);
210
+ }
211
+ };
212
+
213
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
214
+ if (!isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
215
+ setIsOpen(true);
216
+ return;
217
+ }
218
+
219
+ switch (e.key) {
220
+ case 'ArrowDown': {
221
+ e.preventDefault();
222
+ setActiveIndex((prev) => {
223
+ let next = prev + 1;
224
+ while (next < filtered.length && filtered[next].disabled) next++;
225
+ return next < filtered.length ? next : prev;
226
+ });
227
+ break;
228
+ }
229
+ case 'ArrowUp': {
230
+ e.preventDefault();
231
+ setActiveIndex((prev) => {
232
+ let next = prev - 1;
233
+ while (next >= 0 && filtered[next].disabled) next--;
234
+ return next >= 0 ? next : prev;
235
+ });
236
+ break;
237
+ }
238
+ case 'Enter': {
239
+ e.preventDefault();
240
+ if (activeIndex >= 0 && activeIndex < filtered.length) {
241
+ selectOption(filtered[activeIndex]);
242
+ } else if (freeSolo && inputValue) {
243
+ onChange?.(inputValue);
244
+ setIsOpen(false);
245
+ }
246
+ break;
247
+ }
248
+ case 'Escape': {
249
+ setIsOpen(false);
250
+ setActiveIndex(-1);
251
+ break;
252
+ }
253
+ }
254
+ };
255
+
256
+ // Scroll active option into view
257
+ useEffect(() => {
258
+ if (activeIndex < 0) return;
259
+ const optionId = `${listboxId}-opt-${activeIndex}`;
260
+ document.getElementById(optionId)?.scrollIntoView({ block: 'nearest' });
261
+ }, [activeIndex, listboxId]);
262
+
263
+ const activeDescendant =
264
+ activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined;
265
+
266
+ const animStyle: CSSProperties = reducedMotion
267
+ ? {}
268
+ : { animation: 'tkxAutoFadeIn 120ms ease forwards' };
269
+
270
+ const dropdown =
271
+ isOpen && typeof document !== 'undefined' && dropdownPos
272
+ ? createPortal(
273
+ <ul
274
+ id={listboxId}
275
+ role="listbox"
276
+ aria-label={safeLabel}
277
+ className={tkx('absolute z-[9200] list-none m-0 p-1 rounded-lg overflow-y-auto font-sans')}
278
+ style={{
279
+ top: dropdownPos.top,
280
+ left: dropdownPos.left,
281
+ width: dropdownPos.width,
282
+ maxHeight: MAX_DROPDOWN_HEIGHT,
283
+ backgroundColor: theme.surface,
284
+ border: `1px solid ${theme.border}`,
285
+ boxShadow: `0 8px 24px rgba(0,0,0,0.15)`,
286
+ ...animStyle,
287
+ }}
288
+ >
289
+ {isLoading ? (
290
+ <li
291
+ role="option"
292
+ aria-selected={false}
293
+ aria-disabled="true"
294
+ className={tkx('flex items-center gap-2 px-3 py-2 text-sm')}
295
+ style={{ color: theme.textMuted }}
296
+ >
297
+ <Spinner color={theme.primary} />
298
+ Loading...
299
+ </li>
300
+ ) : filtered.length === 0 ? (
301
+ <li
302
+ role="option"
303
+ aria-selected={false}
304
+ aria-disabled="true"
305
+ className={tkx('px-3 py-2 text-sm text-center')}
306
+ style={{ color: theme.textMuted }}
307
+ >
308
+ {safeEmptyMessage}
309
+ </li>
310
+ ) : (
311
+ filtered.map((option, idx) => {
312
+ const isActive = idx === activeIndex;
313
+ const safeOptionLabel = sanitizeString(option.label);
314
+ const safeDesc = option.description
315
+ ? sanitizeString(option.description)
316
+ : null;
317
+
318
+ return (
319
+ <li
320
+ key={option.value}
321
+ id={`${listboxId}-opt-${idx}`}
322
+ role="option"
323
+ aria-selected={option.value === value}
324
+ aria-disabled={option.disabled || undefined}
325
+ className={tkx(
326
+ 'flex items-center gap-2 px-3 py-2 text-sm rounded-md cursor-pointer',
327
+ option.disabled ? 'opacity-50 cursor-not-allowed' : '',
328
+ )}
329
+ style={{
330
+ backgroundColor: isActive ? theme.surfaceAlt : 'transparent',
331
+ color: option.disabled ? theme.textMuted : theme.text,
332
+ transition: reducedMotion ? 'none' : 'background-color 100ms ease',
333
+ }}
334
+ onMouseEnter={() => !option.disabled && setActiveIndex(idx)}
335
+ onMouseDown={(e) => {
336
+ e.preventDefault(); // keep focus on input
337
+ selectOption(option);
338
+ }}
339
+ >
340
+ {option.icon && (
341
+ <span aria-hidden="true" className={tkx('flex-shrink-0')}>
342
+ {option.icon}
343
+ </span>
344
+ )}
345
+ <span className={tkx('flex flex-col min-w-0')}>
346
+ <span className={tkx('truncate')}>{safeOptionLabel}</span>
347
+ {safeDesc && (
348
+ <span
349
+ className={tkx('text-xs truncate')}
350
+ style={{ color: theme.textMuted }}
351
+ >
352
+ {safeDesc}
353
+ </span>
354
+ )}
355
+ </span>
356
+ {option.value === value && (
357
+ <svg
358
+ width="16"
359
+ height="16"
360
+ viewBox="0 0 16 16"
361
+ fill="none"
362
+ aria-hidden="true"
363
+ className={tkx('ml-auto flex-shrink-0')}
364
+ >
365
+ <path
366
+ d="M3 8l3.5 3.5L13 5"
367
+ stroke={theme.primary}
368
+ strokeWidth="2"
369
+ strokeLinecap="round"
370
+ strokeLinejoin="round"
371
+ />
372
+ </svg>
373
+ )}
374
+ </li>
375
+ );
376
+ })
377
+ )}
378
+ </ul>,
379
+ document.body,
380
+ )
381
+ : null;
382
+
383
+ return (
384
+ <div
385
+ ref={wrapperRef}
386
+ className={tkx('relative font-sans', className ?? '')}
387
+ style={style}
388
+ >
389
+ {/* Label */}
390
+ <label
391
+ htmlFor={inputId}
392
+ className={tkx('block text-sm font-medium mb-1.5')}
393
+ style={{ color: theme.text }}
394
+ >
395
+ {safeLabel}
396
+ </label>
397
+
398
+ {/* Input */}
399
+ <div
400
+ className={tkx('relative flex items-center')}
401
+ style={{
402
+ backgroundColor: theme.surface,
403
+ border: `1px solid ${isOpen ? theme.primary : theme.border}`,
404
+ borderRadius: 8,
405
+ transition: reducedMotion ? 'none' : 'border-color 150ms ease',
406
+ }}
407
+ >
408
+ <input
409
+ ref={inputRef}
410
+ id={inputId}
411
+ type="text"
412
+ role="combobox"
413
+ aria-expanded={isOpen}
414
+ aria-controls={listboxId}
415
+ aria-activedescendant={activeDescendant}
416
+ aria-autocomplete="list"
417
+ aria-label={safeLabel}
418
+ value={inputValue}
419
+ placeholder={placeholder}
420
+ onChange={handleInputChange}
421
+ onFocus={() => setIsOpen(true)}
422
+ onKeyDown={handleKeyDown}
423
+ className={tkx(
424
+ 'w-full bg-transparent border-none outline-none text-sm py-2.5 px-3',
425
+ 'placeholder:opacity-50',
426
+ )}
427
+ style={{
428
+ color: theme.text,
429
+ }}
430
+ />
431
+ {isLoading && (
432
+ <span className={tkx('pr-3')}>
433
+ <Spinner color={theme.primary} />
434
+ </span>
435
+ )}
436
+ </div>
437
+
438
+ {dropdown}
439
+
440
+ {/* Keyframes */}
441
+ {isOpen && !reducedMotion && (
442
+ <style>{`
443
+ @keyframes tkxAutoFadeIn {
444
+ from { opacity: 0; transform: translateY(4px); }
445
+ to { opacity: 1; transform: translateY(0); }
446
+ }
447
+ `}</style>
448
+ )}
449
+ </div>
450
+ );
451
+ }
452
+
453
+ export default TkxAutocomplete;