react-magic-portal 1.1.8 → 1.2.0
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 -8
- 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 +3 -3
- 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 +62 -14
- package/packages/example/eslint.config.ts +4 -6
- package/packages/example/package.json +10 -11
- package/packages/example/src/App.tsx +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
# [1.2.0](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.9...v1.2.0) (2025-10-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add root prop to customize MutationObserver scope ([e27aeeb](https://github.com/molvqingtai/react-magic-portal/commit/e27aeeb70ae2b93f9f6ac80d25deb436ca125065))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Performance Improvements
|
|
10
|
+
|
|
11
|
+
* optimize node insertion with position check ([866947b](https://github.com/molvqingtai/react-magic-portal/commit/866947bba294d2887c30a9ce4670ecf6b5429b05))
|
|
12
|
+
|
|
13
|
+
## [1.1.9](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.8...v1.1.9) (2025-10-09)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* remove key prop from MagicPortal to prevent React warning ([2ac6cc1](https://github.com/molvqingtai/react-magic-portal/commit/2ac6cc1d91e33b3bb5e257ed0d36d5c546b2ccd9))
|
|
19
|
+
|
|
1
20
|
## [1.1.8](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.7...v1.1.8) (2025-09-30)
|
|
2
21
|
|
|
3
22
|
|
package/README.md
CHANGED
|
@@ -97,14 +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
|
-
| `
|
|
107
|
-
| `
|
|
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 |
|
|
108
108
|
|
|
109
109
|
### Anchor Types
|
|
110
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.
|
|
3
|
+
"version": "1.2.0",
|
|
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,19 +4,19 @@ 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;
|
|
10
|
-
key?: React.Key;
|
|
11
11
|
}
|
|
12
12
|
declare const MagicPortal: {
|
|
13
13
|
({
|
|
14
14
|
anchor,
|
|
15
15
|
position,
|
|
16
|
+
root,
|
|
16
17
|
children,
|
|
17
18
|
onMount,
|
|
18
|
-
onUnmount
|
|
19
|
-
key
|
|
19
|
+
onUnmount
|
|
20
20
|
}: MagicPortalProps): React.ReactPortal | null;
|
|
21
21
|
displayName: string;
|
|
22
22
|
};
|
|
@@ -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;;cA4DhE,WA1DG,EAAA;;IACI,MAAM;IAAA,QAAA;IAAA,IAAA;IAAA,QAAA;IAAA,OAAA;IAAA;EAAA,CAAA,EAgEhB,gBAhEgB,CAAA,EAgEA,KAAA,CAAA,WAhEA,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)},u=(...e)=>t=>{let n=e.map(e=>l(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():l(e[n],null))},d=({anchor:l,position:d=`append`,
|
|
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)},u=(...e)=>t=>{let n=e.map(e=>l(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():l(e[n],null))},d=({anchor:l,position:d=`append`,root:f=document.body,children:p,onMount:m,onUnmount:h})=>{let g=i(null),[_,v]=a(null),y=t(e=>{if(!e)return;let t=g.current;if(!t)return;let n=d===`prepend`||d===`append`?t:t.parentElement;if(!n)return;let r=e.parentElement===n;if(r)switch(d){case`append`:r=e===n.lastElementChild;break;case`prepend`:r=e===n.firstElementChild;break;case`before`:r=e.nextElementSibling===t;break;case`after`:r=e.previousElementSibling===t;break}r||t.insertAdjacentElement({before:`beforebegin`,prepend:`afterbegin`,append:`beforeend`,after:`afterend`}[d],e)},[d]),b=e.Children.map(p,t=>{if(!e.isValidElement(t))return null;let n=s(t);return e.cloneElement(t,{ref:u(n,y)})}),x=t(()=>{g.current=c(l),v(d===`prepend`||d===`append`?g.current:g.current?.parentElement??null)},[l,d]);return r(()=>{x();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>_?.contains(e))&&x()});return e.observe(f,{childList:!0,subtree:!0}),()=>e.disconnect()},[x,l,_,f]),n(()=>{if(_&&g.current)return m?.(g.current,_),()=>{h?.(g.current,_)}},[m,h,_]),_&&o(b,_)};d.displayName=`MagicPortal`;var f=d;export{f 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
|
|
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\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 =\n position === 'prepend' || position === 'append' ? anchorElement : anchorElement.parentElement\n if (!containerElement) {\n return\n }\n\n let alreadyPlaced = node.parentElement === containerElement\n\n if (alreadyPlaced) {\n switch (position) {\n case 'append':\n alreadyPlaced = node === containerElement.lastElementChild\n break\n case 'prepend':\n alreadyPlaced = node === containerElement.firstElementChild\n break\n case 'before':\n alreadyPlaced = node.nextElementSibling === anchorElement\n break\n case 'after':\n alreadyPlaced = node.previousElementSibling === anchorElement\n break\n }\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 setContainer(\n position === 'prepend' || position === 'append' ? anchorRef.current : (anchorRef.current?.parentElement ?? null)\n )\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)\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"8IAeA,MAAM,EAAiB,GAAgC,CAErD,IAAI,EAAS,OAAO,yBAAyB,EAAQ,MAAO,MAAM,EAAE,IAChE,EAAU,GAAU,mBAAoB,GAAU,EAAO,eAY7D,OAXI,EACM,EAAgB,KAG1B,EAAS,OAAO,yBAAyB,EAAS,MAAM,EAAE,IAC1D,EAAU,GAAU,mBAAoB,GAAU,EAAO,eACrD,EACM,EAAQ,MAAuC,IAIjD,EAAQ,MAAuC,KAAS,EAAgB,MAG5E,EAAiB,GACjB,OAAO,GAAW,SACb,SAAS,cAAc,EAAO,CAC5B,OAAO,GAAW,WACpB,GAAQ,CACN,GAAU,YAAa,EACzB,EAAO,QAEP,EAOL,GAAa,EAA+B,IAAa,CAC7D,GAAI,OAAO,GAAQ,WACjB,OAAO,EAAI,EAAM,CACR,GAAQ,OACjB,EAAI,QAAU,IAIZ,GAAsC,GAAG,IACrC,GAAY,CAClB,IAAM,EAAW,EAAK,IAAK,GAAQ,EAAO,EAAK,EAAK,CAAC,CACrD,UACE,EAAS,SAAS,EAAS,IAAW,OAAO,GAAY,WAAa,GAAS,CAAG,EAAO,EAAK,GAAQ,KAAK,CAAE,EAI7G,GAAe,CACnB,SACA,WAAW,SACX,OAAO,SAAS,KAChB,WACA,UACA,eACsB,CACtB,IAAM,EAAY,EAAuB,KAAK,CACxC,CAAC,EAAW,GAAgB,EAAyB,KAAK,CAE1D,EAAa,EAChB,GAAyB,CACxB,GAAI,CAAC,EACH,OAGF,IAAM,EAAgB,EAAU,QAChC,GAAI,CAAC,EACH,OAGF,IAAM,EACJ,IAAa,WAAa,IAAa,SAAW,EAAgB,EAAc,cAClF,GAAI,CAAC,EACH,OAGF,IAAI,EAAgB,EAAK,gBAAkB,EAE3C,GAAI,EACF,OAAQ,EAAR,CACE,IAAK,SACH,EAAgB,IAAS,EAAiB,iBAC1C,MACF,IAAK,UACH,EAAgB,IAAS,EAAiB,kBAC1C,MACF,IAAK,SACH,EAAgB,EAAK,qBAAuB,EAC5C,MACF,IAAK,QACH,EAAgB,EAAK,yBAA2B,EAChD,MAID,GAOH,EAAc,sBANM,CAClB,OAAQ,cACR,QAAS,aACT,OAAQ,YACR,MAAO,WACR,CAC+C,GAAW,EAAK,EAGpE,CAAC,EAAS,CACX,CAEK,EAAQ,EAAM,SAAS,IAAI,EAAW,GAAS,CACnD,GAAI,CAAC,EAAM,eAAe,EAAK,CAC7B,OAAO,KAET,IAAM,EAAc,EAAc,EAAK,CACvC,OAAO,EAAM,aAAa,EAAiC,CACzD,IAAK,EAAS,EAAa,EAAW,CACvC,CAAC,EACF,CAEI,EAAS,MAAkB,CAC/B,EAAU,QAAU,EAAc,EAAO,CACzC,EACE,IAAa,WAAa,IAAa,SAAW,EAAU,QAAW,EAAU,SAAS,eAAiB,KAC5G,EACA,CAAC,EAAQ,EAAS,CAAC,CA4BtB,OA1BA,MAAsB,CACpB,GAAQ,CAER,IAAM,EAAW,IAAI,iBAAkB,GAAc,CAInD,CAHuB,EACpB,SAAS,CAAE,aAAY,kBAAmB,CAAC,GAAG,EAAY,GAAG,EAAa,CAAC,CAC3E,KAAM,GAAS,GAAW,SAAS,EAAK,CAAC,EACzB,GAAQ,EAC3B,CAMF,OAJA,EAAS,QAAQ,EAAM,CACrB,UAAW,GACX,QAAS,GACV,CAAC,KACW,EAAS,YAAY,EACjC,CAAC,EAAQ,EAAQ,EAAW,EAAK,CAAC,CAErC,MAAgB,CACd,GAAI,GAAa,EAAU,QAEzB,OADA,IAAU,EAAU,QAAS,EAAU,KAC1B,CACX,IAAY,EAAU,QAAU,EAAU,GAG7C,CAAC,EAAS,EAAW,EAAU,CAAC,CAE5B,GAAa,EAAa,EAAO,EAAU,EAGpD,EAAY,YAAc,cAE1B,IAAA,EAAe"}
|
|
@@ -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,10 +4,10 @@ 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
|
|
10
|
-
key?: React.Key
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -62,25 +62,73 @@ const mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
const MagicPortal = ({
|
|
65
|
+
const MagicPortal = ({
|
|
66
|
+
anchor,
|
|
67
|
+
position = 'append',
|
|
68
|
+
root = document.body,
|
|
69
|
+
children,
|
|
70
|
+
onMount,
|
|
71
|
+
onUnmount
|
|
72
|
+
}: MagicPortalProps) => {
|
|
66
73
|
const anchorRef = useRef<Element | null>(null)
|
|
67
74
|
const [container, setContainer] = useState<Element | null>(null)
|
|
68
75
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
const insertNode = useCallback(
|
|
77
|
+
(node: Element | null) => {
|
|
78
|
+
if (!node) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const anchorElement = anchorRef.current
|
|
83
|
+
if (!anchorElement) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const containerElement =
|
|
88
|
+
position === 'prepend' || position === 'append' ? anchorElement : anchorElement.parentElement
|
|
89
|
+
if (!containerElement) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let alreadyPlaced = node.parentElement === containerElement
|
|
94
|
+
|
|
95
|
+
if (alreadyPlaced) {
|
|
96
|
+
switch (position) {
|
|
97
|
+
case 'append':
|
|
98
|
+
alreadyPlaced = node === containerElement.lastElementChild
|
|
99
|
+
break
|
|
100
|
+
case 'prepend':
|
|
101
|
+
alreadyPlaced = node === containerElement.firstElementChild
|
|
102
|
+
break
|
|
103
|
+
case 'before':
|
|
104
|
+
alreadyPlaced = node.nextElementSibling === anchorElement
|
|
105
|
+
break
|
|
106
|
+
case 'after':
|
|
107
|
+
alreadyPlaced = node.previousElementSibling === anchorElement
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!alreadyPlaced) {
|
|
76
113
|
const positionMap = {
|
|
77
114
|
before: 'beforebegin',
|
|
78
115
|
prepend: 'afterbegin',
|
|
79
116
|
append: 'beforeend',
|
|
80
117
|
after: 'afterend'
|
|
81
118
|
} as const
|
|
82
|
-
|
|
83
|
-
}
|
|
119
|
+
anchorElement.insertAdjacentElement(positionMap[position], node)
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
[position]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const nodes = React.Children.map(children, (item) => {
|
|
126
|
+
if (!React.isValidElement(item)) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
const originalRef = getElementRef(item)
|
|
130
|
+
return React.cloneElement(item as React.ReactElement<any>, {
|
|
131
|
+
ref: mergeRef(originalRef, insertNode)
|
|
84
132
|
})
|
|
85
133
|
})
|
|
86
134
|
|
|
@@ -101,12 +149,12 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
|
|
|
101
149
|
!isSelfMutation && update()
|
|
102
150
|
})
|
|
103
151
|
|
|
104
|
-
observer.observe(
|
|
152
|
+
observer.observe(root, {
|
|
105
153
|
childList: true,
|
|
106
154
|
subtree: true
|
|
107
155
|
})
|
|
108
156
|
return () => observer.disconnect()
|
|
109
|
-
}, [update, anchor, container])
|
|
157
|
+
}, [update, anchor, container, root])
|
|
110
158
|
|
|
111
159
|
useEffect(() => {
|
|
112
160
|
if (container && anchorRef.current) {
|
|
@@ -117,7 +165,7 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
|
|
|
117
165
|
}
|
|
118
166
|
}, [onMount, onUnmount, container])
|
|
119
167
|
|
|
120
|
-
return container && createPortal(nodes, container
|
|
168
|
+
return container && createPortal(nodes, container)
|
|
121
169
|
}
|
|
122
170
|
|
|
123
171
|
MagicPortal.displayName = 'MagicPortal'
|
|
@@ -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,8 +17,7 @@ export default defineConfig([
|
|
|
18
17
|
}
|
|
19
18
|
},
|
|
20
19
|
pluginJs.configs.recommended,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
reactRefresh.configs.vite
|
|
20
|
+
tseslint.configs.recommended,
|
|
21
|
+
eslintReact.configs['recommended-typescript'],
|
|
22
|
+
prettierPlugin
|
|
25
23
|
])
|
|
@@ -11,22 +11,21 @@
|
|
|
11
11
|
"check": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"react": "^19.
|
|
15
|
-
"react-dom": "^19.
|
|
14
|
+
"react": "^19.2.0",
|
|
15
|
+
"react-dom": "^19.2.0",
|
|
16
16
|
"react-magic-portal": "workspace:*"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@eslint/
|
|
20
|
-
"@
|
|
21
|
-
"@types/react
|
|
19
|
+
"@eslint-react/eslint-plugin": "^2.0.6",
|
|
20
|
+
"@eslint/js": "^9.37.0",
|
|
21
|
+
"@types/react": "^19.2.2",
|
|
22
|
+
"@types/react-dom": "^19.2.1",
|
|
22
23
|
"@vitejs/plugin-react": "^5.0.4",
|
|
23
|
-
"eslint": "^9.
|
|
24
|
+
"eslint": "^9.37.0",
|
|
24
25
|
"eslint-plugin-prettier": "^5.5.4",
|
|
25
|
-
"eslint-plugin-react-hooks": "^5.2.0",
|
|
26
|
-
"eslint-plugin-react-refresh": "^0.4.22",
|
|
27
26
|
"globals": "^16.4.0",
|
|
28
|
-
"typescript": "~5.9.
|
|
29
|
-
"typescript-eslint": "^8.
|
|
30
|
-
"vite": "^7.1.
|
|
27
|
+
"typescript": "~5.9.3",
|
|
28
|
+
"typescript-eslint": "^8.46.0",
|
|
29
|
+
"vite": "^7.1.9"
|
|
31
30
|
}
|
|
32
31
|
}
|
|
@@ -27,7 +27,9 @@ function App() {
|
|
|
27
27
|
)}
|
|
28
28
|
</div>
|
|
29
29
|
<div className="controls">
|
|
30
|
-
<button onClick={() => setShowAnchor(!showAnchor)}>
|
|
30
|
+
<button type="button" onClick={() => setShowAnchor(!showAnchor)}>
|
|
31
|
+
{showAnchor ? 'Hide Anchor' : 'Show Anchor'}
|
|
32
|
+
</button>
|
|
31
33
|
</div>
|
|
32
34
|
|
|
33
35
|
{/* Target anchor examples */}
|