react-morpheus 0.1.4 → 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
@@ -2,56 +2,158 @@
2
2
 
3
3
  A controlled React component for morphing one UI state into another.
4
4
 
5
- `Morpheus` does not manage its own open state. Keep the `expanded` boolean in
6
- your app state and pass your trigger to `collapsedContent` so Morpheus can
7
- render, measure, and morph from it. Opening is the responsibility of the source
8
- component's own click handler.
9
- Overlay clicks call `onClose`; update your controlled state there. Use
10
- `beforeClose` and `afterClose` for work that should run around the closing
11
- animation.
5
+ React Morpheus exists for interfaces where a control should become the next
6
+ piece of UI instead of opening a detached popover. The component keeps the
7
+ source surface, destination surface, and overlay explicit. Your app still owns
8
+ state and content; Morpheus owns the measured transition between those two
9
+ surfaces.
10
+
11
+ ## Demo
12
+
13
+ <!-- react-morpheus-demo:start -->
14
+ https://github.com/user-attachments/assets/f90e7096-f587-4793-8e80-476cf844569d
15
+ <!-- react-morpheus-demo:end -->
16
+
17
+ ## Installation
18
+
19
+ ```sh
20
+ bun add react-morpheus
21
+ ```
22
+
23
+ ```sh
24
+ npm i react-morpheus
25
+ ```
26
+
27
+ ```sh
28
+ yarn add react-morpheus
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Import the component and stylesheet, keep the open state in your app, and pass
34
+ both surfaces to Morpheus.
12
35
 
13
36
  ```tsx
14
- import { Morpheus, MorphAnchor } from "react-morpheus";
37
+ import { useState } from "react";
38
+ import {
39
+ Morpheus,
40
+ MorphAnchor,
41
+ morphSpringPresets,
42
+ } from "react-morpheus";
15
43
  import "react-morpheus/style.css";
16
44
 
17
45
  function Example() {
18
46
  const [expanded, setExpanded] = useState(false);
19
47
  const source = (
20
48
  <button type="button" onClick={() => setExpanded(true)}>
21
- Open
49
+ Open menu
22
50
  </button>
23
51
  );
24
52
 
25
53
  return (
26
54
  <Morpheus
27
- direction="bottom"
28
55
  anchor={MorphAnchor.TopMiddle}
29
56
  expanded={expanded}
30
57
  onClose={() => setExpanded(false)}
31
- beforeClose={() => console.log("closing")}
32
- afterClose={() => console.log("closed")}
33
- collapsedContent={source}
34
- expandedContent={<div>Expanded content</div>}
58
+ overlayColor="#0f172a"
59
+ overlayOpacity={0.18}
60
+ overlayBlur={3}
35
61
  overlayZIndex={1200}
62
+ viewportPadding={16}
63
+ spring={morphSpringPresets.smooth}
64
+ collapsedContent={source}
65
+ expandedContent={
66
+ <section>
67
+ <h2>San Francisco</h2>
68
+ <p>Context, actions, and details live here.</p>
69
+ <button type="button" onClick={() => setExpanded(false)}>
70
+ Close
71
+ </button>
72
+ </section>
73
+ }
36
74
  />
37
75
  );
38
76
  }
39
77
  ```
40
78
 
41
- `overlayZIndex` controls the expanded morph surface. The overlay renders one
42
- layer below it so apps can move the whole morphing stack above their own
43
- chrome. Without this prop, Morpheus uses surface `1000` and overlay `999`.
79
+ `Morpheus` does not manage its own open state. Opening is the responsibility of
80
+ the source component's own click handler. Overlay clicks call `onClose`; update
81
+ your controlled state there. Use `beforeClose` and `afterClose` for work that
82
+ should run around the closing animation.
83
+
84
+ ## Props
85
+
86
+ | Prop | Type | Required | Notes |
87
+ | --- | --- | --- | --- |
88
+ | `anchor` | `MorphAnchor` | Required | The edge or point that stays visually anchored. |
89
+ | `expanded` | `boolean` | Required | Controlled open state owned by your app. |
90
+ | `onClose` | `() => void` | Optional | Called when the overlay requests closing. Use it to update your controlled open state. |
91
+ | `beforeClose` | `() => void` | Optional | Called when the closing animation starts, after `expanded` has changed to `false`. Useful for close-intent side effects. |
92
+ | `afterClose` | `() => void` | Optional | Called after the closing animation completes. |
93
+ | `collapsedContent` | `ReactNode` | Required | The source surface Morpheus measures, renders, and uses as the opener. |
94
+ | `expandedContent` | `ReactNode` | Required | The destination surface Morpheus measures and animates into. |
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`. |
99
+ | `overlayColor` | `string` | Optional | Backdrop color shown while expanded. |
100
+ | `overlayOpacity` | `number` | Optional | Backdrop opacity from `0` to `1`. |
101
+ | `overlayBlur` | `number` | Optional | Backdrop blur in pixels. |
102
+ | `overlayZIndex` | `number` | Optional | Stacking level for the expanded surface. Defaults to `1000`; the overlay renders one layer below it. |
103
+ | `spring` | `Transition` | Optional | Framer Motion transition. Pass your own transition or use `morphSpringPresets`. |
44
104
 
45
- Do not place Morpheus inside a container that clips overflow, such as
46
- `overflow: hidden`, `overflow: clip`, or scroll containers. The expanded surface
47
- is positioned from the local source element and can extend outside its original
48
- container while animating.
105
+ ## Pitfalls
49
106
 
50
- ## Scripts
107
+ ### Responsiveness
51
108
 
52
- - `bun run build` builds declarations and bundled ESM output.
109
+ Let the collapsed trigger keep its natural size. Morpheus reads that box before
110
+ animating to the expanded surface.
111
+
112
+ Put responsive width rules on `className`, such as viewport-bound max widths, so
113
+ the animated shell has stable constraints on narrow screens.
114
+
115
+ Pick an anchor that matches the source location. Top anchors feel right for
116
+ menus opening below a trigger; bottom anchors feel right for surfaces opening
117
+ upward; middle anchors feel better for centered inspectors or command surfaces.
118
+
119
+ ### Parent Container Overflow
120
+
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.
130
+
131
+ ### Stacking
132
+
133
+ `overlayZIndex` controls the portalled expanded surface. The overlay renders one
134
+ layer below it so apps can move the whole morphing stack above their own chrome.
135
+ Without this prop, Morpheus uses surface `1000` and overlay `999`.
136
+
137
+ ## Development
138
+
139
+ `example.html` is a small local playground for trying Morpheus in a browser
140
+ without setting up an app shell. Use it to check the default interaction,
141
+ anchors and overlay behavior while changing the component.
142
+
143
+ ### Scripts
144
+
145
+ - `bun run build` builds declarations, bundled ESM output, and CSS.
53
146
  - `bun run check` runs TypeScript without emitting files.
54
147
  - `bun run publish:npm` builds and publishes to npm.
55
148
  - `bun run release:patch` bumps the patch version, builds, tags, and publishes.
56
149
  - `bun run release:minor` bumps the minor version, builds, tags, and publishes.
57
150
  - `bun run release:major` bumps the major version, builds, tags, and publishes.
151
+
152
+ ## Credits
153
+
154
+ Inspired by Family Wallet and Benji Taylor's
155
+ [Family Values](https://benji.org/family-values) post.
156
+
157
+ ## License
158
+
159
+ Released under the [MIT License](https://github.com/shivekkhurana/react-morpheus/blob/master/LICENSE).
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,9 +118,11 @@ 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;
125
+ settledState: "collapsed" | "expanded" | null;
120
126
  collapsedContent: ReactNode;
121
127
  expandedContent: ReactNode;
122
128
  collapsedLayerSize: PanelSize;
@@ -135,9 +141,8 @@ type MorphLayerInteractivityProps = {
135
141
  inert?: boolean;
136
142
  };
137
143
 
138
- 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;
139
145
 
140
- declare const defaultAnchorByDirection: Record<MorphDirection, MorphAnchor>;
141
146
  declare const morphSpringPresets: {
142
147
  balanced: {
143
148
  type: "spring";
@@ -171,4 +176,4 @@ declare const morphSpringPresets: {
171
176
  };
172
177
  };
173
178
 
174
- 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,8 +503,54 @@ 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,
553
+ settledState,
394
554
  collapsedContent,
395
555
  expandedContent,
396
556
  collapsedLayerSize,
@@ -423,6 +583,7 @@ function MorphContentLayers({
423
583
  opacity: sourceOpacityTransition
424
584
  },
425
585
  style: {
586
+ display: expanded && settledState === "expanded" ? "none" : void 0,
426
587
  width: collapsedLayerSize.width,
427
588
  height: collapsedLayerSize.height,
428
589
  pointerEvents: expanded ? "none" : "auto",
@@ -448,6 +609,7 @@ function MorphContentLayers({
448
609
  opacity: targetOpacityTransition
449
610
  },
450
611
  style: {
612
+ display: !expanded && settledState === "collapsed" ? "none" : void 0,
451
613
  width: expandedLayerSize.width,
452
614
  height: expandedLayerSize.height,
453
615
  pointerEvents: expanded ? "auto" : "none",
@@ -458,8 +620,26 @@ function MorphContentLayers({
458
620
  )
459
621
  ] });
460
622
  }
461
- function Morpheus({
462
- 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({
463
643
  anchor,
464
644
  expanded,
465
645
  onClose,
@@ -468,6 +648,9 @@ function Morpheus({
468
648
  collapsedContent,
469
649
  expandedContent,
470
650
  className = "rmorph:relative rmorph:inline-block rmorph:align-top",
651
+ shellClassName,
652
+ portalContainer,
653
+ viewportPadding = 16,
471
654
  overlayColor = "#05070a",
472
655
  overlayOpacity = 0.54,
473
656
  overlayBlur = 0,
@@ -475,10 +658,51 @@ function Morpheus({
475
658
  spring = defaultPanelSpring
476
659
  }) {
477
660
  const measurements = useMorphMeasurements();
661
+ const anchorRef = useRef2(null);
662
+ const [defaultPortalContainer, setDefaultPortalContainer] = useState2(null);
478
663
  const [animationEnabled, setAnimationEnabled] = useState2(false);
479
664
  const [shellOverflowVisible, setShellOverflowVisible] = useState2(false);
665
+ const [inlineSettledState, setInlineSettledState] = useState2(expanded ? "expanded" : "collapsed");
666
+ const [portalPhase, setPortalPhase] = useState2(
667
+ expanded ? "expanded" : "collapsed"
668
+ );
669
+ const [portalVisualExpanded, setPortalVisualExpanded] = useState2(expanded);
670
+ const [portalGeometry, setPortalGeometry] = useState2(
671
+ null
672
+ );
480
673
  const hasOpenedRef = useRef2(expanded);
481
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]);
482
706
  useEffect(() => {
483
707
  if (!measurements.ready) {
484
708
  return;
@@ -486,109 +710,339 @@ function Morpheus({
486
710
  const frame = requestAnimationFrame(() => setAnimationEnabled(true));
487
711
  return () => cancelAnimationFrame(frame);
488
712
  }, [measurements.ready]);
489
- 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,
490
786
  expanded,
491
- 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,
492
851
  anchor,
493
852
  spring,
494
853
  animationEnabled,
495
854
  collapsed: measurements.collapsed,
496
855
  expandedSurface: measurements.expanded
497
856
  });
498
- const overflowVisible = expanded && shellOverflowVisible;
499
857
  const shouldRenderStaticSource = !measurements.ready && !expanded;
500
- return /* @__PURE__ */ jsxs(Fragment, { children: [
501
- /* @__PURE__ */ jsx2(
502
- 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,
503
882
  {
504
- expanded,
505
- onClose,
506
- color: overlayColor,
507
- opacity: overlayOpacity,
508
- blur: overlayBlur,
509
- 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
510
898
  }
511
- ),
512
- /* @__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: [
513
913
  /* @__PURE__ */ jsx2(
514
- MorphMeasurementNodes,
914
+ MorphOverlay,
515
915
  {
516
- collapsedRef: measurements.collapsedRef,
517
- expandedRef: measurements.expandedRef,
518
- collapsedContent,
519
- expandedContent
916
+ expanded,
917
+ onClose,
918
+ color: overlayColor,
919
+ opacity: overlayOpacity,
920
+ blur: overlayBlur,
921
+ zIndex
520
922
  }
521
923
  ),
522
- /* @__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: [
523
1001
  /* @__PURE__ */ jsx2(
524
- "div",
1002
+ MorphOverlay,
525
1003
  {
526
- "aria-hidden": "true",
527
- style: {
528
- visibility: "hidden",
529
- width: motionState.collapsedLayerSize.width,
530
- height: motionState.collapsedLayerSize.height
531
- }
1004
+ expanded,
1005
+ onClose,
1006
+ color: overlayColor,
1007
+ opacity: overlayOpacity,
1008
+ blur: overlayBlur,
1009
+ zIndex
532
1010
  }
533
1011
  ),
534
1012
  /* @__PURE__ */ jsx2(
535
- MorphShell,
1013
+ PortalMorphShell,
536
1014
  {
537
- expanded,
538
- position: motionState.anchorPosition,
539
- animatedSize: motionState.animatedSize,
540
- visualStyle: motionState.visualStyle,
541
- 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",
542
1021
  zIndex,
543
1022
  spring: motionState.activeSpring,
1023
+ className: shellClassName,
544
1024
  onMorphComplete: () => {
545
- if (expanded) {
1025
+ if (expandedRef.current && portalPhaseRef.current === "opening" && portalVisualExpanded) {
546
1026
  hasOpenedRef.current = true;
547
1027
  beforeCloseCalledRef.current = false;
548
- setShellOverflowVisible(true);
1028
+ setPortalPhase("expanded");
549
1029
  return;
550
1030
  }
551
- if (hasOpenedRef.current) {
552
- hasOpenedRef.current = false;
553
- afterClose?.();
554
- }
555
- },
556
- onMorphStart: () => {
557
- if (!expanded && hasOpenedRef.current && !beforeCloseCalledRef.current) {
558
- beforeCloseCalledRef.current = true;
559
- beforeClose?.();
1031
+ if (!expandedRef.current && portalPhaseRef.current === "closing" && !portalVisualExpanded) {
1032
+ setPortalPhase("handoff");
560
1033
  }
561
- setShellOverflowVisible(false);
562
1034
  },
563
- children: /* @__PURE__ */ jsx2(
564
- MorphContentLayers,
565
- {
566
- expanded,
567
- collapsedContent,
568
- expandedContent,
569
- collapsedLayerSize: motionState.collapsedLayerSize,
570
- expandedLayerSize: motionState.expandedLayerSize,
571
- sourcePosition: motionState.sourcePosition,
572
- targetPosition: motionState.targetPosition,
573
- sourceGrowthScale: motionState.sourceGrowthScale,
574
- collapsedToExpandedScale: motionState.collapsedToExpandedScale,
575
- transformOrigin: motionState.transformOrigin,
576
- sourceTransition: motionState.sourceTransition,
577
- sourceOpacityTransition: motionState.sourceOpacityTransition,
578
- targetTransition: motionState.targetTransition,
579
- targetOpacityTransition: motionState.targetOpacityTransition
580
- }
581
- )
1035
+ children: contentLayers(portalVisualExpanded, portalSettledState, true)
582
1036
  }
583
1037
  )
584
- ] }) })
585
- ] })
1038
+ ] }),
1039
+ resolvedPortalContainer
1040
+ ) : null
586
1041
  ] });
587
1042
  }
588
1043
  export {
589
1044
  MorphAnchor,
590
1045
  Morpheus,
591
1046
  Morpheus as default,
592
- defaultAnchorByDirection,
593
1047
  morphSpringPresets
594
1048
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-morpheus",
3
- "version": "0.1.4",
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",