react-morpheus 0.1.5 → 0.1.8

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/README.md CHANGED
@@ -52,7 +52,6 @@ function Example() {
52
52
 
53
53
  return (
54
54
  <Morpheus
55
- direction="bottom"
56
55
  anchor={MorphAnchor.TopMiddle}
57
56
  expanded={expanded}
58
57
  onClose={() => setExpanded(false)}
@@ -60,6 +59,7 @@ function Example() {
60
59
  overlayOpacity={0.18}
61
60
  overlayBlur={3}
62
61
  overlayZIndex={1200}
62
+ viewportPadding={16}
63
63
  spring={morphSpringPresets.smooth}
64
64
  collapsedContent={source}
65
65
  expandedContent={
@@ -85,8 +85,7 @@ should run around the closing animation.
85
85
 
86
86
  | Prop | Type | Required | Notes |
87
87
  | --- | --- | --- | --- |
88
- | `direction` | `"top" \| "right" \| "bottom" \| "left"` | Required | Where the panel opens from. Also chooses the default anchor when `anchor` is omitted. |
89
- | `anchor` | `MorphAnchor` | Optional | The edge or point that stays visually anchored. |
88
+ | `anchor` | `MorphAnchor` | Required | The edge or point that stays visually anchored. |
90
89
  | `expanded` | `boolean` | Required | Controlled open state owned by your app. |
91
90
  | `onClose` | `() => void` | Optional | Called when the overlay requests closing. Use it to update your controlled open state. |
92
91
  | `beforeClose` | `() => void` | Optional | Called when the closing animation starts, after `expanded` has changed to `false`. Useful for close-intent side effects. |
@@ -94,6 +93,9 @@ should run around the closing animation.
94
93
  | `collapsedContent` | `ReactNode` | Required | The source surface Morpheus measures, renders, and uses as the opener. |
95
94
  | `expandedContent` | `ReactNode` | Required | The destination surface Morpheus measures and animates into. |
96
95
  | `className` | `string` | Optional | Classes applied to the root wrapper. Useful for block layout and responsive width constraints. |
96
+ | `shellClassName` | `string` | Optional | Classes applied directly to the animated shell, including when it is portalled. |
97
+ | `portalContainer` | `HTMLElement \| null` | Optional | Portal root for the expanded surface. Defaults to `document.body` after mount; pass `null` for legacy inline rendering. |
98
+ | `viewportPadding` | `number` | Optional | Minimum viewport gutter for the expanded shell. Defaults to `16`. |
97
99
  | `overlayColor` | `string` | Optional | Backdrop color shown while expanded. |
98
100
  | `overlayOpacity` | `number` | Optional | Backdrop opacity from `0` to `1`. |
99
101
  | `overlayBlur` | `number` | Optional | Backdrop blur in pixels. |
@@ -116,14 +118,19 @@ upward; middle anchors feel better for centered inspectors or command surfaces.
116
118
 
117
119
  ### Parent Container Overflow
118
120
 
119
- Do not place Morpheus inside a container that clips overflow, such as
120
- `overflow: hidden`, `overflow: clip`, or scroll containers. The expanded surface
121
- is positioned from the local source element and can extend outside its original
122
- container while animating.
121
+ Expanded surfaces and their overlays portal to `document.body` by default, so
122
+ they can escape clipped, transformed, and independently stacked ancestors. The
123
+ collapsed source remains in its original layout position. Pass another
124
+ document-level `portalContainer` when needed, or `null` to opt into the legacy
125
+ inline behavior.
126
+
127
+ Targets are clamped to the viewport using `viewportPadding`. When their natural
128
+ size is larger than the available viewport, the shell stays bounded and becomes
129
+ scrollable after the opening animation settles.
123
130
 
124
131
  ### Stacking
125
132
 
126
- `overlayZIndex` controls the expanded morph surface. The overlay renders one
133
+ `overlayZIndex` controls the portalled expanded surface. The overlay renders one
127
134
  layer below it so apps can move the whole morphing stack above their own chrome.
128
135
  Without this prop, Morpheus uses surface `1000` and overlay `999`.
129
136
 
@@ -131,7 +138,7 @@ Without this prop, Morpheus uses surface `1000` and overlay `999`.
131
138
 
132
139
  `example.html` is a small local playground for trying Morpheus in a browser
133
140
  without setting up an app shell. Use it to check the default interaction,
134
- directions, anchors, and overlay behavior while changing the component.
141
+ anchors and overlay behavior while changing the component.
135
142
 
136
143
  ### Scripts
137
144
 
package/dist/index.d.ts CHANGED
@@ -2,7 +2,6 @@ import * as react from 'react';
2
2
  import { ReactNode, RefObject } from 'react';
3
3
  import { Transition } from 'framer-motion';
4
4
 
5
- type MorphDirection = "top" | "right" | "bottom" | "left";
6
5
  declare enum MorphAnchor {
7
6
  LeftTop = "left-top",
8
7
  LeftMiddle = "left-middle",
@@ -18,6 +17,10 @@ type PanelSize = {
18
17
  width: number;
19
18
  height: number;
20
19
  };
20
+ type PanelRect = PanelSize & {
21
+ left: number;
22
+ top: number;
23
+ };
21
24
  type ContentOffset = {
22
25
  x: number;
23
26
  y: number;
@@ -49,8 +52,7 @@ type MorphMeasurements = {
49
52
  };
50
53
  type MorphMotionInput = {
51
54
  expanded: boolean;
52
- direction: MorphDirection;
53
- anchor?: MorphAnchor | undefined;
55
+ anchor: MorphAnchor;
54
56
  spring: Transition;
55
57
  animationEnabled: boolean;
56
58
  collapsed: MorphMeasuredSurface;
@@ -74,8 +76,7 @@ type MorphMotionState = {
74
76
  visualStyle: PanelVisualStyle;
75
77
  };
76
78
  type MorphProps = {
77
- direction: MorphDirection;
78
- anchor?: MorphAnchor | undefined;
79
+ anchor: MorphAnchor;
79
80
  expanded: boolean;
80
81
  onClose?: (() => void) | undefined;
81
82
  beforeClose?: (() => void) | undefined;
@@ -83,6 +84,9 @@ type MorphProps = {
83
84
  collapsedContent: ReactNode;
84
85
  expandedContent: ReactNode;
85
86
  className?: string;
87
+ shellClassName?: string;
88
+ portalContainer?: HTMLElement | null;
89
+ viewportPadding?: number;
86
90
  overlayColor?: string;
87
91
  overlayOpacity?: number;
88
92
  overlayBlur?: number;
@@ -114,6 +118,7 @@ type MorphShellProps = {
114
118
  onMorphComplete: () => void;
115
119
  onMorphStart: () => void;
116
120
  children: ReactNode;
121
+ className?: string | undefined;
117
122
  };
118
123
  type MorphContentLayersProps = {
119
124
  expanded: boolean;
@@ -136,9 +141,8 @@ type MorphLayerInteractivityProps = {
136
141
  inert?: boolean;
137
142
  };
138
143
 
139
- declare function Morpheus({ direction, anchor, expanded, onClose, beforeClose, afterClose, collapsedContent, expandedContent, className, overlayColor, overlayOpacity, overlayBlur, overlayZIndex: zIndex, spring, }: MorphProps): react.JSX.Element;
144
+ declare function Morpheus(props: MorphProps): react.JSX.Element;
140
145
 
141
- declare const defaultAnchorByDirection: Record<MorphDirection, MorphAnchor>;
142
146
  declare const morphSpringPresets: {
143
147
  balanced: {
144
148
  type: "spring";
@@ -172,4 +176,4 @@ declare const morphSpringPresets: {
172
176
  };
173
177
  };
174
178
 
175
- export { type ContentOffset, MorphAnchor, type MorphContentLayersProps, type MorphDirection, type MorphLayerInteractivityProps, type MorphMeasuredSurface, type MorphMeasurementNodesProps, type MorphMeasurements, type MorphMotionInput, type MorphMotionState, type MorphOverlayProps, type MorphProps, type MorphShellProps, type MorphSpringPreset, type MorphSurfaceSnapshot, Morpheus, type PanelPosition, type PanelSize, type PanelVisualStyle, Morpheus as default, defaultAnchorByDirection, morphSpringPresets };
179
+ export { type ContentOffset, MorphAnchor, type MorphContentLayersProps, type MorphLayerInteractivityProps, type MorphMeasuredSurface, type MorphMeasurementNodesProps, type MorphMeasurements, type MorphMotionInput, type MorphMotionState, type MorphOverlayProps, type MorphProps, type MorphShellProps, type MorphSpringPreset, type MorphSurfaceSnapshot, Morpheus, type PanelPosition, type PanelRect, type PanelSize, type PanelVisualStyle, Morpheus as default, morphSpringPresets };
package/dist/index.js CHANGED
@@ -1,6 +1,15 @@
1
1
  // src/component.tsx
2
2
  import { motion as motion2 } from "framer-motion";
3
- import { useEffect, useRef as useRef2, useState as useState2 } from "react";
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useLayoutEffect as useLayoutEffect2,
9
+ useRef as useRef2,
10
+ useState as useState2
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
4
13
 
5
14
  // src/measurement.ts
6
15
  import { useLayoutEffect, useRef, useState } from "react";
@@ -40,8 +49,14 @@ function getSurfaceVisualStyle(element) {
40
49
  }
41
50
  function measureSurface(element) {
42
51
  const measuredElement = getMeasuredElement(element);
43
- const width = measuredElement.offsetWidth;
44
- const height = measuredElement.offsetHeight;
52
+ const width = Math.max(
53
+ measuredElement.offsetWidth,
54
+ measuredElement.scrollWidth
55
+ );
56
+ const height = Math.max(
57
+ measuredElement.offsetHeight,
58
+ measuredElement.scrollHeight
59
+ );
45
60
  if (width === 0 || height === 0) {
46
61
  return null;
47
62
  }
@@ -115,12 +130,6 @@ var MorphAnchor = /* @__PURE__ */ ((MorphAnchor2) => {
115
130
  })(MorphAnchor || {});
116
131
 
117
132
  // src/motion.ts
118
- var defaultAnchorByDirection = {
119
- top: "bottom-middle" /* BottomMiddle */,
120
- right: "left-middle" /* LeftMiddle */,
121
- bottom: "top-middle" /* TopMiddle */,
122
- left: "right-middle" /* RightMiddle */
123
- };
124
133
  var anchorTransformOrigins = {
125
134
  ["left-top" /* LeftTop */]: "left top",
126
135
  ["left-middle" /* LeftMiddle */]: "left center",
@@ -173,8 +182,7 @@ var sourceContentFade = {
173
182
  duration: 0.12,
174
183
  ease: "easeOut"
175
184
  };
176
- var sourceReturnDelay = 0.06;
177
- var sourceGrowthRatio = 2;
185
+ var sourceReturnDelay = 0.1;
178
186
  var instantTransition = { duration: 0 };
179
187
  var createSourceContentMotion = (spring) => ({
180
188
  opacity: sourceContentFade,
@@ -191,6 +199,12 @@ var createTargetContentMotion = (spring) => ({
191
199
  top: spring
192
200
  });
193
201
  var safeScale = (from, to) => to === 0 ? 1 : from / to;
202
+ function getAxisScale(fromSize, toSize) {
203
+ return {
204
+ x: safeScale(toSize.width, fromSize.width),
205
+ y: safeScale(toSize.height, fromSize.height)
206
+ };
207
+ }
194
208
  function getAnchoredPanelPosition(anchor, collapsedSize, targetSize) {
195
209
  const alignLeft = 0;
196
210
  const alignCenterX = (collapsedSize.width - targetSize.width) / 2;
@@ -230,7 +244,6 @@ function getLayerInteractivityProps(interactive) {
230
244
  }
231
245
  function getMorphMotionState({
232
246
  expanded,
233
- direction,
234
247
  anchor,
235
248
  spring,
236
249
  animationEnabled,
@@ -241,10 +254,9 @@ function getMorphMotionState({
241
254
  const expandedLayerSize = expandedSurface.size;
242
255
  const animatedSize = expanded ? expandedLayerSize : collapsedLayerSize;
243
256
  const visualStyle = expanded ? expandedSurface.visualStyle : collapsed.visualStyle;
244
- const panelAnchor = anchor ?? defaultAnchorByDirection[direction];
245
257
  const sourcePanelPosition = { left: 0, top: 0 };
246
258
  const targetPanelPosition = getAnchoredPanelPosition(
247
- panelAnchor,
259
+ anchor,
248
260
  collapsedLayerSize,
249
261
  expandedLayerSize
250
262
  );
@@ -254,17 +266,17 @@ function getMorphMotionState({
254
266
  anchorPosition: expanded ? targetPanelPosition : sourcePanelPosition,
255
267
  animatedSize,
256
268
  collapsedLayerSize,
257
- collapsedToExpandedScale: {
258
- x: safeScale(collapsedLayerSize.width, expandedLayerSize.width),
259
- y: safeScale(collapsedLayerSize.height, expandedLayerSize.height)
260
- },
269
+ collapsedToExpandedScale: getAxisScale(
270
+ expandedLayerSize,
271
+ collapsedLayerSize
272
+ ),
261
273
  expandedLayerSize,
262
- sourceGrowthScale: {
263
- x: sourceGrowthRatio,
264
- y: sourceGrowthRatio
265
- },
274
+ sourceGrowthScale: getAxisScale(collapsedLayerSize, expandedLayerSize),
266
275
  sourceOpacityTransition: {
267
276
  ...sourceContentFade,
277
+ // On close, the source layer is still being non-uniformly scaled from
278
+ // the target geometry. Keep it transparent until the spring has brought
279
+ // it close enough to its natural proportions to avoid a stretched flash.
268
280
  delay: expanded ? 0 : sourceReturnDelay
269
281
  },
270
282
  sourcePosition: expanded ? invertPosition(targetPanelPosition) : sourcePanelPosition,
@@ -272,7 +284,7 @@ function getMorphMotionState({
272
284
  targetOpacityTransition: { ...contentFade, delay: expanded ? 0.03 : 0 },
273
285
  targetPosition: expanded ? sourcePanelPosition : targetPanelPosition,
274
286
  targetTransition: createTargetContentMotion(activeSpring),
275
- transformOrigin: anchorTransformOrigins[panelAnchor],
287
+ transformOrigin: anchorTransformOrigins[anchor],
276
288
  visualStyle
277
289
  };
278
290
  }
@@ -324,22 +336,129 @@ function MorphOverlay({
324
336
  ) : null });
325
337
  }
326
338
 
339
+ // src/placement.ts
340
+ var clamp = (value, minimum, maximum) => Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
341
+ function getBoundedPanelSize(naturalSize, viewport, padding) {
342
+ const safePadding = Math.max(0, padding);
343
+ return {
344
+ width: Math.min(
345
+ naturalSize.width,
346
+ Math.max(1, viewport.width - safePadding * 2)
347
+ ),
348
+ height: Math.min(
349
+ naturalSize.height,
350
+ Math.max(1, viewport.height - safePadding * 2)
351
+ )
352
+ };
353
+ }
354
+ function getAnchoredTargetRect(anchor, source, targetSize, viewport, padding) {
355
+ const sourceCenterX = source.left + source.width / 2;
356
+ const sourceCenterY = source.top + source.height / 2;
357
+ let left = source.left;
358
+ let top = source.top;
359
+ switch (anchor) {
360
+ case "left-top" /* LeftTop */:
361
+ break;
362
+ case "left-middle" /* LeftMiddle */:
363
+ top = sourceCenterY - targetSize.height / 2;
364
+ break;
365
+ case "left-bottom" /* LeftBottom */:
366
+ top = source.top + source.height - targetSize.height;
367
+ break;
368
+ case "top-middle" /* TopMiddle */:
369
+ left = sourceCenterX - targetSize.width / 2;
370
+ break;
371
+ case "middle-middle" /* MiddleMiddle */:
372
+ left = sourceCenterX - targetSize.width / 2;
373
+ top = sourceCenterY - targetSize.height / 2;
374
+ break;
375
+ case "right-top" /* RightTop */:
376
+ left = source.left + source.width - targetSize.width;
377
+ break;
378
+ case "right-middle" /* RightMiddle */:
379
+ left = source.left + source.width - targetSize.width;
380
+ top = sourceCenterY - targetSize.height / 2;
381
+ break;
382
+ case "right-bottom" /* RightBottom */:
383
+ left = source.left + source.width - targetSize.width;
384
+ top = source.top + source.height - targetSize.height;
385
+ break;
386
+ case "bottom-middle" /* BottomMiddle */:
387
+ left = sourceCenterX - targetSize.width / 2;
388
+ top = source.top + source.height - targetSize.height;
389
+ break;
390
+ }
391
+ const safePadding = Math.max(0, padding);
392
+ const minimumLeft = viewport.left + safePadding;
393
+ const minimumTop = viewport.top + safePadding;
394
+ const maximumLeft = viewport.left + viewport.width - safePadding - targetSize.width;
395
+ const maximumTop = viewport.top + viewport.height - safePadding - targetSize.height;
396
+ return {
397
+ left: clamp(left, minimumLeft, maximumLeft),
398
+ top: clamp(top, minimumTop, maximumTop),
399
+ ...targetSize
400
+ };
401
+ }
402
+ function getPortalLayerPositions(expanded, source, target) {
403
+ const sourceFromTarget = {
404
+ left: source.left - target.left,
405
+ top: source.top - target.top
406
+ };
407
+ const targetFromSource = {
408
+ left: target.left - source.left,
409
+ top: target.top - source.top
410
+ };
411
+ const origin = { left: 0, top: 0 };
412
+ return expanded ? {
413
+ sourcePosition: sourceFromTarget,
414
+ targetPosition: origin
415
+ } : {
416
+ sourcePosition: origin,
417
+ targetPosition: targetFromSource
418
+ };
419
+ }
420
+
327
421
  // src/component.tsx
328
422
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
329
423
  var liveSurfaceContentClassName = "rmorph:absolute";
424
+ var MorphMeasurementContext = createContext(false);
425
+ function mergeClassNames(...classNames) {
426
+ return classNames.filter(Boolean).join(" ");
427
+ }
428
+ function getViewportBounds() {
429
+ const viewport = window.visualViewport;
430
+ if (viewport) {
431
+ return {
432
+ left: viewport.offsetLeft,
433
+ top: viewport.offsetTop,
434
+ width: viewport.width,
435
+ height: viewport.height
436
+ };
437
+ }
438
+ return {
439
+ left: 0,
440
+ top: 0,
441
+ width: document.documentElement.clientWidth,
442
+ height: document.documentElement.clientHeight
443
+ };
444
+ }
330
445
  function MorphMeasurementNodes({
331
446
  collapsedRef,
332
447
  expandedRef,
333
448
  collapsedContent,
334
449
  expandedContent
335
450
  }) {
336
- return /* @__PURE__ */ jsxs(Fragment, { children: [
451
+ const measurementStyle = {
452
+ overflow: "clip"
453
+ };
454
+ return /* @__PURE__ */ jsxs(MorphMeasurementContext.Provider, { value: true, children: [
337
455
  /* @__PURE__ */ jsx2(
338
456
  "div",
339
457
  {
340
458
  ref: collapsedRef,
341
- "aria-hidden": "true",
459
+ inert: true,
342
460
  className: "rmorph:invisible rmorph:absolute rmorph:inset-x-0 rmorph:top-0 rmorph:pointer-events-none",
461
+ style: measurementStyle,
343
462
  children: collapsedContent
344
463
  }
345
464
  ),
@@ -347,8 +466,9 @@ function MorphMeasurementNodes({
347
466
  "div",
348
467
  {
349
468
  ref: expandedRef,
350
- "aria-hidden": "true",
469
+ inert: true,
351
470
  className: "rmorph:invisible rmorph:absolute rmorph:inset-x-0 rmorph:top-0 rmorph:pointer-events-none",
471
+ style: measurementStyle,
352
472
  children: expandedContent
353
473
  }
354
474
  )
@@ -364,12 +484,13 @@ function MorphShell({
364
484
  spring,
365
485
  onMorphComplete,
366
486
  onMorphStart,
367
- children
487
+ children,
488
+ className
368
489
  }) {
369
490
  return /* @__PURE__ */ jsx2(
370
491
  motion2.div,
371
492
  {
372
- className: "rmorph:absolute rmorph:shadow-xs",
493
+ className: mergeClassNames("rmorph:absolute rmorph:shadow-xs", className),
373
494
  initial: false,
374
495
  animate: {
375
496
  width: animatedSize.width,
@@ -389,6 +510,51 @@ function MorphShell({
389
510
  }
390
511
  );
391
512
  }
513
+ function PortalMorphShell({
514
+ expanded,
515
+ lifecyclePhase,
516
+ geometry,
517
+ collapsedVisualStyle,
518
+ expandedVisualStyle,
519
+ overflow,
520
+ zIndex,
521
+ spring,
522
+ onMorphComplete,
523
+ onMorphStart,
524
+ children,
525
+ className
526
+ }) {
527
+ const rect = expanded ? geometry.target : geometry.source;
528
+ const visualStyle = expanded ? expandedVisualStyle : collapsedVisualStyle;
529
+ return /* @__PURE__ */ jsx2(
530
+ motion2.div,
531
+ {
532
+ "data-morpheus-portal-shell": "",
533
+ "data-morpheus-phase": lifecyclePhase,
534
+ inert: lifecyclePhase === "handoff" || void 0,
535
+ className: mergeClassNames("rmorph:fixed rmorph:shadow-xs", className),
536
+ initial: false,
537
+ animate: {
538
+ left: rect.left,
539
+ top: rect.top,
540
+ width: rect.width,
541
+ height: rect.height,
542
+ backgroundColor: visualStyle.backgroundColor,
543
+ borderRadius: visualStyle.borderRadius
544
+ },
545
+ onAnimationComplete: onMorphComplete,
546
+ ...onMorphStart ? { onAnimationStart: onMorphStart } : {},
547
+ style: {
548
+ overflow,
549
+ overscrollBehavior: "contain",
550
+ pointerEvents: lifecyclePhase === "handoff" ? "none" : void 0,
551
+ zIndex
552
+ },
553
+ transition: spring,
554
+ children
555
+ }
556
+ );
557
+ }
392
558
  function MorphContentLayers({
393
559
  expanded,
394
560
  settledState,
@@ -461,8 +627,26 @@ function MorphContentLayers({
461
627
  )
462
628
  ] });
463
629
  }
464
- function Morpheus({
465
- direction,
630
+ function MeasurementTreePlaceholder({
631
+ className,
632
+ collapsedContent
633
+ }) {
634
+ return /* @__PURE__ */ jsx2("div", { className, children: /* @__PURE__ */ jsx2("div", { className: "rmorph:relative rmorph:mx-auto rmorph:block rmorph:w-fit rmorph:align-top", children: /* @__PURE__ */ jsx2("div", { className: "rmorph:inline-block rmorph:align-top", children: collapsedContent }) }) });
635
+ }
636
+ function Morpheus(props) {
637
+ const insideMeasurementTree = useContext(MorphMeasurementContext);
638
+ if (insideMeasurementTree) {
639
+ return /* @__PURE__ */ jsx2(
640
+ MeasurementTreePlaceholder,
641
+ {
642
+ className: props.className ?? "rmorph:relative rmorph:inline-block rmorph:align-top",
643
+ collapsedContent: props.collapsedContent
644
+ }
645
+ );
646
+ }
647
+ return /* @__PURE__ */ jsx2(MorpheusRoot, { ...props });
648
+ }
649
+ function MorpheusRoot({
466
650
  anchor,
467
651
  expanded,
468
652
  onClose,
@@ -471,6 +655,9 @@ function Morpheus({
471
655
  collapsedContent,
472
656
  expandedContent,
473
657
  className = "rmorph:relative rmorph:inline-block rmorph:align-top",
658
+ shellClassName,
659
+ portalContainer,
660
+ viewportPadding = 16,
474
661
  overlayColor = "#05070a",
475
662
  overlayOpacity = 0.54,
476
663
  overlayBlur = 0,
@@ -478,13 +665,51 @@ function Morpheus({
478
665
  spring = defaultPanelSpring
479
666
  }) {
480
667
  const measurements = useMorphMeasurements();
668
+ const anchorRef = useRef2(null);
669
+ const [defaultPortalContainer, setDefaultPortalContainer] = useState2(null);
481
670
  const [animationEnabled, setAnimationEnabled] = useState2(false);
482
671
  const [shellOverflowVisible, setShellOverflowVisible] = useState2(false);
483
- const [settledState, setSettledState] = useState2(
672
+ const [inlineSettledState, setInlineSettledState] = useState2(expanded ? "expanded" : "collapsed");
673
+ const [portalPhase, setPortalPhase] = useState2(
484
674
  expanded ? "expanded" : "collapsed"
485
675
  );
676
+ const [portalVisualExpanded, setPortalVisualExpanded] = useState2(expanded);
677
+ const [portalGeometry, setPortalGeometry] = useState2(
678
+ null
679
+ );
486
680
  const hasOpenedRef = useRef2(expanded);
487
681
  const beforeCloseCalledRef = useRef2(false);
682
+ const beforeCloseRef = useRef2(beforeClose);
683
+ const afterCloseRef = useRef2(afterClose);
684
+ const previousExpandedRef = useRef2(expanded);
685
+ const portalStartFrameRef = useRef2(null);
686
+ const handoffFirstFrameRef = useRef2(null);
687
+ const handoffSecondFrameRef = useRef2(null);
688
+ const portalPhaseRef = useRef2(portalPhase);
689
+ const expandedRef = useRef2(expanded);
690
+ portalPhaseRef.current = portalPhase;
691
+ expandedRef.current = expanded;
692
+ beforeCloseRef.current = beforeClose;
693
+ afterCloseRef.current = afterClose;
694
+ const cancelPortalFrames = useCallback(() => {
695
+ if (portalStartFrameRef.current !== null) {
696
+ cancelAnimationFrame(portalStartFrameRef.current);
697
+ portalStartFrameRef.current = null;
698
+ }
699
+ if (handoffFirstFrameRef.current !== null) {
700
+ cancelAnimationFrame(handoffFirstFrameRef.current);
701
+ handoffFirstFrameRef.current = null;
702
+ }
703
+ if (handoffSecondFrameRef.current !== null) {
704
+ cancelAnimationFrame(handoffSecondFrameRef.current);
705
+ handoffSecondFrameRef.current = null;
706
+ }
707
+ }, []);
708
+ useEffect(() => {
709
+ if (portalContainer === void 0) {
710
+ setDefaultPortalContainer(document.body);
711
+ }
712
+ }, [portalContainer]);
488
713
  useEffect(() => {
489
714
  if (!measurements.ready) {
490
715
  return;
@@ -492,113 +717,339 @@ function Morpheus({
492
717
  const frame = requestAnimationFrame(() => setAnimationEnabled(true));
493
718
  return () => cancelAnimationFrame(frame);
494
719
  }, [measurements.ready]);
495
- const motionState = getMorphMotionState({
720
+ const resolvedPortalContainer = portalContainer === void 0 ? defaultPortalContainer : portalContainer;
721
+ const portalEnabled = resolvedPortalContainer !== null;
722
+ const panelAnchor = anchor;
723
+ const updatePortalGeometry = useCallback(() => {
724
+ const sourceElement = anchorRef.current;
725
+ if (!sourceElement || !measurements.ready) {
726
+ return;
727
+ }
728
+ const sourceBounds = sourceElement.getBoundingClientRect();
729
+ const source = {
730
+ left: sourceBounds.left,
731
+ top: sourceBounds.top,
732
+ width: sourceBounds.width,
733
+ height: sourceBounds.height
734
+ };
735
+ const viewport = getViewportBounds();
736
+ const targetSize = getBoundedPanelSize(
737
+ measurements.expanded.size,
738
+ viewport,
739
+ viewportPadding
740
+ );
741
+ const target = getAnchoredTargetRect(
742
+ panelAnchor,
743
+ source,
744
+ targetSize,
745
+ viewport,
746
+ viewportPadding
747
+ );
748
+ setPortalGeometry((current) => {
749
+ if (current?.source.left === source.left && current.source.top === source.top && current.source.width === source.width && current.source.height === source.height && current.target.left === target.left && current.target.top === target.top && current.target.width === target.width && current.target.height === target.height) {
750
+ return current;
751
+ }
752
+ return { source, target };
753
+ });
754
+ }, [
755
+ measurements.expanded.size,
756
+ measurements.ready,
757
+ panelAnchor,
758
+ viewportPadding
759
+ ]);
760
+ useLayoutEffect2(() => {
761
+ if (!portalEnabled || !measurements.ready) {
762
+ return;
763
+ }
764
+ const expandedChanged = previousExpandedRef.current !== expanded;
765
+ previousExpandedRef.current = expanded;
766
+ updatePortalGeometry();
767
+ if (!expandedChanged) {
768
+ return;
769
+ }
770
+ cancelPortalFrames();
771
+ if (expanded) {
772
+ setPortalPhase("opening");
773
+ setPortalVisualExpanded(false);
774
+ portalStartFrameRef.current = requestAnimationFrame(() => {
775
+ portalStartFrameRef.current = null;
776
+ if (!expandedRef.current || portalPhaseRef.current !== "opening") {
777
+ return;
778
+ }
779
+ setPortalVisualExpanded(true);
780
+ });
781
+ return;
782
+ }
783
+ if (portalPhaseRef.current !== "collapsed" && portalPhaseRef.current !== "closing") {
784
+ setPortalPhase("closing");
785
+ setPortalVisualExpanded(false);
786
+ if (hasOpenedRef.current && !beforeCloseCalledRef.current) {
787
+ beforeCloseCalledRef.current = true;
788
+ beforeCloseRef.current?.();
789
+ }
790
+ }
791
+ }, [
792
+ cancelPortalFrames,
496
793
  expanded,
497
- direction,
794
+ measurements.ready,
795
+ portalEnabled,
796
+ updatePortalGeometry
797
+ ]);
798
+ useLayoutEffect2(() => {
799
+ if (portalPhase !== "handoff" || expanded) {
800
+ return;
801
+ }
802
+ handoffFirstFrameRef.current = requestAnimationFrame(() => {
803
+ handoffFirstFrameRef.current = null;
804
+ if (expandedRef.current || portalPhaseRef.current !== "handoff") {
805
+ return;
806
+ }
807
+ handoffSecondFrameRef.current = requestAnimationFrame(() => {
808
+ handoffSecondFrameRef.current = null;
809
+ if (expandedRef.current || portalPhaseRef.current !== "handoff") {
810
+ return;
811
+ }
812
+ setPortalPhase("collapsed");
813
+ setPortalGeometry(null);
814
+ if (hasOpenedRef.current) {
815
+ hasOpenedRef.current = false;
816
+ afterCloseRef.current?.();
817
+ }
818
+ });
819
+ });
820
+ return cancelPortalFrames;
821
+ }, [cancelPortalFrames, expanded, portalPhase]);
822
+ useEffect(() => {
823
+ if (!portalEnabled || portalPhase === "collapsed" || !measurements.ready) {
824
+ return;
825
+ }
826
+ let frame = null;
827
+ const scheduleUpdate = () => {
828
+ if (frame !== null) {
829
+ return;
830
+ }
831
+ frame = requestAnimationFrame(() => {
832
+ frame = null;
833
+ updatePortalGeometry();
834
+ });
835
+ };
836
+ const observer = new ResizeObserver(scheduleUpdate);
837
+ if (anchorRef.current) {
838
+ observer.observe(anchorRef.current);
839
+ }
840
+ window.addEventListener("scroll", scheduleUpdate, true);
841
+ window.addEventListener("resize", scheduleUpdate);
842
+ window.visualViewport?.addEventListener("resize", scheduleUpdate);
843
+ window.visualViewport?.addEventListener("scroll", scheduleUpdate);
844
+ return () => {
845
+ if (frame !== null) {
846
+ cancelAnimationFrame(frame);
847
+ }
848
+ observer.disconnect();
849
+ window.removeEventListener("scroll", scheduleUpdate, true);
850
+ window.removeEventListener("resize", scheduleUpdate);
851
+ window.visualViewport?.removeEventListener("resize", scheduleUpdate);
852
+ window.visualViewport?.removeEventListener("scroll", scheduleUpdate);
853
+ };
854
+ }, [measurements.ready, portalEnabled, portalPhase, updatePortalGeometry]);
855
+ useEffect(() => cancelPortalFrames, [cancelPortalFrames]);
856
+ const motionState = getMorphMotionState({
857
+ expanded: portalEnabled ? portalVisualExpanded : expanded,
498
858
  anchor,
499
859
  spring,
500
860
  animationEnabled,
501
861
  collapsed: measurements.collapsed,
502
862
  expandedSurface: measurements.expanded
503
863
  });
504
- const overflowVisible = expanded && shellOverflowVisible;
505
864
  const shouldRenderStaticSource = !measurements.ready && !expanded;
506
- return /* @__PURE__ */ jsxs(Fragment, { children: [
507
- /* @__PURE__ */ jsx2(
508
- MorphOverlay,
865
+ const portalPresent = portalPhase !== "collapsed";
866
+ const portalIsRendered = portalEnabled && portalPresent && portalGeometry !== null && resolvedPortalContainer !== null;
867
+ const inlineSourceHidden = portalIsRendered && portalPhase !== "handoff";
868
+ const portalSettledState = portalPhase === "expanded" || portalPhase === "closing" ? "expanded" : "collapsed";
869
+ const portalTargetIsBounded = portalGeometry !== null && (portalGeometry.target.width < measurements.expanded.size.width || portalGeometry.target.height < measurements.expanded.size.height);
870
+ const contentLayers = (surfaceExpanded, settledState, portalMode = false) => {
871
+ const layerPositions = portalMode && portalGeometry ? getPortalLayerPositions(
872
+ surfaceExpanded,
873
+ portalGeometry.source,
874
+ portalGeometry.target
875
+ ) : {
876
+ sourcePosition: motionState.sourcePosition,
877
+ targetPosition: motionState.targetPosition
878
+ };
879
+ const sourceGrowthScale = portalMode && portalGeometry ? getAxisScale(
880
+ motionState.collapsedLayerSize,
881
+ portalGeometry.target
882
+ ) : motionState.sourceGrowthScale;
883
+ const collapsedToExpandedScale = portalMode && portalGeometry ? getAxisScale(
884
+ motionState.expandedLayerSize,
885
+ portalGeometry.source
886
+ ) : motionState.collapsedToExpandedScale;
887
+ return /* @__PURE__ */ jsx2(
888
+ MorphContentLayers,
509
889
  {
510
- expanded,
511
- onClose,
512
- color: overlayColor,
513
- opacity: overlayOpacity,
514
- blur: overlayBlur,
515
- zIndex
890
+ expanded: surfaceExpanded,
891
+ settledState,
892
+ collapsedContent,
893
+ expandedContent,
894
+ collapsedLayerSize: motionState.collapsedLayerSize,
895
+ expandedLayerSize: motionState.expandedLayerSize,
896
+ sourcePosition: layerPositions.sourcePosition,
897
+ targetPosition: layerPositions.targetPosition,
898
+ sourceGrowthScale,
899
+ collapsedToExpandedScale,
900
+ transformOrigin: motionState.transformOrigin,
901
+ sourceTransition: motionState.sourceTransition,
902
+ sourceOpacityTransition: motionState.sourceOpacityTransition,
903
+ targetTransition: motionState.targetTransition,
904
+ targetOpacityTransition: motionState.targetOpacityTransition
516
905
  }
517
- ),
518
- /* @__PURE__ */ jsxs("div", { className, "aria-live": "polite", children: [
906
+ );
907
+ };
908
+ const measurementNodes = /* @__PURE__ */ jsx2(
909
+ MorphMeasurementNodes,
910
+ {
911
+ collapsedRef: measurements.collapsedRef,
912
+ expandedRef: measurements.expandedRef,
913
+ collapsedContent,
914
+ expandedContent
915
+ }
916
+ );
917
+ if (!portalEnabled && portalContainer === null) {
918
+ const overflowVisible = expanded && shellOverflowVisible;
919
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
519
920
  /* @__PURE__ */ jsx2(
520
- MorphMeasurementNodes,
921
+ MorphOverlay,
521
922
  {
522
- collapsedRef: measurements.collapsedRef,
523
- expandedRef: measurements.expandedRef,
524
- collapsedContent,
525
- expandedContent
923
+ expanded,
924
+ onClose,
925
+ color: overlayColor,
926
+ opacity: overlayOpacity,
927
+ blur: overlayBlur,
928
+ zIndex
526
929
  }
527
930
  ),
528
- /* @__PURE__ */ jsx2("div", { className: "rmorph:relative rmorph:mx-auto rmorph:block rmorph:w-fit rmorph:align-top", children: shouldRenderStaticSource ? /* @__PURE__ */ jsx2("div", { className: "rmorph:inline-block rmorph:align-top", children: collapsedContent }) : /* @__PURE__ */ jsxs(Fragment, { children: [
931
+ /* @__PURE__ */ jsxs("div", { className, "aria-live": "polite", children: [
932
+ measurementNodes,
933
+ /* @__PURE__ */ jsx2("div", { className: "rmorph:relative rmorph:mx-auto rmorph:block rmorph:w-fit rmorph:align-top", children: shouldRenderStaticSource ? /* @__PURE__ */ jsx2("div", { className: "rmorph:inline-block rmorph:align-top", children: collapsedContent }) : /* @__PURE__ */ jsxs(Fragment, { children: [
934
+ /* @__PURE__ */ jsx2(
935
+ "div",
936
+ {
937
+ "aria-hidden": "true",
938
+ style: {
939
+ visibility: "hidden",
940
+ width: motionState.collapsedLayerSize.width,
941
+ height: motionState.collapsedLayerSize.height
942
+ }
943
+ }
944
+ ),
945
+ /* @__PURE__ */ jsx2(
946
+ MorphShell,
947
+ {
948
+ expanded,
949
+ position: motionState.anchorPosition,
950
+ animatedSize: motionState.animatedSize,
951
+ visualStyle: motionState.visualStyle,
952
+ overflowVisible,
953
+ zIndex,
954
+ spring: motionState.activeSpring,
955
+ className: shellClassName,
956
+ onMorphComplete: () => {
957
+ if (expanded) {
958
+ hasOpenedRef.current = true;
959
+ beforeCloseCalledRef.current = false;
960
+ setInlineSettledState("expanded");
961
+ setShellOverflowVisible(true);
962
+ return;
963
+ }
964
+ setInlineSettledState("collapsed");
965
+ if (hasOpenedRef.current) {
966
+ hasOpenedRef.current = false;
967
+ afterClose?.();
968
+ }
969
+ },
970
+ onMorphStart: () => {
971
+ if (!expanded && hasOpenedRef.current && !beforeCloseCalledRef.current) {
972
+ beforeCloseCalledRef.current = true;
973
+ beforeClose?.();
974
+ }
975
+ setShellOverflowVisible(false);
976
+ setInlineSettledState(expanded ? "collapsed" : "expanded");
977
+ },
978
+ children: contentLayers(expanded, inlineSettledState)
979
+ }
980
+ )
981
+ ] }) })
982
+ ] })
983
+ ] });
984
+ }
985
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
986
+ /* @__PURE__ */ jsxs("div", { className, "aria-live": "polite", children: [
987
+ measurementNodes,
988
+ /* @__PURE__ */ jsx2("div", { className: "rmorph:relative rmorph:mx-auto rmorph:block rmorph:w-fit rmorph:align-top", children: /* @__PURE__ */ jsx2(
989
+ "div",
990
+ {
991
+ ref: anchorRef,
992
+ "data-morpheus-inline-source": "",
993
+ ...getLayerInteractivityProps(!inlineSourceHidden),
994
+ className: mergeClassNames(
995
+ "rmorph:inline-block rmorph:align-top rmorph:shadow-xs",
996
+ shellClassName
997
+ ),
998
+ style: {
999
+ visibility: inlineSourceHidden ? "hidden" : void 0,
1000
+ pointerEvents: inlineSourceHidden ? "none" : void 0
1001
+ },
1002
+ children: collapsedContent
1003
+ }
1004
+ ) })
1005
+ ] }),
1006
+ portalIsRendered && portalGeometry && resolvedPortalContainer ? createPortal(
1007
+ /* @__PURE__ */ jsxs(Fragment, { children: [
529
1008
  /* @__PURE__ */ jsx2(
530
- "div",
1009
+ MorphOverlay,
531
1010
  {
532
- "aria-hidden": "true",
533
- style: {
534
- visibility: "hidden",
535
- width: motionState.collapsedLayerSize.width,
536
- height: motionState.collapsedLayerSize.height
537
- }
1011
+ expanded,
1012
+ onClose,
1013
+ color: overlayColor,
1014
+ opacity: overlayOpacity,
1015
+ blur: overlayBlur,
1016
+ zIndex
538
1017
  }
539
1018
  ),
540
1019
  /* @__PURE__ */ jsx2(
541
- MorphShell,
1020
+ PortalMorphShell,
542
1021
  {
543
- expanded,
544
- position: motionState.anchorPosition,
545
- animatedSize: motionState.animatedSize,
546
- visualStyle: motionState.visualStyle,
547
- overflowVisible,
1022
+ expanded: portalVisualExpanded,
1023
+ lifecyclePhase: portalPhase,
1024
+ geometry: portalGeometry,
1025
+ collapsedVisualStyle: measurements.collapsed.visualStyle,
1026
+ expandedVisualStyle: measurements.expanded.visualStyle,
1027
+ overflow: portalPhase === "expanded" ? portalTargetIsBounded ? "auto" : "visible" : "hidden",
548
1028
  zIndex,
549
1029
  spring: motionState.activeSpring,
1030
+ className: shellClassName,
550
1031
  onMorphComplete: () => {
551
- if (expanded) {
1032
+ if (expandedRef.current && portalPhaseRef.current === "opening" && portalVisualExpanded) {
552
1033
  hasOpenedRef.current = true;
553
1034
  beforeCloseCalledRef.current = false;
554
- setSettledState("expanded");
555
- setShellOverflowVisible(true);
1035
+ setPortalPhase("expanded");
556
1036
  return;
557
1037
  }
558
- setSettledState("collapsed");
559
- if (hasOpenedRef.current) {
560
- hasOpenedRef.current = false;
561
- afterClose?.();
562
- }
563
- },
564
- onMorphStart: () => {
565
- if (!expanded && hasOpenedRef.current && !beforeCloseCalledRef.current) {
566
- beforeCloseCalledRef.current = true;
567
- beforeClose?.();
1038
+ if (!expandedRef.current && portalPhaseRef.current === "closing" && !portalVisualExpanded) {
1039
+ setPortalPhase("handoff");
568
1040
  }
569
- setShellOverflowVisible(false);
570
- setSettledState(expanded ? "collapsed" : "expanded");
571
1041
  },
572
- children: /* @__PURE__ */ jsx2(
573
- MorphContentLayers,
574
- {
575
- expanded,
576
- settledState,
577
- collapsedContent,
578
- expandedContent,
579
- collapsedLayerSize: motionState.collapsedLayerSize,
580
- expandedLayerSize: motionState.expandedLayerSize,
581
- sourcePosition: motionState.sourcePosition,
582
- targetPosition: motionState.targetPosition,
583
- sourceGrowthScale: motionState.sourceGrowthScale,
584
- collapsedToExpandedScale: motionState.collapsedToExpandedScale,
585
- transformOrigin: motionState.transformOrigin,
586
- sourceTransition: motionState.sourceTransition,
587
- sourceOpacityTransition: motionState.sourceOpacityTransition,
588
- targetTransition: motionState.targetTransition,
589
- targetOpacityTransition: motionState.targetOpacityTransition
590
- }
591
- )
1042
+ children: contentLayers(portalVisualExpanded, portalSettledState, true)
592
1043
  }
593
1044
  )
594
- ] }) })
595
- ] })
1045
+ ] }),
1046
+ resolvedPortalContainer
1047
+ ) : null
596
1048
  ] });
597
1049
  }
598
1050
  export {
599
1051
  MorphAnchor,
600
1052
  Morpheus,
601
1053
  Morpheus as default,
602
- defaultAnchorByDirection,
603
1054
  morphSpringPresets
604
1055
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-morpheus",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "A React component for morphing one surface into other.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "build:js": "tsup src/index.ts --format esm --dts --external react --external react-dom --external framer-motion --clean",
30
30
  "build:css": "tailwindcss -i src/style.css -o dist/style.css --minify",
31
31
  "check": "tsc --noEmit",
32
+ "test": "bun test",
32
33
  "publish:npm": "bun run scripts/publish.ts",
33
34
  "release:minor": "bun run scripts/release.ts minor",
34
35
  "release:major": "bun run scripts/release.ts major",
@@ -40,7 +41,9 @@
40
41
  "react-dom": "^19.0.0"
41
42
  },
42
43
  "devDependencies": {
44
+ "@happy-dom/global-registrator": "^20.11.1",
43
45
  "@tailwindcss/cli": "^4.3.3",
46
+ "@testing-library/react": "^16.3.2",
44
47
  "@types/bun": "^1.3.14",
45
48
  "@types/react": "^19.2.17",
46
49
  "@types/react-dom": "^19.2.3",