react-morpheus 0.1.5 → 0.1.7

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,6 @@ var sourceContentFade = {
173
182
  duration: 0.12,
174
183
  ease: "easeOut"
175
184
  };
176
- var sourceReturnDelay = 0.06;
177
- var sourceGrowthRatio = 2;
178
185
  var instantTransition = { duration: 0 };
179
186
  var createSourceContentMotion = (spring) => ({
180
187
  opacity: sourceContentFade,
@@ -191,6 +198,12 @@ var createTargetContentMotion = (spring) => ({
191
198
  top: spring
192
199
  });
193
200
  var safeScale = (from, to) => to === 0 ? 1 : from / to;
201
+ function getAxisScale(fromSize, toSize) {
202
+ return {
203
+ x: safeScale(toSize.width, fromSize.width),
204
+ y: safeScale(toSize.height, fromSize.height)
205
+ };
206
+ }
194
207
  function getAnchoredPanelPosition(anchor, collapsedSize, targetSize) {
195
208
  const alignLeft = 0;
196
209
  const alignCenterX = (collapsedSize.width - targetSize.width) / 2;
@@ -230,7 +243,6 @@ function getLayerInteractivityProps(interactive) {
230
243
  }
231
244
  function getMorphMotionState({
232
245
  expanded,
233
- direction,
234
246
  anchor,
235
247
  spring,
236
248
  animationEnabled,
@@ -241,10 +253,9 @@ function getMorphMotionState({
241
253
  const expandedLayerSize = expandedSurface.size;
242
254
  const animatedSize = expanded ? expandedLayerSize : collapsedLayerSize;
243
255
  const visualStyle = expanded ? expandedSurface.visualStyle : collapsed.visualStyle;
244
- const panelAnchor = anchor ?? defaultAnchorByDirection[direction];
245
256
  const sourcePanelPosition = { left: 0, top: 0 };
246
257
  const targetPanelPosition = getAnchoredPanelPosition(
247
- panelAnchor,
258
+ anchor,
248
259
  collapsedLayerSize,
249
260
  expandedLayerSize
250
261
  );
@@ -254,25 +265,19 @@ function getMorphMotionState({
254
265
  anchorPosition: expanded ? targetPanelPosition : sourcePanelPosition,
255
266
  animatedSize,
256
267
  collapsedLayerSize,
257
- collapsedToExpandedScale: {
258
- x: safeScale(collapsedLayerSize.width, expandedLayerSize.width),
259
- y: safeScale(collapsedLayerSize.height, expandedLayerSize.height)
260
- },
268
+ collapsedToExpandedScale: getAxisScale(
269
+ expandedLayerSize,
270
+ collapsedLayerSize
271
+ ),
261
272
  expandedLayerSize,
262
- sourceGrowthScale: {
263
- x: sourceGrowthRatio,
264
- y: sourceGrowthRatio
265
- },
266
- sourceOpacityTransition: {
267
- ...sourceContentFade,
268
- delay: expanded ? 0 : sourceReturnDelay
269
- },
273
+ sourceGrowthScale: getAxisScale(collapsedLayerSize, expandedLayerSize),
274
+ sourceOpacityTransition: { ...sourceContentFade },
270
275
  sourcePosition: expanded ? invertPosition(targetPanelPosition) : sourcePanelPosition,
271
276
  sourceTransition: createSourceContentMotion(activeSpring),
272
277
  targetOpacityTransition: { ...contentFade, delay: expanded ? 0.03 : 0 },
273
278
  targetPosition: expanded ? sourcePanelPosition : targetPanelPosition,
274
279
  targetTransition: createTargetContentMotion(activeSpring),
275
- transformOrigin: anchorTransformOrigins[panelAnchor],
280
+ transformOrigin: anchorTransformOrigins[anchor],
276
281
  visualStyle
277
282
  };
278
283
  }
@@ -324,22 +329,129 @@ function MorphOverlay({
324
329
  ) : null });
325
330
  }
326
331
 
332
+ // src/placement.ts
333
+ var clamp = (value, minimum, maximum) => Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
334
+ function getBoundedPanelSize(naturalSize, viewport, padding) {
335
+ const safePadding = Math.max(0, padding);
336
+ return {
337
+ width: Math.min(
338
+ naturalSize.width,
339
+ Math.max(1, viewport.width - safePadding * 2)
340
+ ),
341
+ height: Math.min(
342
+ naturalSize.height,
343
+ Math.max(1, viewport.height - safePadding * 2)
344
+ )
345
+ };
346
+ }
347
+ function getAnchoredTargetRect(anchor, source, targetSize, viewport, padding) {
348
+ const sourceCenterX = source.left + source.width / 2;
349
+ const sourceCenterY = source.top + source.height / 2;
350
+ let left = source.left;
351
+ let top = source.top;
352
+ switch (anchor) {
353
+ case "left-top" /* LeftTop */:
354
+ break;
355
+ case "left-middle" /* LeftMiddle */:
356
+ top = sourceCenterY - targetSize.height / 2;
357
+ break;
358
+ case "left-bottom" /* LeftBottom */:
359
+ top = source.top + source.height - targetSize.height;
360
+ break;
361
+ case "top-middle" /* TopMiddle */:
362
+ left = sourceCenterX - targetSize.width / 2;
363
+ break;
364
+ case "middle-middle" /* MiddleMiddle */:
365
+ left = sourceCenterX - targetSize.width / 2;
366
+ top = sourceCenterY - targetSize.height / 2;
367
+ break;
368
+ case "right-top" /* RightTop */:
369
+ left = source.left + source.width - targetSize.width;
370
+ break;
371
+ case "right-middle" /* RightMiddle */:
372
+ left = source.left + source.width - targetSize.width;
373
+ top = sourceCenterY - targetSize.height / 2;
374
+ break;
375
+ case "right-bottom" /* RightBottom */:
376
+ left = source.left + source.width - targetSize.width;
377
+ top = source.top + source.height - targetSize.height;
378
+ break;
379
+ case "bottom-middle" /* BottomMiddle */:
380
+ left = sourceCenterX - targetSize.width / 2;
381
+ top = source.top + source.height - targetSize.height;
382
+ break;
383
+ }
384
+ const safePadding = Math.max(0, padding);
385
+ const minimumLeft = viewport.left + safePadding;
386
+ const minimumTop = viewport.top + safePadding;
387
+ const maximumLeft = viewport.left + viewport.width - safePadding - targetSize.width;
388
+ const maximumTop = viewport.top + viewport.height - safePadding - targetSize.height;
389
+ return {
390
+ left: clamp(left, minimumLeft, maximumLeft),
391
+ top: clamp(top, minimumTop, maximumTop),
392
+ ...targetSize
393
+ };
394
+ }
395
+ function getPortalLayerPositions(expanded, source, target) {
396
+ const sourceFromTarget = {
397
+ left: source.left - target.left,
398
+ top: source.top - target.top
399
+ };
400
+ const targetFromSource = {
401
+ left: target.left - source.left,
402
+ top: target.top - source.top
403
+ };
404
+ const origin = { left: 0, top: 0 };
405
+ return expanded ? {
406
+ sourcePosition: sourceFromTarget,
407
+ targetPosition: origin
408
+ } : {
409
+ sourcePosition: origin,
410
+ targetPosition: targetFromSource
411
+ };
412
+ }
413
+
327
414
  // src/component.tsx
328
415
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
329
416
  var liveSurfaceContentClassName = "rmorph:absolute";
417
+ var MorphMeasurementContext = createContext(false);
418
+ function mergeClassNames(...classNames) {
419
+ return classNames.filter(Boolean).join(" ");
420
+ }
421
+ function getViewportBounds() {
422
+ const viewport = window.visualViewport;
423
+ if (viewport) {
424
+ return {
425
+ left: viewport.offsetLeft,
426
+ top: viewport.offsetTop,
427
+ width: viewport.width,
428
+ height: viewport.height
429
+ };
430
+ }
431
+ return {
432
+ left: 0,
433
+ top: 0,
434
+ width: document.documentElement.clientWidth,
435
+ height: document.documentElement.clientHeight
436
+ };
437
+ }
330
438
  function MorphMeasurementNodes({
331
439
  collapsedRef,
332
440
  expandedRef,
333
441
  collapsedContent,
334
442
  expandedContent
335
443
  }) {
336
- return /* @__PURE__ */ jsxs(Fragment, { children: [
444
+ const measurementStyle = {
445
+ overflow: "clip"
446
+ };
447
+ return /* @__PURE__ */ jsxs(MorphMeasurementContext.Provider, { value: true, children: [
337
448
  /* @__PURE__ */ jsx2(
338
449
  "div",
339
450
  {
340
451
  ref: collapsedRef,
341
- "aria-hidden": "true",
452
+ inert: true,
342
453
  className: "rmorph:invisible rmorph:absolute rmorph:inset-x-0 rmorph:top-0 rmorph:pointer-events-none",
454
+ style: measurementStyle,
343
455
  children: collapsedContent
344
456
  }
345
457
  ),
@@ -347,8 +459,9 @@ function MorphMeasurementNodes({
347
459
  "div",
348
460
  {
349
461
  ref: expandedRef,
350
- "aria-hidden": "true",
462
+ inert: true,
351
463
  className: "rmorph:invisible rmorph:absolute rmorph:inset-x-0 rmorph:top-0 rmorph:pointer-events-none",
464
+ style: measurementStyle,
352
465
  children: expandedContent
353
466
  }
354
467
  )
@@ -364,12 +477,13 @@ function MorphShell({
364
477
  spring,
365
478
  onMorphComplete,
366
479
  onMorphStart,
367
- children
480
+ children,
481
+ className
368
482
  }) {
369
483
  return /* @__PURE__ */ jsx2(
370
484
  motion2.div,
371
485
  {
372
- className: "rmorph:absolute rmorph:shadow-xs",
486
+ className: mergeClassNames("rmorph:absolute rmorph:shadow-xs", className),
373
487
  initial: false,
374
488
  animate: {
375
489
  width: animatedSize.width,
@@ -389,6 +503,51 @@ function MorphShell({
389
503
  }
390
504
  );
391
505
  }
506
+ function PortalMorphShell({
507
+ expanded,
508
+ lifecyclePhase,
509
+ geometry,
510
+ collapsedVisualStyle,
511
+ expandedVisualStyle,
512
+ overflow,
513
+ zIndex,
514
+ spring,
515
+ onMorphComplete,
516
+ onMorphStart,
517
+ children,
518
+ className
519
+ }) {
520
+ const rect = expanded ? geometry.target : geometry.source;
521
+ const visualStyle = expanded ? expandedVisualStyle : collapsedVisualStyle;
522
+ return /* @__PURE__ */ jsx2(
523
+ motion2.div,
524
+ {
525
+ "data-morpheus-portal-shell": "",
526
+ "data-morpheus-phase": lifecyclePhase,
527
+ inert: lifecyclePhase === "handoff" || void 0,
528
+ className: mergeClassNames("rmorph:fixed rmorph:shadow-xs", className),
529
+ initial: false,
530
+ animate: {
531
+ left: rect.left,
532
+ top: rect.top,
533
+ width: rect.width,
534
+ height: rect.height,
535
+ backgroundColor: visualStyle.backgroundColor,
536
+ borderRadius: visualStyle.borderRadius
537
+ },
538
+ onAnimationComplete: onMorphComplete,
539
+ ...onMorphStart ? { onAnimationStart: onMorphStart } : {},
540
+ style: {
541
+ overflow,
542
+ overscrollBehavior: "contain",
543
+ pointerEvents: lifecyclePhase === "handoff" ? "none" : void 0,
544
+ zIndex
545
+ },
546
+ transition: spring,
547
+ children
548
+ }
549
+ );
550
+ }
392
551
  function MorphContentLayers({
393
552
  expanded,
394
553
  settledState,
@@ -461,8 +620,26 @@ function MorphContentLayers({
461
620
  )
462
621
  ] });
463
622
  }
464
- function Morpheus({
465
- direction,
623
+ function MeasurementTreePlaceholder({
624
+ className,
625
+ collapsedContent
626
+ }) {
627
+ 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 }) }) });
628
+ }
629
+ function Morpheus(props) {
630
+ const insideMeasurementTree = useContext(MorphMeasurementContext);
631
+ if (insideMeasurementTree) {
632
+ return /* @__PURE__ */ jsx2(
633
+ MeasurementTreePlaceholder,
634
+ {
635
+ className: props.className ?? "rmorph:relative rmorph:inline-block rmorph:align-top",
636
+ collapsedContent: props.collapsedContent
637
+ }
638
+ );
639
+ }
640
+ return /* @__PURE__ */ jsx2(MorpheusRoot, { ...props });
641
+ }
642
+ function MorpheusRoot({
466
643
  anchor,
467
644
  expanded,
468
645
  onClose,
@@ -471,6 +648,9 @@ function Morpheus({
471
648
  collapsedContent,
472
649
  expandedContent,
473
650
  className = "rmorph:relative rmorph:inline-block rmorph:align-top",
651
+ shellClassName,
652
+ portalContainer,
653
+ viewportPadding = 16,
474
654
  overlayColor = "#05070a",
475
655
  overlayOpacity = 0.54,
476
656
  overlayBlur = 0,
@@ -478,13 +658,51 @@ function Morpheus({
478
658
  spring = defaultPanelSpring
479
659
  }) {
480
660
  const measurements = useMorphMeasurements();
661
+ const anchorRef = useRef2(null);
662
+ const [defaultPortalContainer, setDefaultPortalContainer] = useState2(null);
481
663
  const [animationEnabled, setAnimationEnabled] = useState2(false);
482
664
  const [shellOverflowVisible, setShellOverflowVisible] = useState2(false);
483
- const [settledState, setSettledState] = useState2(
665
+ const [inlineSettledState, setInlineSettledState] = useState2(expanded ? "expanded" : "collapsed");
666
+ const [portalPhase, setPortalPhase] = useState2(
484
667
  expanded ? "expanded" : "collapsed"
485
668
  );
669
+ const [portalVisualExpanded, setPortalVisualExpanded] = useState2(expanded);
670
+ const [portalGeometry, setPortalGeometry] = useState2(
671
+ null
672
+ );
486
673
  const hasOpenedRef = useRef2(expanded);
487
674
  const beforeCloseCalledRef = useRef2(false);
675
+ const beforeCloseRef = useRef2(beforeClose);
676
+ const afterCloseRef = useRef2(afterClose);
677
+ const previousExpandedRef = useRef2(expanded);
678
+ const portalStartFrameRef = useRef2(null);
679
+ const handoffFirstFrameRef = useRef2(null);
680
+ const handoffSecondFrameRef = useRef2(null);
681
+ const portalPhaseRef = useRef2(portalPhase);
682
+ const expandedRef = useRef2(expanded);
683
+ portalPhaseRef.current = portalPhase;
684
+ expandedRef.current = expanded;
685
+ beforeCloseRef.current = beforeClose;
686
+ afterCloseRef.current = afterClose;
687
+ const cancelPortalFrames = useCallback(() => {
688
+ if (portalStartFrameRef.current !== null) {
689
+ cancelAnimationFrame(portalStartFrameRef.current);
690
+ portalStartFrameRef.current = null;
691
+ }
692
+ if (handoffFirstFrameRef.current !== null) {
693
+ cancelAnimationFrame(handoffFirstFrameRef.current);
694
+ handoffFirstFrameRef.current = null;
695
+ }
696
+ if (handoffSecondFrameRef.current !== null) {
697
+ cancelAnimationFrame(handoffSecondFrameRef.current);
698
+ handoffSecondFrameRef.current = null;
699
+ }
700
+ }, []);
701
+ useEffect(() => {
702
+ if (portalContainer === void 0) {
703
+ setDefaultPortalContainer(document.body);
704
+ }
705
+ }, [portalContainer]);
488
706
  useEffect(() => {
489
707
  if (!measurements.ready) {
490
708
  return;
@@ -492,113 +710,339 @@ function Morpheus({
492
710
  const frame = requestAnimationFrame(() => setAnimationEnabled(true));
493
711
  return () => cancelAnimationFrame(frame);
494
712
  }, [measurements.ready]);
495
- const motionState = getMorphMotionState({
713
+ const resolvedPortalContainer = portalContainer === void 0 ? defaultPortalContainer : portalContainer;
714
+ const portalEnabled = resolvedPortalContainer !== null;
715
+ const panelAnchor = anchor;
716
+ const updatePortalGeometry = useCallback(() => {
717
+ const sourceElement = anchorRef.current;
718
+ if (!sourceElement || !measurements.ready) {
719
+ return;
720
+ }
721
+ const sourceBounds = sourceElement.getBoundingClientRect();
722
+ const source = {
723
+ left: sourceBounds.left,
724
+ top: sourceBounds.top,
725
+ width: sourceBounds.width,
726
+ height: sourceBounds.height
727
+ };
728
+ const viewport = getViewportBounds();
729
+ const targetSize = getBoundedPanelSize(
730
+ measurements.expanded.size,
731
+ viewport,
732
+ viewportPadding
733
+ );
734
+ const target = getAnchoredTargetRect(
735
+ panelAnchor,
736
+ source,
737
+ targetSize,
738
+ viewport,
739
+ viewportPadding
740
+ );
741
+ setPortalGeometry((current) => {
742
+ 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) {
743
+ return current;
744
+ }
745
+ return { source, target };
746
+ });
747
+ }, [
748
+ measurements.expanded.size,
749
+ measurements.ready,
750
+ panelAnchor,
751
+ viewportPadding
752
+ ]);
753
+ useLayoutEffect2(() => {
754
+ if (!portalEnabled || !measurements.ready) {
755
+ return;
756
+ }
757
+ const expandedChanged = previousExpandedRef.current !== expanded;
758
+ previousExpandedRef.current = expanded;
759
+ updatePortalGeometry();
760
+ if (!expandedChanged) {
761
+ return;
762
+ }
763
+ cancelPortalFrames();
764
+ if (expanded) {
765
+ setPortalPhase("opening");
766
+ setPortalVisualExpanded(false);
767
+ portalStartFrameRef.current = requestAnimationFrame(() => {
768
+ portalStartFrameRef.current = null;
769
+ if (!expandedRef.current || portalPhaseRef.current !== "opening") {
770
+ return;
771
+ }
772
+ setPortalVisualExpanded(true);
773
+ });
774
+ return;
775
+ }
776
+ if (portalPhaseRef.current !== "collapsed" && portalPhaseRef.current !== "closing") {
777
+ setPortalPhase("closing");
778
+ setPortalVisualExpanded(false);
779
+ if (hasOpenedRef.current && !beforeCloseCalledRef.current) {
780
+ beforeCloseCalledRef.current = true;
781
+ beforeCloseRef.current?.();
782
+ }
783
+ }
784
+ }, [
785
+ cancelPortalFrames,
496
786
  expanded,
497
- direction,
787
+ measurements.ready,
788
+ portalEnabled,
789
+ updatePortalGeometry
790
+ ]);
791
+ useLayoutEffect2(() => {
792
+ if (portalPhase !== "handoff" || expanded) {
793
+ return;
794
+ }
795
+ handoffFirstFrameRef.current = requestAnimationFrame(() => {
796
+ handoffFirstFrameRef.current = null;
797
+ if (expandedRef.current || portalPhaseRef.current !== "handoff") {
798
+ return;
799
+ }
800
+ handoffSecondFrameRef.current = requestAnimationFrame(() => {
801
+ handoffSecondFrameRef.current = null;
802
+ if (expandedRef.current || portalPhaseRef.current !== "handoff") {
803
+ return;
804
+ }
805
+ setPortalPhase("collapsed");
806
+ setPortalGeometry(null);
807
+ if (hasOpenedRef.current) {
808
+ hasOpenedRef.current = false;
809
+ afterCloseRef.current?.();
810
+ }
811
+ });
812
+ });
813
+ return cancelPortalFrames;
814
+ }, [cancelPortalFrames, expanded, portalPhase]);
815
+ useEffect(() => {
816
+ if (!portalEnabled || portalPhase === "collapsed" || !measurements.ready) {
817
+ return;
818
+ }
819
+ let frame = null;
820
+ const scheduleUpdate = () => {
821
+ if (frame !== null) {
822
+ return;
823
+ }
824
+ frame = requestAnimationFrame(() => {
825
+ frame = null;
826
+ updatePortalGeometry();
827
+ });
828
+ };
829
+ const observer = new ResizeObserver(scheduleUpdate);
830
+ if (anchorRef.current) {
831
+ observer.observe(anchorRef.current);
832
+ }
833
+ window.addEventListener("scroll", scheduleUpdate, true);
834
+ window.addEventListener("resize", scheduleUpdate);
835
+ window.visualViewport?.addEventListener("resize", scheduleUpdate);
836
+ window.visualViewport?.addEventListener("scroll", scheduleUpdate);
837
+ return () => {
838
+ if (frame !== null) {
839
+ cancelAnimationFrame(frame);
840
+ }
841
+ observer.disconnect();
842
+ window.removeEventListener("scroll", scheduleUpdate, true);
843
+ window.removeEventListener("resize", scheduleUpdate);
844
+ window.visualViewport?.removeEventListener("resize", scheduleUpdate);
845
+ window.visualViewport?.removeEventListener("scroll", scheduleUpdate);
846
+ };
847
+ }, [measurements.ready, portalEnabled, portalPhase, updatePortalGeometry]);
848
+ useEffect(() => cancelPortalFrames, [cancelPortalFrames]);
849
+ const motionState = getMorphMotionState({
850
+ expanded: portalEnabled ? portalVisualExpanded : expanded,
498
851
  anchor,
499
852
  spring,
500
853
  animationEnabled,
501
854
  collapsed: measurements.collapsed,
502
855
  expandedSurface: measurements.expanded
503
856
  });
504
- const overflowVisible = expanded && shellOverflowVisible;
505
857
  const shouldRenderStaticSource = !measurements.ready && !expanded;
506
- return /* @__PURE__ */ jsxs(Fragment, { children: [
507
- /* @__PURE__ */ jsx2(
508
- MorphOverlay,
858
+ const portalPresent = portalPhase !== "collapsed";
859
+ const portalIsRendered = portalEnabled && portalPresent && portalGeometry !== null && resolvedPortalContainer !== null;
860
+ const inlineSourceHidden = portalIsRendered && portalPhase !== "handoff";
861
+ const portalSettledState = portalPhase === "expanded" || portalPhase === "closing" ? "expanded" : "collapsed";
862
+ const portalTargetIsBounded = portalGeometry !== null && (portalGeometry.target.width < measurements.expanded.size.width || portalGeometry.target.height < measurements.expanded.size.height);
863
+ const contentLayers = (surfaceExpanded, settledState, portalMode = false) => {
864
+ const layerPositions = portalMode && portalGeometry ? getPortalLayerPositions(
865
+ surfaceExpanded,
866
+ portalGeometry.source,
867
+ portalGeometry.target
868
+ ) : {
869
+ sourcePosition: motionState.sourcePosition,
870
+ targetPosition: motionState.targetPosition
871
+ };
872
+ const sourceGrowthScale = portalMode && portalGeometry ? getAxisScale(
873
+ motionState.collapsedLayerSize,
874
+ portalGeometry.target
875
+ ) : motionState.sourceGrowthScale;
876
+ const collapsedToExpandedScale = portalMode && portalGeometry ? getAxisScale(
877
+ motionState.expandedLayerSize,
878
+ portalGeometry.source
879
+ ) : motionState.collapsedToExpandedScale;
880
+ return /* @__PURE__ */ jsx2(
881
+ MorphContentLayers,
509
882
  {
510
- expanded,
511
- onClose,
512
- color: overlayColor,
513
- opacity: overlayOpacity,
514
- blur: overlayBlur,
515
- zIndex
883
+ expanded: surfaceExpanded,
884
+ settledState,
885
+ collapsedContent,
886
+ expandedContent,
887
+ collapsedLayerSize: motionState.collapsedLayerSize,
888
+ expandedLayerSize: motionState.expandedLayerSize,
889
+ sourcePosition: layerPositions.sourcePosition,
890
+ targetPosition: layerPositions.targetPosition,
891
+ sourceGrowthScale,
892
+ collapsedToExpandedScale,
893
+ transformOrigin: motionState.transformOrigin,
894
+ sourceTransition: motionState.sourceTransition,
895
+ sourceOpacityTransition: motionState.sourceOpacityTransition,
896
+ targetTransition: motionState.targetTransition,
897
+ targetOpacityTransition: motionState.targetOpacityTransition
516
898
  }
517
- ),
518
- /* @__PURE__ */ jsxs("div", { className, "aria-live": "polite", children: [
899
+ );
900
+ };
901
+ const measurementNodes = /* @__PURE__ */ jsx2(
902
+ MorphMeasurementNodes,
903
+ {
904
+ collapsedRef: measurements.collapsedRef,
905
+ expandedRef: measurements.expandedRef,
906
+ collapsedContent,
907
+ expandedContent
908
+ }
909
+ );
910
+ if (!portalEnabled && portalContainer === null) {
911
+ const overflowVisible = expanded && shellOverflowVisible;
912
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
519
913
  /* @__PURE__ */ jsx2(
520
- MorphMeasurementNodes,
914
+ MorphOverlay,
521
915
  {
522
- collapsedRef: measurements.collapsedRef,
523
- expandedRef: measurements.expandedRef,
524
- collapsedContent,
525
- expandedContent
916
+ expanded,
917
+ onClose,
918
+ color: overlayColor,
919
+ opacity: overlayOpacity,
920
+ blur: overlayBlur,
921
+ zIndex
526
922
  }
527
923
  ),
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: [
924
+ /* @__PURE__ */ jsxs("div", { className, "aria-live": "polite", children: [
925
+ measurementNodes,
926
+ /* @__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: [
927
+ /* @__PURE__ */ jsx2(
928
+ "div",
929
+ {
930
+ "aria-hidden": "true",
931
+ style: {
932
+ visibility: "hidden",
933
+ width: motionState.collapsedLayerSize.width,
934
+ height: motionState.collapsedLayerSize.height
935
+ }
936
+ }
937
+ ),
938
+ /* @__PURE__ */ jsx2(
939
+ MorphShell,
940
+ {
941
+ expanded,
942
+ position: motionState.anchorPosition,
943
+ animatedSize: motionState.animatedSize,
944
+ visualStyle: motionState.visualStyle,
945
+ overflowVisible,
946
+ zIndex,
947
+ spring: motionState.activeSpring,
948
+ className: shellClassName,
949
+ onMorphComplete: () => {
950
+ if (expanded) {
951
+ hasOpenedRef.current = true;
952
+ beforeCloseCalledRef.current = false;
953
+ setInlineSettledState("expanded");
954
+ setShellOverflowVisible(true);
955
+ return;
956
+ }
957
+ setInlineSettledState("collapsed");
958
+ if (hasOpenedRef.current) {
959
+ hasOpenedRef.current = false;
960
+ afterClose?.();
961
+ }
962
+ },
963
+ onMorphStart: () => {
964
+ if (!expanded && hasOpenedRef.current && !beforeCloseCalledRef.current) {
965
+ beforeCloseCalledRef.current = true;
966
+ beforeClose?.();
967
+ }
968
+ setShellOverflowVisible(false);
969
+ setInlineSettledState(expanded ? "collapsed" : "expanded");
970
+ },
971
+ children: contentLayers(expanded, inlineSettledState)
972
+ }
973
+ )
974
+ ] }) })
975
+ ] })
976
+ ] });
977
+ }
978
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
979
+ /* @__PURE__ */ jsxs("div", { className, "aria-live": "polite", children: [
980
+ measurementNodes,
981
+ /* @__PURE__ */ jsx2("div", { className: "rmorph:relative rmorph:mx-auto rmorph:block rmorph:w-fit rmorph:align-top", children: /* @__PURE__ */ jsx2(
982
+ "div",
983
+ {
984
+ ref: anchorRef,
985
+ "data-morpheus-inline-source": "",
986
+ ...getLayerInteractivityProps(!inlineSourceHidden),
987
+ className: mergeClassNames(
988
+ "rmorph:inline-block rmorph:align-top rmorph:shadow-xs",
989
+ shellClassName
990
+ ),
991
+ style: {
992
+ visibility: inlineSourceHidden ? "hidden" : void 0,
993
+ pointerEvents: inlineSourceHidden ? "none" : void 0
994
+ },
995
+ children: collapsedContent
996
+ }
997
+ ) })
998
+ ] }),
999
+ portalIsRendered && portalGeometry && resolvedPortalContainer ? createPortal(
1000
+ /* @__PURE__ */ jsxs(Fragment, { children: [
529
1001
  /* @__PURE__ */ jsx2(
530
- "div",
1002
+ MorphOverlay,
531
1003
  {
532
- "aria-hidden": "true",
533
- style: {
534
- visibility: "hidden",
535
- width: motionState.collapsedLayerSize.width,
536
- height: motionState.collapsedLayerSize.height
537
- }
1004
+ expanded,
1005
+ onClose,
1006
+ color: overlayColor,
1007
+ opacity: overlayOpacity,
1008
+ blur: overlayBlur,
1009
+ zIndex
538
1010
  }
539
1011
  ),
540
1012
  /* @__PURE__ */ jsx2(
541
- MorphShell,
1013
+ PortalMorphShell,
542
1014
  {
543
- expanded,
544
- position: motionState.anchorPosition,
545
- animatedSize: motionState.animatedSize,
546
- visualStyle: motionState.visualStyle,
547
- overflowVisible,
1015
+ expanded: portalVisualExpanded,
1016
+ lifecyclePhase: portalPhase,
1017
+ geometry: portalGeometry,
1018
+ collapsedVisualStyle: measurements.collapsed.visualStyle,
1019
+ expandedVisualStyle: measurements.expanded.visualStyle,
1020
+ overflow: portalPhase === "expanded" ? portalTargetIsBounded ? "auto" : "visible" : "hidden",
548
1021
  zIndex,
549
1022
  spring: motionState.activeSpring,
1023
+ className: shellClassName,
550
1024
  onMorphComplete: () => {
551
- if (expanded) {
1025
+ if (expandedRef.current && portalPhaseRef.current === "opening" && portalVisualExpanded) {
552
1026
  hasOpenedRef.current = true;
553
1027
  beforeCloseCalledRef.current = false;
554
- setSettledState("expanded");
555
- setShellOverflowVisible(true);
1028
+ setPortalPhase("expanded");
556
1029
  return;
557
1030
  }
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?.();
1031
+ if (!expandedRef.current && portalPhaseRef.current === "closing" && !portalVisualExpanded) {
1032
+ setPortalPhase("handoff");
568
1033
  }
569
- setShellOverflowVisible(false);
570
- setSettledState(expanded ? "collapsed" : "expanded");
571
1034
  },
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
- )
1035
+ children: contentLayers(portalVisualExpanded, portalSettledState, true)
592
1036
  }
593
1037
  )
594
- ] }) })
595
- ] })
1038
+ ] }),
1039
+ resolvedPortalContainer
1040
+ ) : null
596
1041
  ] });
597
1042
  }
598
1043
  export {
599
1044
  MorphAnchor,
600
1045
  Morpheus,
601
1046
  Morpheus as default,
602
- defaultAnchorByDirection,
603
1047
  morphSpringPresets
604
1048
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-morpheus",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",