├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .release-it.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs ├── .vitepress │ ├── components │ │ ├── Badge.vue │ │ ├── Design │ │ │ ├── Container.vue │ │ │ └── Panel.vue │ │ ├── Examples │ │ │ ├── Basic.vue │ │ │ ├── Conditional.vue │ │ │ ├── Disable.vue │ │ │ └── Multiple.vue │ │ ├── Hero.vue │ │ ├── ResizeObserver │ │ │ └── ResizeObserver.vue │ │ ├── SplitDisplay.vue │ │ └── index.js │ ├── config.js │ ├── env.d.ts │ ├── public │ │ ├── logo.png │ │ └── portal-vue-logo.gif │ └── theme │ │ └── index.js ├── api │ ├── portal-target.md │ └── portal.md ├── examples._md ├── guide │ ├── SSR.md │ ├── advanced.md │ ├── caveats.md │ ├── getting-started.md │ ├── installation.md │ ├── migration.md │ └── portal-vue-teleport.md └── index.md ├── example ├── components │ ├── App.vue │ ├── comp-as-root │ │ └── comp-as-root.vue │ ├── default-content-on-target │ │ └── index.vue │ ├── disabled │ │ └── index.vue │ ├── empty-portal │ │ └── index.vue │ ├── mount-to │ │ ├── Test.vue │ │ └── mount-to-external.vue │ ├── multiple │ │ ├── destination.vue │ │ ├── multiple.vue │ │ └── source.vue │ ├── portal-container.vue │ ├── programmatic │ │ └── index.vue │ ├── router-view-with-portals │ │ ├── a.vue │ │ ├── b.vue │ │ └── index.vue │ ├── scoped-slots │ │ └── index.vue │ ├── scoped-styles │ │ ├── Destination.vue │ │ ├── Index.vue │ │ └── Source.vue │ ├── source-switch │ │ ├── destination.vue │ │ ├── source-switch.vue │ │ └── source.vue │ ├── target-switch │ │ ├── destination.vue │ │ ├── source-comp.vue │ │ └── target-switch.vue │ ├── test-component.vue │ ├── toggle │ │ ├── destination.vue │ │ ├── source-comp.vue │ │ └── toggle-example.vue │ ├── transitions │ │ └── transitions.vue │ └── wrapper-slot │ │ └── WrapperSlot.vue ├── index.html ├── main.ts ├── router.ts ├── styles │ ├── _variables.scss │ └── index.css └── vite.config.ts ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── scripts └── docs-check.sh ├── src ├── __tests__ │ ├── __snapshots__ │ │ ├── portal-target.spec.ts.snap │ │ └── the-portal.spec.ts.snap │ ├── integration.spec.ts │ ├── portal-target.spec.ts │ ├── resources │ │ ├── CustomTransition.vue │ │ ├── HappyPath.vue │ │ ├── PortalDisabled.vue │ │ ├── PortalDisabledScoped.vue │ │ ├── PortalSlim.vue │ │ ├── PortalSwitchTarget.vue │ │ ├── PortalWithMountedTarget.vue │ │ ├── ScopedSlot.vue │ │ ├── TargetDefaultContent.vue │ │ ├── TargetMultiple.vue │ │ └── TargetSlim.vue │ ├── the-portal.spec.ts │ └── wormhole.spec.ts ├── components │ ├── portal-target.ts │ └── portal.ts ├── composables │ └── wormhole.ts ├── env.d.ts ├── index.ts ├── types.ts ├── utils │ ├── index.ts │ └── mountPortalTarget.ts └── wormhole.ts ├── tsconfig.app.json ├── tsconfig.build.json ├── tsconfig.config.json ├── tsconfig.json ├── tsconfig.vitest.json ├── vite.config.dev.ts └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | /types -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: ['@linusborg/eslint-config'], 7 | rules: { 8 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 9 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 10 | }, 11 | overrides: [ 12 | { 13 | files: ['*.cjs', '*.js', '*.mjs', '*.cts', '*.ts', '*.mts'], 14 | env: { 15 | node: true, 16 | }, 17 | }, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: linusborg 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'ci' 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read # to fetch code (actions/checkout) 12 | 13 | jobs: 14 | build-and-typecheck: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v2 21 | 22 | - name: Set node version to 14.19 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 14.19 26 | cache: 'pnpm' 27 | 28 | - run: pnpm install 29 | 30 | - name: Run Vite build & vue-tsc 31 | run: pnpm run build 32 | 33 | unit-test: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v3 37 | 38 | - name: Install pnpm 39 | uses: pnpm/action-setup@v2 40 | 41 | - name: Set node version to 14.19 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: 14.19 45 | cache: 'pnpm' 46 | 47 | - run: pnpm install 48 | 49 | - name: Run unit tests 50 | run: pnpm run test 51 | 52 | lint: 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v3 56 | 57 | - name: Install pnpm 58 | uses: pnpm/action-setup@v2 59 | 60 | - name: Set node version to 14.19 61 | uses: actions/setup-node@v3 62 | with: 63 | node-version: 14.19 64 | cache: 'pnpm' 65 | 66 | - run: pnpm install 67 | 68 | - name: Lint codebase 69 | run: pnpm run lint -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | dist/ 5 | types/ 6 | 7 | /tests/e2e/videos/ 8 | /tests/e2e/screenshots/ 9 | 10 | # local env files 11 | .env.local 12 | .env.*.local 13 | docs/.vuepress/dist 14 | 15 | # Log files 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | # Editor directories and files 21 | .idea 22 | .vscode 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw* 28 | 29 | TODO.md 30 | 31 | *_OLD.ts 32 | *_OLD.d.ts 33 | *_OLD.tsx 34 | 35 | *.tsbuildinfo -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.circleci 2 | /.vscode 3 | /docs 4 | /example 5 | /scripts 6 | /tests 7 | /types 8 | netlify.toml 9 | TODO.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release v${version}" 4 | }, 5 | "github": { 6 | "release": true 7 | }, 8 | "hooks": { 9 | "before:init": ["pnpm lint", "pnpm test:ci"], 10 | "after:bump": ["pnpm build"], 11 | "after:release": ["echo 🥳 Successfully released ${name} v${version}."] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "cSpell.words": [ 6 | "testnode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Thorsten Lünborg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PortalVue 2 | 3 | > A Portal Component for Vue 3, to render DOM outside of a component, anywhere in the document. 4 | 5 | 6 |

7 | PortalVue Logo 8 |

9 | 10 |

11 | 12 | Buy Me a Coffee at ko-fi.com 13 | 14 |

15 | 16 | For more detailed documentation and additional Information, [please visit the docs](https://next.portal-vue.linusb.org). 17 | 18 | > Looking for the version for Vue 2.\*? [Docs for PortalVue 2.*, compatible with Vue 2, are here](https://v2.portal-vue.linusb.org) 19 | 20 | ## Installation 21 | 22 | ```bash 23 | npm i portal-vue 24 | 25 | # or 26 | 27 | yarn add portal-vue 28 | ``` 29 | 30 | ```javascript 31 | import PortalVue from 'portal-vue' 32 | Vue.use(PortalVue) 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```html 38 | 39 |

This slot content will be rendered wherever the with name 'destination' 40 | is located.

41 | 42 | 43 | 44 | 48 | 49 | ``` 50 | 51 | ## Nuxt module 52 | 53 | v3 does not yet have a nuxt module integration. PRs welcome. 54 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Badge.vue: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 18 | 19 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Design/Container.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 27 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Design/Panel.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 41 | 42 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Examples/Basic.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Examples/Conditional.vue: -------------------------------------------------------------------------------- 1 | 24 | 33 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Examples/Disable.vue: -------------------------------------------------------------------------------- 1 | 23 | 32 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Examples/Multiple.vue: -------------------------------------------------------------------------------- 1 | 26 | 36 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Hero.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 64 | 67 | 68 | 115 | -------------------------------------------------------------------------------- /docs/.vitepress/components/ResizeObserver/ResizeObserver.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 74 | 75 | -------------------------------------------------------------------------------- /docs/.vitepress/components/SplitDisplay.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 148 | 149 | 152 | 153 | 201 | -------------------------------------------------------------------------------- /docs/.vitepress/components/index.js: -------------------------------------------------------------------------------- 1 | import Hero from './Hero.vue' 2 | import Badge from './Badge.vue' 3 | // import ResizeObserver from './ResizeObserver/ResizeObserver.vue' 4 | export default function components(app) { 5 | // app.component('ResizeObserver', ResizeObserver) 6 | app.component('Hero', Hero) 7 | app.component('Badge', Badge) 8 | } 9 | -------------------------------------------------------------------------------- /docs/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('vitepress') 2 | const { version } = require('../../package.json') 3 | 4 | module.exports = defineConfig({ 5 | title: 'Portal-Vue', 6 | description: 7 | 'A set of Vue 3 components to move your content anywhere in the DOM.', 8 | 9 | themeConfig: { 10 | socialLinks: [ 11 | { 12 | icon: 'github', 13 | link: 'https://github.com/linusborg/portal-vue', 14 | }, 15 | ], 16 | siteTitle: 'Portal-Vue', 17 | repo: 'linusborg/portal-vue', 18 | repoLabel: 'GitHub', 19 | 20 | outline: 'deep', 21 | outlineTitle: 'On this page', 22 | editLink: { 23 | pattern: 'https://github.com/linusborg/portal-vue/edit/docs/:path', 24 | text: 'Edit this page on GitHub', 25 | }, 26 | lastUpdatedText: 'Last Updated', 27 | nav: [ 28 | { 29 | text: 'Guide', 30 | link: '/guide/getting-started', 31 | activeMatch: '^/guide/', 32 | }, 33 | { 34 | text: 'API', 35 | link: '/api/portal', 36 | activeMatch: '^/api/', 37 | }, 38 | { 39 | text: 'Docs for v2', 40 | link: 'https://v2.portal-vue.linusb.org', 41 | }, 42 | ], 43 | sidebar: { 44 | '/guide/': [{ text: 'Guide', items: getGuideSidebar() }], 45 | '/api/': [{ text: 'API', items: getApiSidebar() }], 46 | }, 47 | }, 48 | vite: { 49 | define: { 50 | __PORTAL_VUE_VERSION__: JSON.stringify(version), 51 | }, 52 | }, 53 | }) 54 | 55 | function getGuideSidebar() { 56 | return [ 57 | { text: 'Installation', link: '/guide/installation' }, 58 | { text: 'Getting Started', link: '/guide/getting-started' }, 59 | { text: 'Advanced Usage', link: '/guide/advanced' }, 60 | { text: 'Caveats', link: '/guide/caveats' }, 61 | { text: 'Migrating from 2.0', link: '/guide/migration' }, 62 | ] 63 | } 64 | function getApiSidebar() { 65 | return [ 66 | { text: 'Portal', link: '/api/portal' }, 67 | { text: 'PortalTarget', link: '/api/portal-target' }, 68 | // { 69 | // text: 'createPortalTarget', 70 | // link: '/api/mounting-portal', 71 | // }, 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /docs/.vitepress/env.d.ts: -------------------------------------------------------------------------------- 1 | declare const __PORTAL_VUE_VERSION__: string 2 | -------------------------------------------------------------------------------- /docs/.vitepress/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinusBorg/portal-vue/024509fa1545c43ea2c306827ab718229814d2b3/docs/.vitepress/public/logo.png -------------------------------------------------------------------------------- /docs/.vitepress/public/portal-vue-logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinusBorg/portal-vue/024509fa1545c43ea2c306827ab718229814d2b3/docs/.vitepress/public/portal-vue-logo.gif -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | // import plugin from 'portal-vue' 2 | import DefaultTheme from 'vitepress/theme' 3 | import components from '../components' 4 | export default { 5 | ...DefaultTheme, 6 | enhanceApp(ctx) { 7 | DefaultTheme.enhanceApp(ctx) 8 | ctx.app.use(components) 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /docs/api/portal-target.md: -------------------------------------------------------------------------------- 1 | --- 2 | # sidebar: auto 3 | prev: ./portal 4 | next: ./mounting-portal 5 | --- 6 | 7 | # PortalTarget 8 | 9 | This component is an outlet for any content that was sent by a `` component. It renders received content and doesn't do much else. 10 | 11 | ## Example usage 12 | 13 | ```html 14 | 15 | ``` 16 | 17 | ::: tip Fragment 18 | 19 | `PortalTarget` renders a Fragment now, which means: there is no wrapping element. 20 | 21 | ::: 22 | 23 | ## Props API 24 | 25 | ### `multiple` 26 | 27 | When `multiple` is `true`, the portal will be able to receive and render content from multiple `` component at the same time. 28 | 29 | You should use the `order` prop on the `` to define the order in which the contents should be rendered: 30 | 31 | **Source** 32 | 33 | 34 | ```html {10} 35 | 36 |

some content

37 |
38 | 39 |

some other content

40 |
41 | 42 | 43 | ``` 44 | 45 | **Result** 46 | 47 | 48 | ```html 49 |

some other content

50 |

some content

51 | ``` 52 | 53 | ### `name` 54 | 55 | | Type | Required | Default | 56 | | -------- | -------- | ------- | 57 | | `String` | yes | none | 58 | 59 | Defines the name of this portal-target. `` components can send content to this instance by this name. 60 | 61 | ### `slotProps` 62 | 63 | | Type | Required | Default | 64 | | -------- | -------- | ------- | 65 | | `Object` | no | `{}` | 66 | 67 | ::: tip 68 | This prop is only useful when the PortalTarget received content from a [scoped Slot](https://vuejs.org/v2/guide/components.html#Scoped-Slots) of a ``. 69 | ::: 70 | 71 | The `slotProps` object is used as props to render the scoped slot from a ``. 72 | 73 | **Source** 74 | 75 | 76 | ```html {2,9} 77 | 78 |

This scoped slot content is so {{ props.state }}

79 |
80 | 81 | 82 | ``` 83 | 84 | **Result** 85 | 86 | 87 | ```html 88 |

This scoped slot content is so cool!

89 | ``` 90 | 91 | It has a counterpart of the same name on the `` component to pass props to the slot content when the `` is disabled. 92 | 93 | ## Slots API 94 | 95 | ### Default slot 96 | 97 | Any existing slot content is rendered in case that no content from any source Portal is available. 98 | 99 | Example: 100 | 101 | **Source** 102 | 103 | 104 | ```html {2} 105 | 106 |

This is rendered when no other content is available.

107 |
108 | ``` 109 | 110 | **Result** 111 | 112 | 113 | ```html 114 |

This is rendered when no other content is available.

115 | ``` 116 | 117 | ### Default scoped slot 118 | 119 | The default slot can also be scoped. The scoped slot receives the [`slotProps`](#slotprops) prop as its argument. 120 | 121 | Example: 122 | 123 | **Source** 124 | 125 | 126 | ```html {1-3} 127 | 128 |

129 | {{props.message}} This is rendered when no other content is available. 130 |

131 |
132 | ``` 133 | 134 | **Result** 135 | 136 | 137 | ```html 138 |

This is rendered when no other content is available.

139 | ``` 140 | 141 | ### `wrapper` 142 | 143 | This slot can be used to define markup that should wrap the content received from a ``. This is usually only useful in combination with [`multiple`](#multiple), as for content from a single portal, you can just wrap the `` as a whole. 144 | 145 | The slot receives an array as its only prop, which contains the raw vnodes representing the content sent from the source portal(s). 146 | 147 | These vnodes can be rendered with Vue's dynamic component syntax: 148 | 149 | `` 150 | 151 | Example: 152 | 153 | **Source** 154 | 155 | ```html 156 | 157 | 160 | 161 | ``` 162 | 163 | This slot is also useful to [add transitions (see advanced Guide)](../guide/advanced#transitions ). 164 | ## Events API 165 | 166 | ### `change` 167 | 168 | Emitted every time the component re-renders because the content from the `` changed. 169 | 170 | It receives an object with two properties: 171 | 172 | ```js 173 | { 174 | hasContent: boolean, 175 | sources: string[] 176 | } 177 | ``` 178 | 179 | |Property| type | description| 180 | |----------|---------|-----------------------------------------------------------------------| 181 | |hasContent|boolean | indicated wether there is currently and content for the `PortalTarget`| 182 | |sources | string[]| Array with the names of the portal(s) that sent content | 183 | 184 | 185 | ```html {4} 186 | 189 | 190 | 199 | ``` 200 | -------------------------------------------------------------------------------- /docs/api/portal.md: -------------------------------------------------------------------------------- 1 | --- 2 | # sidebar: auto 3 | prev: false 4 | next: ./portal-target 5 | --- 6 | 7 | # Portal Component 8 | 9 | Wrap any content that you want to render somewhere else in a `Portal` component. 10 | 11 | ## Example usage 12 | 13 | ```html 14 | 19 |

This content will be sent through the portal

20 |
21 | ``` 22 | 23 | ## Props API 24 | 25 | ### `disabled` 26 | 27 | | Type | Required | Default | 28 | | --------- | -------- | ------- | 29 | | `Boolean` | no | `false` | 30 | 31 | When `true`, the slot content will _not_ be sent through the portal to the defined PortalTarget. 32 | 33 | Instead, it will be rendered in place: 34 | 35 | **Source** 36 | 37 | 38 | ```html 39 | 40 |

some content

41 |
42 | ``` 43 | 44 | **Result** 45 | 46 | 47 | ```html 48 |

some content

49 | ``` 50 | 51 | ::: tip Fragment 52 | 53 | `Portal` now renders a fragment, which means it doesn't render a root node around its content. Thats a new features supported in Vue 3, and pretty useful here - no superfluous wrapper element anymore! 54 | 55 | :::: 56 | 57 | ::: warning Local component state 58 | 59 | When toggling between enabled/disabled state, components in the portal slot are destroyed and re-created, which means any changes to their local state are lost. 60 | 61 | ::: 62 | ### `name` 63 | 64 | | Type | Required | Default | 65 | | -------- | -------- | --------------- | 66 | | `String` | no | a random String | 67 | 68 | This optional prop can usually be left out, because `Portal` can generate a random string to provide an identifier for the source of the content being sent to the `PortalTarget`. 69 | 70 | But it might be a good idea to name your `Portal` components so you can debug them easier if need would be. 71 | 72 | ### `order` 73 | 74 | | Type | Required | Default | 75 | | -------- | -------- | ------- | 76 | | `Number` | no\* | 0 | 77 | 78 | This prop defines the order position in the output of the `PortalTarget`. 79 | 80 | ::: tip 81 | This prop is only useful when the Portal is sending content to a `PortalTarget` which has the `multiple` prop set. 82 | ::: 83 | 84 | **Source** 85 | 86 | 87 | ```html 88 |

some content

89 |

some other content

90 | 91 | 92 | ``` 93 | 94 | **Result** 95 | 96 | 97 | ```html 98 |

some other content

99 |

some content

100 | ``` 101 | 102 | ### `slotProps` 103 | 104 | | Type | Required | Default | 105 | | -------- | -------- | ------- | 106 | | `Object` | no | `{}` | 107 | 108 | This prop is only useful if: 109 | 110 | - the `disabled` prop is `true`, **and** 111 | - the `Portal`'s slot content is a [Scoped Slot](https://vuejs.org/v2/guide/components.html#Scoped-Slots). 112 | 113 | If that's the case, then the object you pass to `slotProps` is used to define the props that are passed to the scoped slot to display the content correctly in-place: 114 | 115 | It has a (more useful) counterpart in the `PortalTarget` component 116 | 117 | **Source** 118 | 119 | 120 | ```html 121 | 127 |

This scoped slot content is {{ props.state }}

128 |
129 | ``` 130 | 131 | **Result** 132 | 133 | 134 | ```html 135 |

This scoped slot content is disabled!

136 | ``` 137 | 138 | ### `to` 139 | 140 | | Type | Required | Default | 141 | | -------- | --------- | --------------- | 142 | | `String` | yes | a random String | 143 | 144 | This defines the name of the `PortalTarget` component that the slot content should be rendered in. 145 | 146 | **Source** 147 | 148 | 149 | ```html 150 | 151 |

some content

152 |
153 | 154 |
155 | 156 |
157 | ``` 158 | 159 | **Result** 160 | 161 | 162 | ```html 163 | 164 | 165 |
166 |

some content

167 |
168 | ``` 169 | -------------------------------------------------------------------------------- /docs/examples._md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar: auto 3 | --- 4 | 5 | # Examples 6 | 7 | :::tip Call for Examples 8 | This section is pretty empty right now. It could need some more lovely examples. 9 | 10 | PRs are welcome :) 11 | ::: 12 | 13 | ## Basic 14 | 15 | This is the starter example from the "Getting Started" page, demonstrating the basic usage. 16 | 17 | 18 | 19 | <<< @/docs/.vuepress/components/Examples/Basic.vue{8,17} 20 | 21 | 22 | ## Dynamic sidebar content 23 | 24 | By selectively rendering different `` components that all send to the same `` (but not at the same time), we can replace the ``'s content on demand, e.g. to put something into a sidebar area from teh main component: 25 | 26 | ::: warning Missing 27 | The example is still missing, haven't gotten around to migrating it from [this codepen of v1](https://codepen.io/LinusBorg/pen/xdQZqa) 28 | ::: 29 | 30 | ## More to follow 31 | 32 | More examples will hopefully follow soon. Send me some suggestions! 33 | -------------------------------------------------------------------------------- /docs/guide/SSR.md: -------------------------------------------------------------------------------- 1 | # Server-Side Rendering (SSR) 2 | 3 | When using [Vue's SSR capabilities](https://ssr.vuejs.org), portal-vue can't work reliably for a couple of reasons: 4 | 5 | 1. The internal Store (the "Wormhole") that's caching vnodes and connecting `` components to their `` counterparts, is a singleton. As such, changes to the Wormhole persist between requests, leading to all sorts of problems. 6 | 2. In SSR, Vue renders the page directly to a string, there are not reactive updates applied. Consequently, a `` appearing before a `` will render an empty div on the server whereas it will render the sent content on the client, resulting in a hydration vdom mismatch error, while a `` _following_ a `` would technically work. 7 | 8 | ## Solutions 9 | 10 | ### Disabling the portal on the server 11 | 12 | For the aforementioned reasons, starting with `2.1.2`, content won't be cached in the Wormhole anymore when on the server. Consequently, the HTML rendered by the server won't contain any DOM nodes in place of any `` 13 | 14 | ### Handling on the client 15 | 16 | We want to display the `` content on the client, though. In order to prevent any hydration mismatches, we can use a _really_ tiny [component called ``](https://github.com/egoist/vue-no-ssr), written by [@egoist](https://github.com/egoist), which can solve this problem. 17 | 18 | We wrap our `` elements in it, and it will prevent rendering on the server as well as on the client during hydration, preventing the error described above. Immediatly _after_ hyration, it will render the previously "hidden" content, so that the `` will render its content. Usually the user can hardly notice this as the update is near-immediate. 19 | 20 | Example: 21 | 22 | ```html 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/guide/advanced.md: -------------------------------------------------------------------------------- 1 | --- 2 | # sidebar: auto 3 | prev: ./getting-started 4 | next: ./caveats 5 | --- 6 | 7 | # Advanced Usage 8 | 9 | ## Switching targets & sources 10 | 11 | The `to` prop of `` and the `name` prop of `` can be changed dynamically with `v-bind`, allowing you to send content of one `` to a different ``, or switch the source of a `` from one `` to another. 12 | 13 | 14 | ```html 15 | 16 | Content will be dynamically sent to the destination that `name` evaluates to 17 | 18 | 19 | 20 | by changing the 'name', you can define which portal's content should be shown. 21 | 22 | ``` 23 | ## Scoped Slots 24 | 25 | PortalVue can also be used with [Scoped Slots](https://vuejs.org/v2/guide/components.html#Scoped-Slots)! This allows you to send a scoped slot to a PortalTarget, which can then provide props for the slot content: 26 | 27 | 28 | ```html 29 | 30 |

{{message}}

31 |
32 | 33 | 37 | ``` 38 | 39 | **Result:** 40 | 41 | 42 | ```html 43 | 44 |

Hello from the Target to You!

45 | ``` 46 | 47 | ## Transitions 48 | 49 | ### Portal Transitions 50 | 51 | You can pass transitions to a `` without problems. It will behave just the same when the content is being rendered in the ``: 52 | 53 | 54 | ```html 55 | 56 | 57 |

You have {{messages.length}} new messages

58 |

No unread messages

59 |
60 |
61 | ``` 62 | 63 | However, if you use a `` for multiple ``s, you likely want to define the transition on the target end instead. This is also supported. 64 | #### PortalTarget Transitions 65 | 66 | 67 | ```html 68 | 69 | 74 | 75 | ``` 76 | 77 | Transitions for Targets underwent a redesign in PortalVue `3.0`. The new syntax is admittedly a bit more verbose and has a hack-ish feel to it, but it's a valid use of Vue's v-slot syntax and was necessary to get rid of some nasty edge cases with target Transitions that we had in PortalVue `2.*`. 78 | 79 | Basically, you pass a transition to a slot named `wrapper` and get an array called `nodes` from its slot props. 80 | 81 | You can the use Vue'S `` to turn those into the content of the transition. 82 | 83 | Here's a second example, using a `` instead: 84 | 85 | 86 | ```html 87 | 88 | 93 | 94 | ``` 95 | 96 | ## Proper namespacing 97 | 98 | In order to move content from `Portals` to `PortalTargets`, some intermediary state management is required to coordinate between the two. We call this the "wormhole". 99 | 100 | In PortalVue `<=2.*`, this wormhole was a singleton instance, and as a consequence, the namespace for `to` and `name` properties was also global. 101 | 102 | In PortalVue `3.0` we still use a default wormhole, but now also support creating your own wormhole instance(s) and providing them to your portal components in different areas of your app - or different apps on the same page. 103 | 104 | This makes working with names a bit less prone to conflicts, especially when 3rd-party libraries that you are using in your projects also use portal-vue to move things around without you even knowing. 105 | 106 | So how does it work? 107 | 108 | TODO: properly document using `createWormhole()` 109 | 110 | ## Rendering outside of the Vue-App 111 | 112 | TODO: Introduce `createPortalTarget` and explain limited usage scenarios 113 | -------------------------------------------------------------------------------- /docs/guide/caveats.md: -------------------------------------------------------------------------------- 1 | --- 2 | prev: ./advanced 3 | next: ./migration 4 | --- 5 | 6 | # Known Caveats 7 | 8 | Admittedly, portal-vue uses a little bit of trickery to do what it does. With this come some caveats, which are documented below. 9 | 10 | ## Local state lost when toggling `disabled` 11 | 12 | When toggling the `Portal` component's `disabled` state, components in the portal slot are destroyed and re-created, which means any changes to their local state are lost. 13 | 14 | If you need to persist state, use some sort of [state management](https://portal-vue-next-preview.netlify.com/) 15 | 16 | ## provide/inject 17 | 18 | Due to the way that Vue resolves provides from parent components, it will look for provided objects in the parent of `PortalTarget`, so any `provide`d objects of a parent of `Portal` will not be available to components within a portal - but it will have access to those provided by parents of the `PortalTarget`. 19 | 20 | Also, when using `multiple` portals sending content to one `PortalTarget`, it would be unclear which `Portal` component's injections should be used. 21 | 22 | ## `$parent` 23 | 24 | For the same reason, `this.$parent` will not give you the parent of the `Portal`, instead it will give you the `PortalTarget`, so code relying on `$parent` might break. 25 | 26 | ## [vue-router](https://router.vuejs.org) 27 | 28 | `RouterView` internally walks the $parent chain to find out how many (if any) parent `RouterView` components there are. Therefore `RouterView` inside a `Portal` will not be able to properly match its nested routes. See [#289](https://github.com/LinusBorg/portal-vue/issues/289) for discussion. 29 | 30 | ## `$refs` 31 | 32 | TODO: verify if this part about refs still applies as-is or needs changing. 33 | 34 | In rare cases, you might want to access a DOM element / component that is within the Portal content via a `ref`. This works, but sometimes requires a double `$nextTick`: 35 | 36 | ```javascript 37 | this.$nextTick().then( 38 | this.$nextTick(() => { 39 | this.$refs.yourRef // element should now be in the DOM. 40 | }) 41 | ) 42 | ``` 43 | 44 | The reason is that depending on the scenario, it _can_ take one tick for the content to be sent to the Wormhole (the middleman between `Portal` and `PortalTarget`), and another one to be picked up by the `PortalTarget`. 45 | -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | # sidebar: auto 3 | prev: ./installation 4 | next: ./advanced 5 | --- 6 | # Getting Started with Portal-Vue 7 | 8 | ## What is PortalVue? 9 | 10 | PortalVue is a set of two components that allow you to render a component's template 11 | (or a part of it) anywhere in the document - even outside the part controlled by your Vue App! 12 | 13 | ::: tip What about Vue 3's `Teleport`? 14 | Good question! For most scenario's, you might not even need `portal-vue`, since the new `Teleport` component does it better than this library does (read: Without the [caveats](./caveats.md)). 15 | 16 | For an in-depth explanation, look [here](#) 17 | ::: 18 | 19 | ## Setup 20 | 21 | Install Package: 22 | 23 | ```bash 24 | npm install --save portal-vue@next 25 | 26 | # or with yarn 27 | yarn add portal-vue@next 28 | ``` 29 | 30 | Add it to your application: 31 | 32 | ```js 33 | import PortalVue from 'portal-vue' 34 | import { createApp } from 'vue' 35 | import App from './App.vue' 36 | 37 | const app = createApp(App) 38 | 39 | app.use(PortalVue) 40 | 41 | app.mount('#app') 42 | ``` 43 | 44 | For more detailed installation instructions, additional options and installation via CDN, 45 | see the [Installation](./installation.md) section of the documentation. 46 | 47 | ## Browser Support 48 | 49 | This project is build for modern Javascript, sind Vue itself also targets modern browsers. It supports all browsers that also support ES Modules - those are: 50 | 51 | * Chrome >=61 52 | * Firefox >=60 53 | * Safari >=11 54 | * Edge >=16 55 | 56 | If you need to support older browsers for some reason, make sure to include `node_modules/portal-vue/dist` in the list of files that you transpile with babel. 57 | 58 | Vue CLI offers a dedicated option for this with [`transpileDependencies`](https://cli.vuejs.org/config/#transpiledependencies) 59 | 60 | ```js 61 | // vue.config.js 62 | module.exports = { 63 | transpileDependencies: ['portal-vue'] 64 | } 65 | ``` 66 | 67 | ## Usage 68 | 69 | :::tip About the examples 70 | The following examples contain live demos. When looking at them, keep in mind that for demo purposes, we move content around within one component, however in reality the `` can be positioned anywhere in your App. 71 | 72 | Also, the code of the Examples uses the Single-File-Component Format ("`.vue`" files). If you're not familiar with this, check out the official docs [here](https://vuejs.org/v2/guide/single-file-components.html). 73 | ::: 74 | 75 | ### The Basics 76 | 77 | 78 | ```html 79 | 80 |

This slot content will be rendered wherever the 81 | with name 'destination' 82 | is located. 83 |

84 |
85 | 86 | 87 | 91 | 92 | ``` 93 | 94 | 98 | 99 | ### Enabling/Disabling the Portal 100 | 101 | 102 | ```html 103 | 104 |

105 | This slot content will be rendered right here as long as the `disabled` prop 106 | evaluates to `true`,
107 | and will be rendered at the defined destination as when it is set to `false` 108 | (which is the default). 109 |

110 |
111 | ``` 112 | 113 | 117 | 118 | ### Conditional rendering with v-if 119 | 120 | 121 | ```html 122 | 123 |
    124 |
  • 125 | When 'usePortal' evaluates to 'true', the portal's slot content will be 126 | rendered at the destination. 127 |
  • 128 |
  • 129 | When it evaluates to 'false', the content will be removed from the 130 | destination 131 |
  • 132 |
133 |
134 | ``` 135 | 136 | 140 | 141 | ### Multiple Portals, one Target 142 | 143 | The `PortalTarget` component has a `multiple` mode, which allows to render content from multiple `Portal` components _at the same time_. 144 | 145 | The order the content is rendered in can be adjusted through the `order` prop on the `Portal` components: 146 | 147 | 148 | ```html 149 | 150 |

some content

151 |
152 | 153 |

some other content

154 |
155 | 156 |
157 | 158 |
159 | ``` 160 | 161 | **Result** 162 | 163 | 164 | ```html 165 |
166 |

some other content

167 |

some content

168 |
169 | ``` 170 | 171 | 179 | 180 | ## Use Cases 181 | 182 | ### Positioning Modals & Overlays 183 | 184 | In older browsers, `position: fixed` works unreliably when the element with that property is nested in a node tree that has other `position` values. 185 | 186 | But we normally need it to render components like modals, dialogs, notifications, snackbars and similar UI elements in a fixed position. 187 | 188 | Also, z-indices can be a problem when trying to render things on top of each other somewhere in the DOM. 189 | 190 | With PortalVue, you can render your modal/overlay/dropdown component to a `` that you can position as the very last in the page's `body`, making styling and positioning much easier and less error-prone. 191 | 192 | Now you can position your components with `position: absolute` instead 193 | 194 | 195 | ```html 196 | 197 |
198 |
199 | 200 | 201 | This overlay can be positioned absolutely very easily. 202 | 203 | 204 |
205 | 206 |
207 | 208 | 209 | 210 | ``` 211 | 212 | ### Rendering dynamic widgets 213 | 214 | If you use Vue for small bits and pieces on your website, but want to render something in a location at the other end of the page, PortalVue got you covered. 215 | 216 | ### Tell us about your use case! 217 | 218 | We're sure you will find use cases beyond the ones we mentioned. If you do, please 219 | let us know by opening an issue on Github 220 | and we will include it here. 221 | -------------------------------------------------------------------------------- /docs/guide/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | # sidebar: auto 3 | prev: false 4 | next: ./getting-started 5 | --- 6 | 7 | # Installation 8 | 9 | ## Possible ways to install 10 | 11 | ### NPM 12 | 13 | This is the recommended way to install this Plugin. 14 | 15 | Install with npm as a dependency: 16 | 17 | ```bash 18 | npm i portal-vue 19 | 20 | # or with yarn, respectively: 21 | yarn add portal-vue 22 | ``` 23 | 24 | Then include the package in your application and install the plugin: 25 | 26 | ```javascript 27 | import PortalVue from 'portal-vue' 28 | import { createApp } from 'vue' 29 | import App from './App.vue' 30 | 31 | const app = createApp(App) 32 | 33 | app.use(PortalVue) 34 | 35 | app.mount('#app') 36 | ``` 37 | 38 | ### CDN 39 | 40 | PortalVue is available through a couple of CDNs, I recommend 41 | unpkg.com 42 | 43 | Just include the script tag _after_ the one of Vue.js 44 | 45 | ```html 46 | 47 | 48 | ``` 49 | 50 | The components will be named `` and ``, respectively. 51 | 52 | :::tip 53 | PortalVue provides a UMD [build](#builds) (`/dist/portal-vue.umd.min.js`) which should be used in browsers, and which auto-installs itself when included via a script tag. 54 | 55 | Unpkg and jsdelivr automatically give you this build. if you include it from another source, make sure to include the right one. 56 | ::: 57 | 58 | ## Options 59 | 60 | When installing with `Vue.use()`, you can pass options to change the component names. 61 | 62 | ```javascript 63 | app.use(PortalVue, { 64 | portalName: 'my-portal', // default: 'portal' 65 | portalTargetName: 'my-target', // default:'portal-target' 66 | }) 67 | ``` 68 | 69 | These options would make the components available globally as `` and `` respectively. 70 | 71 | ## Using the components locally 72 | 73 | If you don't want to register the components globally, you can import the components locally. But you still need to isntall the plugin. just pass `false`for the component options: 74 | 75 | ```js 76 | app.use(PortalVue, { 77 | portalName: false, 78 | portalTargetName: false, 79 | }) 80 | ``` 81 | 82 | then import the component(s) in those components that you need them in and register them locally, which also allows to rename them: 83 | 84 | ```javascript 85 | import { Portal, PortalTarget } from 'portal-vue' 86 | 87 | export default { 88 | components: { 89 | MyPortal: Portal, 90 | PortalTarget, 91 | }, 92 | } 93 | ``` 94 | 95 | ## Typescript 96 | 97 | Portal-Vue 3 comes with full TS support. if you register the component globally, you need to tell your Vue IDE Extension (Volar) about them by adding a d.ts. file declaring these globally registered components: 98 | 99 | ```ts 100 | declare module 'vue' { // Vue 3 101 | export interface GlobalComponents { 102 | Portal: typeof import('portal-vue')['Portal'] 103 | PortalTarget: typeof import('portal-vue')['PortalTarget'] 104 | } 105 | } 106 | 107 | export {} 108 | ``` 109 | 110 | Don't forget to `"include"` this file in your tsconfig. 111 | ## Custom Wormhole instance 112 | 113 | If you potentially have more than one Vue app on a page, you can avoid name conflicts by creating your own wormhole instance just for your app. This also means that your app can't send content to `PortalTarget` components in other apps running in the page, so it's probably an edge case. 114 | 115 | ```js 116 | import PortalVue, { createWormhole } from 'portal-vue' 117 | app.use(PortalVue, { 118 | wormhole: createWormhole() 119 | }) 120 | ``` 121 | 122 | ## Builds 123 | 124 | Portal-Vue ships in four different Builds. 125 | 126 | | Type | File | Usage | 127 | | -------------- | ----------------------- | -------------------------------------------------------- | 128 | | UMD (minified) | `portal-vue.umd.js` | To be included in a browser | 129 | | UMD | `portal-vue.umd.dev.js` | To be included in a browser. Non minified for debugging. | 130 | | ESM | `portal-vue.esm.mjs` | For usage with bundlers that _do_ support ESModules. | 131 | 132 | _Notes_ 133 | 134 | ### UMD 135 | 136 | When including Portal-vue from a CDN, make sure you get one of the of UMD builds. 137 | 138 | **About CDNs**: `unpkg.com` and `jsdelivr.com` will load the umd lib automatically. 139 | 140 | If you include it from other sources directly in your HTML, make sure to import `portal-vue/dist/portal-vue.umd.min.js` 141 | 142 | ### ESM 143 | 144 | Webpack >=2, rollup, and parcel all can natively understand ESModules, so this is the best build to use with those bundlers. 145 | 146 | The ESM version is marked as the default export of `package.json` for consumers that understand the `"module"` field in `package.json` (which is true for all the aforementioned bundlers), so doing `import PortalVue from 'portal-vue'` will automatically give you the ESM build if the bundler supports it. 147 | 148 | -------------------------------------------------------------------------------- /docs/guide/migration.md: -------------------------------------------------------------------------------- 1 | --- 2 | # sidebar: auto 3 | prev: ./caveats 4 | next: false 5 | --- 6 | 7 | 21 | 22 | ## Migrating from PortalVue 2 23 | 24 | PortalVue 3 is a complete re-write in order to optimize the codebase with new things Vue 3 gives us, and in the process, work over the APIs and features a bit, cleaning up some cruft here and there. 25 | 26 | From consumer's perspective, not too much has changed, so most use cases should just continue to work or need little adjusting to make them work again. 27 | 28 | One notable exception is ``, which was dropped in this release and is replaced with a small utility function to mount a `PortalTarget` for a normal `Portal` (see *Changes* further down.) 29 | 30 | ### Sidenotes: Vue 3 & Teleport 31 | 32 | One question developers already accustomed with Vue 3 will will likely have is: 33 | 34 | > "Do we actually still need this library? Vue 3 has `` now!" 35 | 36 | The honest answer is: You probably don't need this library anymore for the typical use cases like moving modals to `` etc. But if you are moving content _within_ your app, from one component to another, then `` on its own is not a good choice, and you will likely still profit from using this library. 37 | 38 | One thing to note here is that PortalVue still does **not** use ``under the hood. The main reason for that decision is that there would be a lot more behavioral differences that might make migration harder if we re-built PortalVue 3 on top of `Teleport`. 39 | 40 | So think of PortalVue 3 as a kind of Migration release that should give you more or less the same behavior you had with PortalVue 2 in you Vue 2 apps, but also comes with most of the caveats that the previous versions had. 41 | 42 | We do plan to release another major version later (maybe even as a new, separate library), which *will* be built in top of `Teleport`, at which point you can migrate to the new version in your Vue 3 app at your own pace if you want to. 43 | 44 | ## Migration Strategy 45 | 46 | The bread-and-butter use case should be fairly easy to migrate, as the `Portal` and `PortalTarget` Components only lost a couple of props that are now no longer required in Vue 3, like `slim` (see below). 47 | 48 | Notable breaking changes that do need some revamping affect two use cases: 49 | 50 | 1. Transitions defined on the `PortalTarget` side 51 | 2. Removal of `MountingPortal`, which is now better solved with `Teleport`, save for one edge case (`multiple` prop) for which we will cover a migration path further down. 52 | 53 | ## List of Changes 54 | 55 | ### Installation 56 | 57 | As the global API of creating a Vue app and registering Plugins changed a bit, you also need to adapt your Plugin installation a bit. 58 | 59 | See the chapter on [Installation](./installation.md) for further instructions. 60 | ### Portal Component 61 | 62 | #### `slim` prop removed 63 | 64 | In Vue 3, components no longer require a root element, so `slim` is no longer necessary. `Portal` will no longer render a root element, independent of the number of elements in its slot content. 65 | 66 | If you need a wrapping element, wrap `` in an element yourself: 67 | 68 | ```html 69 | 70 | 71 | 72 | 73 |
74 | 75 |
76 | ``` 77 | ### PortalTarget Component 78 | 79 | #### `slim` prop removed 80 | 81 | In Vue 3, components no longer require a root element, so `slim` is no longer necessary. `Portal` will no longer render a root element, independent of the number of elements in its slot content. 82 | 83 | If you need a wrapping element, wrap `` in an element yourself: 84 | 85 | ```html 86 | 87 | 88 | 89 | 90 |
91 | 92 |
93 | ``` 94 | 95 | #### New: `v-slot:wrapper` 96 | 97 | You can now pass an additional named slot to `PortalTarget` that can be used to wrap the contents coming from multiple `Portal` in markup individually: 98 | 99 | ```html 100 | 101 | 102 | 107 | 108 | ``` 109 | 110 | See: [PortalTarget API: Wrapper slot](../api/portal-target.md#wrapper) 111 | 112 | #### `transition`, `transition-events` props removed. 113 | 114 | Instead of these props, you can now use the new `v-slot:wrapper` to wrap content in `` or `` components. [See the docs for more info here](./advanced.md#portaltarget-transitions) 115 | 116 | ### Removed: MountingPortal 117 | 118 | This component was removed. Depending on your use case, you have two alternative migration paths: 119 | 120 | 1. You move content from one `Portal` only: Use Vue's own `Teleport` instead ([Teleport docs](https://v3.vuejs.org/api/built-in-components.html#teleport)) 121 | 2. You want to move content from multiple Places to the same mounted Portal: use `createPortalTarget()` utility function: 122 | 123 | ```html 124 | 129 | 130 | 143 | ``` 144 | 145 | In practice, you would likely call this function somewhere more global once, so that all other `Portal`components can move content this single `PortalTarget`. 146 | 147 | 148 | 149 | ### Wormhole 150 | 151 | The wormhole is the connecting "store" between the `Portal` and `PortalTarget` components. in PortalVue 2, it was a singleton, which meant that all apps and libraries on one page shared the same namespace for `to=""` names. 152 | 153 | PortalVue 3 still provides a default instance to all components in an app when installing the plugin, but now you can optionally create your own instance and use that instead of the default one. 154 | 155 | [Read more here](./installation.md#custom-wormhole-instance) 156 | 157 | 158 | ### Testing -------------------------------------------------------------------------------- /docs/guide/portal-vue-teleport.md: -------------------------------------------------------------------------------- 1 | # PortalVue vs. `` 2 | 3 | TODO: write comparison between portal-vue and teleport -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar: false 3 | outline: false 4 | --- 5 | 6 | 7 | 8 | 9 | ## Usage example 10 | 11 | ```html 12 | 13 |

This slot content will be rendered wherever the 14 | with name 'destination' 15 | is located. 16 |

17 |
18 | 19 | 20 | 25 | 26 | ``` 27 | -------------------------------------------------------------------------------- /example/components/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | 28 | 45 | -------------------------------------------------------------------------------- /example/components/comp-as-root/comp-as-root.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 57 | -------------------------------------------------------------------------------- /example/components/default-content-on-target/index.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /example/components/disabled/index.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 66 | -------------------------------------------------------------------------------- /example/components/empty-portal/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /example/components/mount-to/Test.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example/components/mount-to/mount-to-external.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 47 | -------------------------------------------------------------------------------- /example/components/multiple/destination.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | -------------------------------------------------------------------------------- /example/components/multiple/multiple.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 55 | -------------------------------------------------------------------------------- /example/components/multiple/source.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 63 | -------------------------------------------------------------------------------- /example/components/portal-container.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 26 | 27 | 53 | -------------------------------------------------------------------------------- /example/components/programmatic/index.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 54 | -------------------------------------------------------------------------------- /example/components/router-view-with-portals/a.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /example/components/router-view-with-portals/b.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /example/components/router-view-with-portals/index.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 28 | 29 | 35 | -------------------------------------------------------------------------------- /example/components/scoped-slots/index.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /example/components/scoped-styles/Destination.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /example/components/scoped-styles/Index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | -------------------------------------------------------------------------------- /example/components/scoped-styles/Source.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /example/components/source-switch/destination.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /example/components/source-switch/source-switch.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 58 | -------------------------------------------------------------------------------- /example/components/source-switch/source.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /example/components/target-switch/destination.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /example/components/target-switch/source-comp.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /example/components/target-switch/target-switch.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 56 | -------------------------------------------------------------------------------- /example/components/test-component.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /example/components/toggle/destination.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /example/components/toggle/source-comp.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 48 | 49 | 61 | -------------------------------------------------------------------------------- /example/components/toggle/toggle-example.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 51 | -------------------------------------------------------------------------------- /example/components/transitions/transitions.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 103 | -------------------------------------------------------------------------------- /example/components/wrapper-slot/WrapperSlot.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 35 | 36 | 42 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | portal-vue example 6 | 7 | 8 |
9 |
10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /example/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp, h, Transition, TransitionGroup } from 'vue' 2 | import PortalVuePlugin from 'portal-vue' 3 | import App from './components/App.vue' 4 | import PortalContainer from './components/portal-container.vue' 5 | import router from './router' 6 | 7 | import './styles/index.css' 8 | 9 | const app = createApp(App) 10 | 11 | app.use(router) 12 | 13 | app.use(PortalVuePlugin) 14 | 15 | app.component('fade', (_, { slots }) => { 16 | return h( 17 | Transition, 18 | { 19 | mode: 'out-in', 20 | name: 'fade', 21 | // appear: true, 22 | }, 23 | slots.default 24 | ) 25 | }) 26 | 27 | app.component('fadeGroup', (_, { slots }) => { 28 | return h( 29 | TransitionGroup, 30 | { 31 | name: 'fade', 32 | tag: 'div', 33 | }, 34 | slots.default 35 | ) 36 | }) 37 | 38 | app.component('container', PortalContainer) 39 | 40 | app.mount('#app') 41 | -------------------------------------------------------------------------------- /example/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' 2 | 3 | import ToggleExample from './components/toggle/toggle-example.vue' 4 | import TargetSwitch from './components/target-switch/target-switch.vue' 5 | import SourceSwitch from './components/source-switch/source-switch.vue' 6 | import Disabled from './components/disabled/index.vue' 7 | import ScopedSlots from './components/scoped-slots/index.vue' 8 | import ScopedStyles from './components/scoped-styles/Index.vue' 9 | import CompAsRoot from './components/comp-as-root/comp-as-root.vue' 10 | import Programmatic from './components/programmatic/index.vue' 11 | import RouterViewWithPortals from './components/router-view-with-portals/index.vue' 12 | import RouterViewWithPortalsA from './components/router-view-with-portals/a.vue' 13 | import RouterViewWithPortalsB from './components/router-view-with-portals/b.vue' 14 | import MountToExternal from './components/mount-to/mount-to-external.vue' 15 | import EmptyPortal from './components/empty-portal/index.vue' 16 | import DefaultSlotContent from './components/default-content-on-target/index.vue' 17 | import Transitions from './components/transitions/transitions.vue' 18 | import Multiple from './components/multiple/multiple.vue' 19 | import WrapperSlot from './components/wrapper-slot/WrapperSlot.vue' 20 | 21 | const routes: RouteRecordRaw[] = [ 22 | { 23 | path: '/', 24 | redirect: '/toggle', 25 | }, 26 | { 27 | path: '/toggle', 28 | component: ToggleExample, 29 | }, 30 | { 31 | path: '/target-switch', 32 | component: TargetSwitch, 33 | }, 34 | { 35 | path: '/source-switch', 36 | component: SourceSwitch, 37 | }, 38 | { 39 | path: '/disabled', 40 | component: Disabled, 41 | }, 42 | { 43 | path: '/scoped-slots-props', 44 | component: ScopedSlots, 45 | }, 46 | { 47 | path: '/scoped-styles', 48 | component: ScopedStyles, 49 | }, 50 | { 51 | path: '/component-as-root-element', 52 | component: CompAsRoot, 53 | }, 54 | { 55 | path: '/empty', 56 | component: EmptyPortal, 57 | }, 58 | { 59 | path: '/programmatic', 60 | component: Programmatic, 61 | }, 62 | { 63 | path: '/router-view-with-portals', 64 | component: RouterViewWithPortals, 65 | children: [ 66 | { path: 'a', alias: '', component: RouterViewWithPortalsA }, 67 | { path: 'b', component: RouterViewWithPortalsB }, 68 | ], 69 | }, 70 | { 71 | path: '/default-slot-content-for-target', 72 | component: DefaultSlotContent, 73 | }, 74 | { 75 | path: '/transitions', 76 | component: Transitions, 77 | }, 78 | { 79 | path: '/Mount-to-external-element', 80 | component: MountToExternal, 81 | }, 82 | { 83 | path: '/multiple', 84 | component: Multiple, 85 | }, 86 | { 87 | path: '/wrapper-slot', 88 | component: WrapperSlot, 89 | }, 90 | ] 91 | 92 | const router = createRouter({ 93 | history: createWebHistory(), 94 | routes, 95 | }) 96 | 97 | export { routes, router as default } 98 | -------------------------------------------------------------------------------- /example/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $vueColor: rgb(66, 185, 131); 2 | $red: rgb(135, 29, 29); 3 | -------------------------------------------------------------------------------- /example/styles/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vue-color: rgb(66, 185, 131); 3 | --red: rgb(135, 29, 29); 4 | 5 | } 6 | html { 7 | font-family: sans-serif; 8 | padding: 0; 9 | } 10 | 11 | body { 12 | padding: 0; 13 | margin: 0; 14 | } 15 | .controls { 16 | list-style-type: none; 17 | display: block; 18 | border-bottom: 1px solid black; 19 | margin-bottom: 10px; 20 | } 21 | .controls--item { 22 | display: inline-block; 23 | padding: 3px; 24 | } 25 | .controls--link { 26 | text-decoration: none; 27 | display: block; 28 | border-radius: 5px; 29 | padding: 5px; 30 | color: var(--vue-color); 31 | background-color: white; 32 | border: 2px solid var(--vue-color); 33 | } 34 | .controls--link:hover { 35 | color: white; 36 | background-color: var(--vue-color); 37 | border: 2px solid var(--vue-color); 38 | } 39 | .controls--link-active { 40 | color: white; 41 | background-color: var(--vue-color); 42 | border: 2px solid var(--vue-color); 43 | } 44 | 45 | .wrapper { 46 | display: flex; 47 | } 48 | 49 | .fade-enter-active, 50 | .fade-leave-active { 51 | transition: opacity 1.5s; 52 | } 53 | .fade-enter-from, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { 54 | opacity: 0; 55 | } 56 | 57 | .fade-group-leave-to { 58 | opacity: 0; 59 | } 60 | .fade-group-leave-to { 61 | position: absolute; 62 | } 63 | .fade-group-move { 64 | transition: transform 1.5s; 65 | } 66 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import vue from '@vitejs/plugin-vue' 3 | import { version } from '../package.json' 4 | 5 | export default { 6 | define: { 7 | __PORTAL_VUE_VERSION__: JSON.stringify(version), 8 | __NODE_ENV__: JSON.stringify(process.env.NODE_ENV), 9 | }, 10 | resolve: { 11 | alias: { 12 | 'portal-vue': path.join(__dirname, '../src/index.ts'), 13 | }, 14 | }, 15 | plugins: [vue()], 16 | } 17 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | ignore = "./scripts/docs-check.sh" 3 | publish = "docs/.vitepress/dist" 4 | command = "pnpm docs:build" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portal-vue", 3 | "version": "3.0.0", 4 | "license": "MIT", 5 | "repository": "https://github.com/LinusBorg/portal-vue", 6 | "author": { 7 | "name": "Thorsten Lünborg", 8 | "url": "https://github.com/LinusBorg/" 9 | }, 10 | "private": false, 11 | "main": "dist/portal-vue.umd.js", 12 | "jsdelivr": "dist/portal-vue.umd.js", 13 | "unpkg": "dist/portal-vue.umd.js", 14 | "module": "dist/portal-vue.mjs", 15 | "types": "types/index.d.ts", 16 | "exports": { 17 | "./package.json": "./package.json", 18 | ".": { 19 | "types": "./types/index.d.ts", 20 | "import": "./dist/portal-vue.mjs", 21 | "require": "./dist/portal-vue.umd.js" 22 | }, 23 | "./dev": { 24 | "types": "./types/index.d.ts", 25 | "import": "./dist/portal-vue.es.dev.mjs", 26 | "require": "./dist/portal-vue.umd.dev.js" 27 | }, 28 | "./dist/portal-vue.es.js": "./dist/portal-vue.mjs", 29 | "./dist/portal-vue.es.dev.js": "./dist/portal-vue.es.dev.mjs", 30 | "./*": "./*" 31 | }, 32 | "files": [ 33 | "dist", 34 | "src", 35 | "types", 36 | "package.json" 37 | ], 38 | "scripts": { 39 | "app:dev": "cd example && vite", 40 | "build": "pnpm lib:build && pnpm types:build", 41 | "lib:build": "vite build && vite build -c vite.config.dev.ts", 42 | "types:build": "vue-tsc --declaration --emitDeclarationOnly -p tsconfig.build.json", 43 | "types:check": "vue-tsc --noEmit -p tsconfig.app.json", 44 | "docs:dev": "vitepress dev docs", 45 | "docs:build": "vitepress build docs", 46 | "lint": "eslint --fix './*.{js,ts}' 'src/**/*.{ts,tsx,vue}' 'example/**/*.{ts,tsx,vue}'", 47 | "test": "vitest", 48 | "test:ci": "vitest --run", 49 | "release": "release-it" 50 | }, 51 | "engines": { 52 | "node": ">=14.19" 53 | }, 54 | "peerDependencies": { 55 | "vue": "^3.0.4" 56 | }, 57 | "peerDependenciesMeta": { 58 | "vue": { 59 | "optional": true 60 | } 61 | }, 62 | "devDependencies": { 63 | "@linusborg/eslint-config": "^0.3.0", 64 | "@types/jsdom": "^20.0.1", 65 | "@vitejs/plugin-vue": "^4.0.0", 66 | "@vue/test-utils": "^2.0.0", 67 | "@vue/tsconfig": "^0.1.3", 68 | "autoprefixer": "^10.2.4", 69 | "eslint": "^8.30.0", 70 | "jsdom": "^20.0.3", 71 | "lint-staged": "^13.1.0", 72 | "nanoid": "^4.0.0", 73 | "postcss": "^8.4.20", 74 | "prettier": "^2.8.1", 75 | "release-it": "^15.5.1", 76 | "typescript": "^4.9.0", 77 | "vite": "^4.0.0", 78 | "vitepress": "1.0.0-alpha.32", 79 | "vitest": "^0.25.8", 80 | "vue": "^3.0.4", 81 | "vue-router": "^4.0.0", 82 | "vue-tsc": "^1.0.14", 83 | "yorkie": "^2.0.0" 84 | }, 85 | "pnpm": { 86 | "peerDependencyRules": { 87 | "ignoreMissing": [ 88 | "react", 89 | "react-dom", 90 | "@types/react", 91 | "@algolia/client-search" 92 | ] 93 | } 94 | }, 95 | "packageManager": "pnpm@7.18.1", 96 | "gitHooks": { 97 | "pre-commit": "lint-staged" 98 | }, 99 | "lint-staged": { 100 | "*.{js,jsx,vue,ts,tsx}": [ 101 | "eslint", 102 | "git add" 103 | ] 104 | }, 105 | "postcss": { 106 | "plugins": { 107 | "autoprefixer": {} 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /scripts/docs-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # check if doc files changes for netlify 4 | # needed because we cannot use && in netlify.toml 5 | 6 | git diff --quiet 'HEAD^' HEAD ./docs/ && ! git diff 'HEAD^' HEAD ./pnpm-lock.yaml && ! git diff 'HEAD^' HEAD ./package.json -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/portal-target.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`PortalTarget > renders slot content when no other content is available 1`] = `"

Test

"`; 4 | 5 | exports[`PortalTarget renders slot content when no other content is available 1`] = `"

Test

"`; 6 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/the-portal.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`Portal > renders locally when \`disabled\` prop is true 1`] = `"Test"`; 4 | 5 | exports[`Portal renders locally when \`disabled\` prop is true 1`] = `"Test"`; 6 | -------------------------------------------------------------------------------- /src/__tests__/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest' 2 | import type { App, ComponentOptions } from 'vue' 3 | import { nextTick } from 'vue' 4 | import { mount } from '@vue/test-utils' 5 | import { type PluginOptions, install as PortalPlugin } from '..' 6 | import { createWormhole } from '..' 7 | 8 | const curriedPlugin = (options: PluginOptions = {}) => { 9 | const wormhole = createWormhole() 10 | return (app: App) => PortalPlugin(app, { ...options, wormhole }) 11 | } 12 | 13 | async function mountScenario( 14 | Component: ComponentOptions, 15 | options: Record = {} 16 | ) { 17 | const wrapper = mount(Component, { 18 | global: { 19 | ...options, 20 | plugins: [...(options.plugins || []), curriedPlugin()], 21 | }, 22 | }) 23 | const portals = wrapper.findAllComponents({ name: 'Portal' }) 24 | const targets = wrapper.findAllComponents({ name: 'PortalTarget' }) 25 | 26 | await nextTick() 27 | 28 | return { 29 | wrapper, 30 | portal: portals[0], 31 | target: targets[0], 32 | portals, 33 | targets, 34 | } 35 | } 36 | 37 | describe('Integration Tests', () => { 38 | beforeEach(() => { 39 | const el = document.querySelector('#target') 40 | if (el) { 41 | el.parentNode?.removeChild(el) 42 | } 43 | // ad a fresh version to the body 44 | const newEl = document.createElement('DIV') 45 | newEl.id = 'target' 46 | document.body.appendChild(newEl) 47 | }) 48 | 49 | it('Happy Path (Simplest scenario)', async () => { 50 | const component = (await import('./resources/HappyPath.vue')).default 51 | const { wrapper, target } = await mountScenario(component) 52 | 53 | const pArray = await target.findAll('p') 54 | expect(target.exists()).toBe(true) 55 | expect(pArray[0].text()).toBe('Test1') 56 | expect(pArray[1].text()).toBe('Test2') 57 | 58 | await wrapper.setData({ show: true }) 59 | 60 | expect(wrapper.find('#additional').text()).toBe('Test3') 61 | }) 62 | 63 | it('Scoped Slot (Happy Path)', async () => { 64 | const component = (await import('./resources/ScopedSlot.vue')).default 65 | const { target } = await mountScenario(component) 66 | 67 | const p = await target.find('p') 68 | expect(p.text()).toBe('Your message reads: Hi!') 69 | }) 70 | 71 | it('Portal: Disabled', async () => { 72 | const component = (await import('./resources/PortalDisabled.vue')).default 73 | const { wrapper, portal, target } = await mountScenario(component) 74 | 75 | const p = await target.find('p') 76 | expect(p.text()).toBe('Test') 77 | 78 | await wrapper.setData({ disabled: true }) 79 | 80 | const portalP = await portal.find('p') 81 | // empty component, root element is comment node 82 | expect(target.vm.$el).toEqual(document.createComment('')) 83 | expect(portalP.exists() && portalP.text()).toBe('Test') 84 | }) 85 | it('Portal: Disabled with Scoped Slot', async () => { 86 | const component = (await import('./resources/PortalDisabledScoped.vue')) 87 | .default 88 | const { portal } = await mountScenario(component) 89 | 90 | expect(portal.find('p').text()).toBe('Hi!') 91 | }) 92 | 93 | it('Portal: Switch Target', async () => { 94 | const component = (await import('./resources/PortalSwitchTarget.vue')) 95 | .default 96 | const { wrapper, targets } = await mountScenario(component, {}) 97 | 98 | expect(targets[0].find('p').text()).toBe('Content') 99 | // empty component, root element is comment node 100 | expect(targets[1].vm.$el).toEqual(document.createComment('')) 101 | 102 | await wrapper.setData({ target: 'target2' }) 103 | 104 | expect(targets[1].find('p').text()).toBe('Content') 105 | // empty component, root element is comment node 106 | expect(targets[0].vm.$el).toEqual(document.createComment('')) 107 | }) 108 | 109 | it('Target: Default content', async () => { 110 | const component = (await import('./resources/TargetDefaultContent.vue')) 111 | .default 112 | const { wrapper, target } = await mountScenario(component) 113 | 114 | expect(target.find('p').text()).toBe('Portal Content') 115 | 116 | await wrapper.setData({ disabled: true }) 117 | 118 | expect(target.find('p').text()).toBe('Default Content') 119 | }) 120 | 121 | it('Target: Multiple Portals', async () => { 122 | const component = (await import('./resources/TargetMultiple.vue')).default 123 | const { target } = await mountScenario(component) 124 | 125 | const pWrapper = await target.findAll('p') 126 | expect(pWrapper.length).toBe(2) 127 | expect(pWrapper[0].text()).toBe('Content2') 128 | expect(pWrapper[1].text()).toBe('Content1') 129 | }) 130 | 131 | it('works with mountPortalTarget feature', async () => { 132 | const component = (await import('./resources/PortalWithMountedTarget.vue')) 133 | .default 134 | const el = document.createElement('DIV') 135 | el.id = 'external-target' 136 | document.body.appendChild(el) 137 | await mountScenario(component) 138 | 139 | expect(el.textContent).toBe('Test') 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /src/__tests__/portal-target.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | import { type Slot, h, nextTick } from 'vue' 3 | import { mount } from '@vue/test-utils' 4 | import PortalTarget from '../components/portal-target' 5 | import { wormholeSymbol } from '../composables/wormhole' 6 | import { createWormhole } from '../wormhole' 7 | 8 | const createWormholeMock = () => { 9 | const wh = createWormhole(false) 10 | 11 | vi.spyOn(wh, 'open') 12 | vi.spyOn(wh, 'close') 13 | 14 | return wh 15 | } 16 | // Utils 17 | 18 | function createWrapper(props = {}, options = {}) { 19 | const wh = createWormholeMock() 20 | return { 21 | wh, 22 | wrapper: mount(PortalTarget, { 23 | props: { 24 | name: 'target', 25 | ...props, 26 | } as any, 27 | global: { 28 | provide: { 29 | [wormholeSymbol as unknown as string]: wh, 30 | }, 31 | }, 32 | ...options, 33 | }), 34 | } 35 | } 36 | 37 | function generateSlotFn(text = '') { 38 | return (() => h('div', { class: 'testnode' }, text) as unknown) as Slot 39 | } 40 | 41 | describe('PortalTarget', () => { 42 | it('renders a single element for single vNode with slim prop & single slot element', async () => { 43 | const { wrapper, wh } = createWrapper() 44 | const content = generateSlotFn() 45 | wh.open({ 46 | from: 'source', 47 | to: 'target', 48 | content, 49 | }) 50 | 51 | await nextTick() 52 | 53 | expect(wrapper.html()).toBe( 54 | `
55 |
` 56 | ) 57 | }) 58 | 59 | it('renders slot content when no other content is available', function () { 60 | const { wrapper } = createWrapper( 61 | {}, 62 | { 63 | slots: { 64 | default: h('p', { class: 'default' }, 'Test'), 65 | }, 66 | } 67 | ) 68 | expect(wrapper.html()).toMatchSnapshot() 69 | expect(wrapper.find('p.default').exists()).toBe(true) 70 | }) 71 | 72 | it('emits change event', async () => { 73 | const { wrapper, wh } = createWrapper() 74 | 75 | await nextTick() 76 | 77 | const content = generateSlotFn() 78 | wh.open({ 79 | to: 'target', 80 | from: 'source', 81 | content, 82 | }) 83 | 84 | await nextTick() 85 | 86 | expect(wrapper.emitted().change[0]).toMatchObject([ 87 | { 88 | hasContent: true, 89 | sources: ['source'], 90 | }, 91 | ]) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /src/__tests__/resources/CustomTransition.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/__tests__/resources/HappyPath.vue: -------------------------------------------------------------------------------- 1 | 15 | 23 | -------------------------------------------------------------------------------- /src/__tests__/resources/PortalDisabled.vue: -------------------------------------------------------------------------------- 1 | 13 | 18 | -------------------------------------------------------------------------------- /src/__tests__/resources/PortalDisabledScoped.vue: -------------------------------------------------------------------------------- 1 | 13 | 16 | -------------------------------------------------------------------------------- /src/__tests__/resources/PortalSlim.vue: -------------------------------------------------------------------------------- 1 | 8 | 13 | -------------------------------------------------------------------------------- /src/__tests__/resources/PortalSwitchTarget.vue: -------------------------------------------------------------------------------- 1 | 16 | 23 | -------------------------------------------------------------------------------- /src/__tests__/resources/PortalWithMountedTarget.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/__tests__/resources/ScopedSlot.vue: -------------------------------------------------------------------------------- 1 | 13 | 18 | -------------------------------------------------------------------------------- /src/__tests__/resources/TargetDefaultContent.vue: -------------------------------------------------------------------------------- 1 | 15 | 20 | -------------------------------------------------------------------------------- /src/__tests__/resources/TargetMultiple.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 22 | -------------------------------------------------------------------------------- /src/__tests__/resources/TargetSlim.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | -------------------------------------------------------------------------------- /src/__tests__/the-portal.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | import Portal from '@/components/portal' 3 | import { wormholeSymbol } from '@/composables/wormhole' 4 | import type { PortalProps } from '@/types' 5 | import { createWormhole } from '@/wormhole' 6 | import { mount } from '@vue/test-utils' 7 | 8 | const createWormholeMock = () => { 9 | const wh = createWormhole(false) 10 | 11 | vi.spyOn(wh, 'open') 12 | vi.spyOn(wh, 'close') 13 | 14 | return wh 15 | } 16 | 17 | function createWrapper(props: Partial = {}, options = {}) { 18 | const wormholeMock = createWormholeMock() 19 | return { 20 | wh: wormholeMock, 21 | wrapper: mount(Portal, { 22 | props: { 23 | to: 'destination', 24 | name: 'source', 25 | ...props, 26 | } as any, 27 | slots: { 28 | default: `Test`, 29 | }, 30 | global: { 31 | provide: { 32 | [wormholeSymbol as unknown as string]: wormholeMock, 33 | }, 34 | ...options, 35 | }, 36 | }), 37 | } 38 | } 39 | 40 | describe('Portal', function () { 41 | it('renders nothing/a Fragment', function () { 42 | // expect(wrapper.$refs.portal.$el.nodeName).to.equal('#comment') 43 | const { wrapper } = createWrapper() 44 | expect(wrapper.html()).toBe('') 45 | }) 46 | 47 | it('calls Wormhole.open with right content', function () { 48 | const { wh } = createWrapper() 49 | expect(wh.open).toHaveBeenCalledWith( 50 | expect.objectContaining({ 51 | to: 'destination', 52 | from: 'source', 53 | content: expect.any(Function), 54 | }) 55 | ) 56 | }) 57 | 58 | it('calls Wormhole close & sendUpdate when destination changes', async () => { 59 | const { wrapper, wh } = createWrapper() 60 | await wrapper.setProps({ to: 'destination2' }) 61 | 62 | expect(wh.close).toHaveBeenCalled() 63 | // no idea why it's 3 and not 2 though. maybe related to test-utils 64 | expect(wh.open).toHaveBeenCalledTimes(3) 65 | }) 66 | 67 | it('calls Wormhole.close() when destroyed', () => { 68 | const { wrapper, wh } = createWrapper() 69 | wrapper.unmount() 70 | expect(wh.close).toHaveBeenCalledWith({ 71 | to: 'destination', 72 | from: 'source', 73 | }) 74 | }) 75 | 76 | it('renders locally when `disabled` prop is true', () => { 77 | const { wrapper } = createWrapper({ disabled: true }) 78 | expect(wrapper.find('span').exists()).toBe(true) 79 | expect(wrapper.html()).toMatchSnapshot() 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/__tests__/wormhole.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { Slot, h } from 'vue' 3 | import { createWormhole } from '@/wormhole' 4 | 5 | const createSlotFn = () => (() => h('div')) as unknown as Slot 6 | 7 | describe('Wormhole', () => { 8 | it('correctly adds content on send', () => { 9 | const wormhole = createWormhole() 10 | wormhole.open({ 11 | from: 'test-portal', 12 | to: 'target', 13 | content: createSlotFn(), 14 | }) 15 | 16 | expect(wormhole.transports.get('target')?.get('test-portal')).toEqual({ 17 | from: 'test-portal', 18 | to: 'target', 19 | content: expect.any(Function), 20 | order: Infinity, 21 | }) 22 | }) 23 | 24 | it('removes content on close()', function () { 25 | const wormhole = createWormhole() 26 | const content = { 27 | from: 'test-portal', 28 | to: 'target', 29 | content: createSlotFn(), 30 | order: Infinity, 31 | } 32 | 33 | wormhole.open(content) 34 | expect(wormhole.transports.get('target')?.get('test-portal')).toMatchObject( 35 | content 36 | ) 37 | 38 | wormhole.close({ 39 | from: 'test-portal', 40 | to: 'target', 41 | }) 42 | expect( 43 | wormhole.transports.get('target')?.get('test-portal') 44 | ).toBeUndefined() 45 | }) 46 | 47 | it('only removes transports from the same source portal', () => { 48 | const wormhole = createWormhole() 49 | wormhole.open({ 50 | from: 'test-portal1', 51 | to: 'target', 52 | content: createSlotFn(), 53 | }) 54 | 55 | wormhole.open({ 56 | from: 'test-portal2', 57 | to: 'target', 58 | content: createSlotFn(), 59 | }) 60 | 61 | wormhole.close({ 62 | from: 'test-portal1', 63 | to: 'target', 64 | }) 65 | expect(wormhole.transports.get('target')?.get('test-portal2')).toBeDefined() 66 | expect( 67 | wormhole.transports.get('target')?.get('test-portal1') 68 | ).toBeUndefined() 69 | }) 70 | 71 | it('returns latest transport when not called with `returnAll`', () => { 72 | const wormhole = createWormhole() 73 | wormhole.open({ 74 | from: 'test-portal1', 75 | to: 'target', 76 | order: 2, 77 | content: createSlotFn(), 78 | }) 79 | 80 | wormhole.open({ 81 | from: 'test-portal2', 82 | to: 'target', 83 | order: 1, 84 | content: createSlotFn(), 85 | }) 86 | 87 | const content = wormhole.getContentForTarget('target') 88 | const order = content.map((t) => t.order) 89 | expect(order).toMatchObject([1]) 90 | }) 91 | 92 | it('returns content for target properly sorted when multiple', () => { 93 | const wormhole = createWormhole() 94 | wormhole.open({ 95 | from: 'test-portal1', 96 | to: 'target', 97 | order: 2, 98 | content: createSlotFn(), 99 | }) 100 | 101 | wormhole.open({ 102 | from: 'test-portal2', 103 | to: 'target', 104 | order: 1, 105 | content: createSlotFn(), 106 | }) 107 | 108 | const content = wormhole.getContentForTarget('target', true) 109 | const order = content.map((t) => t.order) 110 | expect(order).toMatchObject([1, 2]) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/components/portal-target.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type FunctionalComponent, 3 | type VNode, 4 | computed, 5 | defineComponent, 6 | h, 7 | watch, 8 | } from 'vue' 9 | import { useWormhole } from '../composables/wormhole' 10 | 11 | const PortalTargetContent: FunctionalComponent = (_, { slots }) => { 12 | return slots.default?.() 13 | } 14 | 15 | export default defineComponent({ 16 | compatConfig: { MODE: 3 }, 17 | name: 'portalTarget', 18 | props: { 19 | multiple: { type: Boolean, default: false }, 20 | name: { type: String, required: true }, 21 | slotProps: { type: Object, default: () => ({}) }, 22 | }, 23 | emits: ['change'], 24 | setup(props, { emit, slots }) { 25 | const wormhole = useWormhole() 26 | 27 | const slotVnodes = computed<{ vnodes: VNode[]; vnodesFn: () => VNode[] }>( 28 | () => { 29 | const transports = wormhole.getContentForTarget( 30 | props.name, 31 | props.multiple 32 | ) 33 | const wrapperSlot = slots.wrapper 34 | const rawNodes = transports.map((t) => t.content(props.slotProps)) 35 | const vnodes = wrapperSlot 36 | ? rawNodes.flatMap((nodes) => 37 | nodes.length ? wrapperSlot(nodes) : [] 38 | ) 39 | : rawNodes.flat(1) 40 | return { 41 | vnodes, 42 | vnodesFn: () => vnodes, // just to make Vue happy. raw vnodes in a slot give a DEV warning 43 | } 44 | } 45 | ) 46 | 47 | watch( 48 | slotVnodes, 49 | ({ vnodes }) => { 50 | const hasContent = vnodes.length > 0 51 | const content = wormhole.transports.get(props.name) 52 | const sources = content ? [...content.keys()] : [] 53 | emit('change', { hasContent, sources }) 54 | }, 55 | { flush: 'post' } 56 | ) 57 | return () => { 58 | const hasContent = !!slotVnodes.value.vnodes.length 59 | if (hasContent) { 60 | return [ 61 | // this node is a necessary hack to force Vue to change the scoped-styles boundary 62 | // TODO: find less hacky solution 63 | h('div', { 64 | style: 'display: none', 65 | key: '__portal-vue-hacky-scoped-slot-repair__', 66 | }), 67 | // we wrap the slot content in a functional component 68 | // so that transitions in the slot can properly determine first render 69 | // for `appear` behavior to work properly 70 | h(PortalTargetContent, slotVnodes.value.vnodesFn), 71 | ] 72 | } else { 73 | return slots.default?.() 74 | } 75 | } 76 | }, 77 | }) 78 | -------------------------------------------------------------------------------- /src/components/portal.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Slots, 3 | defineComponent, 4 | onBeforeUnmount, 5 | onMounted, 6 | onUpdated, 7 | watch, 8 | } from 'vue' 9 | import { useWormhole } from '../composables/wormhole' 10 | import type { Name, PortalProps } from '../types' 11 | import { __DEV__, assertStaticProps, inBrowser } from '../utils' 12 | 13 | export function usePortal(props: PortalProps, slots: Slots) { 14 | const wormhole = useWormhole() 15 | 16 | function sendUpdate() { 17 | if (!inBrowser) return 18 | const { to, name: from, order } = props 19 | if (slots.default) { 20 | wormhole.open({ 21 | to, 22 | from: from!, 23 | order, 24 | content: slots.default, 25 | }) 26 | } else { 27 | clear() 28 | } 29 | } 30 | 31 | function clear(target?: Name) { 32 | wormhole.close({ 33 | to: target ?? props.to, 34 | from: props.name, 35 | }) 36 | } 37 | onMounted(() => { 38 | if (!props.disabled) { 39 | sendUpdate() 40 | } 41 | }) 42 | 43 | onUpdated(() => { 44 | if (props.disabled) { 45 | clear() 46 | } else { 47 | sendUpdate() 48 | } 49 | }) 50 | 51 | onBeforeUnmount(() => { 52 | clear() 53 | }) 54 | 55 | watch( 56 | () => props.to, 57 | (newTo, oldTo) => { 58 | if (props.disabled) return 59 | if (oldTo && oldTo !== newTo) { 60 | clear(oldTo) 61 | } 62 | sendUpdate() 63 | } 64 | ) 65 | } 66 | 67 | export default defineComponent({ 68 | compatConfig: { MODE: 3 }, 69 | name: 'portal', 70 | props: { 71 | disabled: { type: Boolean }, 72 | name: { type: [String, Symbol], default: () => Symbol() }, 73 | order: { type: Number }, 74 | slotProps: { type: Object, default: () => ({}) }, 75 | to: { 76 | type: String, 77 | default: () => String(Math.round(Math.random() * 10000000)), 78 | }, 79 | }, 80 | setup(props, { slots }) { 81 | __DEV__ && assertStaticProps('Portal', props, ['order', 'name']) 82 | usePortal(props, slots) 83 | 84 | return () => { 85 | if (props.disabled && slots.default) { 86 | return slots.default(props.slotProps) 87 | } else { 88 | return null 89 | } 90 | } 91 | }, 92 | }) 93 | -------------------------------------------------------------------------------- /src/composables/wormhole.ts: -------------------------------------------------------------------------------- 1 | import { type InjectionKey, inject, provide } from 'vue' 2 | import type { Wormhole } from '../types' 3 | 4 | export const wormholeSymbol = Symbol('wormhole') as InjectionKey 5 | 6 | export function useWormhole() { 7 | const wh = inject(wormholeSymbol) 8 | 9 | if (!wh) { 10 | throw new Error(` 11 | [portal-vue]: Necessary Injection not found. Make sur you installed the plugin properly.`) 12 | } 13 | 14 | return wh 15 | } 16 | 17 | export function provideWormhole(wormhole: Wormhole) { 18 | provide(wormholeSymbol, wormhole) 19 | } 20 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const __PORTAL_VUE_VERSION__: string 4 | declare const __NODE_ENV__: string 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | import Portal from './components/portal' 3 | import PortalTarget from './components/portal-target' 4 | import { 5 | provideWormhole, 6 | useWormhole, 7 | wormholeSymbol, 8 | } from './composables/wormhole' 9 | import type { Wormhole as TWormhole } from './types' 10 | import { createWormhole, wormhole as defaultWormhole } from './wormhole' 11 | export { mountPortalTarget } from './utils/mountPortalTarget' 12 | export interface PluginOptions { 13 | portalName?: string | false 14 | portalTargetName?: string | false 15 | MountingPortalName?: string 16 | wormhole?: TWormhole 17 | } 18 | 19 | export default function install(app: App, options: PluginOptions = {}) { 20 | options.portalName !== false && 21 | app.component(options.portalName || 'Portal', Portal) 22 | options.portalTargetName !== false && 23 | app.component(options.portalTargetName || 'PortalTarget', PortalTarget) 24 | 25 | const wormhole = options.wormhole ?? defaultWormhole 26 | app.provide(wormholeSymbol, wormhole) 27 | } 28 | 29 | export const Wormhole = defaultWormhole 30 | 31 | export const version = __PORTAL_VUE_VERSION__ 32 | 33 | export { 34 | install, 35 | Portal, 36 | PortalTarget, 37 | useWormhole, 38 | provideWormhole, 39 | TWormhole, 40 | createWormhole, 41 | } 42 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Slot } from 'vue' 2 | 3 | export type Name = string | symbol 4 | 5 | export interface StringBoolMap { 6 | [key: string]: boolean 7 | } 8 | export interface TransportInput { 9 | to: Name 10 | from: Name 11 | order?: number 12 | content: Slot 13 | } 14 | 15 | export type TransportsHub = Map 16 | 17 | export type TransportsByTarget = Map 18 | 19 | export interface Transport { 20 | to: Name 21 | from: Name 22 | order: number 23 | content: Slot 24 | } 25 | 26 | export interface TransportCloser { 27 | to: Name 28 | from?: Name 29 | } 30 | 31 | export interface PortalProps { 32 | to: Name 33 | name?: Name 34 | disabled?: boolean 35 | order?: number 36 | slotProps?: Record 37 | } 38 | 39 | export type PortalTargetProps = Partial<{ 40 | multiple: boolean 41 | name: Name 42 | slotProps: object 43 | }> 44 | 45 | export type Wormhole = Readonly<{ 46 | open: (t: TransportInput) => void 47 | close: (t: TransportCloser) => void 48 | getContentForTarget: (t: Name, returnAll?: boolean) => Transport[] 49 | transports: TransportsHub 50 | }> 51 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'vue' 2 | 3 | export const inBrowser = typeof window !== 'undefined' 4 | 5 | export const __DEV__ = __NODE_ENV__ === 'development' 6 | 7 | export function warn(msg: string) { 8 | console.log('[portal-vue]: ' + msg) 9 | } 10 | 11 | export function assertStaticProps( 12 | component: string, 13 | props: Record, 14 | propNames: string[] 15 | ) { 16 | propNames.forEach( 17 | (name) => { 18 | watch( 19 | () => props[name], 20 | () => { 21 | warn( 22 | `Prop '${name}' of component ${component} is static, but was dynamically changed by the parent. 23 | This change will not have any effect.` 24 | ) 25 | } 26 | ) 27 | }, 28 | { flush: 'post' } 29 | ) 30 | } 31 | 32 | export function stableSort(array: T[], compareFn: Function) { 33 | return array 34 | .map((v: T, idx: number) => { 35 | return [idx, v] as [number, T] 36 | }) 37 | .sort(function (a, b) { 38 | return compareFn(a[1], b[1]) || a[0] - b[0] 39 | }) 40 | .map((c) => c[1]) 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/mountPortalTarget.ts: -------------------------------------------------------------------------------- 1 | import type { PortalTargetProps } from '../types' 2 | import { 3 | type ComponentInternalInstance, 4 | createApp, 5 | getCurrentInstance, 6 | h, 7 | onBeforeUnmount, 8 | onMounted, 9 | } from 'vue' 10 | import PortalTarget from '../components/portal-target' 11 | 12 | export function mountPortalTarget( 13 | targetProps: PortalTargetProps, 14 | el: HTMLElement | string 15 | ) { 16 | const app = createApp({ 17 | // @ts-expect-error no idea why h() doesn't like this import 18 | render: () => h(PortalTarget, targetProps), 19 | }) 20 | 21 | if (!targetProps.multiple) { 22 | // this is hacky as it relies on internals, but works. 23 | // TODO: can we get rid of this by somehow properly replacing the target's .parent? 24 | const provides = 25 | ( 26 | getCurrentInstance() as ComponentInternalInstance & { 27 | provides: Record 28 | } 29 | ).provides ?? {} 30 | app._context.provides = Object.create(provides) 31 | //Object.assign(app._context.provides, Object.create(provides)) 32 | } 33 | onMounted(() => { 34 | app.mount(el) 35 | }) 36 | onBeforeUnmount(() => { 37 | app.unmount() 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/wormhole.ts: -------------------------------------------------------------------------------- 1 | import { reactive, readonly } from 'vue' 2 | import type { 3 | Name, 4 | Transport, 5 | TransportCloser, 6 | TransportInput, 7 | TransportsHub, 8 | Wormhole, 9 | } from './types' 10 | import { inBrowser, stableSort } from './utils' 11 | 12 | export function createWormhole(asReadonly = true): Wormhole { 13 | const transports: TransportsHub = reactive(new Map()) 14 | 15 | function open(transport: TransportInput) { 16 | if (!inBrowser) return 17 | 18 | const { to, from, content, order = Infinity } = transport 19 | if (!to || !from || !content) return 20 | 21 | if (!transports.has(to)) { 22 | transports.set(to, new Map()) 23 | } 24 | const transportsForTarget = transports.get(to)! 25 | 26 | const newTransport = { 27 | to, 28 | from, 29 | content, 30 | order, 31 | } as Transport 32 | 33 | transportsForTarget.set(from, newTransport) 34 | } 35 | 36 | function close(transport: TransportCloser) { 37 | const { to, from } = transport 38 | if (!to || !from) return 39 | const transportsForTarget = transports.get(to) 40 | if (!transportsForTarget) { 41 | return 42 | } 43 | transportsForTarget.delete(from) 44 | if (!transportsForTarget.size) { 45 | transports.delete(to) 46 | } 47 | } 48 | 49 | function getContentForTarget(target: Name, returnAll?: boolean) { 50 | const transportsForTarget = transports.get(target) 51 | if (!transportsForTarget) return [] 52 | 53 | const content = Array.from(transportsForTarget?.values() || []) 54 | 55 | if (!returnAll) { 56 | // return Transport that was added last 57 | return [content.pop()] as Transport[] 58 | } 59 | // return all Transports, sorted by their order property 60 | return stableSort( 61 | content, 62 | (a: Transport, b: Transport) => a.order - b.order 63 | ) 64 | } 65 | 66 | const wh: Wormhole = { 67 | open, 68 | close, 69 | transports, 70 | getContentForTarget, 71 | } 72 | return asReadonly ? (readonly(wh) as Wormhole) : wh 73 | } 74 | 75 | export const wormhole = createWormhole() 76 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "src/env.d.ts", 5 | "src/**/*", 6 | "src/**/*.vue", 7 | "example/**/*", 8 | "example/**/*.vue" 9 | ], 10 | "exclude": ["src/**/__tests__/*"], 11 | "compilerOptions": { 12 | "target": "ES2019", 13 | "composite": true, 14 | "outDir": "dist", 15 | "allowJs": true, 16 | "baseUrl": ".", 17 | "rootDir": "src", 18 | "lib": ["ES2019", "DOM", "DOM.Iterable"], 19 | "paths": { 20 | "@/*": ["./src/*"], 21 | "portal-vue": ["src/index.ts"] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "types", 6 | "outDir": "dist", 7 | "rootDir": "src" 8 | }, 9 | "include": ["src/**/*", "src/**/*.vue", "src/env.d.ts"], 10 | "exclude": ["src/__tests__/**/*"] 11 | } -------------------------------------------------------------------------------- /tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.config.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | }, 10 | { 11 | "path": "./tsconfig.vitest.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "composite": true, 6 | "lib": [], 7 | "types": ["node", "jsdom"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.dev.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig, mergeConfig } from 'vite' 3 | import { version } from './package.json' 4 | import baseConfig from './vite.config' 5 | 6 | export default mergeConfig( 7 | baseConfig, 8 | defineConfig({ 9 | define: { 10 | __PORTAL_VUE_VERSION__: JSON.stringify(version), 11 | __NODE_ENV__: '"development"', 12 | }, 13 | build: { 14 | minify: false, 15 | emptyOutDir: false, 16 | lib: { 17 | entry: path.resolve(__dirname, 'src/index.ts'), 18 | name: 'PortalVue', 19 | fileName: (format) => 20 | `portal-vue.[format].dev.${format === 'es' ? 'mjs' : 'js'}`, 21 | }, 22 | }, 23 | }) 24 | ) 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import path from 'path' 3 | import { fileURLToPath, URL } from 'node:url' 4 | import { defineConfig } from 'vite' 5 | import vue from '@vitejs/plugin-vue' 6 | import { version } from './package.json' 7 | 8 | export default defineConfig({ 9 | test: { 10 | environment: 'jsdom', 11 | }, 12 | define: { 13 | __PORTAL_VUE_VERSION__: JSON.stringify(version), 14 | __NODE_ENV__: JSON.stringify(process.env.NODE_ENV), 15 | }, 16 | resolve: { 17 | alias: { 18 | '@': fileURLToPath(new URL('./src', import.meta.url)), 19 | }, 20 | }, 21 | plugins: [vue()], 22 | build: { 23 | sourcemap: true, 24 | lib: { 25 | entry: path.resolve(__dirname, 'src/index.ts'), 26 | name: 'PortalVue', 27 | }, 28 | rollupOptions: { 29 | // make sure to externalize deps that shouldn't be bundled 30 | // into your library 31 | external: ['vue'], 32 | output: { 33 | banner: ` 34 | /** 35 | * Copyright ${new Date(Date.now()).getFullYear()} Thorsten Luenborg 36 | * @license MIT 37 | */`, 38 | // Provide global variables to use in the UMD build 39 | // for externalized deps 40 | exports: 'named', 41 | globals: { 42 | vue: 'Vue', 43 | }, 44 | }, 45 | }, 46 | }, 47 | }) 48 | --------------------------------------------------------------------------------