react-magic-portal 1.1.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.
Files changed (45) hide show
  1. package/.commitlintrc +5 -0
  2. package/.github/dependabot.yml +11 -0
  3. package/.github/workflows/cd.yml +85 -0
  4. package/.github/workflows/ci.yml +65 -0
  5. package/.husky/commit-msg +1 -0
  6. package/.husky/pre-commit +1 -0
  7. package/.prettierrc +6 -0
  8. package/.releaserc +13 -0
  9. package/CHANGELOG.md +13 -0
  10. package/LICENSE +21 -0
  11. package/README.md +186 -0
  12. package/__tests__/eslint.config.ts +25 -0
  13. package/__tests__/package.json +42 -0
  14. package/__tests__/src/MagicPortal.test.tsx +506 -0
  15. package/__tests__/tsconfig.json +23 -0
  16. package/__tests__/vite.config.ts +11 -0
  17. package/eslint.config.mts +16 -0
  18. package/package.json +64 -0
  19. package/packages/component/.prettierrc +6 -0
  20. package/packages/component/README.md +6 -0
  21. package/packages/component/dist/index.d.ts +27 -0
  22. package/packages/component/dist/index.d.ts.map +1 -0
  23. package/packages/component/dist/index.js +2 -0
  24. package/packages/component/dist/index.js.map +1 -0
  25. package/packages/component/eslint.config.ts +25 -0
  26. package/packages/component/package.json +70 -0
  27. package/packages/component/src/index.ts +123 -0
  28. package/packages/component/tsconfig.json +27 -0
  29. package/packages/example/.prettierrc +6 -0
  30. package/packages/example/README.md +6 -0
  31. package/packages/example/eslint.config.ts +25 -0
  32. package/packages/example/index.html +13 -0
  33. package/packages/example/package.json +32 -0
  34. package/packages/example/pnpm-lock.yaml +2098 -0
  35. package/packages/example/public/vite.svg +1 -0
  36. package/packages/example/src/App.css +332 -0
  37. package/packages/example/src/App.tsx +82 -0
  38. package/packages/example/src/assets/react.svg +1 -0
  39. package/packages/example/src/components/portal-content.tsx +33 -0
  40. package/packages/example/src/index.css +68 -0
  41. package/packages/example/src/main.tsx +13 -0
  42. package/packages/example/src/vite-env.d.ts +1 -0
  43. package/packages/example/tsconfig.json +25 -0
  44. package/packages/example/vite.config.ts +7 -0
  45. package/pnpm-workspace.yaml +3 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["container"],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback } from 'react'\nimport ReactDOM 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.ReactNode\n onMount?: (anchor: Element, container: HTMLDivElement) => void\n onUnmount?: (anchor: Element, container: HTMLDivElement) => void\n ref?: React.Ref<HTMLDivElement | null>\n key?: React.Key\n}\n\nconst MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount, ref, key }: MagicPortalProps) => {\n const [container, setContainer] = useState<HTMLDivElement | null>(null)\n const anchorRef = useRef<Element | null>(null)\n\n const updateRef = useCallback(\n (element: HTMLDivElement | null) => {\n if (ref) {\n if (typeof ref === 'function') {\n ref(element)\n } else {\n ref.current = element\n }\n }\n },\n [ref]\n )\n\n const createContainer = useCallback(\n (anchorElement: Element): HTMLDivElement | null => {\n const container = document.createElement('div')\n container.style.display = 'contents'\n\n const positionMap = {\n before: 'beforebegin',\n prepend: 'afterbegin',\n append: 'beforeend',\n after: 'afterend'\n } as const\n\n const result = anchorElement.insertAdjacentElement(positionMap[position], container)\n\n return result as HTMLDivElement | null\n },\n [position]\n )\n\n const resolveAnchor = useCallback((): Element | null => {\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 }, [anchor])\n\n const updateAnchor = useCallback(() => {\n const newAnchor = resolveAnchor()\n\n setContainer((prevContainer) => {\n prevContainer?.remove()\n anchorRef.current = newAnchor\n const newContainer = newAnchor ? createContainer(newAnchor) : null\n updateRef(newContainer)\n return newContainer\n })\n }, [resolveAnchor, createContainer, updateRef])\n\n useEffect(() => {\n updateAnchor()\n\n const observer = new MutationObserver((mutations) => {\n const shouldUpdate = mutations.some((mutation) => {\n const { addedNodes, removedNodes } = mutation\n\n // Check if current anchor is removed\n if (anchorRef.current && Array.from(removedNodes).includes(anchorRef.current)) {\n return true\n }\n\n // Only check added nodes when anchor is a string selector\n if (typeof anchor === 'string') {\n return Array.from(addedNodes).some(\n (node) => node.nodeType === Node.ELEMENT_NODE && node instanceof Element && node.matches?.(anchor)\n )\n }\n\n return false\n })\n\n if (shouldUpdate) {\n updateAnchor()\n }\n })\n\n observer.observe(document.body, {\n childList: true,\n subtree: true\n })\n\n return () => observer.disconnect()\n }, [updateAnchor, anchor])\n\n useEffect(() => {\n if (anchorRef.current && container) {\n onMount?.(anchorRef.current, container)\n return () => {\n onUnmount?.(anchorRef.current!, container)\n }\n }\n }, [container, onMount, onUnmount])\n\n return container ? ReactDOM.createPortal(children, container, key) : null\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"sGAaA,MAAM,GAAe,CAAE,SAAQ,WAAW,SAAU,WAAU,UAAS,YAAW,MAAK,SAA4B,CACjH,GAAM,CAAC,EAAW,GAAgB,EAAgC,KAAK,CACjE,EAAY,EAAuB,KAAK,CAExC,EAAY,EACf,GAAmC,CAC9B,IACE,OAAO,GAAQ,WACjB,EAAI,EAAQ,CAEZ,EAAI,QAAU,IAIpB,CAAC,EAAI,CACN,CAEK,EAAkB,EACrB,GAAkD,CACjD,IAAMA,EAAY,SAAS,cAAc,MAAM,CAY/C,MAXA,GAAU,MAAM,QAAU,WASX,EAAc,sBAPT,CAClB,OAAQ,cACR,QAAS,aACT,OAAQ,YACR,MAAO,WACR,CAE8D,GAAWA,EAAU,EAItF,CAAC,EAAS,CACX,CAEK,EAAgB,MAChB,OAAO,GAAW,SACb,SAAS,cAAc,EAAO,CAC5B,OAAO,GAAW,WACpB,GAAQ,CACN,GAAU,YAAa,EACzB,EAAO,QAEP,EAER,CAAC,EAAO,CAAC,CAEN,EAAe,MAAkB,CACrC,IAAM,EAAY,GAAe,CAEjC,EAAc,GAAkB,CAC9B,GAAe,QAAQ,CACvB,EAAU,QAAU,EACpB,IAAM,EAAe,EAAY,EAAgB,EAAU,CAAG,KAE9D,OADA,EAAU,EAAa,CAChB,GACP,EACD,CAAC,EAAe,EAAiB,EAAU,CAAC,CA8C/C,OA5CA,MAAgB,CACd,GAAc,CAEd,IAAM,EAAW,IAAI,iBAAkB,GAAc,CAC9B,EAAU,KAAM,GAAa,CAChD,GAAM,CAAE,aAAY,gBAAiB,EAcrC,OAXI,EAAU,SAAW,MAAM,KAAK,EAAa,CAAC,SAAS,EAAU,QAAQ,CACpE,GAIL,OAAO,GAAW,SACb,MAAM,KAAK,EAAW,CAAC,KAC3B,GAAS,EAAK,WAAa,KAAK,cAAgB,aAAgB,SAAW,EAAK,UAAU,EAAO,CACnG,CAGI,IACP,EAGA,GAAc,EAEhB,CAOF,OALA,EAAS,QAAQ,SAAS,KAAM,CAC9B,UAAW,GACX,QAAS,GACV,CAAC,KAEW,EAAS,YAAY,EACjC,CAAC,EAAc,EAAO,CAAC,CAE1B,MAAgB,CACd,GAAI,EAAU,SAAW,EAEvB,OADA,IAAU,EAAU,QAAS,EAAU,KAC1B,CACX,IAAY,EAAU,QAAU,EAAU,GAG7C,CAAC,EAAW,EAAS,EAAU,CAAC,CAE5B,EAAY,EAAS,aAAa,EAAU,EAAW,EAAI,CAAG,MAGvE,EAAY,YAAc,cAE1B,IAAA,EAAe"}
@@ -0,0 +1,25 @@
1
+ import globals from 'globals'
2
+ import pluginJs from '@eslint/js'
3
+ import { defineConfig } from 'eslint/config'
4
+ import tseslint from 'typescript-eslint'
5
+ import prettierPlugin from 'eslint-plugin-prettier/recommended'
6
+ import reactHooks from 'eslint-plugin-react-hooks'
7
+ import reactRefresh from 'eslint-plugin-react-refresh'
8
+
9
+ export default defineConfig([
10
+ { files: ['**/*.{js,mjs,cjs,ts}'] },
11
+ {
12
+ languageOptions: {
13
+ globals: { ...globals.browser, ...globals.node },
14
+ parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname }
15
+ }
16
+ },
17
+ pluginJs.configs.recommended,
18
+ ...tseslint.configs.recommended,
19
+ prettierPlugin,
20
+ reactHooks.configs['recommended-latest'],
21
+ reactRefresh.configs.vite,
22
+ {
23
+ ignores: ['**/dist/*']
24
+ }
25
+ ])
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "react-magic-portal",
3
+ "version": "1.0.0",
4
+ "description": "React Portal with dynamic mounting support",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "dev": "tsdown src/index.ts --dts --format esm --sourcemap --watch",
9
+ "build": "tsdown src/index.ts --dts --format esm --sourcemap --minify --clean",
10
+ "lint": "eslint --fix --cache",
11
+ "check": "tsc --noEmit"
12
+ },
13
+ "keywords": [
14
+ "react",
15
+ "portal",
16
+ "dynamic",
17
+ "browser-extension",
18
+ "content-script",
19
+ "dom",
20
+ "mutation-observer",
21
+ "inject",
22
+ "mount",
23
+ "anchor",
24
+ "reactdom",
25
+ "createportal",
26
+ "spa",
27
+ "single-page-application",
28
+ "web-extension",
29
+ "chrome-extension",
30
+ "firefox-extension",
31
+ "dynamic-content",
32
+ "dom-manipulation",
33
+ "typescript"
34
+ ],
35
+ "author": "molvqingtai",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/molvqingtai/react-magic-portal.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/molvqingtai/react-magic-portal/issues"
43
+ },
44
+ "homepage": "https://github.com/molvqingtai/react-magic-portal#readme",
45
+ "peerDependencies": {
46
+ "react": ">=18.0.0",
47
+ "react-dom": ">=18.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^9.36.0",
51
+ "@types/node": "^24.5.2",
52
+ "@types/react": "^19.1.13",
53
+ "@types/react-dom": "^19.1.9",
54
+ "eslint": "^9.36.0",
55
+ "eslint-config-prettier": "^10.1.8",
56
+ "eslint-plugin-prettier": "^5.5.4",
57
+ "eslint-plugin-react-hooks": "^5.2.0",
58
+ "eslint-plugin-react-refresh": "^0.4.21",
59
+ "globals": "^16.4.0",
60
+ "prettier": "^3.6.2",
61
+ "react": "^19.1.1",
62
+ "react-dom": "^19.1.1",
63
+ "tsdown": "^0.15.4",
64
+ "typescript": "^5.9.2",
65
+ "typescript-eslint": "^8.44.1"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }
@@ -0,0 +1,123 @@
1
+ import React, { useEffect, useState, useRef, useCallback } from 'react'
2
+ import ReactDOM from 'react-dom'
3
+
4
+ export interface MagicPortalProps {
5
+ anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null
6
+ position?: 'append' | 'prepend' | 'before' | 'after'
7
+ children: React.ReactNode
8
+ onMount?: (anchor: Element, container: HTMLDivElement) => void
9
+ onUnmount?: (anchor: Element, container: HTMLDivElement) => void
10
+ ref?: React.Ref<HTMLDivElement | null>
11
+ key?: React.Key
12
+ }
13
+
14
+ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount, ref, key }: MagicPortalProps) => {
15
+ const [container, setContainer] = useState<HTMLDivElement | null>(null)
16
+ const anchorRef = useRef<Element | null>(null)
17
+
18
+ const updateRef = useCallback(
19
+ (element: HTMLDivElement | null) => {
20
+ if (ref) {
21
+ if (typeof ref === 'function') {
22
+ ref(element)
23
+ } else {
24
+ ref.current = element
25
+ }
26
+ }
27
+ },
28
+ [ref]
29
+ )
30
+
31
+ const createContainer = useCallback(
32
+ (anchorElement: Element): HTMLDivElement | null => {
33
+ const container = document.createElement('div')
34
+ container.style.display = 'contents'
35
+
36
+ const positionMap = {
37
+ before: 'beforebegin',
38
+ prepend: 'afterbegin',
39
+ append: 'beforeend',
40
+ after: 'afterend'
41
+ } as const
42
+
43
+ const result = anchorElement.insertAdjacentElement(positionMap[position], container)
44
+
45
+ return result as HTMLDivElement | null
46
+ },
47
+ [position]
48
+ )
49
+
50
+ const resolveAnchor = useCallback((): Element | null => {
51
+ if (typeof anchor === 'string') {
52
+ return document.querySelector(anchor)
53
+ } else if (typeof anchor === 'function') {
54
+ return anchor()
55
+ } else if (anchor && 'current' in anchor) {
56
+ return anchor.current
57
+ } else {
58
+ return anchor
59
+ }
60
+ }, [anchor])
61
+
62
+ const updateAnchor = useCallback(() => {
63
+ const newAnchor = resolveAnchor()
64
+
65
+ setContainer((prevContainer) => {
66
+ prevContainer?.remove()
67
+ anchorRef.current = newAnchor
68
+ const newContainer = newAnchor ? createContainer(newAnchor) : null
69
+ updateRef(newContainer)
70
+ return newContainer
71
+ })
72
+ }, [resolveAnchor, createContainer, updateRef])
73
+
74
+ useEffect(() => {
75
+ updateAnchor()
76
+
77
+ const observer = new MutationObserver((mutations) => {
78
+ const shouldUpdate = mutations.some((mutation) => {
79
+ const { addedNodes, removedNodes } = mutation
80
+
81
+ // Check if current anchor is removed
82
+ if (anchorRef.current && Array.from(removedNodes).includes(anchorRef.current)) {
83
+ return true
84
+ }
85
+
86
+ // Only check added nodes when anchor is a string selector
87
+ if (typeof anchor === 'string') {
88
+ return Array.from(addedNodes).some(
89
+ (node) => node.nodeType === Node.ELEMENT_NODE && node instanceof Element && node.matches?.(anchor)
90
+ )
91
+ }
92
+
93
+ return false
94
+ })
95
+
96
+ if (shouldUpdate) {
97
+ updateAnchor()
98
+ }
99
+ })
100
+
101
+ observer.observe(document.body, {
102
+ childList: true,
103
+ subtree: true
104
+ })
105
+
106
+ return () => observer.disconnect()
107
+ }, [updateAnchor, anchor])
108
+
109
+ useEffect(() => {
110
+ if (anchorRef.current && container) {
111
+ onMount?.(anchorRef.current, container)
112
+ return () => {
113
+ onUnmount?.(anchorRef.current!, container)
114
+ }
115
+ }
116
+ }, [container, onMount, onUnmount])
117
+
118
+ return container ? ReactDOM.createPortal(children, container, key) : null
119
+ }
120
+
121
+ MagicPortal.displayName = 'MagicPortal'
122
+
123
+ export default MagicPortal
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "rootDir": ".",
5
+ "target": "ESNext",
6
+ "useDefineForClassFields": true,
7
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
8
+ "module": "ESNext",
9
+ "skipLibCheck": true,
10
+
11
+ /* Build mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": false,
14
+ "moduleDetection": "force",
15
+ "declaration": true,
16
+ "declarationMap": true,
17
+ "outDir": "dist",
18
+ "jsx": "react-jsx",
19
+
20
+ /* Linting */
21
+ "strict": true,
22
+ "noUnusedLocals": true,
23
+ "noUnusedParameters": true,
24
+ "noFallthroughCasesInSwitch": true
25
+ },
26
+ "include": ["src", "eslint.config.ts"]
27
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "semi": false,
3
+ "singleQuote": true,
4
+ "trailingComma": "none",
5
+ "printWidth": 120
6
+ }
@@ -0,0 +1,6 @@
1
+ # Run
2
+
3
+ ```bash
4
+ pnpm install
5
+ pnpm dev
6
+ ```
@@ -0,0 +1,25 @@
1
+ import globals from 'globals'
2
+ import pluginJs from '@eslint/js'
3
+ import { defineConfig } from 'eslint/config'
4
+ import tseslint from 'typescript-eslint'
5
+ import prettierPlugin from 'eslint-plugin-prettier/recommended'
6
+ import reactHooks from 'eslint-plugin-react-hooks'
7
+ import reactRefresh from 'eslint-plugin-react-refresh'
8
+
9
+ export default defineConfig([
10
+ { files: ['**/*.{js,mjs,cjs,ts}'] },
11
+ {
12
+ languageOptions: {
13
+ globals: { ...globals.browser, ...globals.node },
14
+ parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname }
15
+ }
16
+ },
17
+ pluginJs.configs.recommended,
18
+ ...tseslint.configs.recommended,
19
+ prettierPlugin,
20
+ reactHooks.configs['recommended-latest'],
21
+ reactRefresh.configs.vite,
22
+ {
23
+ ignores: ['**/dist/*']
24
+ }
25
+ ])
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Vite + React + TS</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "example",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint --fix --cache",
10
+ "preview": "vite preview",
11
+ "check": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "react": "^19.1.1",
15
+ "react-dom": "^19.1.1",
16
+ "react-magic-portal": "workspace:*"
17
+ },
18
+ "devDependencies": {
19
+ "@eslint/js": "^9.36.0",
20
+ "@types/react": "^19.1.13",
21
+ "@types/react-dom": "^19.1.9",
22
+ "@vitejs/plugin-react": "^5.0.3",
23
+ "eslint": "^9.36.0",
24
+ "eslint-plugin-prettier": "^5.5.4",
25
+ "eslint-plugin-react-hooks": "^5.2.0",
26
+ "eslint-plugin-react-refresh": "^0.4.21",
27
+ "globals": "^16.4.0",
28
+ "typescript": "~5.9.2",
29
+ "typescript-eslint": "^8.44.1",
30
+ "vite": "^7.1.7"
31
+ }
32
+ }