cynx-lib 1.3.89 → 1.3.91
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/package.json +1 -1
- package/ui/components/ActionsMenu.jsx +351 -158
package/package.json
CHANGED
|
@@ -1,158 +1,351 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
|
-
import { createPortal } from 'react-dom';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
const [
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
function ActionSubmenu({ items, parentRect, closeAll, onMouseEnter, onMouseLeave }) {
|
|
5
|
+
const subRef = useRef(null);
|
|
6
|
+
const [pos, setPos] = useState({
|
|
7
|
+
top: parentRect ? parentRect.top - 5 : 0,
|
|
8
|
+
left: parentRect ? parentRect.right + 4 : 0,
|
|
9
|
+
transformOrigin: 'top left',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
if (!subRef.current || !parentRect) return;
|
|
14
|
+
const subWidth = subRef.current.offsetWidth || 190;
|
|
15
|
+
const subHeight = subRef.current.offsetHeight || 150;
|
|
16
|
+
|
|
17
|
+
const spaceRight = window.innerWidth - parentRect.right;
|
|
18
|
+
let left;
|
|
19
|
+
let transformOriginX = 'left';
|
|
20
|
+
if (spaceRight >= subWidth + 12) {
|
|
21
|
+
left = parentRect.right + 4;
|
|
22
|
+
transformOriginX = 'left';
|
|
23
|
+
} else {
|
|
24
|
+
left = parentRect.left - subWidth - 4;
|
|
25
|
+
transformOriginX = 'right';
|
|
26
|
+
}
|
|
27
|
+
left = Math.max(8, Math.min(left, window.innerWidth - subWidth - 8));
|
|
28
|
+
|
|
29
|
+
let top = parentRect.top - 5;
|
|
30
|
+
let transformOriginY = 'top';
|
|
31
|
+
if (top + subHeight > window.innerHeight - 10) {
|
|
32
|
+
top = Math.max(10, window.innerHeight - subHeight - 10);
|
|
33
|
+
transformOriginY = 'bottom';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setPos({
|
|
37
|
+
top,
|
|
38
|
+
left,
|
|
39
|
+
transformOrigin: `${transformOriginY} ${transformOriginX}`,
|
|
40
|
+
});
|
|
41
|
+
}, [parentRect]);
|
|
42
|
+
|
|
43
|
+
return createPortal(
|
|
44
|
+
<div
|
|
45
|
+
ref={subRef}
|
|
46
|
+
data-cynx-submenu="true"
|
|
47
|
+
onMouseEnter={onMouseEnter}
|
|
48
|
+
onMouseLeave={onMouseLeave}
|
|
49
|
+
style={{
|
|
50
|
+
position: 'fixed',
|
|
51
|
+
zIndex: 100000,
|
|
52
|
+
minWidth: 190,
|
|
53
|
+
padding: 5,
|
|
54
|
+
top: pos.top + 'px',
|
|
55
|
+
left: pos.left + 'px',
|
|
56
|
+
transformOrigin: pos.transformOrigin,
|
|
57
|
+
background: 'var(--surface,#fff)',
|
|
58
|
+
border: '1.5px solid var(--border-light,#e2e8f0)',
|
|
59
|
+
borderRadius: 'var(--rads,8px)',
|
|
60
|
+
boxShadow: 'var(--shadow-lg, 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.06))',
|
|
61
|
+
animation: 'actionsDrop .12s cubic-bezier(.16,1,.3,1)',
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{items.map((subItem, j) => (
|
|
65
|
+
subItem.dividerBefore ? (
|
|
66
|
+
<div key={j} style={{ height: 1, background: 'var(--border-light,#e2e8f0)', margin: '4px 0' }} />
|
|
67
|
+
) : (
|
|
68
|
+
<div
|
|
69
|
+
key={j}
|
|
70
|
+
onClick={() => { closeAll(); subItem.onClick?.(); }}
|
|
71
|
+
style={{
|
|
72
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
73
|
+
padding: '6px 10px', borderRadius: 'var(--rads,8px)',
|
|
74
|
+
fontSize: 12, cursor: 'pointer', transition: 'all .14s',
|
|
75
|
+
color: (subItem.variant === 'danger' || subItem.danger) ? 'var(--danger, #ef4444)' : subItem.variant === 'success' ? 'var(--success, #16a34a)' : subItem.variant === 'warning' ? 'var(--warning, #f59e0b)' : 'var(--primary-text, #18181b)',
|
|
76
|
+
fontWeight: 500,
|
|
77
|
+
}}
|
|
78
|
+
onMouseEnter={e => {
|
|
79
|
+
e.currentTarget.style.background = (subItem.variant === 'danger' || subItem.danger) ? 'var(--danger-bg, rgba(239,68,68,.10))' : subItem.variant === 'success' ? 'var(--success-bg, rgba(22,163,74,.10))' : subItem.variant === 'warning' ? 'var(--warning-bg, rgba(245,158,11,.12))' : 'var(--grey-1, #f4f4f5)';
|
|
80
|
+
}}
|
|
81
|
+
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
|
82
|
+
>
|
|
83
|
+
{subItem.icon && (
|
|
84
|
+
<span style={{
|
|
85
|
+
width: 24, height: 24, borderRadius: 5,
|
|
86
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
87
|
+
flexShrink: 0, color: 'inherit', fontSize: 12,
|
|
88
|
+
}}>{subItem.icon}</span>
|
|
89
|
+
)}
|
|
90
|
+
<span>{subItem.label}</span>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
))}
|
|
94
|
+
</div>,
|
|
95
|
+
document.body
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ActionsMenu({ items, align = 'right' }) {
|
|
100
|
+
const [open, setOpen] = useState(false);
|
|
101
|
+
const [closing, setClosing] = useState(false);
|
|
102
|
+
const [activeSub, setActiveSub] = useState(null); // { index, items, rect }
|
|
103
|
+
const wrapRef = useRef(null);
|
|
104
|
+
const menuRef = useRef(null);
|
|
105
|
+
const btnRef = useRef(null);
|
|
106
|
+
const subTimerRef = useRef(null);
|
|
107
|
+
|
|
108
|
+
const close = useCallback(() => {
|
|
109
|
+
setClosing(true);
|
|
110
|
+
setActiveSub(null);
|
|
111
|
+
clearTimeout(subTimerRef.current);
|
|
112
|
+
setTimeout(() => { setClosing(false); setOpen(false); }, 140);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const openSub = (index, subItems, e) => {
|
|
116
|
+
clearTimeout(subTimerRef.current);
|
|
117
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
118
|
+
setActiveSub({ index, items: subItems, rect });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const scheduleCloseSub = () => {
|
|
122
|
+
clearTimeout(subTimerRef.current);
|
|
123
|
+
subTimerRef.current = setTimeout(() => {
|
|
124
|
+
setActiveSub(null);
|
|
125
|
+
}, 150);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const cancelCloseSub = () => {
|
|
129
|
+
clearTimeout(subTimerRef.current);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const positionMenu = useCallback(() => {
|
|
133
|
+
const btn = btnRef.current;
|
|
134
|
+
const menu = menuRef.current;
|
|
135
|
+
if (!btn || !menu) return;
|
|
136
|
+
const br = btn.getBoundingClientRect();
|
|
137
|
+
const mw = menu.offsetWidth || 180;
|
|
138
|
+
const mh = menu.offsetHeight || 120;
|
|
139
|
+
const spaceBelow = window.innerHeight - br.bottom;
|
|
140
|
+
const spaceAbove = br.top;
|
|
141
|
+
const openUp = spaceBelow < mh + 12 && spaceAbove > spaceBelow;
|
|
142
|
+
|
|
143
|
+
let top, left;
|
|
144
|
+
if (openUp) {
|
|
145
|
+
top = br.top - mh - 6;
|
|
146
|
+
} else {
|
|
147
|
+
top = br.bottom + 6;
|
|
148
|
+
}
|
|
149
|
+
if (align === 'left') {
|
|
150
|
+
left = br.left;
|
|
151
|
+
} else {
|
|
152
|
+
left = br.right - mw;
|
|
153
|
+
}
|
|
154
|
+
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
|
|
155
|
+
|
|
156
|
+
menu.style.top = top + 'px';
|
|
157
|
+
menu.style.left = left + 'px';
|
|
158
|
+
menu.style.transformOrigin = openUp
|
|
159
|
+
? (align === 'left' ? 'bottom left' : 'bottom right')
|
|
160
|
+
: (align === 'left' ? 'top left' : 'top right');
|
|
161
|
+
menu.classList.toggle('open-up', openUp);
|
|
162
|
+
if (!closing) {
|
|
163
|
+
menu.style.animationName = openUp ? 'actionsDropUp' : 'actionsDrop';
|
|
164
|
+
}
|
|
165
|
+
}, [align, closing]);
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!open) return;
|
|
169
|
+
function onDocClick(e) {
|
|
170
|
+
if (
|
|
171
|
+
wrapRef.current && !wrapRef.current.contains(e.target) &&
|
|
172
|
+
menuRef.current && !menuRef.current.contains(e.target) &&
|
|
173
|
+
!e.target.closest?.('[data-cynx-submenu]')
|
|
174
|
+
) {
|
|
175
|
+
close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function onScroll() { positionMenu(); }
|
|
179
|
+
document.addEventListener('mousedown', onDocClick);
|
|
180
|
+
document.addEventListener('scroll', onScroll, true);
|
|
181
|
+
window.addEventListener('resize', onScroll);
|
|
182
|
+
return () => {
|
|
183
|
+
document.removeEventListener('mousedown', onDocClick);
|
|
184
|
+
document.removeEventListener('scroll', onScroll, true);
|
|
185
|
+
window.removeEventListener('resize', onScroll);
|
|
186
|
+
};
|
|
187
|
+
}, [open, close, positionMenu]);
|
|
188
|
+
|
|
189
|
+
useLayoutEffect(() => {
|
|
190
|
+
if (open) {
|
|
191
|
+
positionMenu();
|
|
192
|
+
}
|
|
193
|
+
}, [open, positionMenu]);
|
|
194
|
+
|
|
195
|
+
function toggle() {
|
|
196
|
+
if (open) close();
|
|
197
|
+
else { setOpen(true); setClosing(false); setActiveSub(null); }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const isOpen = open && !closing;
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div ref={wrapRef} style={{ position: 'relative', display: 'inline-block' }}>
|
|
204
|
+
<button
|
|
205
|
+
ref={btnRef}
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={toggle}
|
|
208
|
+
style={{
|
|
209
|
+
width: 32, height: 32, borderRadius: 'var(--rads,8px)',
|
|
210
|
+
border: isOpen ? '1px solid var(--accent,#f99e2c)' : '1px solid var(--border-light,#d7d8e0)',
|
|
211
|
+
background: 'var(--surface,#fff)',
|
|
212
|
+
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
213
|
+
color: isOpen ? 'var(--accent, #f99e2c)' : 'var(--muted,#797b8d)', transition: 'all .14s',
|
|
214
|
+
boxShadow: isOpen ? 'var(--pd, rgba(249,158,44,.12))' : 'var(--shadow, 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06))',
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
218
|
+
<circle cx="5" cy="12" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="19" cy="12" r="1.5" />
|
|
219
|
+
</svg>
|
|
220
|
+
</button>
|
|
221
|
+
|
|
222
|
+
{open && typeof document !== 'undefined' && createPortal(
|
|
223
|
+
<div
|
|
224
|
+
ref={menuRef}
|
|
225
|
+
style={{
|
|
226
|
+
position: 'fixed', zIndex: 99999, minWidth: 180, padding: 5,
|
|
227
|
+
background: 'var(--surface,#fff)', border: '1.5px solid var(--border-light,#e2e8f0)',
|
|
228
|
+
borderRadius: 'var(--rads,8px)',
|
|
229
|
+
boxShadow: 'var(--shadow-lg, 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.06))',
|
|
230
|
+
transformOrigin: align === 'left' ? 'top left' : 'top right',
|
|
231
|
+
animation: closing
|
|
232
|
+
? 'actionsClose .14s ease forwards'
|
|
233
|
+
: 'actionsDrop .14s cubic-bezier(.16,1,.3,1)',
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
{items.map((item, i) => {
|
|
237
|
+
if (item.dividerBefore) {
|
|
238
|
+
return <div key={i} style={{ height: 1, background: 'var(--border-light,#e2e8f0)', margin: '4px 0' }} />;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hasSub = Array.isArray(item.items) && item.items.length > 0;
|
|
242
|
+
const isSubActive = activeSub && activeSub.index === i;
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
key={i}
|
|
247
|
+
onClick={(e) => {
|
|
248
|
+
if (hasSub) {
|
|
249
|
+
e.stopPropagation();
|
|
250
|
+
openSub(i, item.items, e);
|
|
251
|
+
} else {
|
|
252
|
+
close();
|
|
253
|
+
item.onClick?.();
|
|
254
|
+
}
|
|
255
|
+
}}
|
|
256
|
+
onMouseEnter={(e) => {
|
|
257
|
+
if (hasSub) {
|
|
258
|
+
openSub(i, item.items, e);
|
|
259
|
+
} else {
|
|
260
|
+
clearTimeout(subTimerRef.current);
|
|
261
|
+
setActiveSub(null);
|
|
262
|
+
}
|
|
263
|
+
e.currentTarget.style.background = (item.variant === 'danger' || item.danger)
|
|
264
|
+
? 'var(--danger-bg, rgba(239,68,68,.10))'
|
|
265
|
+
: item.variant === 'success'
|
|
266
|
+
? 'var(--success-bg, rgba(22,163,74,.10))'
|
|
267
|
+
: item.variant === 'warning'
|
|
268
|
+
? 'var(--warning-bg, rgba(245,158,11,.12))'
|
|
269
|
+
: 'var(--grey-1, #f4f4f5)';
|
|
270
|
+
}}
|
|
271
|
+
onMouseLeave={(e) => {
|
|
272
|
+
if (hasSub) {
|
|
273
|
+
scheduleCloseSub();
|
|
274
|
+
}
|
|
275
|
+
if (!isSubActive) {
|
|
276
|
+
e.currentTarget.style.background = 'transparent';
|
|
277
|
+
}
|
|
278
|
+
}}
|
|
279
|
+
style={{
|
|
280
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
281
|
+
padding: '6px 10px', borderRadius: 'var(--rads,8px)',
|
|
282
|
+
fontSize: 12, cursor: 'pointer', transition: 'all .14s',
|
|
283
|
+
background: isSubActive ? 'var(--grey-1, #f4f4f5)' : 'transparent',
|
|
284
|
+
color: (item.variant === 'danger' || item.danger)
|
|
285
|
+
? 'var(--danger, #ef4444)'
|
|
286
|
+
: item.variant === 'success'
|
|
287
|
+
? 'var(--success, #16a34a)'
|
|
288
|
+
: item.variant === 'warning'
|
|
289
|
+
? 'var(--warning, #f59e0b)'
|
|
290
|
+
: 'var(--primary-text, #18181b)',
|
|
291
|
+
fontWeight: 500,
|
|
292
|
+
}}
|
|
293
|
+
>
|
|
294
|
+
{item.icon && (
|
|
295
|
+
<span style={{
|
|
296
|
+
width: 24, height: 24, borderRadius: 5,
|
|
297
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
298
|
+
flexShrink: 0, color: 'inherit', fontSize: 12,
|
|
299
|
+
}}>{item.icon}</span>
|
|
300
|
+
)}
|
|
301
|
+
<span>{item.label}</span>
|
|
302
|
+
|
|
303
|
+
{hasSub && (
|
|
304
|
+
<svg
|
|
305
|
+
width="12"
|
|
306
|
+
height="12"
|
|
307
|
+
viewBox="0 0 24 24"
|
|
308
|
+
fill="none"
|
|
309
|
+
stroke="currentColor"
|
|
310
|
+
strokeWidth="2.5"
|
|
311
|
+
strokeLinecap="round"
|
|
312
|
+
strokeLinejoin="round"
|
|
313
|
+
style={{ marginLeft: 'auto', opacity: 0.55 }}
|
|
314
|
+
>
|
|
315
|
+
<polyline points="9 18 15 12 9 6" />
|
|
316
|
+
</svg>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
})}
|
|
321
|
+
</div>,
|
|
322
|
+
document.body
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{open && activeSub && (
|
|
326
|
+
<ActionSubmenu
|
|
327
|
+
items={activeSub.items}
|
|
328
|
+
parentRect={activeSub.rect}
|
|
329
|
+
closeAll={close}
|
|
330
|
+
onMouseEnter={cancelCloseSub}
|
|
331
|
+
onMouseLeave={scheduleCloseSub}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
<style>{`
|
|
336
|
+
@keyframes actionsDrop {
|
|
337
|
+
from { opacity: 0; transform: translateY(-6px); }
|
|
338
|
+
to { opacity: 1; transform: translateY(0); }
|
|
339
|
+
}
|
|
340
|
+
@keyframes actionsDropUp {
|
|
341
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
342
|
+
to { opacity: 1; transform: translateY(0); }
|
|
343
|
+
}
|
|
344
|
+
@keyframes actionsClose {
|
|
345
|
+
from { opacity: 1; transform: translateY(0); }
|
|
346
|
+
to { opacity: 0; transform: translateY(-4px); }
|
|
347
|
+
}
|
|
348
|
+
`}</style>
|
|
349
|
+
</div>
|
|
350
|
+
);
|
|
351
|
+
}
|