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.
- package/dist/index.cjs +12 -12
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2207 -2018
- 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/TkxDataGrid.d.ts +33 -0
- package/dist/src/components/TkxDataGrid.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/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/index.d.ts +13 -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/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/TkxDataGrid.tsx +540 -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/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/index.ts +13 -0
- package/src/hooks/index.ts +1 -1
- package/src/i18n/I18nProvider.tsx +36 -0
- package/src/i18n/index.ts +237 -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,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;
|