@mhome/ui 0.1.2 → 0.1.5
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/dialog.jsx +43 -15
- package/src/components/drawer.jsx +6 -1
- package/src/components/menu.jsx +377 -51
- package/src/components/typography.jsx +5 -2
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { cn, spacingToPx, useIsDarkMode } from "../lib/utils";
|
|
4
4
|
import { getBackground } from "../common/adaptive-theme-provider";
|
|
5
|
-
import {
|
|
5
|
+
import { backgroundTypeState, useRecoilValue } from "../global-state";
|
|
6
6
|
|
|
7
7
|
const Dialog = ({
|
|
8
8
|
open,
|
|
@@ -330,14 +330,23 @@ const DialogContent = React.forwardRef(
|
|
|
330
330
|
);
|
|
331
331
|
DialogContent.displayName = "DialogContent";
|
|
332
332
|
|
|
333
|
+
// Context to track if DialogTitle is inside DialogHeader
|
|
334
|
+
const DialogHeaderContext = React.createContext(false);
|
|
335
|
+
|
|
333
336
|
const DialogHeader = ({ className, ...props }) => (
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
337
|
+
<DialogHeaderContext.Provider value={true}>
|
|
338
|
+
<div
|
|
339
|
+
className={cn(
|
|
340
|
+
"flex flex-col space-y-1.5 px-6 pt-6",
|
|
341
|
+
className
|
|
342
|
+
)}
|
|
343
|
+
style={{
|
|
344
|
+
textAlign: "left", // Ensure left alignment for all children
|
|
345
|
+
...props.style,
|
|
346
|
+
}}
|
|
347
|
+
{...props}
|
|
348
|
+
/>
|
|
349
|
+
</DialogHeaderContext.Provider>
|
|
341
350
|
);
|
|
342
351
|
DialogHeader.displayName = "DialogHeader";
|
|
343
352
|
|
|
@@ -364,6 +373,9 @@ const DialogTitle = React.forwardRef(
|
|
|
364
373
|
bgColor.startsWith("radial-gradient") ||
|
|
365
374
|
bgColor.startsWith("conic-gradient"));
|
|
366
375
|
|
|
376
|
+
// Check if DialogTitle is inside DialogHeader
|
|
377
|
+
const isInsideDialogHeader = React.useContext(DialogHeaderContext);
|
|
378
|
+
|
|
367
379
|
// Convert sx prop to style if provided, handling MUI spacing
|
|
368
380
|
const sxStyles = React.useMemo(() => {
|
|
369
381
|
if (!sx) return {};
|
|
@@ -455,14 +467,30 @@ const DialogTitle = React.forwardRef(
|
|
|
455
467
|
...restStyle
|
|
456
468
|
} = style || {};
|
|
457
469
|
|
|
458
|
-
// Check if padding is explicitly set
|
|
459
|
-
//
|
|
470
|
+
// Check if padding is explicitly set in style, sxStyles, or className
|
|
471
|
+
// Check for padding-related Tailwind classes in className
|
|
472
|
+
const hasPaddingInClassName = className && (
|
|
473
|
+
/\b(p|px|py|pt|pb|pl|pr)-\d+/.test(className) ||
|
|
474
|
+
/\bpadding/.test(className)
|
|
475
|
+
);
|
|
476
|
+
|
|
460
477
|
const hasAnyPadding =
|
|
461
478
|
padding !== undefined ||
|
|
462
479
|
paddingTop !== undefined ||
|
|
463
480
|
paddingBottom !== undefined ||
|
|
464
481
|
paddingLeft !== undefined ||
|
|
465
|
-
paddingRight !== undefined
|
|
482
|
+
paddingRight !== undefined ||
|
|
483
|
+
sxStyles.padding !== undefined ||
|
|
484
|
+
sxStyles.paddingTop !== undefined ||
|
|
485
|
+
sxStyles.paddingBottom !== undefined ||
|
|
486
|
+
sxStyles.paddingLeft !== undefined ||
|
|
487
|
+
sxStyles.paddingRight !== undefined ||
|
|
488
|
+
hasPaddingInClassName;
|
|
489
|
+
|
|
490
|
+
// DialogTitle should have default padding when used standalone (not inside DialogHeader)
|
|
491
|
+
// When inside DialogHeader, DialogHeader provides px-6 pt-6, so DialogTitle doesn't need padding
|
|
492
|
+
// When used standalone, DialogTitle should have px-6 pt-6 to match DialogHeader's padding
|
|
493
|
+
const defaultPaddingClass = !isInsideDialogHeader && !hasAnyPadding ? "px-6 pt-6" : "";
|
|
466
494
|
|
|
467
495
|
return (
|
|
468
496
|
<h2
|
|
@@ -471,9 +499,8 @@ const DialogTitle = React.forwardRef(
|
|
|
471
499
|
"rounded-t-[inherit]",
|
|
472
500
|
"text-lg font-semibold leading-none tracking-tight",
|
|
473
501
|
"text-foreground",
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
// For normal use, padding is handled by DialogHeader
|
|
502
|
+
"text-left", // Always left align
|
|
503
|
+
defaultPaddingClass,
|
|
477
504
|
className
|
|
478
505
|
)}
|
|
479
506
|
style={{
|
|
@@ -485,12 +512,13 @@ const DialogTitle = React.forwardRef(
|
|
|
485
512
|
? { background: backgroundColor }
|
|
486
513
|
: { backgroundColor }
|
|
487
514
|
: {}),
|
|
488
|
-
// Allow padding override via style prop
|
|
515
|
+
// Allow padding override via style prop (for standalone use)
|
|
489
516
|
padding: padding,
|
|
490
517
|
paddingLeft: paddingLeft,
|
|
491
518
|
paddingRight: paddingRight,
|
|
492
519
|
paddingTop: paddingTop,
|
|
493
520
|
paddingBottom: paddingBottom,
|
|
521
|
+
textAlign: "left", // Ensure left alignment
|
|
494
522
|
...sxStyles,
|
|
495
523
|
...restStyle,
|
|
496
524
|
}}
|
|
@@ -17,6 +17,7 @@ const Drawer = React.forwardRef(
|
|
|
17
17
|
variant = "temporary",
|
|
18
18
|
ModalProps,
|
|
19
19
|
SlideProps,
|
|
20
|
+
BackdropProps,
|
|
20
21
|
...props
|
|
21
22
|
},
|
|
22
23
|
ref
|
|
@@ -173,9 +174,13 @@ const Drawer = React.forwardRef(
|
|
|
173
174
|
opacity: open ? 1 : 0,
|
|
174
175
|
transition: `opacity ${transitionDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
|
175
176
|
pointerEvents: open ? "auto" : "none",
|
|
177
|
+
...BackdropProps?.style,
|
|
176
178
|
}}
|
|
177
179
|
onClick={handleBackdropClick}
|
|
178
180
|
aria-hidden="true"
|
|
181
|
+
{...Object.fromEntries(
|
|
182
|
+
Object.entries(BackdropProps || {}).filter(([key]) => key !== "style")
|
|
183
|
+
)}
|
|
179
184
|
/>
|
|
180
185
|
)}
|
|
181
186
|
|
|
@@ -200,7 +205,7 @@ const Drawer = React.forwardRef(
|
|
|
200
205
|
}}
|
|
201
206
|
role="presentation"
|
|
202
207
|
{...Object.fromEntries(
|
|
203
|
-
Object.entries(props).filter(([key]) => key !== "style")
|
|
208
|
+
Object.entries(props).filter(([key]) => key !== "style" && key !== "BackdropProps")
|
|
204
209
|
)}
|
|
205
210
|
>
|
|
206
211
|
{/* Paper (drawer content) */}
|
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,13 +12,68 @@ 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 });
|
|
27
|
+
const previousPositionRef = React.useRef({ top: 0, left: 0 });
|
|
28
|
+
|
|
29
|
+
// Extract borderRadius to a stable value to prevent infinite loops
|
|
30
|
+
// Use a ref to track the last borderRadius value to avoid dependency issues
|
|
31
|
+
// MUST be called before any early returns (React Hooks rule)
|
|
32
|
+
const borderRadiusRef = React.useRef(PaperProps?.style?.borderRadius || "8px");
|
|
33
|
+
if (PaperProps?.style?.borderRadius !== undefined) {
|
|
34
|
+
borderRadiusRef.current = PaperProps.style.borderRadius;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Process children to add borderRadius info to MenuItems
|
|
38
|
+
// We'll do this in the render phase instead of useMemo to avoid issues
|
|
39
|
+
const processChildren = (childrenToProcess) => {
|
|
40
|
+
if (!childrenToProcess) return childrenToProcess;
|
|
41
|
+
|
|
42
|
+
const borderRadius = borderRadiusRef.current;
|
|
43
|
+
|
|
44
|
+
// Get all MenuItem children first
|
|
45
|
+
const childrenArray = React.Children.toArray(childrenToProcess).filter(
|
|
46
|
+
(c) => {
|
|
47
|
+
if (!React.isValidElement(c)) return false;
|
|
48
|
+
const type = c.type;
|
|
49
|
+
return type?.displayName === "MenuItem" ||
|
|
50
|
+
(typeof type === 'function' && type.name === 'MenuItem');
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return React.Children.map(childrenToProcess, (child) => {
|
|
55
|
+
if (!React.isValidElement(child)) return child;
|
|
56
|
+
|
|
57
|
+
// Check if child is a MenuItem
|
|
58
|
+
const type = child.type;
|
|
59
|
+
const isMenuItem = type?.displayName === "MenuItem" ||
|
|
60
|
+
(typeof type === 'function' && type.name === 'MenuItem');
|
|
61
|
+
|
|
62
|
+
if (!isMenuItem) return child;
|
|
63
|
+
|
|
64
|
+
const childIndex = childrenArray.findIndex((c) => c === child);
|
|
65
|
+
const isFirst = childIndex === 0;
|
|
66
|
+
const isLast = childIndex === childrenArray.length - 1;
|
|
67
|
+
|
|
68
|
+
// Clone child and pass borderRadius info
|
|
69
|
+
return React.cloneElement(child, {
|
|
70
|
+
...child.props,
|
|
71
|
+
_isFirst: isFirst,
|
|
72
|
+
_isLast: isLast,
|
|
73
|
+
_menuBorderRadius: borderRadius,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
};
|
|
21
77
|
|
|
22
78
|
React.useImperativeHandle(ref, () => menuRef.current);
|
|
23
79
|
|
|
@@ -54,50 +110,126 @@ const Menu = React.forwardRef(
|
|
|
54
110
|
}
|
|
55
111
|
return;
|
|
56
112
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
113
|
+
|
|
114
|
+
if (positionType === "fixed") {
|
|
115
|
+
// Fixed positioning: use viewport coordinates
|
|
116
|
+
const viewportWidth = window.innerWidth;
|
|
117
|
+
const viewportHeight = window.innerHeight;
|
|
118
|
+
|
|
119
|
+
// Get menu dimensions (estimate if not yet rendered)
|
|
120
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
121
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
122
|
+
|
|
123
|
+
// Calculate initial position based on anchorOrigin
|
|
124
|
+
let top = 0;
|
|
125
|
+
let left = 0;
|
|
126
|
+
const gap = 8;
|
|
127
|
+
|
|
128
|
+
// Vertical positioning
|
|
129
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
130
|
+
top = anchorRect.bottom + gap;
|
|
131
|
+
} else if (anchorOrigin.vertical === 'top') {
|
|
132
|
+
top = anchorRect.top - menuHeight - gap;
|
|
133
|
+
} else {
|
|
134
|
+
// center
|
|
135
|
+
top = anchorRect.top + (anchorRect.height - menuHeight) / 2;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Horizontal positioning
|
|
139
|
+
if (anchorOrigin.horizontal === 'left') {
|
|
140
|
+
left = anchorRect.left;
|
|
141
|
+
} else if (anchorOrigin.horizontal === 'right') {
|
|
142
|
+
left = anchorRect.right - menuWidth;
|
|
143
|
+
} else {
|
|
144
|
+
// center
|
|
145
|
+
left = anchorRect.left + (anchorRect.width - menuWidth) / 2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Boundary detection and adjustment
|
|
149
|
+
const padding = 16;
|
|
150
|
+
|
|
151
|
+
// Adjust horizontal position if menu would overflow
|
|
152
|
+
if (left + menuWidth > viewportWidth - padding) {
|
|
153
|
+
left = viewportWidth - menuWidth - padding;
|
|
154
|
+
}
|
|
155
|
+
if (left < padding) {
|
|
156
|
+
left = padding;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Adjust vertical position if menu would overflow
|
|
160
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
161
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
162
|
+
// Try to show above anchor
|
|
163
|
+
top = anchorRect.top - menuHeight - gap;
|
|
164
|
+
}
|
|
165
|
+
// Clamp to viewport
|
|
166
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
167
|
+
top = viewportHeight - menuHeight - padding;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (top < padding) {
|
|
171
|
+
top = padding;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (isMounted) {
|
|
175
|
+
setPosition({ top, left });
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// Absolute positioning: use relative coordinates
|
|
179
|
+
// Find the nearest positioned ancestor
|
|
180
|
+
let container = anchorEl.parentElement;
|
|
181
|
+
while (container && container !== document.body && container.isConnected) {
|
|
182
|
+
try {
|
|
183
|
+
const style = window.getComputedStyle(container);
|
|
184
|
+
if (style.position === 'relative' || style.position === 'absolute' || style.position === 'fixed') {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
container = container.parentElement;
|
|
188
|
+
} catch (e) {
|
|
64
189
|
break;
|
|
65
190
|
}
|
|
66
|
-
container = container.parentElement;
|
|
67
|
-
} catch (e) {
|
|
68
|
-
// If getComputedStyle fails, break the loop
|
|
69
|
-
break;
|
|
70
191
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
192
|
+
|
|
193
|
+
let containerRect = { top: 0, left: 0 };
|
|
194
|
+
if (container && container !== document.body && container.isConnected && container.getBoundingClientRect) {
|
|
195
|
+
try {
|
|
196
|
+
containerRect = container.getBoundingClientRect();
|
|
197
|
+
if (!containerRect || isNaN(containerRect.top) || isNaN(containerRect.left)) {
|
|
198
|
+
containerRect = { top: 0, left: 0 };
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
79
201
|
containerRect = { top: 0, left: 0 };
|
|
80
202
|
}
|
|
81
|
-
} catch (e) {
|
|
82
|
-
containerRect = { top: 0, left: 0 };
|
|
83
203
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
204
|
+
|
|
205
|
+
// Calculate relative position
|
|
206
|
+
const gap = 8;
|
|
207
|
+
let relativeTop = 0;
|
|
208
|
+
let relativeLeft = 0;
|
|
209
|
+
|
|
210
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
211
|
+
relativeTop = anchorRect.bottom - containerRect.top + gap;
|
|
212
|
+
} else if (anchorOrigin.vertical === 'top') {
|
|
213
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
214
|
+
relativeTop = anchorRect.top - containerRect.top - menuHeight - gap;
|
|
215
|
+
} else {
|
|
216
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
217
|
+
relativeTop = anchorRect.top - containerRect.top + (anchorRect.height - menuHeight) / 2;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (anchorOrigin.horizontal === 'left') {
|
|
221
|
+
relativeLeft = anchorRect.left - containerRect.left;
|
|
222
|
+
} else if (anchorOrigin.horizontal === 'right') {
|
|
223
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
224
|
+
relativeLeft = anchorRect.right - containerRect.left - menuWidth;
|
|
225
|
+
} else {
|
|
226
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
227
|
+
relativeLeft = anchorRect.left - containerRect.left + (anchorRect.width - menuWidth) / 2;
|
|
228
|
+
}
|
|
229
|
+
|
|
92
230
|
if (isMounted) {
|
|
93
|
-
setPosition({ top:
|
|
231
|
+
setPosition({ top: relativeTop, left: relativeLeft });
|
|
94
232
|
}
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Only update state if component is still mounted
|
|
99
|
-
if (isMounted) {
|
|
100
|
-
setPosition({ top: relativeTop, left: relativeLeft });
|
|
101
233
|
}
|
|
102
234
|
} catch (e) {
|
|
103
235
|
// Fallback position - only update if mounted
|
|
@@ -107,13 +239,180 @@ const Menu = React.forwardRef(
|
|
|
107
239
|
}
|
|
108
240
|
});
|
|
109
241
|
|
|
242
|
+
// Update position on scroll and resize (only for fixed positioning)
|
|
243
|
+
if (positionType === "fixed") {
|
|
244
|
+
const handleUpdate = () => {
|
|
245
|
+
rafId = requestAnimationFrame(() => {
|
|
246
|
+
if (isMounted) {
|
|
247
|
+
// Re-run position calculation
|
|
248
|
+
const anchorRect = anchorEl?.getBoundingClientRect();
|
|
249
|
+
if (anchorRect) {
|
|
250
|
+
const viewportWidth = window.innerWidth;
|
|
251
|
+
const viewportHeight = window.innerHeight;
|
|
252
|
+
const menuWidth = menuRef.current?.offsetWidth || PaperProps?.style?.minWidth || 180;
|
|
253
|
+
const menuHeight = menuRef.current?.offsetHeight || 100;
|
|
254
|
+
const gap = 8;
|
|
255
|
+
const padding = 16;
|
|
256
|
+
|
|
257
|
+
let top = anchorOrigin.vertical === 'bottom'
|
|
258
|
+
? anchorRect.bottom + gap
|
|
259
|
+
: anchorOrigin.vertical === 'top'
|
|
260
|
+
? anchorRect.top - menuHeight - gap
|
|
261
|
+
: anchorRect.top + (anchorRect.height - menuHeight) / 2;
|
|
262
|
+
|
|
263
|
+
let left = anchorOrigin.horizontal === 'left'
|
|
264
|
+
? anchorRect.left
|
|
265
|
+
: anchorOrigin.horizontal === 'right'
|
|
266
|
+
? anchorRect.right - menuWidth
|
|
267
|
+
: anchorRect.left + (anchorRect.width - menuWidth) / 2;
|
|
268
|
+
|
|
269
|
+
if (left + menuWidth > viewportWidth - padding) {
|
|
270
|
+
left = viewportWidth - menuWidth - padding;
|
|
271
|
+
}
|
|
272
|
+
if (left < padding) left = padding;
|
|
273
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
274
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
275
|
+
top = anchorRect.top - menuHeight - gap;
|
|
276
|
+
}
|
|
277
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
278
|
+
top = viewportHeight - menuHeight - padding;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (top < padding) top = padding;
|
|
282
|
+
|
|
283
|
+
setPosition({ top, left });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
window.addEventListener('scroll', handleUpdate, true);
|
|
290
|
+
window.addEventListener('resize', handleUpdate);
|
|
291
|
+
|
|
292
|
+
return () => {
|
|
293
|
+
isMounted = false;
|
|
294
|
+
if (rafId !== null) {
|
|
295
|
+
cancelAnimationFrame(rafId);
|
|
296
|
+
}
|
|
297
|
+
window.removeEventListener('scroll', handleUpdate, true);
|
|
298
|
+
window.removeEventListener('resize', handleUpdate);
|
|
299
|
+
};
|
|
300
|
+
} else {
|
|
301
|
+
return () => {
|
|
302
|
+
isMounted = false;
|
|
303
|
+
if (rafId !== null) {
|
|
304
|
+
cancelAnimationFrame(rafId);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}, [open, anchorEl, anchorOrigin.vertical, anchorOrigin.horizontal, positionType, PaperProps?.style?.minWidth]);
|
|
309
|
+
|
|
310
|
+
// Recalculate position after menu is rendered (to get actual dimensions)
|
|
311
|
+
React.useLayoutEffect(() => {
|
|
312
|
+
if (!open || !anchorEl || !menuRef.current || positionType !== "fixed") return;
|
|
313
|
+
|
|
314
|
+
const updatePositionWithRealSize = () => {
|
|
315
|
+
try {
|
|
316
|
+
const anchorRect = anchorEl.getBoundingClientRect();
|
|
317
|
+
if (!anchorRect) return;
|
|
318
|
+
|
|
319
|
+
const viewportWidth = window.innerWidth;
|
|
320
|
+
const viewportHeight = window.innerHeight;
|
|
321
|
+
|
|
322
|
+
// Get actual menu dimensions
|
|
323
|
+
const menuWidth = menuRef.current.offsetWidth;
|
|
324
|
+
const menuHeight = menuRef.current.offsetHeight;
|
|
325
|
+
|
|
326
|
+
if (!menuWidth || !menuHeight) return; // Menu not fully rendered yet
|
|
327
|
+
|
|
328
|
+
const gap = 8;
|
|
329
|
+
const padding = 16;
|
|
330
|
+
|
|
331
|
+
// Calculate initial position based on anchorOrigin
|
|
332
|
+
let top = 0;
|
|
333
|
+
let left = 0;
|
|
334
|
+
|
|
335
|
+
// Vertical positioning
|
|
336
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
337
|
+
top = anchorRect.bottom + gap;
|
|
338
|
+
} else if (anchorOrigin.vertical === 'top') {
|
|
339
|
+
top = anchorRect.top - menuHeight - gap;
|
|
340
|
+
} else {
|
|
341
|
+
top = anchorRect.top + (anchorRect.height - menuHeight) / 2;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Horizontal positioning
|
|
345
|
+
if (anchorOrigin.horizontal === 'left') {
|
|
346
|
+
left = anchorRect.left;
|
|
347
|
+
} else if (anchorOrigin.horizontal === 'right') {
|
|
348
|
+
left = anchorRect.right - menuWidth;
|
|
349
|
+
} else {
|
|
350
|
+
left = anchorRect.left + (anchorRect.width - menuWidth) / 2;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Strict boundary detection - ensure menu is fully within viewport
|
|
354
|
+
// Adjust horizontal position
|
|
355
|
+
if (left + menuWidth > viewportWidth - padding) {
|
|
356
|
+
// Menu would overflow on the right, align to right edge
|
|
357
|
+
left = viewportWidth - menuWidth - padding;
|
|
358
|
+
}
|
|
359
|
+
if (left < padding) {
|
|
360
|
+
// Menu would overflow on the left
|
|
361
|
+
left = padding;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Adjust vertical position
|
|
365
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
366
|
+
// Menu would overflow on the bottom
|
|
367
|
+
if (anchorOrigin.vertical === 'bottom') {
|
|
368
|
+
// Try to show above anchor
|
|
369
|
+
top = anchorRect.top - menuHeight - gap;
|
|
370
|
+
}
|
|
371
|
+
// If still overflowing, clamp to viewport
|
|
372
|
+
if (top + menuHeight > viewportHeight - padding) {
|
|
373
|
+
top = viewportHeight - menuHeight - padding;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (top < padding) {
|
|
377
|
+
// Menu would overflow on the top
|
|
378
|
+
top = padding;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Ensure menu doesn't exceed viewport in any direction
|
|
382
|
+
if (left < 0) left = padding;
|
|
383
|
+
if (top < 0) top = padding;
|
|
384
|
+
if (left + menuWidth > viewportWidth) {
|
|
385
|
+
left = Math.max(padding, viewportWidth - menuWidth - padding);
|
|
386
|
+
}
|
|
387
|
+
if (top + menuHeight > viewportHeight) {
|
|
388
|
+
top = Math.max(padding, viewportHeight - menuHeight - padding);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Only update position if it actually changed to prevent infinite loops
|
|
392
|
+
const newPosition = { top, left };
|
|
393
|
+
if (
|
|
394
|
+
Math.abs(previousPositionRef.current.top - newPosition.top) > 0.5 ||
|
|
395
|
+
Math.abs(previousPositionRef.current.left - newPosition.left) > 0.5
|
|
396
|
+
) {
|
|
397
|
+
previousPositionRef.current = newPosition;
|
|
398
|
+
setPosition(newPosition);
|
|
399
|
+
}
|
|
400
|
+
} catch (e) {
|
|
401
|
+
// Silently fail if calculation error
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Use requestAnimationFrame to ensure DOM is updated
|
|
406
|
+
const rafId = requestAnimationFrame(() => {
|
|
407
|
+
updatePositionWithRealSize();
|
|
408
|
+
});
|
|
409
|
+
|
|
110
410
|
return () => {
|
|
111
|
-
|
|
112
|
-
if (rafId !== null) {
|
|
411
|
+
if (rafId) {
|
|
113
412
|
cancelAnimationFrame(rafId);
|
|
114
413
|
}
|
|
115
414
|
};
|
|
116
|
-
}, [open, anchorEl]);
|
|
415
|
+
}, [open, anchorEl, anchorOrigin.vertical, anchorOrigin.horizontal, positionType]); // Re-run when menu opens or anchor changes
|
|
117
416
|
|
|
118
417
|
// Handle backdrop click
|
|
119
418
|
React.useEffect(() => {
|
|
@@ -154,12 +453,12 @@ const Menu = React.forwardRef(
|
|
|
154
453
|
const isDark = document.documentElement.classList.contains('dark') ||
|
|
155
454
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
156
455
|
|
|
157
|
-
|
|
456
|
+
const menuElement = (
|
|
158
457
|
<div
|
|
159
458
|
ref={menuRef}
|
|
160
459
|
className={cn("rounded-lg shadow-lg", className)}
|
|
161
460
|
style={{
|
|
162
|
-
position:
|
|
461
|
+
position: positionType,
|
|
163
462
|
top: `${position.top}px`,
|
|
164
463
|
left: `${position.left}px`,
|
|
165
464
|
zIndex: 1300,
|
|
@@ -171,15 +470,19 @@ const Menu = React.forwardRef(
|
|
|
171
470
|
boxShadow: isDark
|
|
172
471
|
? "0px 4px 12px rgba(0, 0, 0, 0.4), 0px 0px 0px 1px rgba(255, 255, 255, 0.1)"
|
|
173
472
|
: "0px 4px 12px rgba(0, 0, 0, 0.15)",
|
|
174
|
-
border
|
|
175
|
-
|
|
176
|
-
|
|
473
|
+
...(border || PaperProps?.style?.border ? {
|
|
474
|
+
border: PaperProps?.style?.border || (isDark
|
|
475
|
+
? "1px solid rgba(255, 255, 255, 0.1)"
|
|
476
|
+
: "1px solid hsl(var(--border))")
|
|
477
|
+
} : {}),
|
|
177
478
|
borderRadius: PaperProps?.style?.borderRadius || "8px",
|
|
178
479
|
minWidth: PaperProps?.style?.minWidth || 180,
|
|
179
480
|
maxWidth: "calc(100vw - 32px)",
|
|
180
|
-
padding: "4px 0",
|
|
481
|
+
padding: "4px 0", // Vertical padding only, no horizontal padding so MenuItem hover extends to border
|
|
181
482
|
height: "auto",
|
|
182
|
-
overflow: "
|
|
483
|
+
overflow: "hidden", // Ensure children don't overflow rounded corners, but allow MenuItem hover to extend to edges
|
|
484
|
+
// Ensure MenuItem can extend to edges
|
|
485
|
+
boxSizing: "border-box",
|
|
183
486
|
...PaperProps?.style,
|
|
184
487
|
...props.style,
|
|
185
488
|
}}
|
|
@@ -187,9 +490,19 @@ const Menu = React.forwardRef(
|
|
|
187
490
|
Object.entries(props || {}).filter(([key]) => key !== "style")
|
|
188
491
|
)}
|
|
189
492
|
>
|
|
190
|
-
{children}
|
|
493
|
+
{processChildren(children)}
|
|
191
494
|
</div>
|
|
192
495
|
);
|
|
496
|
+
|
|
497
|
+
// Use Portal to render menu at body level to avoid positioning constraints
|
|
498
|
+
// Only use Portal if not disabled
|
|
499
|
+
if (disablePortal) {
|
|
500
|
+
return menuElement;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return typeof document !== "undefined"
|
|
504
|
+
? createPortal(menuElement, document.body)
|
|
505
|
+
: null;
|
|
193
506
|
}
|
|
194
507
|
);
|
|
195
508
|
|
|
@@ -197,7 +510,7 @@ Menu.displayName = "Menu";
|
|
|
197
510
|
|
|
198
511
|
const MenuItem = React.forwardRef(
|
|
199
512
|
(
|
|
200
|
-
{ className, onClick, disabled = false, sx, style, children, ...props },
|
|
513
|
+
{ className, onClick, disabled = false, sx, style, children, _isFirst, _isLast, _menuBorderRadius, ...props },
|
|
201
514
|
ref
|
|
202
515
|
) => {
|
|
203
516
|
const [hovered, setHovered] = React.useState(false);
|
|
@@ -222,13 +535,13 @@ const MenuItem = React.forwardRef(
|
|
|
222
535
|
paddingRight: `${menuItemPadding.x}px`,
|
|
223
536
|
paddingTop: `${menuItemPadding.y}px`,
|
|
224
537
|
paddingBottom: `${menuItemPadding.y}px`,
|
|
538
|
+
margin: 0, // Ensure no margin that could cause overflow
|
|
225
539
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
226
540
|
fontSize: menuItemFontSize,
|
|
227
541
|
color: disabled
|
|
228
542
|
? "#666666"
|
|
229
543
|
: "#000000", // Use fixed color for testing
|
|
230
|
-
backgroundColor:
|
|
231
|
-
hovered && !disabled ? "hsl(var(--accent))" : "transparent",
|
|
544
|
+
backgroundColor: hovered && !disabled ? "hsl(var(--accent))" : "transparent",
|
|
232
545
|
opacity: disabled
|
|
233
546
|
? 0.5
|
|
234
547
|
: sxStyles.opacity !== undefined
|
|
@@ -240,7 +553,16 @@ const MenuItem = React.forwardRef(
|
|
|
240
553
|
textAlign: "left",
|
|
241
554
|
fontWeight: "normal",
|
|
242
555
|
width: "100%",
|
|
556
|
+
maxWidth: "100%", // Ensure it doesn't exceed parent width
|
|
243
557
|
boxSizing: "border-box",
|
|
558
|
+
position: "relative",
|
|
559
|
+
// Ensure MenuItem doesn't overflow Menu boundaries
|
|
560
|
+
overflow: "hidden",
|
|
561
|
+
// Apply border radius to match Menu's borderRadius
|
|
562
|
+
borderTopLeftRadius: _isFirst ? _menuBorderRadius : 0,
|
|
563
|
+
borderTopRightRadius: _isFirst ? _menuBorderRadius : 0,
|
|
564
|
+
borderBottomLeftRadius: _isLast ? _menuBorderRadius : 0,
|
|
565
|
+
borderBottomRightRadius: _isLast ? _menuBorderRadius : 0,
|
|
244
566
|
...sxStyles,
|
|
245
567
|
...style,
|
|
246
568
|
};
|
|
@@ -280,7 +602,11 @@ const MenuItem = React.forwardRef(
|
|
|
280
602
|
"focus:outline-none focus:bg-accent focus:text-accent-foreground",
|
|
281
603
|
className
|
|
282
604
|
)}
|
|
283
|
-
style={
|
|
605
|
+
style={{
|
|
606
|
+
...mergedStyle,
|
|
607
|
+
// Use pseudo-element approach: extend hover background to edges
|
|
608
|
+
position: "relative",
|
|
609
|
+
}}
|
|
284
610
|
onClick={handleClick}
|
|
285
611
|
onMouseDown={handleMouseDown}
|
|
286
612
|
onKeyDown={handleKeyDown}
|