react-magic-portal 1.2.1 → 1.3.1
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/CHANGELOG.md +14 -0
- package/README.md +7 -7
- package/__tests__/src/magic-portal.test.tsx +46 -90
- package/package.json +1 -1
- package/packages/component/dist/index.d.ts +2 -2
- package/packages/component/dist/index.d.ts.map +1 -1
- package/packages/component/dist/index.js +1 -1
- package/packages/component/dist/index.js.map +1 -1
- package/packages/component/eslint.config.ts +2 -0
- package/packages/component/src/index.ts +30 -15
- package/packages/example/dist/assets/{index-jJ0JbhKk.js → index-D7od5Grv.js} +12 -12
- package/packages/example/dist/assets/{index-CDQ6J_Ti.css → index-cHGDwajU.css} +1 -1
- package/packages/example/dist/index.html +2 -2
- package/packages/example/src/App.css +2 -2
- package/packages/example/src/App.tsx +8 -8
- package/packages/example/src/components/portal-content.tsx +4 -4
- package/packages/example/src/index.css +42 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [1.3.1](https://github.com/molvqingtai/react-magic-portal/compare/v1.3.0...v1.3.1) (2025-11-07)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Performance Improvements
|
|
5
|
+
|
|
6
|
+
* better position name ([af11ae6](https://github.com/molvqingtai/react-magic-portal/commit/af11ae67e1bf8998067d0cf756128b279ea1f385))
|
|
7
|
+
|
|
8
|
+
# [1.3.0](https://github.com/molvqingtai/react-magic-portal/compare/v1.2.1...v1.3.0) (2025-10-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* enforce single child ([8fe438d](https://github.com/molvqingtai/react-magic-portal/commit/8fe438d149a04d3070fe8eed725d7932492da01c))
|
|
14
|
+
|
|
1
15
|
## [1.2.1](https://github.com/molvqingtai/react-magic-portal/compare/v1.2.0...v1.2.1) (2025-10-10)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ React Magic Portal solves these challenges by automatically detecting when targe
|
|
|
26
26
|
## Features
|
|
27
27
|
|
|
28
28
|
- **Dynamic Anchor Detection** - Automatically detects when target elements appear or disappear in the DOM
|
|
29
|
-
- **Multiple Positioning Options** - Support for `
|
|
29
|
+
- **Multiple Positioning Options** - Support for `last`, `first`, `before`, and `after` positioning
|
|
30
30
|
- **Flexible Anchor Selection** - Support for CSS selectors, element references, functions, and direct elements
|
|
31
31
|
- **Lifecycle Callbacks** - `onMount` and `onUnmount` callbacks for portal lifecycle management
|
|
32
32
|
- **TypeScript Support** - Full TypeScript support with comprehensive type definitions
|
|
@@ -77,11 +77,11 @@ function App() {
|
|
|
77
77
|
<div>Content before target</div>
|
|
78
78
|
</MagicPortal>
|
|
79
79
|
|
|
80
|
-
<MagicPortal anchor="#target" position="
|
|
80
|
+
<MagicPortal anchor="#target" position="first">
|
|
81
81
|
<div>Content at start of target</div>
|
|
82
82
|
</MagicPortal>
|
|
83
83
|
|
|
84
|
-
<MagicPortal anchor="#target" position="
|
|
84
|
+
<MagicPortal anchor="#target" position="last">
|
|
85
85
|
<div>Content at end of target</div>
|
|
86
86
|
</MagicPortal>
|
|
87
87
|
|
|
@@ -100,9 +100,9 @@ function App() {
|
|
|
100
100
|
| Prop | Type | Default | Description |
|
|
101
101
|
| ----------- | ------------------------------------------------------------------------------------------ | --------------- | ------------------------------------------------------------ |
|
|
102
102
|
| `anchor` | `string \| (() => Element \| null) \| Element \| React.RefObject<Element \| null> \| null` | **Required** | The target element where the portal content will be rendered |
|
|
103
|
-
| `position` | `'
|
|
103
|
+
| `position` | `'last' \| 'first' \| 'before' \| 'after'` | `'last'` | Position relative to the anchor element |
|
|
104
104
|
| `root` | `Element` | `document.body` | The root element to observe for DOM mutations |
|
|
105
|
-
| `children` | `React.ReactElement \|
|
|
105
|
+
| `children` | `React.ReactElement \| null` | `undefined` | A single React element to render in the portal (does not support Fragment) |
|
|
106
106
|
| `onMount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is mounted |
|
|
107
107
|
| `onUnmount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is unmounted |
|
|
108
108
|
|
|
@@ -150,7 +150,7 @@ const elementRef = useRef(null)
|
|
|
150
150
|
|
|
151
151
|
### Position Options
|
|
152
152
|
|
|
153
|
-
#### `
|
|
153
|
+
#### `last` (default)
|
|
154
154
|
|
|
155
155
|
Adds content inside the anchor element at the end:
|
|
156
156
|
|
|
@@ -161,7 +161,7 @@ Adds content inside the anchor element at the end:
|
|
|
161
161
|
</div>
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
-
#### `
|
|
164
|
+
#### `first`
|
|
165
165
|
|
|
166
166
|
Adds content inside the anchor element at the beginning:
|
|
167
167
|
|
|
@@ -191,7 +191,7 @@ describe('MagicPortal', () => {
|
|
|
191
191
|
document.body.appendChild(anchor)
|
|
192
192
|
})
|
|
193
193
|
|
|
194
|
-
it('should
|
|
194
|
+
it('should last by default', () => {
|
|
195
195
|
render(
|
|
196
196
|
<MagicPortal anchor="#position-anchor">
|
|
197
197
|
<div data-testid="portal-content">Portal Content</div>
|
|
@@ -204,9 +204,9 @@ describe('MagicPortal', () => {
|
|
|
204
204
|
expect(anchor.contains(portalContent)).toBe(true)
|
|
205
205
|
})
|
|
206
206
|
|
|
207
|
-
it('should
|
|
207
|
+
it('should first when position is first', () => {
|
|
208
208
|
render(
|
|
209
|
-
<MagicPortal anchor="#position-anchor" position="
|
|
209
|
+
<MagicPortal anchor="#position-anchor" position="first">
|
|
210
210
|
<div data-testid="portal-content">Portal Content</div>
|
|
211
211
|
</MagicPortal>
|
|
212
212
|
)
|
|
@@ -382,28 +382,6 @@ describe('MagicPortal', () => {
|
|
|
382
382
|
expect(refCallback).toHaveBeenLastCalledWith(null)
|
|
383
383
|
})
|
|
384
384
|
|
|
385
|
-
it('should handle multiple content elements with refs', () => {
|
|
386
|
-
const ref1 = vi.fn()
|
|
387
|
-
const ref2 = vi.fn()
|
|
388
|
-
const anchor = document.createElement('div')
|
|
389
|
-
anchor.id = 'multi-ref-anchor'
|
|
390
|
-
document.body.appendChild(anchor)
|
|
391
|
-
|
|
392
|
-
render(
|
|
393
|
-
<MagicPortal anchor="#multi-ref-anchor">
|
|
394
|
-
<div ref={ref1} data-testid="content-1">
|
|
395
|
-
Content 1
|
|
396
|
-
</div>
|
|
397
|
-
<span ref={ref2} data-testid="content-2">
|
|
398
|
-
Content 2
|
|
399
|
-
</span>
|
|
400
|
-
</MagicPortal>
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
expect(ref1).toHaveBeenCalledWith(expect.any(HTMLDivElement))
|
|
404
|
-
expect(ref2).toHaveBeenCalledWith(expect.any(HTMLSpanElement))
|
|
405
|
-
})
|
|
406
|
-
|
|
407
385
|
it('should maintain content refs across position changes', async () => {
|
|
408
386
|
const contentRef = vi.fn()
|
|
409
387
|
const anchor = document.createElement('div')
|
|
@@ -411,7 +389,7 @@ describe('MagicPortal', () => {
|
|
|
411
389
|
document.body.appendChild(anchor)
|
|
412
390
|
|
|
413
391
|
const { rerender } = render(
|
|
414
|
-
<MagicPortal anchor="#position-ref-anchor" position="
|
|
392
|
+
<MagicPortal anchor="#position-ref-anchor" position="last">
|
|
415
393
|
<div ref={contentRef} data-testid="portal-content">
|
|
416
394
|
Portal Content
|
|
417
395
|
</div>
|
|
@@ -425,7 +403,7 @@ describe('MagicPortal', () => {
|
|
|
425
403
|
contentRef.mockClear()
|
|
426
404
|
|
|
427
405
|
rerender(
|
|
428
|
-
<MagicPortal anchor="#position-ref-anchor" position="
|
|
406
|
+
<MagicPortal anchor="#position-ref-anchor" position="first">
|
|
429
407
|
<div ref={contentRef} data-testid="portal-content">
|
|
430
408
|
Portal Content
|
|
431
409
|
</div>
|
|
@@ -466,43 +444,26 @@ describe('MagicPortal', () => {
|
|
|
466
444
|
expect(refCalls[0]?.textContent).toBe('Click me')
|
|
467
445
|
})
|
|
468
446
|
|
|
469
|
-
it('should
|
|
447
|
+
it('should log error when multiple children are provided', () => {
|
|
448
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
470
449
|
const anchor = document.createElement('div')
|
|
471
|
-
anchor.id = '
|
|
450
|
+
anchor.id = 'multiple-children-anchor'
|
|
472
451
|
document.body.appendChild(anchor)
|
|
473
452
|
|
|
474
453
|
render(
|
|
475
|
-
|
|
476
|
-
|
|
454
|
+
// @ts-expect-error - intentionally passing multiple children to assert runtime guard
|
|
455
|
+
<MagicPortal anchor="#multiple-children-anchor">
|
|
456
|
+
<span data-testid="first-element">First element</span>
|
|
457
|
+
<span data-testid="second-element">Second element</span>
|
|
477
458
|
</MagicPortal>
|
|
478
459
|
)
|
|
479
460
|
|
|
480
|
-
expect(
|
|
481
|
-
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
it('should handle multiple element content with refs', () => {
|
|
485
|
-
const ref1 = vi.fn()
|
|
486
|
-
const ref2 = vi.fn()
|
|
487
|
-
const anchor = document.createElement('div')
|
|
488
|
-
anchor.id = 'multiple-content-anchor'
|
|
489
|
-
document.body.appendChild(anchor)
|
|
490
|
-
|
|
491
|
-
render(
|
|
492
|
-
<MagicPortal anchor="#multiple-content-anchor">
|
|
493
|
-
<span ref={ref1} data-testid="first-element">
|
|
494
|
-
First element
|
|
495
|
-
</span>
|
|
496
|
-
<div ref={ref2} data-testid="second-element">
|
|
497
|
-
Second element
|
|
498
|
-
</div>
|
|
499
|
-
</MagicPortal>
|
|
461
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
462
|
+
'[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.'
|
|
500
463
|
)
|
|
464
|
+
expect(anchor.children.length).toBe(0)
|
|
501
465
|
|
|
502
|
-
|
|
503
|
-
expect(ref2).toHaveBeenCalledWith(expect.any(HTMLDivElement))
|
|
504
|
-
expect(screen.getByTestId('first-element')).toBeTruthy()
|
|
505
|
-
expect(screen.getByTestId('second-element')).toBeTruthy()
|
|
466
|
+
consoleErrorSpy.mockRestore()
|
|
506
467
|
})
|
|
507
468
|
|
|
508
469
|
it('should handle nested refs correctly', () => {
|
|
@@ -583,10 +544,10 @@ describe('MagicPortal', () => {
|
|
|
583
544
|
|
|
584
545
|
render(
|
|
585
546
|
<div>
|
|
586
|
-
<MagicPortal anchor="#multi-anchor" position="
|
|
547
|
+
<MagicPortal anchor="#multi-anchor" position="first">
|
|
587
548
|
<div data-testid="portal-1">Portal 1</div>
|
|
588
549
|
</MagicPortal>
|
|
589
|
-
<MagicPortal anchor="#multi-anchor" position="
|
|
550
|
+
<MagicPortal anchor="#multi-anchor" position="last">
|
|
590
551
|
<div data-testid="portal-2">Portal 2</div>
|
|
591
552
|
</MagicPortal>
|
|
592
553
|
</div>
|
|
@@ -707,61 +668,56 @@ describe('MagicPortal', () => {
|
|
|
707
668
|
})
|
|
708
669
|
|
|
709
670
|
describe('Text Node Handling', () => {
|
|
710
|
-
it('should not render
|
|
671
|
+
it('should not render Fragment children', () => {
|
|
672
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
711
673
|
const anchor = document.createElement('div')
|
|
712
|
-
anchor.id = '
|
|
674
|
+
anchor.id = 'fragment-anchor'
|
|
713
675
|
document.body.appendChild(anchor)
|
|
714
676
|
|
|
715
677
|
render(
|
|
716
|
-
|
|
717
|
-
|
|
678
|
+
<MagicPortal anchor="#fragment-anchor">
|
|
679
|
+
<>
|
|
680
|
+
<div>Child 1</div>
|
|
681
|
+
<div>Child 2</div>
|
|
682
|
+
</>
|
|
683
|
+
</MagicPortal>
|
|
718
684
|
)
|
|
719
685
|
|
|
720
|
-
//
|
|
686
|
+
// Should log error about Fragment not being supported
|
|
687
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
688
|
+
'[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.'
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
// Anchor should remain empty since Fragment is not supported
|
|
721
692
|
expect(anchor.textContent).toBe('')
|
|
722
693
|
expect(anchor.children.length).toBe(0)
|
|
694
|
+
|
|
695
|
+
consoleErrorSpy.mockRestore()
|
|
723
696
|
})
|
|
724
697
|
|
|
725
|
-
it('should not render
|
|
698
|
+
it('should not render pure text content', () => {
|
|
726
699
|
const anchor = document.createElement('div')
|
|
727
|
-
anchor.id = '
|
|
700
|
+
anchor.id = 'text-only-anchor'
|
|
728
701
|
document.body.appendChild(anchor)
|
|
729
702
|
|
|
730
703
|
render(
|
|
731
|
-
// @ts-expect-error - testing
|
|
732
|
-
<MagicPortal anchor="#
|
|
733
|
-
Some text before
|
|
734
|
-
{/* @ts-expect-error - testing mixed content behavior*/}
|
|
735
|
-
<div data-testid="element-content">Element</div>
|
|
736
|
-
Some text after
|
|
737
|
-
</MagicPortal>
|
|
704
|
+
// @ts-expect-error - testing that text nodes are not rendered
|
|
705
|
+
<MagicPortal anchor="#text-only-anchor">Just plain text</MagicPortal>
|
|
738
706
|
)
|
|
739
707
|
|
|
740
|
-
//
|
|
741
|
-
expect(
|
|
742
|
-
expect(anchor.
|
|
743
|
-
|
|
744
|
-
// Text content should only be from the div element, not the text nodes
|
|
745
|
-
expect(anchor.textContent?.trim()).toBe('Element')
|
|
746
|
-
expect(anchor.textContent).not.toContain('Some text before')
|
|
747
|
-
expect(anchor.textContent).not.toContain('Some text after')
|
|
708
|
+
// Anchor should remain empty since text nodes are not rendered
|
|
709
|
+
expect(anchor.textContent).toBe('')
|
|
710
|
+
expect(anchor.children.length).toBe(0)
|
|
748
711
|
})
|
|
749
712
|
|
|
750
|
-
it('should not render
|
|
713
|
+
it('should not render null children', () => {
|
|
751
714
|
const anchor = document.createElement('div')
|
|
752
|
-
anchor.id = '
|
|
715
|
+
anchor.id = 'null-anchor'
|
|
753
716
|
document.body.appendChild(anchor)
|
|
754
717
|
|
|
755
|
-
render(
|
|
756
|
-
<MagicPortal anchor="#primitive-anchor">
|
|
757
|
-
{42 as any}
|
|
758
|
-
{true as any}
|
|
759
|
-
{null as any}
|
|
760
|
-
{undefined as any}
|
|
761
|
-
</MagicPortal>
|
|
762
|
-
)
|
|
718
|
+
render(<MagicPortal anchor="#null-anchor">{null}</MagicPortal>)
|
|
763
719
|
|
|
764
|
-
// Anchor should remain empty since
|
|
720
|
+
// Anchor should remain empty since null is not rendered
|
|
765
721
|
expect(anchor.textContent).toBe('')
|
|
766
722
|
expect(anchor.children.length).toBe(0)
|
|
767
723
|
})
|
package/package.json
CHANGED
|
@@ -3,9 +3,9 @@ import React from "react";
|
|
|
3
3
|
//#region src/index.d.ts
|
|
4
4
|
interface MagicPortalProps {
|
|
5
5
|
anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null;
|
|
6
|
-
position?: '
|
|
6
|
+
position?: 'last' | 'first' | 'before' | 'after';
|
|
7
7
|
root?: Element;
|
|
8
|
-
children?: React.ReactElement |
|
|
8
|
+
children?: React.ReactElement | null;
|
|
9
9
|
onMount?: (anchor: Element, container: Element) => void;
|
|
10
10
|
onUnmount?: (anchor: Element, container: Element) => void;
|
|
11
11
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;UAGiB,gBAAA;0BACS,kBAAkB,UAAU,KAAA,CAAM,UAAU;EADrD,QAAA,CAAA,EAAA,
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;UAGiB,gBAAA;0BACS,kBAAkB,UAAU,KAAA,CAAM,UAAU;EADrD,QAAA,CAAA,EAAA,MAAA,GAAgB,OAAA,GAAA,QAAA,GAAA,OAAA;EAAA,IAAA,CAAA,EAGxB,OAHwB;UACP,CAAA,EAGb,KAAA,CAAM,YAHO,GAAA,IAAA;SAAkB,CAAA,EAAA,CAAA,MAAA,EAIvB,OAJuB,EAAA,SAAA,EAIH,OAJG,EAAA,GAAA,IAAA;WAA0B,CAAA,EAAA,CAAA,MAAA,EAK/C,OAL+C,EAAA,SAAA,EAK3B,OAL2B,EAAA,GAAA,IAAA;;cAoEhE,WAlEG,EAAA;;IACI,MAAM;IAAA,QAAA;IAAA,IAAA;IAAA,QAAA;IAAA,OAAA;IAAA;EAAA,CAAA,EAwEhB,gBAxEgB,CAAA,EAwEA,KAAA,CAAA,WAxEA,GAAA,IAAA;aACE,EAAA,MAAA"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import e,{useCallback as t,useEffect as n,useLayoutEffect as r,
|
|
1
|
+
import e,{useCallback as t,useEffect as n,useLayoutEffect as r,useMemo as i,useRef as a,useState as o}from"react";import{createPortal as s}from"react-dom";const c=e=>{let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)},l=e=>typeof e==`string`?document.querySelector(e):typeof e==`function`?e():e&&`current`in e?e.current:e,u=(e,t)=>e?t===`first`||t===`last`?e:e.parentElement:null,d=(e,t)=>{if(typeof e==`function`)return e(t);e!=null&&(e.current=t)},f=(...e)=>t=>{let n=e.map(e=>d(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():d(e[n],null))},p=({anchor:d,position:p=`last`,root:m=document.body,children:h,onMount:g,onUnmount:_})=>{let v=a(null),[y,b]=o(null),x=t(e=>{if(!e)return;let t=v.current;if(!t)return;let n=u(t,p);if(!n)return;let r=!1;switch(p){case`last`:r=e.parentElement===n&&n.lastChild===e;break;case`first`:r=e.parentElement===n&&n.firstChild===e;break;case`before`:r=e.parentElement===n&&t.previousSibling===e;break;case`after`:r=e.parentElement===n&&t.nextSibling===e;break}r||t.insertAdjacentElement({before:`beforebegin`,first:`afterbegin`,last:`beforeend`,after:`afterend`}[p],e)},[p]),S=i(()=>{if(e.Children.count(h)>1)return console.error(`[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.`),null;if(!e.isValidElement(h))return null;if(h.type===e.Fragment)return console.error(`[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.`),null;let t=c(h);return e.cloneElement(h,{ref:f(t,x)})},[h,x]),C=t(()=>{v.current=l(d);let e=u(v.current,p);e&&(e.__reactWarnedAboutChildrenConflict=!0),b(e)},[d,p]);return r(()=>{C();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>y?.contains(e))&&C()});return e.observe(m,{childList:!0,subtree:!0}),()=>e.disconnect()},[C,d,y,m]),n(()=>{if(y&&v.current)return g?.(v.current,y),()=>{_?.(v.current,y)}},[g,_,y]),y&&S?s(S,y):null};p.displayName=`MagicPortal`;var m=p;export{m as default};
|
|
2
2
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from 'react'\nimport { createPortal } from 'react-dom'\n\nexport interface MagicPortalProps {\n anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null\n position?: '
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback, useLayoutEffect, useMemo } from 'react'\nimport { createPortal } from 'react-dom'\n\nexport interface MagicPortalProps {\n anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null\n position?: 'last' | 'first' | 'before' | 'after'\n root?: Element\n children?: React.ReactElement | null\n onMount?: (anchor: Element, container: Element) => void\n onUnmount?: (anchor: Element, container: Element) => void\n}\n\n/**\n * https://github.com/radix-ui/primitives/blob/36d954d3c1b41c96b1d2e875b93fc9362c8c09e6/packages/react/slot/src/slot.tsx#L166\n */\nconst getElementRef = (element: React.ReactElement) => {\n // React <=18 in DEV\n let getter = Object.getOwnPropertyDescriptor(element.props, 'ref')?.get\n let mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning\n if (mayWarn) {\n return (element as any).ref as React.Ref<Element>\n }\n // React 19 in DEV\n getter = Object.getOwnPropertyDescriptor(element, 'ref')?.get\n mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning\n if (mayWarn) {\n return (element.props as { ref?: React.Ref<Element> }).ref\n }\n\n // Not DEV\n return (element.props as { ref?: React.Ref<Element> }).ref || ((element as any).ref as React.Ref<Element>)\n}\n\nconst resolveAnchor = (anchor: MagicPortalProps['anchor']) => {\n if (typeof anchor === 'string') {\n return document.querySelector(anchor)\n } else if (typeof anchor === 'function') {\n return anchor()\n } else if (anchor && 'current' in anchor) {\n return anchor.current\n } else {\n return anchor\n }\n}\n\nconst resolveContainer = (anchor: Element | null, position: MagicPortalProps['position']): Element | null => {\n if (!anchor) {\n return null\n }\n\n return position === 'first' || position === 'last' ? anchor : anchor.parentElement\n}\n\n/**\n * https://github.com/facebook/react/blob/d91d28c8ba6fe7c96e651f82fc47c9d5481bf5f9/packages/react-reconciler/src/ReactFiberHooks.js#L2792\n */\nconst setRef = <T>(ref: React.Ref<T> | undefined, value: T) => {\n if (typeof ref === 'function') {\n return ref(value)\n } else if (ref !== null && ref !== undefined) {\n ref.current = value\n }\n}\n\nconst mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[]) => {\n return (node: T) => {\n const cleanups = refs.map((ref) => setRef(ref, node))\n return () =>\n cleanups.forEach((cleanup, index) => (typeof cleanup === 'function' ? cleanup() : setRef(refs[index], null)))\n }\n}\n\nconst MagicPortal = ({\n anchor,\n position = 'last',\n root = document.body,\n children,\n onMount,\n onUnmount\n}: MagicPortalProps) => {\n const anchorRef = useRef<Element | null>(null)\n const [container, setContainer] = useState<Element | null>(null)\n\n const insertNode = useCallback(\n (node: Element | null) => {\n if (!node) {\n return\n }\n\n const anchorElement = anchorRef.current\n if (!anchorElement) {\n return\n }\n\n const containerElement = resolveContainer(anchorElement, position)\n if (!containerElement) {\n return\n }\n\n let alreadyPlaced = false\n\n switch (position) {\n case 'last':\n alreadyPlaced = node.parentElement === containerElement && containerElement.lastChild === node\n break\n case 'first':\n alreadyPlaced = node.parentElement === containerElement && containerElement.firstChild === node\n break\n case 'before':\n alreadyPlaced = node.parentElement === containerElement && anchorElement.previousSibling === node\n break\n case 'after':\n alreadyPlaced = node.parentElement === containerElement && anchorElement.nextSibling === node\n break\n }\n\n if (!alreadyPlaced) {\n const positionMap = {\n before: 'beforebegin',\n first: 'afterbegin',\n last: 'beforeend',\n after: 'afterend'\n } as const\n anchorElement.insertAdjacentElement(positionMap[position], node)\n }\n },\n [position]\n )\n\n const child = useMemo(() => {\n if (React.Children.count(children) > 1) {\n console.error(\n '[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.'\n )\n return null\n }\n\n if (!React.isValidElement(children)) {\n return null\n }\n\n if (children.type === React.Fragment) {\n console.error(\n '[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.'\n )\n return null\n }\n\n const originalRef = getElementRef(children)\n return React.cloneElement(children as React.ReactElement<any>, {\n ref: mergeRef(originalRef, insertNode)\n })\n }, [children, insertNode])\n\n const update = useCallback(() => {\n anchorRef.current = resolveAnchor(anchor)\n const nextContainer = resolveContainer(anchorRef.current, position)\n /**\n * React 19 in DEV\n * Suppress DevTools warning from React runtime about conflicting container children.\n * @see https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L973\n */\n if (nextContainer) {\n ;(nextContainer as { __reactWarnedAboutChildrenConflict?: boolean }).__reactWarnedAboutChildrenConflict = true\n }\n\n setContainer(nextContainer)\n }, [anchor, position])\n\n useLayoutEffect(() => {\n update()\n\n const observer = new MutationObserver((mutations) => {\n const isSelfMutation = mutations\n .flatMap(({ addedNodes, removedNodes }) => [...addedNodes, ...removedNodes])\n .some((node) => container?.contains(node))\n !isSelfMutation && update()\n })\n\n observer.observe(root, {\n childList: true,\n subtree: true\n })\n return () => observer.disconnect()\n }, [update, anchor, container, root])\n\n useEffect(() => {\n if (container && anchorRef.current) {\n onMount?.(anchorRef.current, container)\n return () => {\n onUnmount?.(anchorRef.current!, container)\n }\n }\n }, [onMount, onUnmount, container])\n\n return container && child ? createPortal(child, container) : null\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"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"}
|
|
@@ -27,6 +27,8 @@ const config: Linter.FlatConfig[] = defineConfig([
|
|
|
27
27
|
'@typescript-eslint/no-unused-expressions': 'off',
|
|
28
28
|
'@eslint-react/no-children-map': 'off',
|
|
29
29
|
'@eslint-react/no-clone-element': 'off',
|
|
30
|
+
'@eslint-react/no-children-only': 'off',
|
|
31
|
+
'@eslint-react/no-children-count': 'off',
|
|
30
32
|
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off'
|
|
31
33
|
}
|
|
32
34
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from 'react'
|
|
1
|
+
import React, { useEffect, useState, useRef, useCallback, useLayoutEffect, useMemo } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
|
|
4
4
|
export interface MagicPortalProps {
|
|
5
5
|
anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null
|
|
6
|
-
position?: '
|
|
6
|
+
position?: 'last' | 'first' | 'before' | 'after'
|
|
7
7
|
root?: Element
|
|
8
|
-
children?: React.ReactElement |
|
|
8
|
+
children?: React.ReactElement | null
|
|
9
9
|
onMount?: (anchor: Element, container: Element) => void
|
|
10
10
|
onUnmount?: (anchor: Element, container: Element) => void
|
|
11
11
|
}
|
|
@@ -48,7 +48,7 @@ const resolveContainer = (anchor: Element | null, position: MagicPortalProps['po
|
|
|
48
48
|
return null
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
return position === '
|
|
51
|
+
return position === 'first' || position === 'last' ? anchor : anchor.parentElement
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
@@ -72,7 +72,7 @@ const mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[
|
|
|
72
72
|
|
|
73
73
|
const MagicPortal = ({
|
|
74
74
|
anchor,
|
|
75
|
-
position = '
|
|
75
|
+
position = 'last',
|
|
76
76
|
root = document.body,
|
|
77
77
|
children,
|
|
78
78
|
onMount,
|
|
@@ -100,10 +100,10 @@ const MagicPortal = ({
|
|
|
100
100
|
let alreadyPlaced = false
|
|
101
101
|
|
|
102
102
|
switch (position) {
|
|
103
|
-
case '
|
|
103
|
+
case 'last':
|
|
104
104
|
alreadyPlaced = node.parentElement === containerElement && containerElement.lastChild === node
|
|
105
105
|
break
|
|
106
|
-
case '
|
|
106
|
+
case 'first':
|
|
107
107
|
alreadyPlaced = node.parentElement === containerElement && containerElement.firstChild === node
|
|
108
108
|
break
|
|
109
109
|
case 'before':
|
|
@@ -117,8 +117,8 @@ const MagicPortal = ({
|
|
|
117
117
|
if (!alreadyPlaced) {
|
|
118
118
|
const positionMap = {
|
|
119
119
|
before: 'beforebegin',
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
first: 'afterbegin',
|
|
121
|
+
last: 'beforeend',
|
|
122
122
|
after: 'afterend'
|
|
123
123
|
} as const
|
|
124
124
|
anchorElement.insertAdjacentElement(positionMap[position], node)
|
|
@@ -127,15 +127,30 @@ const MagicPortal = ({
|
|
|
127
127
|
[position]
|
|
128
128
|
)
|
|
129
129
|
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
130
|
+
const child = useMemo(() => {
|
|
131
|
+
if (React.Children.count(children) > 1) {
|
|
132
|
+
console.error(
|
|
133
|
+
'[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.'
|
|
134
|
+
)
|
|
132
135
|
return null
|
|
133
136
|
}
|
|
134
|
-
|
|
135
|
-
|
|
137
|
+
|
|
138
|
+
if (!React.isValidElement(children)) {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (children.type === React.Fragment) {
|
|
143
|
+
console.error(
|
|
144
|
+
'[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.'
|
|
145
|
+
)
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const originalRef = getElementRef(children)
|
|
150
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
136
151
|
ref: mergeRef(originalRef, insertNode)
|
|
137
152
|
})
|
|
138
|
-
})
|
|
153
|
+
}, [children, insertNode])
|
|
139
154
|
|
|
140
155
|
const update = useCallback(() => {
|
|
141
156
|
anchorRef.current = resolveAnchor(anchor)
|
|
@@ -178,7 +193,7 @@ const MagicPortal = ({
|
|
|
178
193
|
}
|
|
179
194
|
}, [onMount, onUnmount, container])
|
|
180
195
|
|
|
181
|
-
return container ? createPortal(
|
|
196
|
+
return container && child ? createPortal(child, container) : null
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
MagicPortal.displayName = 'MagicPortal'
|