tekivex-ui 2.0.2 → 2.2.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 (102) hide show
  1. package/dist/index.cjs +12 -12
  2. package/dist/index.d.ts +7 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2839 -2538
  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/TkxConfigProvider.d.ts +100 -0
  15. package/dist/src/components/TkxConfigProvider.d.ts.map +1 -0
  16. package/dist/src/components/TkxDataGrid.d.ts +33 -0
  17. package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
  18. package/dist/src/components/TkxEmpty.d.ts +870 -0
  19. package/dist/src/components/TkxEmpty.d.ts.map +1 -0
  20. package/dist/src/components/TkxForm.d.ts +54 -0
  21. package/dist/src/components/TkxForm.d.ts.map +1 -0
  22. package/dist/src/components/TkxLayout.d.ts +87 -0
  23. package/dist/src/components/TkxLayout.d.ts.map +1 -0
  24. package/dist/src/components/TkxMasonry.d.ts +12 -0
  25. package/dist/src/components/TkxMasonry.d.ts.map +1 -0
  26. package/dist/src/components/TkxModal.d.ts.map +1 -1
  27. package/dist/src/components/TkxPopover.d.ts +15 -0
  28. package/dist/src/components/TkxPopover.d.ts.map +1 -0
  29. package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
  30. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
  31. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  32. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  33. package/dist/src/components/TkxSnackbar.d.ts +16 -0
  34. package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
  35. package/dist/src/components/TkxSpeedDial.d.ts +18 -0
  36. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
  37. package/dist/src/components/TkxSpin.d.ts +15 -0
  38. package/dist/src/components/TkxSpin.d.ts.map +1 -0
  39. package/dist/src/components/TkxStatistic.d.ts +1746 -0
  40. package/dist/src/components/TkxStatistic.d.ts.map +1 -0
  41. package/dist/src/components/TkxToolbar.d.ts +22 -0
  42. package/dist/src/components/TkxToolbar.d.ts.map +1 -0
  43. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  44. package/dist/src/components/TkxTransferList.d.ts +20 -0
  45. package/dist/src/components/TkxTransferList.d.ts.map +1 -0
  46. package/dist/src/components/TkxTreeView.d.ts +23 -0
  47. package/dist/src/components/TkxTreeView.d.ts.map +1 -0
  48. package/dist/src/components/TkxTypography.d.ts +2614 -0
  49. package/dist/src/components/TkxTypography.d.ts.map +1 -0
  50. package/dist/src/components/index.d.ts +20 -0
  51. package/dist/src/components/index.d.ts.map +1 -1
  52. package/dist/src/i18n/I18nProvider.d.ts +10 -0
  53. package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
  54. package/dist/src/i18n/index.d.ts +50 -0
  55. package/dist/src/i18n/index.d.ts.map +1 -0
  56. package/dist/src/themes/index.d.ts +108 -0
  57. package/dist/src/themes/index.d.ts.map +1 -1
  58. package/package.json +2 -2
  59. package/src/components/TkxAppBar.tsx +260 -0
  60. package/src/components/TkxAutocomplete.tsx +453 -0
  61. package/src/components/TkxBottomNav.tsx +239 -0
  62. package/src/components/TkxBreadcrumb.tsx +322 -0
  63. package/src/components/TkxCard.tsx +3 -1
  64. package/src/components/TkxConfigProvider.tsx +458 -0
  65. package/src/components/TkxDataGrid.tsx +540 -0
  66. package/src/components/TkxEmpty.tsx +219 -0
  67. package/src/components/TkxForm.tsx +607 -0
  68. package/src/components/TkxLayout.tsx +647 -0
  69. package/src/components/TkxMasonry.tsx +176 -0
  70. package/src/components/TkxModal.tsx +4 -3
  71. package/src/components/TkxPopover.tsx +295 -0
  72. package/src/components/TkxRichTextDisplay.tsx +394 -0
  73. package/src/components/TkxSelect.tsx +2 -0
  74. package/src/components/TkxSlider.tsx +1 -0
  75. package/src/components/TkxSnackbar.tsx +314 -0
  76. package/src/components/TkxSpeedDial.tsx +395 -0
  77. package/src/components/TkxSpin.tsx +261 -0
  78. package/src/components/TkxStatistic.tsx +261 -0
  79. package/src/components/TkxToolbar.tsx +260 -0
  80. package/src/components/TkxTooltip.tsx +3 -2
  81. package/src/components/TkxTransferList.tsx +487 -0
  82. package/src/components/TkxTreeView.tsx +421 -0
  83. package/src/components/TkxTypography.tsx +263 -0
  84. package/src/components/index.ts +20 -0
  85. package/src/hooks/index.ts +1 -1
  86. package/src/i18n/I18nProvider.tsx +36 -0
  87. package/src/i18n/index.ts +237 -0
  88. package/src/themes/index.ts +149 -0
  89. package/dist/tests/TkxButton.test.d.ts +0 -2
  90. package/dist/tests/TkxButton.test.d.ts.map +0 -1
  91. package/dist/tests/a11y/components.test.d.ts +0 -2
  92. package/dist/tests/a11y/components.test.d.ts.map +0 -1
  93. package/dist/tests/quantum.test.d.ts +0 -2
  94. package/dist/tests/quantum.test.d.ts.map +0 -1
  95. package/dist/tests/security.test.d.ts +0 -2
  96. package/dist/tests/security.test.d.ts.map +0 -1
  97. package/dist/tests/setup.d.ts +0 -2
  98. package/dist/tests/setup.d.ts.map +0 -1
  99. package/dist/tests/tkx.test.d.ts +0 -2
  100. package/dist/tests/tkx.test.d.ts.map +0 -1
  101. package/dist/tests/wcag.test.d.ts +0 -2
  102. package/dist/tests/wcag.test.d.ts.map +0 -1
@@ -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;
@@ -0,0 +1,239 @@
1
+ import {
2
+ useCallback,
3
+ useRef,
4
+ useEffect,
5
+ type ReactNode,
6
+ type KeyboardEvent,
7
+ } from 'react';
8
+ import { useTheme } from '../themes';
9
+ import { sanitizeString } from '../engine/security';
10
+ import { useReducedMotion } from '../hooks';
11
+ import { tkx } from '../engine/tkx';
12
+
13
+ // ── Types ─────────────────────────────────────────────────────────────────────
14
+
15
+ export interface BottomNavItem {
16
+ id: string;
17
+ label: string;
18
+ icon: ReactNode;
19
+ badge?: number;
20
+ }
21
+
22
+ export interface TkxBottomNavProps {
23
+ items: BottomNavItem[];
24
+ activeId?: string;
25
+ onChange?: (id: string) => void;
26
+ showLabels?: boolean;
27
+ }
28
+
29
+ // ── Constants ─────────────────────────────────────────────────────────────────
30
+
31
+ const MAX_ITEMS = 5;
32
+ const NAV_HEIGHT = 56;
33
+
34
+ // ── Keyframe injection ──────────────────────────────────────────────────────
35
+
36
+ let injected = false;
37
+ function injectKeyframes() {
38
+ if (injected || typeof document === 'undefined') return;
39
+ injected = true;
40
+ const style = document.createElement('style');
41
+ style.id = 'tkx-bottom-nav-kf';
42
+ style.textContent = `
43
+ @keyframes tkx-bottom-nav-pulse {
44
+ 0% { transform: scale(1); }
45
+ 50% { transform: scale(1.15); }
46
+ 100% { transform: scale(1); }
47
+ }
48
+ `;
49
+ document.head.appendChild(style);
50
+ }
51
+
52
+ // ── Component ─────────────────────────────────────────────────────────────────
53
+
54
+ export function TkxBottomNav({
55
+ items,
56
+ activeId,
57
+ onChange,
58
+ showLabels = true,
59
+ }: TkxBottomNavProps) {
60
+ const theme = useTheme();
61
+ const reduced = useReducedMotion();
62
+ const navRef = useRef<HTMLElement>(null);
63
+
64
+ // Inject keyframes once
65
+ useEffect(() => {
66
+ injectKeyframes();
67
+ }, []);
68
+
69
+ // Clamp to max 5 items
70
+ const visibleItems = items.slice(0, MAX_ITEMS);
71
+
72
+ const handleSelect = useCallback(
73
+ (id: string) => {
74
+ onChange?.(id);
75
+ },
76
+ [onChange],
77
+ );
78
+
79
+ // ── Keyboard navigation (left/right arrows) ────────────────────────────
80
+
81
+ const handleKeyDown = useCallback(
82
+ (e: KeyboardEvent<HTMLElement>) => {
83
+ const nav = navRef.current;
84
+ if (!nav) return;
85
+
86
+ const buttons = Array.from(
87
+ nav.querySelectorAll<HTMLButtonElement>('[role="tab"]'),
88
+ );
89
+ const currentIndex = buttons.findIndex(
90
+ (btn) => btn === document.activeElement,
91
+ );
92
+ if (currentIndex === -1) return;
93
+
94
+ let nextIndex = currentIndex;
95
+
96
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
97
+ e.preventDefault();
98
+ nextIndex = (currentIndex + 1) % buttons.length;
99
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
100
+ e.preventDefault();
101
+ nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
102
+ } else if (e.key === 'Home') {
103
+ e.preventDefault();
104
+ nextIndex = 0;
105
+ } else if (e.key === 'End') {
106
+ e.preventDefault();
107
+ nextIndex = buttons.length - 1;
108
+ }
109
+
110
+ if (nextIndex !== currentIndex) {
111
+ buttons[nextIndex].focus();
112
+ handleSelect(visibleItems[nextIndex].id);
113
+ }
114
+ },
115
+ [handleSelect, visibleItems],
116
+ );
117
+
118
+ // ── Transition ──────────────────────────────────────────────────────────
119
+
120
+ const transition = reduced ? 'none' : 'color 200ms ease, transform 150ms ease';
121
+
122
+ return (
123
+ <nav
124
+ ref={navRef}
125
+ role="navigation"
126
+ aria-label="Bottom navigation"
127
+ className={tkx('fixed bottom-0 left-0 right-0 z-[1000] font-sans')}
128
+ style={{
129
+ height: NAV_HEIGHT,
130
+ backgroundColor: theme.surface,
131
+ borderTop: `1px solid ${theme.border}`,
132
+ boxShadow: `0 -2px 12px ${theme.bg}44`,
133
+ }}
134
+ >
135
+ <div
136
+ role="tablist"
137
+ aria-label="Navigation tabs"
138
+ className={tkx('flex items-center justify-around h-full')}
139
+ onKeyDown={handleKeyDown}
140
+ >
141
+ {visibleItems.map((item) => {
142
+ const isActive = item.id === activeId;
143
+ const safeLabel = sanitizeString(item.label);
144
+ const color = isActive ? theme.primary : theme.textMuted;
145
+
146
+ return (
147
+ <button
148
+ key={item.id}
149
+ role="tab"
150
+ aria-selected={isActive}
151
+ aria-label={safeLabel}
152
+ tabIndex={isActive ? 0 : -1}
153
+ onClick={() => handleSelect(item.id)}
154
+ className={tkx(
155
+ 'flex flex-col items-center justify-center gap-0.5',
156
+ 'bg-transparent border-none cursor-pointer p-1',
157
+ 'flex-1 h-full relative',
158
+ 'focus-visible:focus-ring',
159
+ )}
160
+ style={{
161
+ color,
162
+ transition,
163
+ outline: 'none',
164
+ }}
165
+ >
166
+ {/* Active indicator bar */}
167
+ {isActive && (
168
+ <div
169
+ aria-hidden="true"
170
+ style={{
171
+ position: 'absolute',
172
+ top: 0,
173
+ left: '25%',
174
+ right: '25%',
175
+ height: 3,
176
+ borderRadius: '0 0 3px 3px',
177
+ backgroundColor: theme.primary,
178
+ transition: reduced ? 'none' : 'width 200ms ease',
179
+ }}
180
+ />
181
+ )}
182
+
183
+ {/* Icon wrapper with badge */}
184
+ <div
185
+ className={tkx('relative flex items-center justify-center')}
186
+ style={{
187
+ animation:
188
+ isActive && !reduced
189
+ ? 'tkx-bottom-nav-pulse 300ms ease-out'
190
+ : 'none',
191
+ }}
192
+ >
193
+ {item.icon}
194
+
195
+ {/* Badge */}
196
+ {item.badge !== undefined && item.badge > 0 && (
197
+ <span
198
+ aria-label={`${item.badge} notifications`}
199
+ className={tkx(
200
+ 'absolute flex items-center justify-center',
201
+ 'text-[10px] font-bold leading-none rounded-full',
202
+ )}
203
+ style={{
204
+ top: -4,
205
+ right: -8,
206
+ minWidth: 16,
207
+ height: 16,
208
+ padding: '0 4px',
209
+ backgroundColor: theme.danger,
210
+ color: theme.bg,
211
+ }}
212
+ >
213
+ {item.badge > 99 ? '99+' : item.badge}
214
+ </span>
215
+ )}
216
+ </div>
217
+
218
+ {/* Label */}
219
+ {showLabels && (
220
+ <span
221
+ className={tkx('text-[10px] leading-tight mt-0.5')}
222
+ style={{
223
+ fontWeight: isActive ? 600 : 400,
224
+ maxWidth: '100%',
225
+ overflow: 'hidden',
226
+ textOverflow: 'ellipsis',
227
+ whiteSpace: 'nowrap',
228
+ }}
229
+ >
230
+ {safeLabel}
231
+ </span>
232
+ )}
233
+ </button>
234
+ );
235
+ })}
236
+ </div>
237
+ </nav>
238
+ );
239
+ }