react-magic-portal 1.1.9 → 1.2.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 +19 -0
- package/README.md +8 -7
- package/__tests__/eslint.config.ts +5 -6
- package/__tests__/package.json +11 -12
- package/__tests__/src/magic-portal.test.tsx +26 -9
- package/package.json +2 -2
- package/packages/component/dist/index.d.ts +2 -0
- 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 +11 -7
- package/packages/component/package.json +11 -12
- package/packages/component/src/index.ts +78 -16
- package/packages/example/dist/assets/index-jJ0JbhKk.js +49 -0
- package/packages/example/dist/index.html +1 -1
- package/packages/example/eslint.config.ts +4 -6
- package/packages/example/package.json +10 -11
- package/packages/example/src/App.tsx +3 -1
- package/packages/example/dist/assets/index-BeXVndGe.js +0 -49
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## [1.2.1](https://github.com/molvqingtai/react-magic-portal/compare/v1.2.0...v1.2.1) (2025-10-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* fix issue of text nodes not being updated & suppress React runtime warnings ([7307c2a](https://github.com/molvqingtai/react-magic-portal/commit/7307c2ad08be8a27a3e55522b0b9f14e501c61d2))
|
|
7
|
+
|
|
8
|
+
# [1.2.0](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.9...v1.2.0) (2025-10-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add root prop to customize MutationObserver scope ([e27aeeb](https://github.com/molvqingtai/react-magic-portal/commit/e27aeeb70ae2b93f9f6ac80d25deb436ca125065))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Performance Improvements
|
|
17
|
+
|
|
18
|
+
* optimize node insertion with position check ([866947b](https://github.com/molvqingtai/react-magic-portal/commit/866947bba294d2887c30a9ce4670ecf6b5429b05))
|
|
19
|
+
|
|
1
20
|
## [1.1.9](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.8...v1.1.9) (2025-10-09)
|
|
2
21
|
|
|
3
22
|
|
package/README.md
CHANGED
|
@@ -97,13 +97,14 @@ function App() {
|
|
|
97
97
|
|
|
98
98
|
### Props
|
|
99
99
|
|
|
100
|
-
| Prop | Type | Default
|
|
101
|
-
| ----------- | ------------------------------------------------------------------------------------------ |
|
|
102
|
-
| `anchor` | `string \| (() => Element \| null) \| Element \| React.RefObject<Element \| null> \| null` | **Required**
|
|
103
|
-
| `position` | `'append' \| 'prepend' \| 'before' \| 'after'` | `'append'`
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
100
|
+
| Prop | Type | Default | Description |
|
|
101
|
+
| ----------- | ------------------------------------------------------------------------------------------ | --------------- | ------------------------------------------------------------ |
|
|
102
|
+
| `anchor` | `string \| (() => Element \| null) \| Element \| React.RefObject<Element \| null> \| null` | **Required** | The target element where the portal content will be rendered |
|
|
103
|
+
| `position` | `'append' \| 'prepend' \| 'before' \| 'after'` | `'append'` | Position relative to the anchor element |
|
|
104
|
+
| `root` | `Element` | `document.body` | The root element to observe for DOM mutations |
|
|
105
|
+
| `children` | `React.ReactElement \| React.ReactElement[]` | `undefined` | The content to render in the portal |
|
|
106
|
+
| `onMount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is mounted |
|
|
107
|
+
| `onUnmount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is unmounted |
|
|
107
108
|
|
|
108
109
|
### Anchor Types
|
|
109
110
|
|
|
@@ -3,8 +3,7 @@ import pluginJs from '@eslint/js'
|
|
|
3
3
|
import { defineConfig } from 'eslint/config'
|
|
4
4
|
import tseslint from 'typescript-eslint'
|
|
5
5
|
import prettierPlugin from 'eslint-plugin-prettier/recommended'
|
|
6
|
-
import
|
|
7
|
-
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
6
|
+
import eslintReact from '@eslint-react/eslint-plugin'
|
|
8
7
|
|
|
9
8
|
export default defineConfig([
|
|
10
9
|
{
|
|
@@ -18,13 +17,13 @@ export default defineConfig([
|
|
|
18
17
|
}
|
|
19
18
|
},
|
|
20
19
|
pluginJs.configs.recommended,
|
|
21
|
-
|
|
20
|
+
tseslint.configs.recommended,
|
|
21
|
+
eslintReact.configs['recommended-typescript'],
|
|
22
22
|
prettierPlugin,
|
|
23
|
-
reactHooks.configs['recommended-latest'],
|
|
24
|
-
reactRefresh.configs.vite,
|
|
25
23
|
{
|
|
26
24
|
rules: {
|
|
27
|
-
'@typescript-eslint/no-explicit-any': 'off'
|
|
25
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
26
|
+
'@eslint-react/no-forward-ref': 'off'
|
|
28
27
|
}
|
|
29
28
|
}
|
|
30
29
|
])
|
package/__tests__/package.json
CHANGED
|
@@ -18,25 +18,24 @@
|
|
|
18
18
|
"react-magic-portal": "workspace:*"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@eslint/
|
|
21
|
+
"@eslint-react/eslint-plugin": "^2.0.6",
|
|
22
|
+
"@eslint/js": "^9.37.0",
|
|
22
23
|
"@testing-library/react": "^16.3.0",
|
|
23
24
|
"@testing-library/user-event": "^14.6.1",
|
|
24
|
-
"@types/react": "^19.
|
|
25
|
-
"@types/react-dom": "^19.1
|
|
25
|
+
"@types/react": "^19.2.2",
|
|
26
|
+
"@types/react-dom": "^19.2.1",
|
|
26
27
|
"@vitejs/plugin-react": "^5.0.4",
|
|
27
28
|
"@vitest/coverage-v8": "^3.2.4",
|
|
28
29
|
"@vitest/ui": "^3.2.4",
|
|
29
|
-
"eslint": "^9.
|
|
30
|
+
"eslint": "^9.37.0",
|
|
30
31
|
"eslint-plugin-prettier": "^5.5.4",
|
|
31
|
-
"eslint-plugin-react-hooks": "^5.2.0",
|
|
32
|
-
"eslint-plugin-react-refresh": "^0.4.22",
|
|
33
32
|
"globals": "^16.4.0",
|
|
34
|
-
"happy-dom": "^
|
|
35
|
-
"react": "^19.
|
|
36
|
-
"react-dom": "^19.
|
|
37
|
-
"typescript": "^5.9.
|
|
38
|
-
"typescript-eslint": "^8.
|
|
39
|
-
"vite": "^7.1.
|
|
33
|
+
"happy-dom": "^20.0.0",
|
|
34
|
+
"react": "^19.2.0",
|
|
35
|
+
"react-dom": "^19.2.0",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"typescript-eslint": "^8.46.0",
|
|
38
|
+
"vite": "^7.1.9",
|
|
40
39
|
"vitest": "^3.2.4"
|
|
41
40
|
}
|
|
42
41
|
}
|
|
@@ -47,7 +47,9 @@ describe('MagicPortal', () => {
|
|
|
47
47
|
|
|
48
48
|
return (
|
|
49
49
|
<div>
|
|
50
|
-
<button onClick={() => setShowAnchor(true)}>
|
|
50
|
+
<button type="button" onClick={() => setShowAnchor(true)}>
|
|
51
|
+
Show Anchor
|
|
52
|
+
</button>
|
|
51
53
|
{showAnchor && <div id="dynamic-anchor">Anchor</div>}
|
|
52
54
|
<MagicPortal anchor="#dynamic-anchor">
|
|
53
55
|
<div data-testid="portal-content">Portal Content</div>
|
|
@@ -74,7 +76,9 @@ describe('MagicPortal', () => {
|
|
|
74
76
|
|
|
75
77
|
return (
|
|
76
78
|
<div>
|
|
77
|
-
<button onClick={() => setShowAnchor(false)}>
|
|
79
|
+
<button type="button" onClick={() => setShowAnchor(false)}>
|
|
80
|
+
Hide Anchor
|
|
81
|
+
</button>
|
|
78
82
|
{showAnchor && <div id="dynamic-anchor">Anchor</div>}
|
|
79
83
|
<MagicPortal anchor="#dynamic-anchor">
|
|
80
84
|
<div data-testid="portal-content">Portal Content</div>
|
|
@@ -435,22 +439,31 @@ describe('MagicPortal', () => {
|
|
|
435
439
|
|
|
436
440
|
it('should work with forwardRef components as portal content', () => {
|
|
437
441
|
const ForwardedComponent = React.forwardRef<HTMLButtonElement, { children: React.ReactNode }>(
|
|
438
|
-
({ children },
|
|
442
|
+
({ children }, forwardedRef) => (
|
|
443
|
+
<button type="button" ref={forwardedRef}>
|
|
444
|
+
{children}
|
|
445
|
+
</button>
|
|
446
|
+
)
|
|
439
447
|
)
|
|
440
448
|
|
|
441
|
-
|
|
449
|
+
ForwardedComponent.displayName = 'ForwardedComponent'
|
|
450
|
+
|
|
451
|
+
const refCalls: Array<HTMLButtonElement | null> = []
|
|
452
|
+
const handleButtonRef: React.RefCallback<HTMLButtonElement> = (element) => {
|
|
453
|
+
refCalls.push(element)
|
|
454
|
+
}
|
|
442
455
|
const anchor = document.createElement('div')
|
|
443
456
|
anchor.id = 'forward-ref-anchor'
|
|
444
457
|
document.body.appendChild(anchor)
|
|
445
458
|
|
|
446
459
|
render(
|
|
447
460
|
<MagicPortal anchor="#forward-ref-anchor">
|
|
448
|
-
<ForwardedComponent ref={
|
|
461
|
+
<ForwardedComponent ref={handleButtonRef}>Click me</ForwardedComponent>
|
|
449
462
|
</MagicPortal>
|
|
450
463
|
)
|
|
451
464
|
|
|
452
|
-
expect(
|
|
453
|
-
expect(
|
|
465
|
+
expect(refCalls[0]).toBeInstanceOf(HTMLButtonElement)
|
|
466
|
+
expect(refCalls[0]?.textContent).toBe('Click me')
|
|
454
467
|
})
|
|
455
468
|
|
|
456
469
|
it('should handle single element content', () => {
|
|
@@ -527,7 +540,9 @@ describe('MagicPortal', () => {
|
|
|
527
540
|
|
|
528
541
|
return (
|
|
529
542
|
<div>
|
|
530
|
-
<button onClick={() => setShowFirst(!showFirst)}>
|
|
543
|
+
<button type="button" onClick={() => setShowFirst(!showFirst)}>
|
|
544
|
+
Toggle Content
|
|
545
|
+
</button>
|
|
531
546
|
<MagicPortal anchor="#dynamic-content-anchor">
|
|
532
547
|
{showFirst ? (
|
|
533
548
|
<div ref={ref1} data-testid="first-content">
|
|
@@ -601,7 +616,9 @@ describe('MagicPortal', () => {
|
|
|
601
616
|
|
|
602
617
|
return (
|
|
603
618
|
<div>
|
|
604
|
-
<button onClick={addElement}>
|
|
619
|
+
<button type="button" onClick={addElement}>
|
|
620
|
+
Add Target Element
|
|
621
|
+
</button>
|
|
605
622
|
<MagicPortal anchor=".dynamic-target">
|
|
606
623
|
<div data-testid="portal-content">Portal Content</div>
|
|
607
624
|
</MagicPortal>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-magic-portal",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "React Portal with dynamic mounting support",
|
|
5
5
|
"main": "packages/component/dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/molvqingtai/react-magic-portal#readme",
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@commitlint/cli": "^20.
|
|
43
|
+
"@commitlint/cli": "^20.1.0",
|
|
44
44
|
"@commitlint/config-conventional": "^20.0.0",
|
|
45
45
|
"@semantic-release/changelog": "^6.0.3",
|
|
46
46
|
"@semantic-release/git": "^10.0.1",
|
|
@@ -4,6 +4,7 @@ import React from "react";
|
|
|
4
4
|
interface MagicPortalProps {
|
|
5
5
|
anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null;
|
|
6
6
|
position?: 'append' | 'prepend' | 'before' | 'after';
|
|
7
|
+
root?: Element;
|
|
7
8
|
children?: React.ReactElement | React.ReactElement[];
|
|
8
9
|
onMount?: (anchor: Element, container: Element) => void;
|
|
9
10
|
onUnmount?: (anchor: Element, container: Element) => void;
|
|
@@ -12,6 +13,7 @@ declare const MagicPortal: {
|
|
|
12
13
|
({
|
|
13
14
|
anchor,
|
|
14
15
|
position,
|
|
16
|
+
root,
|
|
15
17
|
children,
|
|
16
18
|
onMount,
|
|
17
19
|
onUnmount
|
|
@@ -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,QAAgB,GAAA,SAAA,GAAA,QAAA,GAAA,OAAA;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,QAAgB,GAAA,SAAA,GAAA,QAAA,GAAA,OAAA;EAAA,IAAA,CAAA,EAGxB,OAHwB;UACP,CAAA,EAGb,KAAA,CAAM,YAHO,GAGQ,KAAA,CAAM,YAHd,EAAA;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;aAAqB,EAAA,MAAA"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import e,{useCallback as t,useEffect as n,useLayoutEffect as r,useRef as i,useState as a}from"react";import{createPortal as o}from"react-dom";const s=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)},c=e=>typeof e==`string`?document.querySelector(e):typeof e==`function`?e():e&&`current`in e?e.current:e,l=(e,t)=>{if(typeof e==`function`)return e(t);e!=null&&(e.current=t)},
|
|
1
|
+
import e,{useCallback as t,useEffect as n,useLayoutEffect as r,useRef as i,useState as a}from"react";import{createPortal as o}from"react-dom";const s=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)},c=e=>typeof e==`string`?document.querySelector(e):typeof e==`function`?e():e&&`current`in e?e.current:e,l=(e,t)=>e?t===`prepend`||t===`append`?e:e.parentElement:null,u=(e,t)=>{if(typeof e==`function`)return e(t);e!=null&&(e.current=t)},d=(...e)=>t=>{let n=e.map(e=>u(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():u(e[n],null))},f=({anchor:u,position:f=`append`,root:p=document.body,children:m,onMount:h,onUnmount:g})=>{let _=i(null),[v,y]=a(null),b=t(e=>{if(!e)return;let t=_.current;if(!t)return;let n=l(t,f);if(!n)return;let r=!1;switch(f){case`append`:r=e.parentElement===n&&n.lastChild===e;break;case`prepend`: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`,prepend:`afterbegin`,append:`beforeend`,after:`afterend`}[f],e)},[f]),x=e.Children.map(m,t=>{if(!e.isValidElement(t))return null;let n=s(t);return e.cloneElement(t,{ref:d(n,b)})}),S=t(()=>{_.current=c(u);let e=l(_.current,f);e&&(e.__reactWarnedAboutChildrenConflict=!0),y(e)},[u,f]);return r(()=>{S();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>v?.contains(e))&&S()});return e.observe(p,{childList:!0,subtree:!0}),()=>e.disconnect()},[S,u,v,p]),n(()=>{if(v&&_.current)return h?.(_.current,v),()=>{g?.(_.current,v)}},[h,g,v]),v?o(x,v):null};f.displayName=`MagicPortal`;var p=f;export{p 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?: 'append' | 'prepend' | 'before' | 'after'\n children?: React.ReactElement | React.ReactElement[]\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\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 = ({
|
|
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?: 'append' | 'prepend' | 'before' | 'after'\n root?: Element\n children?: React.ReactElement | React.ReactElement[]\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 === 'prepend' || position === 'append' ? 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 = 'append',\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 'append':\n alreadyPlaced = node.parentElement === containerElement && containerElement.lastChild === node\n break\n case 'prepend':\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 prepend: 'afterbegin',\n append: 'beforeend',\n after: 'afterend'\n } as const\n anchorElement.insertAdjacentElement(positionMap[position], node)\n }\n },\n [position]\n )\n\n const nodes = React.Children.map(children, (item) => {\n if (!React.isValidElement(item)) {\n return null\n }\n const originalRef = getElementRef(item)\n return React.cloneElement(item as React.ReactElement<any>, {\n ref: mergeRef(originalRef, insertNode)\n })\n })\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 ? createPortal(nodes, container) : null\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"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"}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import globals from 'globals'
|
|
2
|
+
import type { Linter } from 'eslint'
|
|
2
3
|
import pluginJs from '@eslint/js'
|
|
3
4
|
import { defineConfig } from 'eslint/config'
|
|
4
5
|
import tseslint from 'typescript-eslint'
|
|
5
6
|
import prettierPlugin from 'eslint-plugin-prettier/recommended'
|
|
6
|
-
import
|
|
7
|
-
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
7
|
+
import eslintReact from '@eslint-react/eslint-plugin'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const config: Linter.FlatConfig[] = defineConfig([
|
|
10
10
|
{
|
|
11
11
|
ignores: ['**/dist/*']
|
|
12
12
|
},
|
|
@@ -18,14 +18,18 @@ export default defineConfig([
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
pluginJs.configs.recommended,
|
|
21
|
-
|
|
21
|
+
tseslint.configs.recommended,
|
|
22
|
+
eslintReact.configs['recommended-typescript'],
|
|
22
23
|
prettierPlugin,
|
|
23
|
-
reactHooks.configs['recommended-latest'],
|
|
24
|
-
reactRefresh.configs.vite,
|
|
25
24
|
{
|
|
26
25
|
rules: {
|
|
27
26
|
'@typescript-eslint/no-explicit-any': 'off',
|
|
28
|
-
'@typescript-eslint/no-unused-expressions': 'off'
|
|
27
|
+
'@typescript-eslint/no-unused-expressions': 'off',
|
|
28
|
+
'@eslint-react/no-children-map': 'off',
|
|
29
|
+
'@eslint-react/no-clone-element': 'off',
|
|
30
|
+
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off'
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
])
|
|
34
|
+
|
|
35
|
+
export default config
|
|
@@ -29,22 +29,21 @@
|
|
|
29
29
|
},
|
|
30
30
|
"homepage": "https://github.com/molvqingtai/react-magic-portal#readme",
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@eslint/
|
|
33
|
-
"@
|
|
34
|
-
"@types/
|
|
35
|
-
"@types/react
|
|
36
|
-
"
|
|
32
|
+
"@eslint-react/eslint-plugin": "^2.0.6",
|
|
33
|
+
"@eslint/js": "^9.37.0",
|
|
34
|
+
"@types/node": "^24.7.1",
|
|
35
|
+
"@types/react": "^19.2.2",
|
|
36
|
+
"@types/react-dom": "^19.2.1",
|
|
37
|
+
"eslint": "^9.37.0",
|
|
37
38
|
"eslint-config-prettier": "^10.1.8",
|
|
38
39
|
"eslint-plugin-prettier": "^5.5.4",
|
|
39
|
-
"eslint-plugin-react-hooks": "^5.2.0",
|
|
40
|
-
"eslint-plugin-react-refresh": "^0.4.22",
|
|
41
40
|
"globals": "^16.4.0",
|
|
42
41
|
"prettier": "^3.6.2",
|
|
43
|
-
"react": "^19.
|
|
44
|
-
"react-dom": "^19.
|
|
45
|
-
"tsdown": "^0.15.
|
|
46
|
-
"typescript": "^5.9.
|
|
47
|
-
"typescript-eslint": "^8.
|
|
42
|
+
"react": "^19.2.0",
|
|
43
|
+
"react-dom": "^19.2.0",
|
|
44
|
+
"tsdown": "^0.15.6",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"typescript-eslint": "^8.46.0"
|
|
48
47
|
},
|
|
49
48
|
"peerDependencies": {
|
|
50
49
|
"react": ">=18.0.0",
|
|
@@ -4,6 +4,7 @@ import { createPortal } from 'react-dom'
|
|
|
4
4
|
export interface MagicPortalProps {
|
|
5
5
|
anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null
|
|
6
6
|
position?: 'append' | 'prepend' | 'before' | 'after'
|
|
7
|
+
root?: Element
|
|
7
8
|
children?: React.ReactElement | React.ReactElement[]
|
|
8
9
|
onMount?: (anchor: Element, container: Element) => void
|
|
9
10
|
onUnmount?: (anchor: Element, container: Element) => void
|
|
@@ -42,6 +43,14 @@ const resolveAnchor = (anchor: MagicPortalProps['anchor']) => {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const resolveContainer = (anchor: Element | null, position: MagicPortalProps['position']): Element | null => {
|
|
47
|
+
if (!anchor) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return position === 'prepend' || position === 'append' ? anchor : anchor.parentElement
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
/**
|
|
46
55
|
* https://github.com/facebook/react/blob/d91d28c8ba6fe7c96e651f82fc47c9d5481bf5f9/packages/react-reconciler/src/ReactFiberHooks.js#L2792
|
|
47
56
|
*/
|
|
@@ -61,33 +70,86 @@ const mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[
|
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
const MagicPortal = ({
|
|
73
|
+
const MagicPortal = ({
|
|
74
|
+
anchor,
|
|
75
|
+
position = 'append',
|
|
76
|
+
root = document.body,
|
|
77
|
+
children,
|
|
78
|
+
onMount,
|
|
79
|
+
onUnmount
|
|
80
|
+
}: MagicPortalProps) => {
|
|
65
81
|
const anchorRef = useRef<Element | null>(null)
|
|
66
82
|
const [container, setContainer] = useState<Element | null>(null)
|
|
67
83
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
const insertNode = useCallback(
|
|
85
|
+
(node: Element | null) => {
|
|
86
|
+
if (!node) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const anchorElement = anchorRef.current
|
|
91
|
+
if (!anchorElement) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const containerElement = resolveContainer(anchorElement, position)
|
|
96
|
+
if (!containerElement) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let alreadyPlaced = false
|
|
101
|
+
|
|
102
|
+
switch (position) {
|
|
103
|
+
case 'append':
|
|
104
|
+
alreadyPlaced = node.parentElement === containerElement && containerElement.lastChild === node
|
|
105
|
+
break
|
|
106
|
+
case 'prepend':
|
|
107
|
+
alreadyPlaced = node.parentElement === containerElement && containerElement.firstChild === node
|
|
108
|
+
break
|
|
109
|
+
case 'before':
|
|
110
|
+
alreadyPlaced = node.parentElement === containerElement && anchorElement.previousSibling === node
|
|
111
|
+
break
|
|
112
|
+
case 'after':
|
|
113
|
+
alreadyPlaced = node.parentElement === containerElement && anchorElement.nextSibling === node
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!alreadyPlaced) {
|
|
75
118
|
const positionMap = {
|
|
76
119
|
before: 'beforebegin',
|
|
77
120
|
prepend: 'afterbegin',
|
|
78
121
|
append: 'beforeend',
|
|
79
122
|
after: 'afterend'
|
|
80
123
|
} as const
|
|
81
|
-
|
|
82
|
-
}
|
|
124
|
+
anchorElement.insertAdjacentElement(positionMap[position], node)
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
[position]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const nodes = React.Children.map(children, (item) => {
|
|
131
|
+
if (!React.isValidElement(item)) {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
const originalRef = getElementRef(item)
|
|
135
|
+
return React.cloneElement(item as React.ReactElement<any>, {
|
|
136
|
+
ref: mergeRef(originalRef, insertNode)
|
|
83
137
|
})
|
|
84
138
|
})
|
|
85
139
|
|
|
86
140
|
const update = useCallback(() => {
|
|
87
141
|
anchorRef.current = resolveAnchor(anchor)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
142
|
+
const nextContainer = resolveContainer(anchorRef.current, position)
|
|
143
|
+
/**
|
|
144
|
+
* React 19 in DEV
|
|
145
|
+
* Suppress DevTools warning from React runtime about conflicting container children.
|
|
146
|
+
* @see https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L973
|
|
147
|
+
*/
|
|
148
|
+
if (nextContainer) {
|
|
149
|
+
;(nextContainer as { __reactWarnedAboutChildrenConflict?: boolean }).__reactWarnedAboutChildrenConflict = true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setContainer(nextContainer)
|
|
91
153
|
}, [anchor, position])
|
|
92
154
|
|
|
93
155
|
useLayoutEffect(() => {
|
|
@@ -100,12 +162,12 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
|
|
|
100
162
|
!isSelfMutation && update()
|
|
101
163
|
})
|
|
102
164
|
|
|
103
|
-
observer.observe(
|
|
165
|
+
observer.observe(root, {
|
|
104
166
|
childList: true,
|
|
105
167
|
subtree: true
|
|
106
168
|
})
|
|
107
169
|
return () => observer.disconnect()
|
|
108
|
-
}, [update, anchor, container])
|
|
170
|
+
}, [update, anchor, container, root])
|
|
109
171
|
|
|
110
172
|
useEffect(() => {
|
|
111
173
|
if (container && anchorRef.current) {
|
|
@@ -116,7 +178,7 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
|
|
|
116
178
|
}
|
|
117
179
|
}, [onMount, onUnmount, container])
|
|
118
180
|
|
|
119
|
-
return container
|
|
181
|
+
return container ? createPortal(nodes, container) : null
|
|
120
182
|
}
|
|
121
183
|
|
|
122
184
|
MagicPortal.displayName = 'MagicPortal'
|