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.
- package/dist/index.cjs +12 -12
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2839 -2538
- package/dist/src/components/TkxAppBar.d.ts +12 -0
- package/dist/src/components/TkxAppBar.d.ts.map +1 -0
- package/dist/src/components/TkxAutocomplete.d.ts +25 -0
- package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
- package/dist/src/components/TkxBottomNav.d.ts +15 -0
- package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
- package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
- package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
- package/dist/src/components/TkxCard.d.ts.map +1 -1
- package/dist/src/components/TkxConfigProvider.d.ts +100 -0
- package/dist/src/components/TkxConfigProvider.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +33 -0
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
- package/dist/src/components/TkxEmpty.d.ts +870 -0
- package/dist/src/components/TkxEmpty.d.ts.map +1 -0
- package/dist/src/components/TkxForm.d.ts +54 -0
- package/dist/src/components/TkxForm.d.ts.map +1 -0
- package/dist/src/components/TkxLayout.d.ts +87 -0
- package/dist/src/components/TkxLayout.d.ts.map +1 -0
- package/dist/src/components/TkxMasonry.d.ts +12 -0
- package/dist/src/components/TkxMasonry.d.ts.map +1 -0
- package/dist/src/components/TkxModal.d.ts.map +1 -1
- package/dist/src/components/TkxPopover.d.ts +15 -0
- package/dist/src/components/TkxPopover.d.ts.map +1 -0
- package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
- package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxSlider.d.ts.map +1 -1
- package/dist/src/components/TkxSnackbar.d.ts +16 -0
- package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
- package/dist/src/components/TkxSpeedDial.d.ts +18 -0
- package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
- package/dist/src/components/TkxSpin.d.ts +15 -0
- package/dist/src/components/TkxSpin.d.ts.map +1 -0
- package/dist/src/components/TkxStatistic.d.ts +1746 -0
- package/dist/src/components/TkxStatistic.d.ts.map +1 -0
- package/dist/src/components/TkxToolbar.d.ts +22 -0
- package/dist/src/components/TkxToolbar.d.ts.map +1 -0
- package/dist/src/components/TkxTooltip.d.ts.map +1 -1
- package/dist/src/components/TkxTransferList.d.ts +20 -0
- package/dist/src/components/TkxTransferList.d.ts.map +1 -0
- package/dist/src/components/TkxTreeView.d.ts +23 -0
- package/dist/src/components/TkxTreeView.d.ts.map +1 -0
- package/dist/src/components/TkxTypography.d.ts +2614 -0
- package/dist/src/components/TkxTypography.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +20 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/i18n/I18nProvider.d.ts +10 -0
- package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
- package/dist/src/i18n/index.d.ts +50 -0
- package/dist/src/i18n/index.d.ts.map +1 -0
- package/dist/src/themes/index.d.ts +108 -0
- package/dist/src/themes/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/TkxAppBar.tsx +260 -0
- package/src/components/TkxAutocomplete.tsx +453 -0
- package/src/components/TkxBottomNav.tsx +239 -0
- package/src/components/TkxBreadcrumb.tsx +322 -0
- package/src/components/TkxCard.tsx +3 -1
- package/src/components/TkxConfigProvider.tsx +458 -0
- package/src/components/TkxDataGrid.tsx +540 -0
- package/src/components/TkxEmpty.tsx +219 -0
- package/src/components/TkxForm.tsx +607 -0
- package/src/components/TkxLayout.tsx +647 -0
- package/src/components/TkxMasonry.tsx +176 -0
- package/src/components/TkxModal.tsx +4 -3
- package/src/components/TkxPopover.tsx +295 -0
- package/src/components/TkxRichTextDisplay.tsx +394 -0
- package/src/components/TkxSelect.tsx +2 -0
- package/src/components/TkxSlider.tsx +1 -0
- package/src/components/TkxSnackbar.tsx +314 -0
- package/src/components/TkxSpeedDial.tsx +395 -0
- package/src/components/TkxSpin.tsx +261 -0
- package/src/components/TkxStatistic.tsx +261 -0
- package/src/components/TkxToolbar.tsx +260 -0
- package/src/components/TkxTooltip.tsx +3 -2
- package/src/components/TkxTransferList.tsx +487 -0
- package/src/components/TkxTreeView.tsx +421 -0
- package/src/components/TkxTypography.tsx +263 -0
- package/src/components/index.ts +20 -0
- package/src/hooks/index.ts +1 -1
- package/src/i18n/I18nProvider.tsx +36 -0
- package/src/i18n/index.ts +237 -0
- package/src/themes/index.ts +149 -0
- package/dist/tests/TkxButton.test.d.ts +0 -2
- package/dist/tests/TkxButton.test.d.ts.map +0 -1
- package/dist/tests/a11y/components.test.d.ts +0 -2
- package/dist/tests/a11y/components.test.d.ts.map +0 -1
- package/dist/tests/quantum.test.d.ts +0 -2
- package/dist/tests/quantum.test.d.ts.map +0 -1
- package/dist/tests/security.test.d.ts +0 -2
- package/dist/tests/security.test.d.ts.map +0 -1
- package/dist/tests/setup.d.ts +0 -2
- package/dist/tests/setup.d.ts.map +0 -1
- package/dist/tests/tkx.test.d.ts +0 -2
- package/dist/tests/tkx.test.d.ts.map +0 -1
- package/dist/tests/wcag.test.d.ts +0 -2
- 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
|
+
}
|