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,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;
|