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.
Files changed (78) hide show
  1. package/dist/index.cjs +12 -12
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2207 -2018
  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/TkxDataGrid.d.ts +33 -0
  15. package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
  16. package/dist/src/components/TkxMasonry.d.ts +12 -0
  17. package/dist/src/components/TkxMasonry.d.ts.map +1 -0
  18. package/dist/src/components/TkxModal.d.ts.map +1 -1
  19. package/dist/src/components/TkxPopover.d.ts +15 -0
  20. package/dist/src/components/TkxPopover.d.ts.map +1 -0
  21. package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
  22. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
  23. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  24. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  25. package/dist/src/components/TkxSnackbar.d.ts +16 -0
  26. package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
  27. package/dist/src/components/TkxSpeedDial.d.ts +18 -0
  28. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
  29. package/dist/src/components/TkxToolbar.d.ts +22 -0
  30. package/dist/src/components/TkxToolbar.d.ts.map +1 -0
  31. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  32. package/dist/src/components/TkxTransferList.d.ts +20 -0
  33. package/dist/src/components/TkxTransferList.d.ts.map +1 -0
  34. package/dist/src/components/TkxTreeView.d.ts +23 -0
  35. package/dist/src/components/TkxTreeView.d.ts.map +1 -0
  36. package/dist/src/components/index.d.ts +13 -0
  37. package/dist/src/components/index.d.ts.map +1 -1
  38. package/dist/src/i18n/I18nProvider.d.ts +10 -0
  39. package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
  40. package/dist/src/i18n/index.d.ts +50 -0
  41. package/dist/src/i18n/index.d.ts.map +1 -0
  42. package/package.json +2 -2
  43. package/src/components/TkxAppBar.tsx +260 -0
  44. package/src/components/TkxAutocomplete.tsx +453 -0
  45. package/src/components/TkxBottomNav.tsx +239 -0
  46. package/src/components/TkxBreadcrumb.tsx +322 -0
  47. package/src/components/TkxCard.tsx +3 -1
  48. package/src/components/TkxDataGrid.tsx +540 -0
  49. package/src/components/TkxMasonry.tsx +176 -0
  50. package/src/components/TkxModal.tsx +4 -3
  51. package/src/components/TkxPopover.tsx +295 -0
  52. package/src/components/TkxRichTextDisplay.tsx +394 -0
  53. package/src/components/TkxSelect.tsx +2 -0
  54. package/src/components/TkxSlider.tsx +1 -0
  55. package/src/components/TkxSnackbar.tsx +314 -0
  56. package/src/components/TkxSpeedDial.tsx +395 -0
  57. package/src/components/TkxToolbar.tsx +260 -0
  58. package/src/components/TkxTooltip.tsx +3 -2
  59. package/src/components/TkxTransferList.tsx +487 -0
  60. package/src/components/TkxTreeView.tsx +421 -0
  61. package/src/components/index.ts +13 -0
  62. package/src/hooks/index.ts +1 -1
  63. package/src/i18n/I18nProvider.tsx +36 -0
  64. package/src/i18n/index.ts +237 -0
  65. package/dist/tests/TkxButton.test.d.ts +0 -2
  66. package/dist/tests/TkxButton.test.d.ts.map +0 -1
  67. package/dist/tests/a11y/components.test.d.ts +0 -2
  68. package/dist/tests/a11y/components.test.d.ts.map +0 -1
  69. package/dist/tests/quantum.test.d.ts +0 -2
  70. package/dist/tests/quantum.test.d.ts.map +0 -1
  71. package/dist/tests/security.test.d.ts +0 -2
  72. package/dist/tests/security.test.d.ts.map +0 -1
  73. package/dist/tests/setup.d.ts +0 -2
  74. package/dist/tests/setup.d.ts.map +0 -1
  75. package/dist/tests/tkx.test.d.ts +0 -2
  76. package/dist/tests/tkx.test.d.ts.map +0 -1
  77. package/dist/tests/wcag.test.d.ts +0 -2
  78. 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;