tekivex-ui 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/index.cjs +12 -12
  2. package/dist/index.d.ts +7 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2839 -2538
  5. package/dist/src/components/TkxAppBar.d.ts +12 -0
  6. package/dist/src/components/TkxAppBar.d.ts.map +1 -0
  7. package/dist/src/components/TkxAutocomplete.d.ts +25 -0
  8. package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
  9. package/dist/src/components/TkxBottomNav.d.ts +15 -0
  10. package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
  11. package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
  12. package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
  13. package/dist/src/components/TkxCard.d.ts.map +1 -1
  14. package/dist/src/components/TkxConfigProvider.d.ts +100 -0
  15. package/dist/src/components/TkxConfigProvider.d.ts.map +1 -0
  16. package/dist/src/components/TkxDataGrid.d.ts +33 -0
  17. package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
  18. package/dist/src/components/TkxEmpty.d.ts +870 -0
  19. package/dist/src/components/TkxEmpty.d.ts.map +1 -0
  20. package/dist/src/components/TkxForm.d.ts +54 -0
  21. package/dist/src/components/TkxForm.d.ts.map +1 -0
  22. package/dist/src/components/TkxLayout.d.ts +87 -0
  23. package/dist/src/components/TkxLayout.d.ts.map +1 -0
  24. package/dist/src/components/TkxMasonry.d.ts +12 -0
  25. package/dist/src/components/TkxMasonry.d.ts.map +1 -0
  26. package/dist/src/components/TkxModal.d.ts.map +1 -1
  27. package/dist/src/components/TkxPopover.d.ts +15 -0
  28. package/dist/src/components/TkxPopover.d.ts.map +1 -0
  29. package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
  30. package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
  31. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  32. package/dist/src/components/TkxSlider.d.ts.map +1 -1
  33. package/dist/src/components/TkxSnackbar.d.ts +16 -0
  34. package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
  35. package/dist/src/components/TkxSpeedDial.d.ts +18 -0
  36. package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
  37. package/dist/src/components/TkxSpin.d.ts +15 -0
  38. package/dist/src/components/TkxSpin.d.ts.map +1 -0
  39. package/dist/src/components/TkxStatistic.d.ts +1746 -0
  40. package/dist/src/components/TkxStatistic.d.ts.map +1 -0
  41. package/dist/src/components/TkxToolbar.d.ts +22 -0
  42. package/dist/src/components/TkxToolbar.d.ts.map +1 -0
  43. package/dist/src/components/TkxTooltip.d.ts.map +1 -1
  44. package/dist/src/components/TkxTransferList.d.ts +20 -0
  45. package/dist/src/components/TkxTransferList.d.ts.map +1 -0
  46. package/dist/src/components/TkxTreeView.d.ts +23 -0
  47. package/dist/src/components/TkxTreeView.d.ts.map +1 -0
  48. package/dist/src/components/TkxTypography.d.ts +2614 -0
  49. package/dist/src/components/TkxTypography.d.ts.map +1 -0
  50. package/dist/src/components/index.d.ts +20 -0
  51. package/dist/src/components/index.d.ts.map +1 -1
  52. package/dist/src/i18n/I18nProvider.d.ts +10 -0
  53. package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
  54. package/dist/src/i18n/index.d.ts +50 -0
  55. package/dist/src/i18n/index.d.ts.map +1 -0
  56. package/dist/src/themes/index.d.ts +108 -0
  57. package/dist/src/themes/index.d.ts.map +1 -1
  58. package/package.json +2 -2
  59. package/src/components/TkxAppBar.tsx +260 -0
  60. package/src/components/TkxAutocomplete.tsx +453 -0
  61. package/src/components/TkxBottomNav.tsx +239 -0
  62. package/src/components/TkxBreadcrumb.tsx +322 -0
  63. package/src/components/TkxCard.tsx +3 -1
  64. package/src/components/TkxConfigProvider.tsx +458 -0
  65. package/src/components/TkxDataGrid.tsx +540 -0
  66. package/src/components/TkxEmpty.tsx +219 -0
  67. package/src/components/TkxForm.tsx +607 -0
  68. package/src/components/TkxLayout.tsx +647 -0
  69. package/src/components/TkxMasonry.tsx +176 -0
  70. package/src/components/TkxModal.tsx +4 -3
  71. package/src/components/TkxPopover.tsx +295 -0
  72. package/src/components/TkxRichTextDisplay.tsx +394 -0
  73. package/src/components/TkxSelect.tsx +2 -0
  74. package/src/components/TkxSlider.tsx +1 -0
  75. package/src/components/TkxSnackbar.tsx +314 -0
  76. package/src/components/TkxSpeedDial.tsx +395 -0
  77. package/src/components/TkxSpin.tsx +261 -0
  78. package/src/components/TkxStatistic.tsx +261 -0
  79. package/src/components/TkxToolbar.tsx +260 -0
  80. package/src/components/TkxTooltip.tsx +3 -2
  81. package/src/components/TkxTransferList.tsx +487 -0
  82. package/src/components/TkxTreeView.tsx +421 -0
  83. package/src/components/TkxTypography.tsx +263 -0
  84. package/src/components/index.ts +20 -0
  85. package/src/hooks/index.ts +1 -1
  86. package/src/i18n/I18nProvider.tsx +36 -0
  87. package/src/i18n/index.ts +237 -0
  88. package/src/themes/index.ts +149 -0
  89. package/dist/tests/TkxButton.test.d.ts +0 -2
  90. package/dist/tests/TkxButton.test.d.ts.map +0 -1
  91. package/dist/tests/a11y/components.test.d.ts +0 -2
  92. package/dist/tests/a11y/components.test.d.ts.map +0 -1
  93. package/dist/tests/quantum.test.d.ts +0 -2
  94. package/dist/tests/quantum.test.d.ts.map +0 -1
  95. package/dist/tests/security.test.d.ts +0 -2
  96. package/dist/tests/security.test.d.ts.map +0 -1
  97. package/dist/tests/setup.d.ts +0 -2
  98. package/dist/tests/setup.d.ts.map +0 -1
  99. package/dist/tests/tkx.test.d.ts +0 -2
  100. package/dist/tests/tkx.test.d.ts.map +0 -1
  101. package/dist/tests/wcag.test.d.ts +0 -2
  102. package/dist/tests/wcag.test.d.ts.map +0 -1
@@ -0,0 +1,176 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useMemo,
5
+ Children,
6
+ type ReactNode,
7
+ type CSSProperties,
8
+ } from 'react';
9
+ import { useTheme } from '../themes';
10
+ import { sanitizeString } from '../engine/security';
11
+ import { useReducedMotion } from '../hooks';
12
+ import { tkx } from '../engine/tkx';
13
+
14
+ // ── Types ─────────────────────────────────────────────────────────────────────
15
+
16
+ export interface TkxMasonryProps {
17
+ columns?: number | { sm?: number; md?: number; lg?: number };
18
+ gap?: number;
19
+ children: ReactNode;
20
+ }
21
+
22
+ // ── Breakpoint constants ────────────────────────────────────────────────────
23
+
24
+ const BREAKPOINTS = {
25
+ sm: 0,
26
+ md: 768,
27
+ lg: 1024,
28
+ };
29
+
30
+ // ── Breakpoint hook ─────────────────────────────────────────────────────────
31
+
32
+ function useBreakpoint(): 'sm' | 'md' | 'lg' {
33
+ const [bp, setBp] = useState<'sm' | 'md' | 'lg'>(() => {
34
+ if (typeof window === 'undefined') return 'lg';
35
+ const w = window.innerWidth;
36
+ if (w >= BREAKPOINTS.lg) return 'lg';
37
+ if (w >= BREAKPOINTS.md) return 'md';
38
+ return 'sm';
39
+ });
40
+
41
+ useEffect(() => {
42
+ if (typeof window === 'undefined') return;
43
+
44
+ const handler = () => {
45
+ const w = window.innerWidth;
46
+ if (w >= BREAKPOINTS.lg) setBp('lg');
47
+ else if (w >= BREAKPOINTS.md) setBp('md');
48
+ else setBp('sm');
49
+ };
50
+
51
+ window.addEventListener('resize', handler);
52
+ return () => window.removeEventListener('resize', handler);
53
+ }, []);
54
+
55
+ return bp;
56
+ }
57
+
58
+ // ── Resolve column count ────────────────────────────────────────────────────
59
+
60
+ function resolveColumns(
61
+ columns: number | { sm?: number; md?: number; lg?: number } | undefined,
62
+ breakpoint: 'sm' | 'md' | 'lg',
63
+ ): number {
64
+ if (columns === undefined) return 3;
65
+ if (typeof columns === 'number') return Math.max(1, columns);
66
+
67
+ // Resolve responsive: fall back from current breakpoint to smaller ones
68
+ if (breakpoint === 'lg') {
69
+ return columns.lg ?? columns.md ?? columns.sm ?? 3;
70
+ }
71
+ if (breakpoint === 'md') {
72
+ return columns.md ?? columns.sm ?? 2;
73
+ }
74
+ return columns.sm ?? 1;
75
+ }
76
+
77
+ // ── Keyframe injection ──────────────────────────────────────────────────────
78
+
79
+ let injected = false;
80
+ function injectKeyframes() {
81
+ if (injected || typeof document === 'undefined') return;
82
+ injected = true;
83
+ const style = document.createElement('style');
84
+ style.id = 'tkx-masonry-kf';
85
+ style.textContent = `
86
+ @keyframes tkx-masonry-fade-in {
87
+ from { opacity: 0; transform: translateY(8px); }
88
+ to { opacity: 1; transform: translateY(0); }
89
+ }
90
+ `;
91
+ document.head.appendChild(style);
92
+ }
93
+
94
+ // ── Component ─────────────────────────────────────────────────────────────────
95
+
96
+ export function TkxMasonry({
97
+ columns,
98
+ gap = 16,
99
+ children,
100
+ }: TkxMasonryProps) {
101
+ const theme = useTheme();
102
+ const reduced = useReducedMotion();
103
+ const breakpoint = useBreakpoint();
104
+
105
+ // Inject keyframes once
106
+ useEffect(() => {
107
+ injectKeyframes();
108
+ }, []);
109
+
110
+ const colCount = resolveColumns(columns, breakpoint);
111
+
112
+ // ── Distribute children into columns ────────────────────────────────────
113
+
114
+ const childArray = Children.toArray(children);
115
+
116
+ const columnBuckets = useMemo(() => {
117
+ const buckets: ReactNode[][] = Array.from({ length: colCount }, () => []);
118
+
119
+ childArray.forEach((child, i) => {
120
+ // Round-robin distribution to approximate masonry layout
121
+ buckets[i % colCount].push(child);
122
+ });
123
+
124
+ return buckets;
125
+ }, [childArray, colCount]);
126
+
127
+ // ── Animation style per item ────────────────────────────────────────────
128
+
129
+ const itemAnimation = (index: number): CSSProperties => {
130
+ if (reduced) return {};
131
+ return {
132
+ animation: `tkx-masonry-fade-in 300ms cubic-bezier(0.16,1,0.3,1) both`,
133
+ animationDelay: `${Math.min(index * 40, 400)}ms`,
134
+ };
135
+ };
136
+
137
+ // ── CSS-columns approach ────────────────────────────────────────────────
138
+ // Using a flex-based column approach for more predictable layout
139
+
140
+ return (
141
+ <div
142
+ role="list"
143
+ aria-label="Masonry grid"
144
+ className={tkx('flex w-full')}
145
+ style={{
146
+ gap,
147
+ alignItems: 'flex-start',
148
+ }}
149
+ >
150
+ {columnBuckets.map((bucket, colIndex) => (
151
+ <div
152
+ key={colIndex}
153
+ className={tkx('flex flex-col')}
154
+ style={{
155
+ flex: 1,
156
+ gap,
157
+ minWidth: 0,
158
+ }}
159
+ >
160
+ {bucket.map((child, itemIndex) => {
161
+ const globalIndex = itemIndex * colCount + colIndex;
162
+ return (
163
+ <div
164
+ key={itemIndex}
165
+ role="listitem"
166
+ style={itemAnimation(globalIndex)}
167
+ >
168
+ {child}
169
+ </div>
170
+ );
171
+ })}
172
+ </div>
173
+ ))}
174
+ </div>
175
+ );
176
+ }
@@ -2,7 +2,7 @@ import { useEffect, useId, type ReactNode } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { useTheme } from '../themes';
4
4
  import { sanitizeString } from '../engine/security';
5
- import { useFocusTrap, useEscapeKey, useAnnounce } from '../hooks';
5
+ import { useFocusTrap, useEscapeKey, useAnnounce, useReducedMotion } from '../hooks';
6
6
  import { tkx } from '../engine/tkx';
7
7
 
8
8
  export type ModalSize = 'sm' | 'md' | 'lg' | 'full';
@@ -28,6 +28,7 @@ export function TkxModal({ isOpen, onClose, title, size = 'md', closeOnOverlayCl
28
28
  const theme = useTheme();
29
29
  const titleId = useId();
30
30
  const trapRef = useFocusTrap(isOpen);
31
+ const reducedMotion = useReducedMotion();
31
32
  const announce = useAnnounce();
32
33
  const safeTitle = sanitizeString(title);
33
34
  const isFull = size === 'full';
@@ -51,7 +52,7 @@ export function TkxModal({ isOpen, onClose, title, size = 'md', closeOnOverlayCl
51
52
  <div
52
53
  aria-hidden="true"
53
54
  onClick={closeOnOverlayClick ? onClose : undefined}
54
- className={tkx('absolute inset-0 animate-fade-in')}
55
+ className={tkx('absolute inset-0', !reducedMotion && 'animate-fade-in')}
55
56
  style={{ backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}
56
57
  />
57
58
 
@@ -61,7 +62,7 @@ export function TkxModal({ isOpen, onClose, title, size = 'md', closeOnOverlayCl
61
62
  role="dialog"
62
63
  aria-modal="true"
63
64
  aria-labelledby={titleId}
64
- className={tkx('relative z-10 flex flex-col overflow-hidden animate-slide-up', isFull ? '' : 'rounded-xl')}
65
+ className={tkx('relative z-10 flex flex-col overflow-hidden', !reducedMotion && 'animate-slide-up', isFull ? '' : 'rounded-xl')}
65
66
  style={{
66
67
  backgroundColor: theme.surface,
67
68
  border: `1px solid ${theme.border}`,
@@ -0,0 +1,295 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
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 { useReducedMotion, useEscapeKey, useClickOutside } from '../hooks';
13
+ import { tkx } from '../engine/tkx';
14
+
15
+ // ── Types ─────────────────────────────────────────────────────────────────────
16
+
17
+ export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right';
18
+
19
+ export interface TkxPopoverProps {
20
+ trigger: ReactNode;
21
+ content: ReactNode;
22
+ placement?: PopoverPlacement;
23
+ isOpen?: boolean;
24
+ onOpenChange?: (open: boolean) => void;
25
+ closeOnClickOutside?: boolean;
26
+ className?: string;
27
+ style?: CSSProperties;
28
+ }
29
+
30
+ // ── Arrow dimensions ────────────────────────────────────────────────────────
31
+
32
+ const ARROW_SIZE = 8;
33
+ const GAP = 8;
34
+
35
+ // ── Position calculator ─────────────────────────────────────────────────────
36
+
37
+ interface PopoverPosition {
38
+ top: number;
39
+ left: number;
40
+ arrowStyle: CSSProperties;
41
+ resolvedPlacement: PopoverPlacement;
42
+ }
43
+
44
+ function calcPosition(
45
+ triggerRect: DOMRect,
46
+ popoverEl: HTMLElement,
47
+ placement: PopoverPlacement,
48
+ ): PopoverPosition {
49
+ const popRect = popoverEl.getBoundingClientRect();
50
+ const scrollX = window.scrollX;
51
+ const scrollY = window.scrollY;
52
+
53
+ const viewW = window.innerWidth;
54
+ const viewH = window.innerHeight;
55
+
56
+ let top = 0;
57
+ let left = 0;
58
+ let resolved = placement;
59
+
60
+ // Attempt requested placement, flip if insufficient space
61
+ const fits = {
62
+ top: triggerRect.top - popRect.height - GAP - ARROW_SIZE > 0,
63
+ bottom: triggerRect.bottom + popRect.height + GAP + ARROW_SIZE < viewH,
64
+ left: triggerRect.left - popRect.width - GAP - ARROW_SIZE > 0,
65
+ right: triggerRect.right + popRect.width + GAP + ARROW_SIZE < viewW,
66
+ };
67
+
68
+ if (!fits[placement]) {
69
+ const flip: Record<PopoverPlacement, PopoverPlacement> = {
70
+ top: 'bottom',
71
+ bottom: 'top',
72
+ left: 'right',
73
+ right: 'left',
74
+ };
75
+ if (fits[flip[placement]]) resolved = flip[placement];
76
+ }
77
+
78
+ switch (resolved) {
79
+ case 'top':
80
+ top = triggerRect.top + scrollY - popRect.height - GAP - ARROW_SIZE;
81
+ left = triggerRect.left + scrollX + triggerRect.width / 2 - popRect.width / 2;
82
+ break;
83
+ case 'bottom':
84
+ top = triggerRect.bottom + scrollY + GAP + ARROW_SIZE;
85
+ left = triggerRect.left + scrollX + triggerRect.width / 2 - popRect.width / 2;
86
+ break;
87
+ case 'left':
88
+ top = triggerRect.top + scrollY + triggerRect.height / 2 - popRect.height / 2;
89
+ left = triggerRect.left + scrollX - popRect.width - GAP - ARROW_SIZE;
90
+ break;
91
+ case 'right':
92
+ top = triggerRect.top + scrollY + triggerRect.height / 2 - popRect.height / 2;
93
+ left = triggerRect.right + scrollX + GAP + ARROW_SIZE;
94
+ break;
95
+ }
96
+
97
+ // Clamp to viewport
98
+ left = Math.max(8, Math.min(left, viewW + scrollX - popRect.width - 8));
99
+ top = Math.max(8, Math.min(top, viewH + scrollY - popRect.height - 8));
100
+
101
+ // Arrow style
102
+ const arrowStyle: CSSProperties = {
103
+ position: 'absolute',
104
+ width: 0,
105
+ height: 0,
106
+ borderStyle: 'solid',
107
+ borderWidth: ARROW_SIZE,
108
+ };
109
+
110
+ switch (resolved) {
111
+ case 'top':
112
+ Object.assign(arrowStyle, {
113
+ bottom: -ARROW_SIZE * 2,
114
+ left: '50%',
115
+ transform: 'translateX(-50%)',
116
+ borderColor: 'var(--tkx-popover-bg) transparent transparent transparent',
117
+ });
118
+ break;
119
+ case 'bottom':
120
+ Object.assign(arrowStyle, {
121
+ top: -ARROW_SIZE * 2,
122
+ left: '50%',
123
+ transform: 'translateX(-50%)',
124
+ borderColor: 'transparent transparent var(--tkx-popover-bg) transparent',
125
+ });
126
+ break;
127
+ case 'left':
128
+ Object.assign(arrowStyle, {
129
+ right: -ARROW_SIZE * 2,
130
+ top: '50%',
131
+ transform: 'translateY(-50%)',
132
+ borderColor: 'transparent transparent transparent var(--tkx-popover-bg)',
133
+ });
134
+ break;
135
+ case 'right':
136
+ Object.assign(arrowStyle, {
137
+ left: -ARROW_SIZE * 2,
138
+ top: '50%',
139
+ transform: 'translateY(-50%)',
140
+ borderColor: 'transparent var(--tkx-popover-bg) transparent transparent',
141
+ });
142
+ break;
143
+ }
144
+
145
+ return { top, left, arrowStyle, resolvedPlacement: resolved };
146
+ }
147
+
148
+ // ── Component ───────────────────────────────────────────────────────────────
149
+
150
+ export function TkxPopover({
151
+ trigger,
152
+ content,
153
+ placement = 'bottom',
154
+ isOpen: controlledOpen,
155
+ onOpenChange,
156
+ closeOnClickOutside = true,
157
+ className,
158
+ style,
159
+ }: TkxPopoverProps) {
160
+ const theme = useTheme();
161
+ const reducedMotion = useReducedMotion();
162
+ const popoverId = useId();
163
+
164
+ const isControlled = controlledOpen !== undefined;
165
+ const [internalOpen, setInternalOpen] = useState(false);
166
+ const open = isControlled ? controlledOpen : internalOpen;
167
+
168
+ const triggerRef = useRef<HTMLDivElement>(null);
169
+ const popoverRef = useRef<HTMLDivElement>(null);
170
+ const wrapperRef = useRef<HTMLDivElement>(null);
171
+
172
+ const [position, setPosition] = useState<PopoverPosition | null>(null);
173
+
174
+ const setOpen = useCallback(
175
+ (value: boolean) => {
176
+ if (!isControlled) setInternalOpen(value);
177
+ onOpenChange?.(value);
178
+ },
179
+ [isControlled, onOpenChange],
180
+ );
181
+
182
+ const close = useCallback(() => setOpen(false), [setOpen]);
183
+ const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
184
+
185
+ useEscapeKey(close, open);
186
+
187
+ // Click outside
188
+ useEffect(() => {
189
+ if (!open || !closeOnClickOutside) return;
190
+ const handler = (e: PointerEvent) => {
191
+ const target = e.target as Node;
192
+ if (triggerRef.current?.contains(target)) return;
193
+ if (popoverRef.current?.contains(target)) return;
194
+ close();
195
+ };
196
+ document.addEventListener('pointerdown', handler);
197
+ return () => document.removeEventListener('pointerdown', handler);
198
+ }, [open, closeOnClickOutside, close]);
199
+
200
+ // Calculate position
201
+ useEffect(() => {
202
+ if (!open || !triggerRef.current) return;
203
+
204
+ const updatePosition = () => {
205
+ if (!triggerRef.current || !popoverRef.current) return;
206
+ const triggerRect = triggerRef.current.getBoundingClientRect();
207
+ setPosition(calcPosition(triggerRect, popoverRef.current, placement));
208
+ };
209
+
210
+ // Initial calc after portal mount
211
+ requestAnimationFrame(updatePosition);
212
+
213
+ window.addEventListener('scroll', updatePosition, true);
214
+ window.addEventListener('resize', updatePosition);
215
+
216
+ return () => {
217
+ window.removeEventListener('scroll', updatePosition, true);
218
+ window.removeEventListener('resize', updatePosition);
219
+ };
220
+ }, [open, placement]);
221
+
222
+ const animationStyle: CSSProperties = reducedMotion
223
+ ? { opacity: 1 }
224
+ : {
225
+ animation: 'tkxPopoverFadeIn 150ms ease forwards',
226
+ opacity: 0,
227
+ };
228
+
229
+ const portalContent = open && typeof document !== 'undefined' ? (
230
+ createPortal(
231
+ <div
232
+ ref={popoverRef}
233
+ id={popoverId}
234
+ role="dialog"
235
+ aria-modal="false"
236
+ className={tkx(
237
+ 'absolute z-[9100] rounded-lg font-sans',
238
+ className ?? '',
239
+ )}
240
+ style={{
241
+ '--tkx-popover-bg': theme.surface,
242
+ top: position?.top ?? -9999,
243
+ left: position?.left ?? -9999,
244
+ backgroundColor: theme.surface,
245
+ border: `1px solid ${theme.border}`,
246
+ boxShadow: `0 8px 24px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.1)`,
247
+ minWidth: 200,
248
+ maxWidth: 420,
249
+ padding: '12px 16px',
250
+ color: theme.text,
251
+ ...animationStyle,
252
+ ...style,
253
+ } as CSSProperties}
254
+ >
255
+ {/* Arrow */}
256
+ {position && <span style={position.arrowStyle} aria-hidden="true" />}
257
+ {content}
258
+ </div>,
259
+ document.body,
260
+ )
261
+ ) : null;
262
+
263
+ return (
264
+ <>
265
+ <div
266
+ ref={triggerRef}
267
+ className={tkx('inline-flex')}
268
+ onClick={toggle}
269
+ onKeyDown={(e) => {
270
+ if (e.key === 'Enter' || e.key === ' ') {
271
+ e.preventDefault();
272
+ toggle();
273
+ }
274
+ }}
275
+ aria-expanded={open}
276
+ aria-haspopup="dialog"
277
+ aria-controls={open ? popoverId : undefined}
278
+ >
279
+ {trigger}
280
+ </div>
281
+ {portalContent}
282
+ {/* Inject animation keyframes */}
283
+ {open && !reducedMotion && (
284
+ <style>{`
285
+ @keyframes tkxPopoverFadeIn {
286
+ from { opacity: 0; transform: translateY(4px); }
287
+ to { opacity: 1; transform: translateY(0); }
288
+ }
289
+ `}</style>
290
+ )}
291
+ </>
292
+ );
293
+ }
294
+
295
+ export default TkxPopover;