@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhome/ui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "mHome UI Component Library",
6
6
  "main": "dist/index.cjs.js",
@@ -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
- // Find the nearest positioned ancestor (position: relative, absolute, or fixed)
59
- let container = anchorEl.parentElement;
60
- while (container && container !== document.body && container.isConnected) {
61
- try {
62
- const style = window.getComputedStyle(container);
63
- if (style.position === 'relative' || style.position === 'absolute' || style.position === 'fixed') {
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
- let containerRect = { top: 0, left: 0 };
74
- if (container && container !== document.body && container.isConnected && container.getBoundingClientRect) {
75
- try {
76
- containerRect = container.getBoundingClientRect();
77
- // Validate containerRect
78
- if (!containerRect || isNaN(containerRect.top) || isNaN(containerRect.left)) {
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
- // Calculate relative position - menu appears below the button
87
- const relativeTop = anchorRect.bottom - containerRect.top + 8;
88
- const relativeLeft = anchorRect.left - containerRect.left;
89
-
90
- // Validate calculated values
91
- if (isNaN(relativeTop) || isNaN(relativeLeft)) {
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: 50, left: 0 });
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
- isMounted = false;
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
- return (
398
+ const menuElement = (
158
399
  <div
159
400
  ref={menuRef}
160
401
  className={cn("rounded-lg shadow-lg", className)}
161
402
  style={{
162
- position: "absolute",
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: isDark
175
- ? "1px solid rgba(255, 255, 255, 0.1)"
176
- : "1px solid hsl(var(--border))",
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", // Ensure content is 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