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,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useId,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { createPortal } from 'react-dom';
|
|
11
|
+
import { useTheme } from '../themes';
|
|
12
|
+
import { sanitizeString } from '../engine/security';
|
|
13
|
+
import { useReducedMotion } from '../hooks';
|
|
14
|
+
import { tkx } from '../engine/tkx';
|
|
15
|
+
|
|
16
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface TkxSnackbarProps {
|
|
19
|
+
message: string;
|
|
20
|
+
isOpen: boolean;
|
|
21
|
+
onClose?: () => void;
|
|
22
|
+
action?: { label: string; onClick: () => void };
|
|
23
|
+
variant?: 'default' | 'success' | 'error' | 'warning' | 'info';
|
|
24
|
+
position?: 'bottom-left' | 'bottom-center' | 'bottom-right' | 'top-center';
|
|
25
|
+
autoHideDuration?: number;
|
|
26
|
+
icon?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const DEFAULT_DURATION = 5000;
|
|
32
|
+
|
|
33
|
+
// ── Keyframe injection ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
let injected = false;
|
|
36
|
+
function injectKeyframes() {
|
|
37
|
+
if (injected || typeof document === 'undefined') return;
|
|
38
|
+
injected = true;
|
|
39
|
+
const style = document.createElement('style');
|
|
40
|
+
style.id = 'tkx-snackbar-kf';
|
|
41
|
+
style.textContent = `
|
|
42
|
+
@keyframes tkx-snackbar-slide-up {
|
|
43
|
+
from { opacity: 0; transform: translateY(24px); }
|
|
44
|
+
to { opacity: 1; transform: translateY(0); }
|
|
45
|
+
}
|
|
46
|
+
@keyframes tkx-snackbar-slide-down {
|
|
47
|
+
from { opacity: 0; transform: translateY(-24px); }
|
|
48
|
+
to { opacity: 1; transform: translateY(0); }
|
|
49
|
+
}
|
|
50
|
+
@keyframes tkx-snackbar-fade-out {
|
|
51
|
+
from { opacity: 1; }
|
|
52
|
+
to { opacity: 0; transform: translateY(8px); }
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
document.head.appendChild(style);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Variant icons ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const VARIANT_ICONS: Record<string, ReactNode> = {
|
|
61
|
+
success: (
|
|
62
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
63
|
+
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-2 14.5l-4-4 1.41-1.41L10 13.67l6.59-6.59L18 8.5l-8 8z" />
|
|
64
|
+
</svg>
|
|
65
|
+
),
|
|
66
|
+
error: (
|
|
67
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
68
|
+
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
|
69
|
+
</svg>
|
|
70
|
+
),
|
|
71
|
+
warning: (
|
|
72
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
73
|
+
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
|
74
|
+
</svg>
|
|
75
|
+
),
|
|
76
|
+
info: (
|
|
77
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
78
|
+
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
|
|
79
|
+
</svg>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ── Position styles ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const POSITION_STYLES: Record<string, CSSProperties> = {
|
|
86
|
+
'bottom-left': {
|
|
87
|
+
bottom: 24,
|
|
88
|
+
left: 24,
|
|
89
|
+
},
|
|
90
|
+
'bottom-center': {
|
|
91
|
+
bottom: 24,
|
|
92
|
+
left: '50%',
|
|
93
|
+
transform: 'translateX(-50%)',
|
|
94
|
+
},
|
|
95
|
+
'bottom-right': {
|
|
96
|
+
bottom: 24,
|
|
97
|
+
right: 24,
|
|
98
|
+
},
|
|
99
|
+
'top-center': {
|
|
100
|
+
top: 24,
|
|
101
|
+
left: '50%',
|
|
102
|
+
transform: 'translateX(-50%)',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export function TkxSnackbar({
|
|
109
|
+
message,
|
|
110
|
+
isOpen,
|
|
111
|
+
onClose,
|
|
112
|
+
action,
|
|
113
|
+
variant = 'default',
|
|
114
|
+
position = 'bottom-center',
|
|
115
|
+
autoHideDuration = DEFAULT_DURATION,
|
|
116
|
+
icon,
|
|
117
|
+
}: TkxSnackbarProps) {
|
|
118
|
+
const theme = useTheme();
|
|
119
|
+
const reduced = useReducedMotion();
|
|
120
|
+
const labelId = useId();
|
|
121
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
122
|
+
const [visible, setVisible] = useState(false);
|
|
123
|
+
const [exiting, setExiting] = useState(false);
|
|
124
|
+
|
|
125
|
+
// Inject keyframes
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
injectKeyframes();
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
// ── Open / close lifecycle ──────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (isOpen) {
|
|
134
|
+
setExiting(false);
|
|
135
|
+
setVisible(true);
|
|
136
|
+
} else if (visible) {
|
|
137
|
+
// Begin exit animation
|
|
138
|
+
if (reduced) {
|
|
139
|
+
setVisible(false);
|
|
140
|
+
} else {
|
|
141
|
+
setExiting(true);
|
|
142
|
+
const exitTimer = setTimeout(() => {
|
|
143
|
+
setVisible(false);
|
|
144
|
+
setExiting(false);
|
|
145
|
+
}, 250);
|
|
146
|
+
return () => clearTimeout(exitTimer);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}, [isOpen, reduced, visible]);
|
|
150
|
+
|
|
151
|
+
// ── Auto-hide timer ─────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!isOpen || autoHideDuration <= 0 || !onClose) return;
|
|
155
|
+
|
|
156
|
+
timerRef.current = setTimeout(() => {
|
|
157
|
+
onClose();
|
|
158
|
+
}, autoHideDuration);
|
|
159
|
+
|
|
160
|
+
return () => {
|
|
161
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
162
|
+
};
|
|
163
|
+
}, [isOpen, autoHideDuration, onClose]);
|
|
164
|
+
|
|
165
|
+
// ── Pause timer on hover ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const handleMouseEnter = useCallback(() => {
|
|
168
|
+
if (timerRef.current) {
|
|
169
|
+
clearTimeout(timerRef.current);
|
|
170
|
+
timerRef.current = null;
|
|
171
|
+
}
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
const handleMouseLeave = useCallback(() => {
|
|
175
|
+
if (isOpen && autoHideDuration > 0 && onClose) {
|
|
176
|
+
timerRef.current = setTimeout(() => onClose(), autoHideDuration);
|
|
177
|
+
}
|
|
178
|
+
}, [isOpen, autoHideDuration, onClose]);
|
|
179
|
+
|
|
180
|
+
// ── Variant colors ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
const variantColorMap: Record<string, string> = {
|
|
183
|
+
default: theme.textMuted,
|
|
184
|
+
success: theme.success,
|
|
185
|
+
error: theme.danger,
|
|
186
|
+
warning: theme.warning,
|
|
187
|
+
info: theme.info,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const accentColor = variantColorMap[variant];
|
|
191
|
+
const safeMessage = sanitizeString(message);
|
|
192
|
+
|
|
193
|
+
// ── Don't render when not visible ───────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
if (!visible || typeof document === 'undefined') return null;
|
|
196
|
+
|
|
197
|
+
// ── Animation ───────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
const isTop = position === 'top-center';
|
|
200
|
+
const enterAnimation = isTop
|
|
201
|
+
? 'tkx-snackbar-slide-down'
|
|
202
|
+
: 'tkx-snackbar-slide-up';
|
|
203
|
+
|
|
204
|
+
let animationStyle: CSSProperties = {};
|
|
205
|
+
if (!reduced) {
|
|
206
|
+
if (exiting) {
|
|
207
|
+
animationStyle = {
|
|
208
|
+
animation: 'tkx-snackbar-fade-out 250ms ease forwards',
|
|
209
|
+
};
|
|
210
|
+
} else {
|
|
211
|
+
animationStyle = {
|
|
212
|
+
animation: `${enterAnimation} 250ms cubic-bezier(0.16,1,0.3,1) both`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Determine icon to show ──────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const displayIcon = icon ?? VARIANT_ICONS[variant] ?? null;
|
|
220
|
+
|
|
221
|
+
return createPortal(
|
|
222
|
+
<div
|
|
223
|
+
role={variant === 'error' ? 'alert' : 'status'}
|
|
224
|
+
aria-live={variant === 'error' ? 'assertive' : 'polite'}
|
|
225
|
+
aria-atomic="true"
|
|
226
|
+
aria-labelledby={labelId}
|
|
227
|
+
className={tkx('fixed z-[9500] font-sans')}
|
|
228
|
+
style={{
|
|
229
|
+
...POSITION_STYLES[position],
|
|
230
|
+
...animationStyle,
|
|
231
|
+
pointerEvents: 'auto',
|
|
232
|
+
}}
|
|
233
|
+
onMouseEnter={handleMouseEnter}
|
|
234
|
+
onMouseLeave={handleMouseLeave}
|
|
235
|
+
>
|
|
236
|
+
<div
|
|
237
|
+
className={tkx(
|
|
238
|
+
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg',
|
|
239
|
+
'min-w-[280px] max-w-[480px]',
|
|
240
|
+
)}
|
|
241
|
+
style={{
|
|
242
|
+
backgroundColor: theme.surface,
|
|
243
|
+
border: `1px solid ${accentColor}44`,
|
|
244
|
+
boxShadow: `0 8px 24px ${theme.bg}aa`,
|
|
245
|
+
color: theme.text,
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
{/* Icon */}
|
|
249
|
+
{displayIcon && (
|
|
250
|
+
<span
|
|
251
|
+
className={tkx('shrink-0 flex items-center')}
|
|
252
|
+
style={{ color: accentColor }}
|
|
253
|
+
>
|
|
254
|
+
{displayIcon}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Message */}
|
|
259
|
+
<span
|
|
260
|
+
id={labelId}
|
|
261
|
+
className={tkx('flex-1 text-sm leading-snug')}
|
|
262
|
+
>
|
|
263
|
+
{safeMessage}
|
|
264
|
+
</span>
|
|
265
|
+
|
|
266
|
+
{/* Action button */}
|
|
267
|
+
{action && (
|
|
268
|
+
<button
|
|
269
|
+
onClick={() => {
|
|
270
|
+
action.onClick();
|
|
271
|
+
onClose?.();
|
|
272
|
+
}}
|
|
273
|
+
className={tkx(
|
|
274
|
+
'bg-transparent border-none cursor-pointer p-0 ml-2',
|
|
275
|
+
'text-sm font-semibold shrink-0',
|
|
276
|
+
'focus-visible:focus-ring',
|
|
277
|
+
)}
|
|
278
|
+
style={{ color: theme.primary }}
|
|
279
|
+
>
|
|
280
|
+
{sanitizeString(action.label)}
|
|
281
|
+
</button>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{/* Close button */}
|
|
285
|
+
{onClose && (
|
|
286
|
+
<button
|
|
287
|
+
aria-label="Dismiss snackbar"
|
|
288
|
+
onClick={onClose}
|
|
289
|
+
className={tkx(
|
|
290
|
+
'bg-transparent border-none cursor-pointer rounded p-[2px] ml-1',
|
|
291
|
+
'shrink-0 flex items-center justify-center',
|
|
292
|
+
'focus-visible:focus-ring',
|
|
293
|
+
)}
|
|
294
|
+
style={{ color: theme.textMuted }}
|
|
295
|
+
>
|
|
296
|
+
<svg
|
|
297
|
+
width="14"
|
|
298
|
+
height="14"
|
|
299
|
+
viewBox="0 0 24 24"
|
|
300
|
+
fill="none"
|
|
301
|
+
stroke="currentColor"
|
|
302
|
+
strokeWidth={2.5}
|
|
303
|
+
aria-hidden="true"
|
|
304
|
+
>
|
|
305
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
306
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
307
|
+
</svg>
|
|
308
|
+
</button>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
</div>,
|
|
312
|
+
document.body,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
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
|
+
import { useReducedMotion, useEscapeKey } from '../hooks';
|
|
15
|
+
import { tkx } from '../engine/tkx';
|
|
16
|
+
|
|
17
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface SpeedDialAction {
|
|
20
|
+
id: string;
|
|
21
|
+
icon: ReactNode;
|
|
22
|
+
label: string;
|
|
23
|
+
onClick?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TkxSpeedDialProps {
|
|
27
|
+
actions: SpeedDialAction[];
|
|
28
|
+
icon?: ReactNode;
|
|
29
|
+
direction?: 'up' | 'down' | 'left' | 'right';
|
|
30
|
+
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
31
|
+
className?: string;
|
|
32
|
+
style?: CSSProperties;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Default plus icon ───────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function PlusIcon({ color, rotated, reducedMotion }: { color: string; rotated: boolean; reducedMotion: boolean }) {
|
|
38
|
+
return (
|
|
39
|
+
<svg
|
|
40
|
+
width="24"
|
|
41
|
+
height="24"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
style={{
|
|
46
|
+
transform: rotated ? 'rotate(45deg)' : 'rotate(0deg)',
|
|
47
|
+
transition: reducedMotion ? 'none' : 'transform 200ms ease',
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<path
|
|
51
|
+
d="M12 5v14M5 12h14"
|
|
52
|
+
stroke={color}
|
|
53
|
+
strokeWidth="2.5"
|
|
54
|
+
strokeLinecap="round"
|
|
55
|
+
/>
|
|
56
|
+
</svg>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Position styles ─────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
type PositionKey = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
63
|
+
|
|
64
|
+
const POSITION_STYLES: Record<PositionKey, CSSProperties> = {
|
|
65
|
+
'bottom-right': { bottom: 24, right: 24 },
|
|
66
|
+
'bottom-left': { bottom: 24, left: 24 },
|
|
67
|
+
'top-right': { top: 24, right: 24 },
|
|
68
|
+
'top-left': { top: 24, left: 24 },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ── Direction offsets for action placement ──────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function getActionOffset(
|
|
74
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
75
|
+
index: number,
|
|
76
|
+
spacing: number,
|
|
77
|
+
): CSSProperties {
|
|
78
|
+
const offset = (index + 1) * spacing;
|
|
79
|
+
switch (direction) {
|
|
80
|
+
case 'up':
|
|
81
|
+
return { bottom: offset, left: '50%', transform: 'translateX(-50%)' };
|
|
82
|
+
case 'down':
|
|
83
|
+
return { top: offset, left: '50%', transform: 'translateX(-50%)' };
|
|
84
|
+
case 'left':
|
|
85
|
+
return { right: offset, top: '50%', transform: 'translateY(-50%)' };
|
|
86
|
+
case 'right':
|
|
87
|
+
return { left: offset, top: '50%', transform: 'translateY(-50%)' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Tooltip placement relative to action button direction ───────────────────
|
|
92
|
+
|
|
93
|
+
function getTooltipPlacement(direction: 'up' | 'down' | 'left' | 'right'): CSSProperties {
|
|
94
|
+
switch (direction) {
|
|
95
|
+
case 'up':
|
|
96
|
+
case 'down':
|
|
97
|
+
return { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: 10 };
|
|
98
|
+
case 'left':
|
|
99
|
+
return { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 10 };
|
|
100
|
+
case 'right':
|
|
101
|
+
return { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 10 };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Component ───────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const MAIN_BUTTON_SIZE = 56;
|
|
108
|
+
const ACTION_BUTTON_SIZE = 48;
|
|
109
|
+
const SPACING = 64;
|
|
110
|
+
|
|
111
|
+
export function TkxSpeedDial({
|
|
112
|
+
actions,
|
|
113
|
+
icon,
|
|
114
|
+
direction = 'up',
|
|
115
|
+
position = 'bottom-right',
|
|
116
|
+
className,
|
|
117
|
+
style,
|
|
118
|
+
}: TkxSpeedDialProps) {
|
|
119
|
+
const theme = useTheme();
|
|
120
|
+
const reducedMotion = useReducedMotion();
|
|
121
|
+
const menuId = useId();
|
|
122
|
+
|
|
123
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
124
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
125
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
126
|
+
const actionRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
|
127
|
+
|
|
128
|
+
const toggle = useCallback(() => {
|
|
129
|
+
setIsOpen((prev) => !prev);
|
|
130
|
+
setFocusedIndex(-1);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const close = useCallback(() => {
|
|
134
|
+
setIsOpen(false);
|
|
135
|
+
setFocusedIndex(-1);
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
useEscapeKey(close, isOpen);
|
|
139
|
+
|
|
140
|
+
// Close on click outside
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!isOpen) return;
|
|
143
|
+
const handler = (e: PointerEvent) => {
|
|
144
|
+
if (!containerRef.current?.contains(e.target as Node)) {
|
|
145
|
+
close();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
document.addEventListener('pointerdown', handler);
|
|
149
|
+
return () => document.removeEventListener('pointerdown', handler);
|
|
150
|
+
}, [isOpen, close]);
|
|
151
|
+
|
|
152
|
+
// Focus management
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (focusedIndex >= 0) {
|
|
155
|
+
actionRefs.current.get(focusedIndex)?.focus();
|
|
156
|
+
}
|
|
157
|
+
}, [focusedIndex]);
|
|
158
|
+
|
|
159
|
+
const handleMainKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
160
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
toggle();
|
|
163
|
+
}
|
|
164
|
+
if (isOpen) {
|
|
165
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
setFocusedIndex(0);
|
|
168
|
+
}
|
|
169
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
setFocusedIndex(0);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleActionKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
177
|
+
switch (e.key) {
|
|
178
|
+
case 'ArrowUp':
|
|
179
|
+
case 'ArrowLeft': {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
setFocusedIndex(index > 0 ? index - 1 : actions.length - 1);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case 'ArrowDown':
|
|
185
|
+
case 'ArrowRight': {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
setFocusedIndex(index < actions.length - 1 ? index + 1 : 0);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'Enter':
|
|
191
|
+
case ' ': {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
actions[index].onClick?.();
|
|
194
|
+
close();
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case 'Escape': {
|
|
198
|
+
close();
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'Home': {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
setFocusedIndex(0);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'End': {
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
setFocusedIndex(actions.length - 1);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const getAnimationDelay = (index: number): string => {
|
|
215
|
+
if (reducedMotion) return '0ms';
|
|
216
|
+
return `${index * 40}ms`;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<>
|
|
221
|
+
{/* Backdrop */}
|
|
222
|
+
{isOpen && typeof document !== 'undefined' &&
|
|
223
|
+
createPortal(
|
|
224
|
+
<div
|
|
225
|
+
aria-hidden="true"
|
|
226
|
+
onClick={close}
|
|
227
|
+
style={{
|
|
228
|
+
position: 'fixed',
|
|
229
|
+
inset: 0,
|
|
230
|
+
backgroundColor: 'rgba(0,0,0,0.3)',
|
|
231
|
+
zIndex: 9000,
|
|
232
|
+
transition: reducedMotion ? 'none' : 'opacity 200ms ease',
|
|
233
|
+
}}
|
|
234
|
+
/>,
|
|
235
|
+
document.body,
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Speed dial container */}
|
|
239
|
+
<div
|
|
240
|
+
ref={containerRef}
|
|
241
|
+
className={tkx('fixed z-[9100] font-sans', className ?? '')}
|
|
242
|
+
style={{
|
|
243
|
+
...POSITION_STYLES[position],
|
|
244
|
+
...style,
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
{/* Action buttons */}
|
|
248
|
+
{isOpen &&
|
|
249
|
+
actions.map((action, index) => {
|
|
250
|
+
const safeLabel = sanitizeString(action.label);
|
|
251
|
+
const offsetStyle = getActionOffset(direction, index, SPACING);
|
|
252
|
+
const tooltipStyle = getTooltipPlacement(direction);
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<div
|
|
256
|
+
key={action.id}
|
|
257
|
+
className={tkx('absolute')}
|
|
258
|
+
style={{
|
|
259
|
+
...offsetStyle,
|
|
260
|
+
opacity: reducedMotion ? 1 : 0,
|
|
261
|
+
animation: reducedMotion
|
|
262
|
+
? 'none'
|
|
263
|
+
: `tkxSpeedDialIn 180ms ease forwards`,
|
|
264
|
+
animationDelay: getAnimationDelay(index),
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
<div className={tkx('relative inline-flex')}>
|
|
268
|
+
{/* Tooltip */}
|
|
269
|
+
<span
|
|
270
|
+
className={tkx(
|
|
271
|
+
'absolute z-10 whitespace-nowrap text-xs py-1 px-2 rounded-md pointer-events-none',
|
|
272
|
+
)}
|
|
273
|
+
style={{
|
|
274
|
+
...tooltipStyle,
|
|
275
|
+
backgroundColor: theme.surfaceAlt,
|
|
276
|
+
color: theme.text,
|
|
277
|
+
border: `1px solid ${theme.border}`,
|
|
278
|
+
boxShadow: `0 2px 8px rgba(0,0,0,0.15)`,
|
|
279
|
+
}}
|
|
280
|
+
role="tooltip"
|
|
281
|
+
>
|
|
282
|
+
{safeLabel}
|
|
283
|
+
</span>
|
|
284
|
+
|
|
285
|
+
{/* Action button */}
|
|
286
|
+
<button
|
|
287
|
+
ref={(el) => {
|
|
288
|
+
if (el) actionRefs.current.set(index, el);
|
|
289
|
+
else actionRefs.current.delete(index);
|
|
290
|
+
}}
|
|
291
|
+
type="button"
|
|
292
|
+
aria-label={safeLabel}
|
|
293
|
+
tabIndex={isOpen ? 0 : -1}
|
|
294
|
+
onClick={() => {
|
|
295
|
+
action.onClick?.();
|
|
296
|
+
close();
|
|
297
|
+
}}
|
|
298
|
+
onKeyDown={(e) => handleActionKeyDown(e, index)}
|
|
299
|
+
className={tkx(
|
|
300
|
+
'inline-flex items-center justify-center rounded-full border-none cursor-pointer',
|
|
301
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
|
302
|
+
'shadow-lg',
|
|
303
|
+
)}
|
|
304
|
+
style={{
|
|
305
|
+
width: ACTION_BUTTON_SIZE,
|
|
306
|
+
height: ACTION_BUTTON_SIZE,
|
|
307
|
+
backgroundColor: theme.surface,
|
|
308
|
+
color: theme.text,
|
|
309
|
+
border: `1px solid ${theme.border}`,
|
|
310
|
+
boxShadow: `0 4px 12px rgba(0,0,0,0.15)`,
|
|
311
|
+
transition: reducedMotion ? 'none' : 'all 150ms ease',
|
|
312
|
+
}}
|
|
313
|
+
onMouseEnter={(e) => {
|
|
314
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = theme.surfaceAlt;
|
|
315
|
+
(e.currentTarget as HTMLElement).style.transform = 'scale(1.1)';
|
|
316
|
+
}}
|
|
317
|
+
onMouseLeave={(e) => {
|
|
318
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = theme.surface;
|
|
319
|
+
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
{action.icon}
|
|
323
|
+
</button>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
})}
|
|
328
|
+
|
|
329
|
+
{/* Main FAB */}
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
|
|
333
|
+
aria-expanded={isOpen}
|
|
334
|
+
aria-controls={menuId}
|
|
335
|
+
aria-haspopup="true"
|
|
336
|
+
onClick={toggle}
|
|
337
|
+
onKeyDown={handleMainKeyDown}
|
|
338
|
+
className={tkx(
|
|
339
|
+
'inline-flex items-center justify-center rounded-full border-none cursor-pointer',
|
|
340
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
|
341
|
+
)}
|
|
342
|
+
style={{
|
|
343
|
+
width: MAIN_BUTTON_SIZE,
|
|
344
|
+
height: MAIN_BUTTON_SIZE,
|
|
345
|
+
backgroundColor: theme.primary,
|
|
346
|
+
color: theme.bg,
|
|
347
|
+
boxShadow: `0 6px 20px ${theme.primary}40`,
|
|
348
|
+
transition: reducedMotion ? 'none' : 'all 200ms ease',
|
|
349
|
+
position: 'relative',
|
|
350
|
+
zIndex: 1,
|
|
351
|
+
}}
|
|
352
|
+
onMouseEnter={(e) => {
|
|
353
|
+
(e.currentTarget as HTMLElement).style.boxShadow = `0 8px 28px ${theme.primary}60`;
|
|
354
|
+
if (!reducedMotion) {
|
|
355
|
+
(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)';
|
|
356
|
+
}
|
|
357
|
+
}}
|
|
358
|
+
onMouseLeave={(e) => {
|
|
359
|
+
(e.currentTarget as HTMLElement).style.boxShadow = `0 6px 20px ${theme.primary}40`;
|
|
360
|
+
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
{icon ?? (
|
|
364
|
+
<PlusIcon
|
|
365
|
+
color={theme.bg}
|
|
366
|
+
rotated={isOpen}
|
|
367
|
+
reducedMotion={reducedMotion}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
</button>
|
|
371
|
+
|
|
372
|
+
{/* Hidden menu region for a11y */}
|
|
373
|
+
<div id={menuId} role="menu" aria-label="Speed dial actions" style={{ display: 'none' }}>
|
|
374
|
+
{actions.map((action) => (
|
|
375
|
+
<div key={action.id} role="menuitem">
|
|
376
|
+
{sanitizeString(action.label)}
|
|
377
|
+
</div>
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
{/* Keyframes */}
|
|
383
|
+
{isOpen && !reducedMotion && (
|
|
384
|
+
<style>{`
|
|
385
|
+
@keyframes tkxSpeedDialIn {
|
|
386
|
+
from { opacity: 0; transform: translateX(-50%) scale(0.5); }
|
|
387
|
+
to { opacity: 1; transform: translateX(-50%) scale(1); }
|
|
388
|
+
}
|
|
389
|
+
`}</style>
|
|
390
|
+
)}
|
|
391
|
+
</>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export default TkxSpeedDial;
|