tekivex-ui 2.5.1 → 2.5.3

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 (80) hide show
  1. package/README.md +436 -83
  2. package/dist/TkxForm-DSMDo2Ol.js +388 -0
  3. package/dist/TkxForm-DwV_n-eE.cjs +12 -0
  4. package/dist/headless.cjs +1 -1
  5. package/dist/headless.js +396 -195
  6. package/dist/i18n.cjs +1 -0
  7. package/dist/i18n.js +757 -0
  8. package/dist/index.cjs +27 -22
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4994 -5089
  12. package/dist/quantum.cjs +91 -0
  13. package/dist/quantum.js +2312 -0
  14. package/dist/realtime.cjs +25 -0
  15. package/dist/realtime.js +944 -0
  16. package/dist/security-C369oOWH.cjs +1 -0
  17. package/dist/security-CzAmaMqa.js +97 -0
  18. package/dist/src/components/TkxDataGrid.d.ts +9 -1
  19. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  20. package/dist/src/components/TkxDropdown.d.ts +41 -0
  21. package/dist/src/components/TkxDropdown.d.ts.map +1 -0
  22. package/dist/src/components/TkxLiveFeed.d.ts +891 -0
  23. package/dist/src/components/TkxLiveFeed.d.ts.map +1 -0
  24. package/dist/src/components/TkxLiveLog.d.ts +892 -0
  25. package/dist/src/components/TkxLiveLog.d.ts.map +1 -0
  26. package/dist/src/components/TkxLiveMetrics.d.ts +24 -0
  27. package/dist/src/components/TkxLiveMetrics.d.ts.map +1 -0
  28. package/dist/src/components/TkxPlayground.d.ts +15 -0
  29. package/dist/src/components/TkxPlayground.d.ts.map +1 -0
  30. package/dist/src/components/TkxQuantumForm.d.ts +20 -0
  31. package/dist/src/components/TkxQuantumForm.d.ts.map +1 -0
  32. package/dist/src/components/TkxRealTimeChart.d.ts +27 -0
  33. package/dist/src/components/TkxRealTimeChart.d.ts.map +1 -0
  34. package/dist/src/components/TkxTable.d.ts +11 -1
  35. package/dist/src/components/TkxTable.d.ts.map +1 -1
  36. package/dist/src/components/TkxThemeBuilder.d.ts +9 -0
  37. package/dist/src/components/TkxThemeBuilder.d.ts.map +1 -0
  38. package/dist/src/components/index.d.ts +1 -0
  39. package/dist/src/components/index.d.ts.map +1 -1
  40. package/dist/src/engine/index.d.ts +2 -0
  41. package/dist/src/engine/index.d.ts.map +1 -1
  42. package/dist/src/engine/quantum-ai.d.ts +201 -0
  43. package/dist/src/engine/quantum-ai.d.ts.map +1 -0
  44. package/dist/src/engine/tkx.d.ts.map +1 -1
  45. package/dist/src/headless/index.d.ts +6 -0
  46. package/dist/src/headless/index.d.ts.map +1 -1
  47. package/dist/src/headless/useInfiniteQuery.d.ts +35 -0
  48. package/dist/src/headless/useInfiniteQuery.d.ts.map +1 -0
  49. package/dist/src/headless/useSSE.d.ts +36 -0
  50. package/dist/src/headless/useSSE.d.ts.map +1 -0
  51. package/dist/src/headless/useWebSocket.d.ts +33 -0
  52. package/dist/src/headless/useWebSocket.d.ts.map +1 -0
  53. package/dist/src/quantum/index.d.ts +9 -0
  54. package/dist/src/quantum/index.d.ts.map +1 -0
  55. package/dist/src/realtime/index.d.ts +9 -0
  56. package/dist/src/realtime/index.d.ts.map +1 -0
  57. package/dist/{TkxForm-BWK4LqY3.js → tkx-BJ10JtMv.js} +372 -721
  58. package/dist/tkx-BeNyC0w7.cjs +4 -0
  59. package/package.json +16 -1
  60. package/src/components/TkxDataGrid.tsx +72 -0
  61. package/src/components/TkxDropdown.tsx +802 -0
  62. package/src/components/TkxLiveFeed.tsx +329 -0
  63. package/src/components/TkxLiveLog.tsx +470 -0
  64. package/src/components/TkxLiveMetrics.tsx +291 -0
  65. package/src/components/TkxPlayground.tsx +736 -0
  66. package/src/components/TkxQuantumForm.tsx +592 -0
  67. package/src/components/TkxRealTimeChart.tsx +381 -0
  68. package/src/components/TkxTable.tsx +351 -77
  69. package/src/components/TkxThemeBuilder.tsx +685 -0
  70. package/src/components/index.ts +4 -0
  71. package/src/engine/index.ts +2 -0
  72. package/src/engine/quantum-ai.ts +1045 -0
  73. package/src/engine/tkx.ts +95 -4
  74. package/src/headless/index.ts +6 -0
  75. package/src/headless/useInfiniteQuery.ts +153 -0
  76. package/src/headless/useSSE.ts +165 -0
  77. package/src/headless/useWebSocket.ts +229 -0
  78. package/src/quantum/index.ts +39 -0
  79. package/src/realtime/index.ts +21 -0
  80. package/dist/TkxForm-ljQjX7KD.cjs +0 -15
@@ -0,0 +1,802 @@
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
+
15
+ // ── Types ──────────────────────────────────────────────────────────────────────
16
+
17
+ export interface DropdownItem {
18
+ key: string;
19
+ label: string;
20
+ icon?: ReactNode;
21
+ description?: string;
22
+ disabled?: boolean;
23
+ danger?: boolean;
24
+ shortcut?: string;
25
+ divider?: boolean;
26
+ children?: DropdownItem[];
27
+ badge?: string | number;
28
+ }
29
+
30
+ export interface DropdownGroup {
31
+ label?: string;
32
+ items: DropdownItem[];
33
+ }
34
+
35
+ export type DropdownPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'right' | 'left';
36
+
37
+ export interface TkxDropdownProps {
38
+ trigger: ReactNode;
39
+ items?: DropdownItem[];
40
+ groups?: DropdownGroup[];
41
+ placement?: DropdownPlacement;
42
+ onSelect?: (key: string, item: DropdownItem) => void;
43
+ selectedKeys?: string[];
44
+ multiSelect?: boolean;
45
+ searchable?: boolean;
46
+ searchPlaceholder?: string;
47
+ closeOnSelect?: boolean;
48
+ disabled?: boolean;
49
+ maxHeight?: number;
50
+ minWidth?: number;
51
+ open?: boolean;
52
+ onOpenChange?: (open: boolean) => void;
53
+ portal?: boolean;
54
+ offset?: number;
55
+ renderItem?: (item: DropdownItem, selected: boolean) => ReactNode;
56
+ }
57
+
58
+ // ── Positioning helpers ────────────────────────────────────────────────────────
59
+
60
+ interface MenuPosition {
61
+ top: number;
62
+ left: number;
63
+ minWidth: number;
64
+ }
65
+
66
+ function calcPosition(
67
+ triggerEl: HTMLElement,
68
+ menuEl: HTMLElement | null,
69
+ placement: DropdownPlacement,
70
+ offset: number,
71
+ ): MenuPosition {
72
+ const tr = triggerEl.getBoundingClientRect();
73
+ const mh = menuEl ? menuEl.offsetHeight : 320;
74
+ const mw = menuEl ? menuEl.offsetWidth : 200;
75
+ const vw = window.innerWidth;
76
+ const vh = window.innerHeight;
77
+ const scrollX = window.scrollX;
78
+ const scrollY = window.scrollY;
79
+
80
+ let top = 0;
81
+ let left = 0;
82
+
83
+ switch (placement) {
84
+ case 'bottom-start':
85
+ top = tr.bottom + scrollY + offset;
86
+ left = tr.left + scrollX;
87
+ break;
88
+ case 'bottom-end':
89
+ top = tr.bottom + scrollY + offset;
90
+ left = tr.right + scrollX - mw;
91
+ break;
92
+ case 'top-start':
93
+ top = tr.top + scrollY - offset - mh;
94
+ left = tr.left + scrollX;
95
+ break;
96
+ case 'top-end':
97
+ top = tr.top + scrollY - offset - mh;
98
+ left = tr.right + scrollX - mw;
99
+ break;
100
+ case 'right':
101
+ top = tr.top + scrollY;
102
+ left = tr.right + scrollX + offset;
103
+ break;
104
+ case 'left':
105
+ top = tr.top + scrollY;
106
+ left = tr.left + scrollX - offset - mw;
107
+ break;
108
+ default:
109
+ top = tr.bottom + scrollY + offset;
110
+ left = tr.left + scrollX;
111
+ }
112
+
113
+ // Flip if overflows viewport
114
+ if (placement.startsWith('bottom') && tr.bottom + mh > vh) {
115
+ top = tr.top + scrollY - offset - mh;
116
+ }
117
+ if (placement.startsWith('top') && tr.top - mh < 0) {
118
+ top = tr.bottom + scrollY + offset;
119
+ }
120
+ if (left + mw > vw + scrollX) {
121
+ left = vw + scrollX - mw - 8;
122
+ }
123
+ if (left < scrollX) {
124
+ left = scrollX + 8;
125
+ }
126
+
127
+ return { top, left, minWidth: tr.width };
128
+ }
129
+
130
+ // ── Text highlight helper ──────────────────────────────────────────────────────
131
+
132
+ function highlightText(text: string, query: string): ReactNode {
133
+ if (!query) return text;
134
+ const lower = text.toLowerCase();
135
+ const idx = lower.indexOf(query.toLowerCase());
136
+ if (idx === -1) return text;
137
+ return (
138
+ <>
139
+ {text.slice(0, idx)}
140
+ <mark style={{ background: 'rgba(0,245,212,0.3)', color: 'inherit', borderRadius: 2 }}>
141
+ {text.slice(idx, idx + query.length)}
142
+ </mark>
143
+ {text.slice(idx + query.length)}
144
+ </>
145
+ );
146
+ }
147
+
148
+ // ── Flatten items for keyboard nav ────────────────────────────────────────────
149
+
150
+ function flattenItems(groups: DropdownGroup[], query: string): DropdownItem[] {
151
+ const flat: DropdownItem[] = [];
152
+ for (const g of groups) {
153
+ for (const item of g.items) {
154
+ if (!item.disabled) {
155
+ if (!query || item.label.toLowerCase().includes(query.toLowerCase())) {
156
+ flat.push(item);
157
+ }
158
+ // Do not include submenu items in the top-level flat list for keyboard nav
159
+ }
160
+ }
161
+ }
162
+ return flat;
163
+ }
164
+
165
+ // ── DropdownMenu (the actual menu panel) ──────────────────────────────────────
166
+
167
+ interface DropdownMenuProps {
168
+ groups: DropdownGroup[];
169
+ position: MenuPosition;
170
+ maxHeight: number;
171
+ minWidth: number;
172
+ selectedKeys: string[];
173
+ multiSelect: boolean;
174
+ searchable: boolean;
175
+ searchPlaceholder: string;
176
+ onSelect: (key: string, item: DropdownItem) => void;
177
+ onClose: () => void;
178
+ renderItem?: (item: DropdownItem, selected: boolean) => ReactNode;
179
+ menuRef: React.RefObject<HTMLDivElement | null>;
180
+ isPortal: boolean;
181
+ menuId: string;
182
+ }
183
+
184
+ function DropdownMenu({
185
+ groups,
186
+ position,
187
+ maxHeight,
188
+ minWidth: propMinWidth,
189
+ selectedKeys,
190
+ multiSelect,
191
+ searchable,
192
+ searchPlaceholder,
193
+ onSelect,
194
+ onClose,
195
+ renderItem,
196
+ menuRef,
197
+ isPortal,
198
+ menuId,
199
+ }: DropdownMenuProps) {
200
+ const theme = useTheme();
201
+ const [query, setQuery] = useState('');
202
+ const [focusedIndex, setFocusedIndex] = useState(0);
203
+ const [openSubmenuKey, setOpenSubmenuKey] = useState<string | null>(null);
204
+ const [submenuFocusedIndex, setSubmenuFocusedIndex] = useState(0);
205
+ const searchRef = useRef<HTMLInputElement>(null);
206
+ const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
207
+ const submenuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
208
+
209
+ const filteredGroups: DropdownGroup[] = query
210
+ ? groups
211
+ .map((g) => ({
212
+ ...g,
213
+ items: g.items.filter(
214
+ (item) =>
215
+ item.label.toLowerCase().includes(query.toLowerCase()) ||
216
+ item.description?.toLowerCase().includes(query.toLowerCase()) ||
217
+ item.children?.some((c) => c.label.toLowerCase().includes(query.toLowerCase())),
218
+ ),
219
+ }))
220
+ .filter((g) => g.items.length > 0)
221
+ : groups;
222
+
223
+ const flatNavigable = flattenItems(filteredGroups, query);
224
+
225
+ useEffect(() => {
226
+ if (searchable && searchRef.current) {
227
+ searchRef.current.focus();
228
+ } else {
229
+ itemRefs.current[0]?.focus();
230
+ }
231
+ setFocusedIndex(0);
232
+ }, [searchable]);
233
+
234
+ // Sync focus to DOM
235
+ useEffect(() => {
236
+ if (!searchable || query !== '') {
237
+ itemRefs.current[focusedIndex]?.focus();
238
+ }
239
+ }, [focusedIndex, searchable, query]);
240
+
241
+ const handleKeyDown = useCallback(
242
+ (e: KeyboardEvent<HTMLDivElement>) => {
243
+ switch (e.key) {
244
+ case 'ArrowDown':
245
+ e.preventDefault();
246
+ if (openSubmenuKey) {
247
+ const submenuItems = flatNavigable.find((i) => i.key === openSubmenuKey)?.children ?? [];
248
+ const enabledSub = submenuItems.filter((i) => !i.disabled);
249
+ setSubmenuFocusedIndex((idx) => Math.min(idx + 1, enabledSub.length - 1));
250
+ } else {
251
+ setFocusedIndex((idx) => Math.min(idx + 1, flatNavigable.length - 1));
252
+ }
253
+ break;
254
+ case 'ArrowUp':
255
+ e.preventDefault();
256
+ if (openSubmenuKey) {
257
+ setSubmenuFocusedIndex((idx) => Math.max(idx - 1, 0));
258
+ } else {
259
+ setFocusedIndex((idx) => Math.max(idx - 1, 0));
260
+ }
261
+ break;
262
+ case 'ArrowRight': {
263
+ e.preventDefault();
264
+ const cur = flatNavigable[focusedIndex];
265
+ if (cur?.children?.length) {
266
+ setOpenSubmenuKey(cur.key);
267
+ setSubmenuFocusedIndex(0);
268
+ }
269
+ break;
270
+ }
271
+ case 'ArrowLeft':
272
+ e.preventDefault();
273
+ setOpenSubmenuKey(null);
274
+ break;
275
+ case 'Enter':
276
+ case ' ':
277
+ e.preventDefault();
278
+ if (openSubmenuKey) {
279
+ const submenuItems = flatNavigable.find((i) => i.key === openSubmenuKey)?.children ?? [];
280
+ const enabledSub = submenuItems.filter((i) => !i.disabled);
281
+ const subItem = enabledSub[submenuFocusedIndex];
282
+ if (subItem) onSelect(subItem.key, subItem);
283
+ } else {
284
+ const item = flatNavigable[focusedIndex];
285
+ if (item) onSelect(item.key, item);
286
+ }
287
+ break;
288
+ case 'Escape':
289
+ e.preventDefault();
290
+ if (openSubmenuKey) {
291
+ setOpenSubmenuKey(null);
292
+ } else {
293
+ onClose();
294
+ }
295
+ break;
296
+ case 'Tab':
297
+ onClose();
298
+ break;
299
+ default:
300
+ break;
301
+ }
302
+ },
303
+ [flatNavigable, focusedIndex, onClose, onSelect, openSubmenuKey, submenuFocusedIndex],
304
+ );
305
+
306
+ // Sync submenu focus
307
+ useEffect(() => {
308
+ if (openSubmenuKey) {
309
+ submenuItemRefs.current[submenuFocusedIndex]?.focus();
310
+ }
311
+ }, [submenuFocusedIndex, openSubmenuKey]);
312
+
313
+ const menuStyle: CSSProperties = {
314
+ position: isPortal ? 'fixed' : 'absolute',
315
+ top: isPortal ? position.top : undefined,
316
+ left: isPortal ? position.left : undefined,
317
+ minWidth: Math.max(propMinWidth, position.minWidth),
318
+ maxHeight,
319
+ overflowY: 'auto',
320
+ background: theme.surface,
321
+ border: `1px solid ${theme.border}`,
322
+ borderRadius: 8,
323
+ boxShadow: `0 8px 32px rgba(0,0,0,0.4)`,
324
+ zIndex: 9999,
325
+ outline: 'none',
326
+ transformOrigin: 'top left',
327
+ animation: 'tkx-dropdown-in 150ms ease forwards',
328
+ };
329
+
330
+ let navIdx = 0;
331
+
332
+ return (
333
+ <div
334
+ ref={menuRef as React.RefObject<HTMLDivElement>}
335
+ id={menuId}
336
+ role="menu"
337
+ aria-label="Dropdown menu"
338
+ tabIndex={-1}
339
+ style={menuStyle}
340
+ onKeyDown={handleKeyDown}
341
+ >
342
+ <style>{`
343
+ @keyframes tkx-dropdown-in {
344
+ from { opacity: 0; transform: scale(0.95); }
345
+ to { opacity: 1; transform: scale(1); }
346
+ }
347
+ `}</style>
348
+
349
+ {searchable && (
350
+ <div style={{ padding: '8px 8px 4px' }}>
351
+ <input
352
+ ref={searchRef}
353
+ type="text"
354
+ value={query}
355
+ onChange={(e) => {
356
+ setQuery(e.target.value);
357
+ setFocusedIndex(0);
358
+ }}
359
+ placeholder={searchPlaceholder}
360
+ aria-label="Search items"
361
+ style={{
362
+ width: '100%',
363
+ boxSizing: 'border-box',
364
+ padding: '6px 10px',
365
+ background: theme.surfaceAlt,
366
+ border: `1px solid ${theme.border}`,
367
+ borderRadius: 6,
368
+ color: theme.text,
369
+ fontSize: 13,
370
+ outline: 'none',
371
+ }}
372
+ />
373
+ </div>
374
+ )}
375
+
376
+ {filteredGroups.length === 0 && (
377
+ <div
378
+ style={{
379
+ padding: '12px 16px',
380
+ color: theme.textMuted,
381
+ fontSize: 13,
382
+ textAlign: 'center',
383
+ }}
384
+ >
385
+ No results found
386
+ </div>
387
+ )}
388
+
389
+ {filteredGroups.map((group, gi) => (
390
+ <div key={gi} role="group" aria-label={group.label}>
391
+ {group.label && (
392
+ <div
393
+ style={{
394
+ padding: '8px 12px 4px',
395
+ fontSize: 11,
396
+ fontWeight: 600,
397
+ letterSpacing: '0.06em',
398
+ textTransform: 'uppercase',
399
+ color: theme.textMuted,
400
+ }}
401
+ >
402
+ {sanitizeString(group.label)}
403
+ </div>
404
+ )}
405
+
406
+ {group.items.map((item) => {
407
+ const currentNavIdx = navIdx;
408
+ if (!item.disabled) navIdx++;
409
+
410
+ const isSelected = selectedKeys.includes(item.key);
411
+ const isFocused = currentNavIdx === focusedIndex && !item.disabled;
412
+ const hasSubmenu = !!item.children?.length;
413
+ const isSubmenuOpen = openSubmenuKey === item.key;
414
+
415
+ const itemContent = renderItem ? (
416
+ renderItem(item, isSelected)
417
+ ) : (
418
+ <DefaultItemContent
419
+ item={item}
420
+ isSelected={isSelected}
421
+ isFocused={isFocused}
422
+ multiSelect={multiSelect}
423
+ query={query}
424
+ theme={theme}
425
+ hasSubmenu={hasSubmenu}
426
+ />
427
+ );
428
+
429
+ return (
430
+ <div key={item.key}>
431
+ {item.divider && (
432
+ <div
433
+ role="separator"
434
+ style={{
435
+ height: 1,
436
+ background: theme.border,
437
+ margin: '4px 0',
438
+ }}
439
+ />
440
+ )}
441
+ <div
442
+ ref={(el) => {
443
+ itemRefs.current[currentNavIdx] = el;
444
+ }}
445
+ role="menuitem"
446
+ aria-disabled={item.disabled}
447
+ aria-checked={multiSelect ? isSelected : undefined}
448
+ aria-selected={!multiSelect ? isSelected : undefined}
449
+ aria-haspopup={hasSubmenu ? 'menu' : undefined}
450
+ aria-expanded={hasSubmenu ? isSubmenuOpen : undefined}
451
+ tabIndex={item.disabled ? -1 : 0}
452
+ style={{
453
+ position: 'relative',
454
+ cursor: item.disabled ? 'not-allowed' : 'pointer',
455
+ opacity: item.disabled ? 0.5 : 1,
456
+ }}
457
+ onClick={() => {
458
+ if (item.disabled) return;
459
+ if (hasSubmenu) {
460
+ setOpenSubmenuKey(isSubmenuOpen ? null : item.key);
461
+ setSubmenuFocusedIndex(0);
462
+ } else {
463
+ onSelect(item.key, item);
464
+ }
465
+ }}
466
+ onMouseEnter={() => {
467
+ if (!item.disabled) {
468
+ setFocusedIndex(currentNavIdx);
469
+ if (hasSubmenu) {
470
+ setOpenSubmenuKey(item.key);
471
+ setSubmenuFocusedIndex(0);
472
+ } else {
473
+ setOpenSubmenuKey(null);
474
+ }
475
+ }
476
+ }}
477
+ onFocus={() => {
478
+ if (!item.disabled) setFocusedIndex(currentNavIdx);
479
+ }}
480
+ >
481
+ {itemContent}
482
+
483
+ {/* Submenu panel */}
484
+ {hasSubmenu && isSubmenuOpen && (
485
+ <div
486
+ role="menu"
487
+ style={{
488
+ position: 'absolute',
489
+ top: 0,
490
+ left: '100%',
491
+ minWidth: 180,
492
+ background: theme.surface,
493
+ border: `1px solid ${theme.border}`,
494
+ borderRadius: 8,
495
+ boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
496
+ zIndex: 10000,
497
+ animation: 'tkx-dropdown-in 120ms ease forwards',
498
+ }}
499
+ >
500
+ {item.children!.map((child, ci) => {
501
+ const isChildSelected = selectedKeys.includes(child.key);
502
+ const isChildFocused = ci === submenuFocusedIndex;
503
+ return (
504
+ <div
505
+ key={child.key}
506
+ ref={(el) => {
507
+ submenuItemRefs.current[ci] = el;
508
+ }}
509
+ role="menuitem"
510
+ aria-disabled={child.disabled}
511
+ aria-selected={isChildSelected}
512
+ tabIndex={child.disabled ? -1 : 0}
513
+ style={{ cursor: child.disabled ? 'not-allowed' : 'pointer', opacity: child.disabled ? 0.5 : 1 }}
514
+ onClick={(e) => {
515
+ e.stopPropagation();
516
+ if (!child.disabled) onSelect(child.key, child);
517
+ }}
518
+ onMouseEnter={() => setSubmenuFocusedIndex(ci)}
519
+ >
520
+ <DefaultItemContent
521
+ item={child}
522
+ isSelected={isChildSelected}
523
+ isFocused={isChildFocused}
524
+ multiSelect={multiSelect}
525
+ query={query}
526
+ theme={theme}
527
+ hasSubmenu={false}
528
+ />
529
+ </div>
530
+ );
531
+ })}
532
+ </div>
533
+ )}
534
+ </div>
535
+ </div>
536
+ );
537
+ })}
538
+ </div>
539
+ ))}
540
+ </div>
541
+ );
542
+ }
543
+
544
+ // ── Default Item Content ───────────────────────────────────────────────────────
545
+
546
+ interface DefaultItemContentProps {
547
+ item: DropdownItem;
548
+ isSelected: boolean;
549
+ isFocused: boolean;
550
+ multiSelect: boolean;
551
+ query: string;
552
+ theme: import('../themes').ThemeTokens;
553
+ hasSubmenu: boolean;
554
+ }
555
+
556
+ function DefaultItemContent({ item, isSelected, isFocused, multiSelect, query, theme, hasSubmenu }: DefaultItemContentProps) {
557
+ const bg = isFocused
558
+ ? item.danger
559
+ ? `${theme.danger}20`
560
+ : `${theme.primary}18`
561
+ : 'transparent';
562
+
563
+ return (
564
+ <div
565
+ style={{
566
+ display: 'flex',
567
+ alignItems: 'center',
568
+ gap: 8,
569
+ padding: '8px 12px',
570
+ background: bg,
571
+ transition: 'background 80ms',
572
+ color: item.danger ? theme.danger : theme.text,
573
+ fontSize: 14,
574
+ userSelect: 'none',
575
+ }}
576
+ >
577
+ {/* Checkmark for multi-select */}
578
+ {multiSelect && (
579
+ <div
580
+ style={{
581
+ width: 16,
582
+ height: 16,
583
+ border: `2px solid ${isSelected ? theme.primary : theme.border}`,
584
+ borderRadius: 4,
585
+ background: isSelected ? theme.primary : 'transparent',
586
+ display: 'flex',
587
+ alignItems: 'center',
588
+ justifyContent: 'center',
589
+ flexShrink: 0,
590
+ transition: 'all 120ms',
591
+ }}
592
+ >
593
+ {isSelected && (
594
+ <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
595
+ <path d="M1 4l3 3L9 1" stroke={theme.bg} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
596
+ </svg>
597
+ )}
598
+ </div>
599
+ )}
600
+
601
+ {/* Icon */}
602
+ {item.icon && (
603
+ <span style={{ display: 'flex', alignItems: 'center', flexShrink: 0, color: item.danger ? theme.danger : theme.textMuted }}>
604
+ {item.icon}
605
+ </span>
606
+ )}
607
+
608
+ {/* Label + description */}
609
+ <div style={{ flex: 1, minWidth: 0 }}>
610
+ <div style={{ fontWeight: isSelected && !multiSelect ? 600 : 400 }}>
611
+ {highlightText(item.label, query)}
612
+ </div>
613
+ {item.description && (
614
+ <div style={{ fontSize: 12, color: theme.textMuted, marginTop: 1 }}>
615
+ {highlightText(item.description, query)}
616
+ </div>
617
+ )}
618
+ </div>
619
+
620
+ {/* Badge */}
621
+ {item.badge !== undefined && (
622
+ <span
623
+ style={{
624
+ background: theme.primary,
625
+ color: theme.bg,
626
+ fontSize: 11,
627
+ fontWeight: 700,
628
+ borderRadius: 10,
629
+ padding: '1px 7px',
630
+ flexShrink: 0,
631
+ }}
632
+ >
633
+ {item.badge}
634
+ </span>
635
+ )}
636
+
637
+ {/* Shortcut */}
638
+ {item.shortcut && !hasSubmenu && (
639
+ <span style={{ fontSize: 12, color: theme.textMuted, flexShrink: 0 }}>
640
+ {item.shortcut}
641
+ </span>
642
+ )}
643
+
644
+ {/* Submenu chevron */}
645
+ {hasSubmenu && (
646
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ flexShrink: 0 }}>
647
+ <path d="M4 2l4 4-4 4" stroke={theme.textMuted} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
648
+ </svg>
649
+ )}
650
+
651
+ {/* Selected indicator (single-select) */}
652
+ {!multiSelect && isSelected && (
653
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ flexShrink: 0 }}>
654
+ <path d="M2 7l4 4L12 3" stroke={theme.primary} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
655
+ </svg>
656
+ )}
657
+ </div>
658
+ );
659
+ }
660
+
661
+ // ── Main TkxDropdown ──────────────────────────────────────────────────────────
662
+
663
+ export function TkxDropdown({
664
+ trigger,
665
+ items,
666
+ groups,
667
+ placement = 'bottom-start',
668
+ onSelect,
669
+ selectedKeys: controlledSelectedKeys,
670
+ multiSelect = false,
671
+ searchable = false,
672
+ searchPlaceholder = 'Search…',
673
+ closeOnSelect,
674
+ disabled = false,
675
+ maxHeight = 320,
676
+ minWidth = 200,
677
+ open: controlledOpen,
678
+ onOpenChange,
679
+ portal = true,
680
+ offset = 6,
681
+ renderItem,
682
+ }: TkxDropdownProps) {
683
+ const [internalOpen, setInternalOpen] = useState(false);
684
+ const [internalSelectedKeys, setInternalSelectedKeys] = useState<string[]>([]);
685
+ const [menuPosition, setMenuPosition] = useState<MenuPosition>({ top: 0, left: 0, minWidth });
686
+
687
+ const triggerRef = useRef<HTMLDivElement>(null);
688
+ const menuRef = useRef<HTMLDivElement>(null);
689
+ const menuId = useId();
690
+
691
+ const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
692
+ const selectedKeys = controlledSelectedKeys !== undefined ? controlledSelectedKeys : internalSelectedKeys;
693
+
694
+ const shouldCloseOnSelect = closeOnSelect !== undefined ? closeOnSelect : !multiSelect;
695
+
696
+ // Normalize to groups
697
+ const resolvedGroups: DropdownGroup[] = groups ?? (items ? [{ items }] : [{ items: [] }]);
698
+
699
+ const setOpen = useCallback(
700
+ (next: boolean) => {
701
+ if (controlledOpen === undefined) setInternalOpen(next);
702
+ onOpenChange?.(next);
703
+ },
704
+ [controlledOpen, onOpenChange],
705
+ );
706
+
707
+ const handleSelect = useCallback(
708
+ (key: string, item: DropdownItem) => {
709
+ if (multiSelect && controlledSelectedKeys === undefined) {
710
+ setInternalSelectedKeys((prev) =>
711
+ prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
712
+ );
713
+ }
714
+ onSelect?.(key, item);
715
+ if (shouldCloseOnSelect) {
716
+ setOpen(false);
717
+ }
718
+ },
719
+ [multiSelect, controlledSelectedKeys, onSelect, shouldCloseOnSelect, setOpen],
720
+ );
721
+
722
+ const handleClose = useCallback(() => setOpen(false), [setOpen]);
723
+
724
+ // Calculate position when opening
725
+ useEffect(() => {
726
+ if (isOpen && triggerRef.current) {
727
+ const pos = calcPosition(triggerRef.current, menuRef.current, placement, offset);
728
+ setMenuPosition(pos);
729
+ }
730
+ }, [isOpen, placement, offset]);
731
+
732
+ // Recalculate after menu mounts (to get actual dimensions)
733
+ useEffect(() => {
734
+ if (isOpen && triggerRef.current && menuRef.current) {
735
+ const pos = calcPosition(triggerRef.current, menuRef.current, placement, offset);
736
+ setMenuPosition(pos);
737
+ }
738
+ }, [isOpen, placement, offset]);
739
+
740
+ // Outside click to close
741
+ useEffect(() => {
742
+ if (!isOpen) return;
743
+ const handler = (e: MouseEvent) => {
744
+ const target = e.target as Node;
745
+ if (
746
+ triggerRef.current &&
747
+ !triggerRef.current.contains(target) &&
748
+ menuRef.current &&
749
+ !menuRef.current.contains(target)
750
+ ) {
751
+ setOpen(false);
752
+ }
753
+ };
754
+ document.addEventListener('mousedown', handler);
755
+ return () => document.removeEventListener('mousedown', handler);
756
+ }, [isOpen, setOpen]);
757
+
758
+ const toggleOpen = () => {
759
+ if (!disabled) setOpen(!isOpen);
760
+ };
761
+
762
+ const menuContent = isOpen ? (
763
+ <DropdownMenu
764
+ groups={resolvedGroups}
765
+ position={menuPosition}
766
+ maxHeight={maxHeight}
767
+ minWidth={minWidth}
768
+ selectedKeys={selectedKeys}
769
+ multiSelect={multiSelect}
770
+ searchable={searchable}
771
+ searchPlaceholder={searchPlaceholder}
772
+ onSelect={handleSelect}
773
+ onClose={handleClose}
774
+ renderItem={renderItem}
775
+ menuRef={menuRef as React.RefObject<HTMLDivElement | null>}
776
+ isPortal={portal}
777
+ menuId={menuId}
778
+ />
779
+ ) : null;
780
+
781
+ return (
782
+ <div style={{ display: 'inline-block', position: 'relative' }}>
783
+ <div
784
+ ref={triggerRef}
785
+ onClick={toggleOpen}
786
+ aria-haspopup="menu"
787
+ aria-expanded={isOpen}
788
+ aria-controls={isOpen ? menuId : undefined}
789
+ aria-disabled={disabled}
790
+ style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1, display: 'inline-block' }}
791
+ >
792
+ {trigger}
793
+ </div>
794
+
795
+ {portal && typeof document !== 'undefined'
796
+ ? createPortal(menuContent, document.body)
797
+ : menuContent}
798
+ </div>
799
+ );
800
+ }
801
+
802
+ export default TkxDropdown;