@mhome/ui 0.1.1 → 0.1.3
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.js +2 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.esm.js +2 -2
- package/dist/index.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/menu.jsx +298 -45
package/package.json
CHANGED
package/src/components/menu.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { cn } from "../lib/utils";
|
|
3
4
|
|
|
4
5
|
const Menu = React.forwardRef(
|
|
@@ -11,11 +12,16 @@ const Menu = React.forwardRef(
|
|
|
11
12
|
PaperProps,
|
|
12
13
|
anchorOrigin = { vertical: "bottom", horizontal: "left" },
|
|
13
14
|
transformOrigin = { vertical: "top", horizontal: "left" },
|
|
15
|
+
position: positionProp = "fixed",
|
|
16
|
+
disablePortal = false,
|
|
17
|
+
border = false,
|
|
14
18
|
children,
|
|
15
19
|
...props
|
|
16
20
|
},
|
|
17
21
|
ref
|
|
18
22
|
) => {
|
|
23
|
+
// If using Portal, position must be fixed to work correctly
|
|
24
|
+
const positionType = disablePortal ? positionProp : "fixed";
|
|
19
25
|
const menuRef = React.useRef(null);
|
|
20
26
|
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
|
21
27
|
|
|
@@ -54,50 +60,126 @@ const Menu = React.forwardRef(
|
|
|
54
60
|
}
|
|
55
61
|
return;
|
|
56
62
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
|
|
64
|
+
if (positionType === "fixed") {
|
|
65
|
+
// Fixed positioning: use viewport coordinates
|
|
66
|
+
const viewportWidth = window.innerWidth;
|
|
67
|
+
const viewportHeight = window.innerHeight;
|
|
68
|
+
|
|
69
|
+
// Get menu dimensions (estimate if not yet rendered)
|
|
70
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
71
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
72
|
+
|
|
73
|
+
// Calculate initial position based on anchorOrigin
|
|
74
|
+
let top = 0;
|
|
75
|
+
let left = 0;
|
|
76
|
+
const gap = 8;
|
|
77
|
+
|
|
78
|
+
// Vertical positioning
|
|
79
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
80
|
+
top = anchorRect.bottom + gap;
|
|
81
|
+
} else if (anchorOrigin.vertical === 'top') {
|
|
82
|
+
top = anchorRect.top - menuHeight - gap;
|
|
83
|
+
} else {
|
|
84
|
+
// center
|
|
85
|
+
top = anchorRect.top + (anchorRect.height - menuHeight) / 2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Horizontal positioning
|
|
89
|
+
if (anchorOrigin.horizontal === 'left') {
|
|
90
|
+
left = anchorRect.left;
|
|
91
|
+
} else if (anchorOrigin.horizontal === 'right') {
|
|
92
|
+
left = anchorRect.right - menuWidth;
|
|
93
|
+
} else {
|
|
94
|
+
// center
|
|
95
|
+
left = anchorRect.left + (anchorRect.width - menuWidth) / 2;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Boundary detection and adjustment
|
|
99
|
+
const padding = 16;
|
|
100
|
+
|
|
101
|
+
// Adjust horizontal position if menu would overflow
|
|
102
|
+
if (left + menuWidth > viewportWidth - padding) {
|
|
103
|
+
left = viewportWidth - menuWidth - padding;
|
|
104
|
+
}
|
|
105
|
+
if (left < padding) {
|
|
106
|
+
left = padding;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Adjust vertical position if menu would overflow
|
|
110
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
111
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
112
|
+
// Try to show above anchor
|
|
113
|
+
top = anchorRect.top - menuHeight - gap;
|
|
114
|
+
}
|
|
115
|
+
// Clamp to viewport
|
|
116
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
117
|
+
top = viewportHeight - menuHeight - padding;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (top < padding) {
|
|
121
|
+
top = padding;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isMounted) {
|
|
125
|
+
setPosition({ top, left });
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
// Absolute positioning: use relative coordinates
|
|
129
|
+
// Find the nearest positioned ancestor
|
|
130
|
+
let container = anchorEl.parentElement;
|
|
131
|
+
while (container && container !== document.body && container.isConnected) {
|
|
132
|
+
try {
|
|
133
|
+
const style = window.getComputedStyle(container);
|
|
134
|
+
if (style.position === 'relative' || style.position === 'absolute' || style.position === 'fixed') {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
container = container.parentElement;
|
|
138
|
+
} catch (e) {
|
|
64
139
|
break;
|
|
65
140
|
}
|
|
66
|
-
container = container.parentElement;
|
|
67
|
-
} catch (e) {
|
|
68
|
-
// If getComputedStyle fails, break the loop
|
|
69
|
-
break;
|
|
70
141
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
142
|
+
|
|
143
|
+
let containerRect = { top: 0, left: 0 };
|
|
144
|
+
if (container && container !== document.body && container.isConnected && container.getBoundingClientRect) {
|
|
145
|
+
try {
|
|
146
|
+
containerRect = container.getBoundingClientRect();
|
|
147
|
+
if (!containerRect || isNaN(containerRect.top) || isNaN(containerRect.left)) {
|
|
148
|
+
containerRect = { top: 0, left: 0 };
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
79
151
|
containerRect = { top: 0, left: 0 };
|
|
80
152
|
}
|
|
81
|
-
} catch (e) {
|
|
82
|
-
containerRect = { top: 0, left: 0 };
|
|
83
153
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
154
|
+
|
|
155
|
+
// Calculate relative position
|
|
156
|
+
const gap = 8;
|
|
157
|
+
let relativeTop = 0;
|
|
158
|
+
let relativeLeft = 0;
|
|
159
|
+
|
|
160
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
161
|
+
relativeTop = anchorRect.bottom - containerRect.top + gap;
|
|
162
|
+
} else if (anchorOrigin.vertical === 'top') {
|
|
163
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
164
|
+
relativeTop = anchorRect.top - containerRect.top - menuHeight - gap;
|
|
165
|
+
} else {
|
|
166
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
167
|
+
relativeTop = anchorRect.top - containerRect.top + (anchorRect.height - menuHeight) / 2;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (anchorOrigin.horizontal === 'left') {
|
|
171
|
+
relativeLeft = anchorRect.left - containerRect.left;
|
|
172
|
+
} else if (anchorOrigin.horizontal === 'right') {
|
|
173
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
174
|
+
relativeLeft = anchorRect.right - containerRect.left - menuWidth;
|
|
175
|
+
} else {
|
|
176
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
177
|
+
relativeLeft = anchorRect.left - containerRect.left + (anchorRect.width - menuWidth) / 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
92
180
|
if (isMounted) {
|
|
93
|
-
setPosition({ top:
|
|
181
|
+
setPosition({ top: relativeTop, left: relativeLeft });
|
|
94
182
|
}
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Only update state if component is still mounted
|
|
99
|
-
if (isMounted) {
|
|
100
|
-
setPosition({ top: relativeTop, left: relativeLeft });
|
|
101
183
|
}
|
|
102
184
|
} catch (e) {
|
|
103
185
|
// Fallback position - only update if mounted
|
|
@@ -107,13 +189,172 @@ const Menu = React.forwardRef(
|
|
|
107
189
|
}
|
|
108
190
|
});
|
|
109
191
|
|
|
192
|
+
// Update position on scroll and resize (only for fixed positioning)
|
|
193
|
+
if (positionType === "fixed") {
|
|
194
|
+
const handleUpdate = () => {
|
|
195
|
+
rafId = requestAnimationFrame(() => {
|
|
196
|
+
if (isMounted) {
|
|
197
|
+
// Re-run position calculation
|
|
198
|
+
const anchorRect = anchorEl?.getBoundingClientRect();
|
|
199
|
+
if (anchorRect) {
|
|
200
|
+
const viewportWidth = window.innerWidth;
|
|
201
|
+
const viewportHeight = window.innerHeight;
|
|
202
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
203
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
204
|
+
const gap = 8;
|
|
205
|
+
const padding = 16;
|
|
206
|
+
|
|
207
|
+
let top = anchorOrigin.vertical === 'bottom'
|
|
208
|
+
? anchorRect.bottom + gap
|
|
209
|
+
: anchorOrigin.vertical === 'top'
|
|
210
|
+
? anchorRect.top - menuHeight - gap
|
|
211
|
+
: anchorRect.top + (anchorRect.height - menuHeight) / 2;
|
|
212
|
+
|
|
213
|
+
let left = anchorOrigin.horizontal === 'left'
|
|
214
|
+
? anchorRect.left
|
|
215
|
+
: anchorOrigin.horizontal === 'right'
|
|
216
|
+
? anchorRect.right - menuWidth
|
|
217
|
+
: anchorRect.left + (anchorRect.width - menuWidth) / 2;
|
|
218
|
+
|
|
219
|
+
if (left + menuWidth > viewportWidth - padding) {
|
|
220
|
+
left = viewportWidth - menuWidth - padding;
|
|
221
|
+
}
|
|
222
|
+
if (left < padding) left = padding;
|
|
223
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
224
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
225
|
+
top = anchorRect.top - menuHeight - gap;
|
|
226
|
+
}
|
|
227
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
228
|
+
top = viewportHeight - menuHeight - padding;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (top < padding) top = padding;
|
|
232
|
+
|
|
233
|
+
setPosition({ top, left });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
window.addEventListener('scroll', handleUpdate, true);
|
|
240
|
+
window.addEventListener('resize', handleUpdate);
|
|
241
|
+
|
|
242
|
+
return () => {
|
|
243
|
+
isMounted = false;
|
|
244
|
+
if (rafId !== null) {
|
|
245
|
+
cancelAnimationFrame(rafId);
|
|
246
|
+
}
|
|
247
|
+
window.removeEventListener('scroll', handleUpdate, true);
|
|
248
|
+
window.removeEventListener('resize', handleUpdate);
|
|
249
|
+
};
|
|
250
|
+
} else {
|
|
251
|
+
return () => {
|
|
252
|
+
isMounted = false;
|
|
253
|
+
if (rafId !== null) {
|
|
254
|
+
cancelAnimationFrame(rafId);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}, [open, anchorEl, anchorOrigin, positionType, PaperProps?.style?.minWidth]);
|
|
259
|
+
|
|
260
|
+
// Recalculate position after menu is rendered (to get actual dimensions)
|
|
261
|
+
React.useLayoutEffect(() => {
|
|
262
|
+
if (!open || !anchorEl || !menuRef.current || positionType !== "fixed") return;
|
|
263
|
+
|
|
264
|
+
const updatePositionWithRealSize = () => {
|
|
265
|
+
try {
|
|
266
|
+
const anchorRect = anchorEl.getBoundingClientRect();
|
|
267
|
+
if (!anchorRect) return;
|
|
268
|
+
|
|
269
|
+
const viewportWidth = window.innerWidth;
|
|
270
|
+
const viewportHeight = window.innerHeight;
|
|
271
|
+
|
|
272
|
+
// Get actual menu dimensions
|
|
273
|
+
const menuWidth = menuRef.current.offsetWidth;
|
|
274
|
+
const menuHeight = menuRef.current.offsetHeight;
|
|
275
|
+
|
|
276
|
+
if (!menuWidth || !menuHeight) return; // Menu not fully rendered yet
|
|
277
|
+
|
|
278
|
+
const gap = 8;
|
|
279
|
+
const padding = 16;
|
|
280
|
+
|
|
281
|
+
// Calculate initial position based on anchorOrigin
|
|
282
|
+
let top = 0;
|
|
283
|
+
let left = 0;
|
|
284
|
+
|
|
285
|
+
// Vertical positioning
|
|
286
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
287
|
+
top = anchorRect.bottom + gap;
|
|
288
|
+
} else if (anchorOrigin.vertical === 'top') {
|
|
289
|
+
top = anchorRect.top - menuHeight - gap;
|
|
290
|
+
} else {
|
|
291
|
+
top = anchorRect.top + (anchorRect.height - menuHeight) / 2;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Horizontal positioning
|
|
295
|
+
if (anchorOrigin.horizontal === 'left') {
|
|
296
|
+
left = anchorRect.left;
|
|
297
|
+
} else if (anchorOrigin.horizontal === 'right') {
|
|
298
|
+
left = anchorRect.right - menuWidth;
|
|
299
|
+
} else {
|
|
300
|
+
left = anchorRect.left + (anchorRect.width - menuWidth) / 2;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Strict boundary detection - ensure menu is fully within viewport
|
|
304
|
+
// Adjust horizontal position
|
|
305
|
+
if (left + menuWidth > viewportWidth - padding) {
|
|
306
|
+
// Menu would overflow on the right, align to right edge
|
|
307
|
+
left = viewportWidth - menuWidth - padding;
|
|
308
|
+
}
|
|
309
|
+
if (left < padding) {
|
|
310
|
+
// Menu would overflow on the left
|
|
311
|
+
left = padding;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Adjust vertical position
|
|
315
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
316
|
+
// Menu would overflow on the bottom
|
|
317
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
318
|
+
// Try to show above anchor
|
|
319
|
+
top = anchorRect.top - menuHeight - gap;
|
|
320
|
+
}
|
|
321
|
+
// If still overflowing, clamp to viewport
|
|
322
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
323
|
+
top = viewportHeight - menuHeight - padding;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (top < padding) {
|
|
327
|
+
// Menu would overflow on the top
|
|
328
|
+
top = padding;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Ensure menu doesn't exceed viewport in any direction
|
|
332
|
+
if (left < 0) left = padding;
|
|
333
|
+
if (top < 0) top = padding;
|
|
334
|
+
if (left + menuWidth > viewportWidth) {
|
|
335
|
+
left = Math.max(padding, viewportWidth - menuWidth - padding);
|
|
336
|
+
}
|
|
337
|
+
if (top + menuHeight > viewportHeight) {
|
|
338
|
+
top = Math.max(padding, viewportHeight - menuHeight - padding);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
setPosition({ top, left });
|
|
342
|
+
} catch (e) {
|
|
343
|
+
// Silently fail if calculation error
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Use requestAnimationFrame to ensure DOM is updated
|
|
348
|
+
const rafId = requestAnimationFrame(() => {
|
|
349
|
+
updatePositionWithRealSize();
|
|
350
|
+
});
|
|
351
|
+
|
|
110
352
|
return () => {
|
|
111
|
-
|
|
112
|
-
if (rafId !== null) {
|
|
353
|
+
if (rafId) {
|
|
113
354
|
cancelAnimationFrame(rafId);
|
|
114
355
|
}
|
|
115
356
|
};
|
|
116
|
-
}, [open, anchorEl]);
|
|
357
|
+
}, [open, anchorEl, anchorOrigin, positionType]); // Re-run when menu opens or anchor changes
|
|
117
358
|
|
|
118
359
|
// Handle backdrop click
|
|
119
360
|
React.useEffect(() => {
|
|
@@ -154,12 +395,12 @@ const Menu = React.forwardRef(
|
|
|
154
395
|
const isDark = document.documentElement.classList.contains('dark') ||
|
|
155
396
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
156
397
|
|
|
157
|
-
|
|
398
|
+
const menuElement = (
|
|
158
399
|
<div
|
|
159
400
|
ref={menuRef}
|
|
160
401
|
className={cn("rounded-lg shadow-lg", className)}
|
|
161
402
|
style={{
|
|
162
|
-
position:
|
|
403
|
+
position: positionType,
|
|
163
404
|
top: `${position.top}px`,
|
|
164
405
|
left: `${position.left}px`,
|
|
165
406
|
zIndex: 1300,
|
|
@@ -171,15 +412,17 @@ const Menu = React.forwardRef(
|
|
|
171
412
|
boxShadow: isDark
|
|
172
413
|
? "0px 4px 12px rgba(0, 0, 0, 0.4), 0px 0px 0px 1px rgba(255, 255, 255, 0.1)"
|
|
173
414
|
: "0px 4px 12px rgba(0, 0, 0, 0.15)",
|
|
174
|
-
border
|
|
175
|
-
|
|
176
|
-
|
|
415
|
+
...(border || PaperProps?.style?.border ? {
|
|
416
|
+
border: PaperProps?.style?.border || (isDark
|
|
417
|
+
? "1px solid rgba(255, 255, 255, 0.1)"
|
|
418
|
+
: "1px solid hsl(var(--border))")
|
|
419
|
+
} : {}),
|
|
177
420
|
borderRadius: PaperProps?.style?.borderRadius || "8px",
|
|
178
421
|
minWidth: PaperProps?.style?.minWidth || 180,
|
|
179
422
|
maxWidth: "calc(100vw - 32px)",
|
|
180
423
|
padding: "4px 0",
|
|
181
424
|
height: "auto",
|
|
182
|
-
overflow: "visible",
|
|
425
|
+
overflow: "visible",
|
|
183
426
|
...PaperProps?.style,
|
|
184
427
|
...props.style,
|
|
185
428
|
}}
|
|
@@ -190,6 +433,16 @@ const Menu = React.forwardRef(
|
|
|
190
433
|
{children}
|
|
191
434
|
</div>
|
|
192
435
|
);
|
|
436
|
+
|
|
437
|
+
// Use Portal to render menu at body level to avoid positioning constraints
|
|
438
|
+
// Only use Portal if not disabled
|
|
439
|
+
if (disablePortal) {
|
|
440
|
+
return menuElement;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return typeof document !== "undefined"
|
|
444
|
+
? createPortal(menuElement, document.body)
|
|
445
|
+
: null;
|
|
193
446
|
}
|
|
194
447
|
);
|
|
195
448
|
|