diff --git a/docs/api/portal-target.md b/docs/api/portal-target.md
index 13f1c94..cfd32b0 100644
--- a/docs/api/portal-target.md
+++ b/docs/api/portal-target.md
@@ -138,29 +138,116 @@ Example:
This is rendered when no other content is available.
```
-### `wrapper`
+### `v-slot:sourceWrapper`
-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.
+This Slot allows to wrap each individual item from a portal (or `multiple` portals) in additional markup. The slot receives an array of `VNodes`:
+
+```html
+
+ Some inline content
+ Some more content
+
+
+
+ Some content from a second portal
+
-The slot receives an array as its only prop, which contains the raw vnodes representing the content sent from the source portal(s).
+
+```
-These vnodes can be rendered with Vue's dynamic component syntax:
+**Result**
+
+```html
+
+
+
Some inline content
+
Some more content
+
+
+
Some content from a second portal
+
+
+
+```
+
+### `v-slot:itemWrapper`
+
+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.
+
+The slot receives a single vnode as its only prop. These vnodes can be rendered with Vue's dynamic component syntax:
``
-Example:
+```html
+
+ Some inline content
+ Some more content
+
+
+
+ Some content from a second portal
+
+
+
+```
+
+**Result**
+
+```html
+
+
+
+
+
Some content from a second portal
+
+
+
+```
+
+### `v-slot:outerWrapper`
+
+This slot is similar to `itemWrapper`, but it will be called only once, and receive *all* vnodes in an array. That allows you to wrap all received content in a shared wrapper element.
+
+Usually, this slot is not very useful as you can instead just put the wrapper around the ``itself. But it's useful for transition groups which would otherwie conflict with the ``'s own root element:
-**Source**
-
```html
-
-
-
+
+
+
+
+
```
-This slot is also useful to [add transitions (see advanced Guide)](../guide/advanced#transitions ).
+### `v-slot:wrapper` deprecated
+
+::: warn This feature is deprecated. Do not use.
+
+This slot has been deprecated in version `3.1` when we introduced to additional slots, in order to provide more clarity in naming. Please use `v-slot:sourceWrapper` instead, which works 100% the same as `v-slot:wrapper` did - or check out the new `v-slot:sourceWrapper` and `v-slot:outerWrapper` slots.
+
+:::
+
## Events API
### `change`
diff --git a/docs/guide/advanced.md b/docs/guide/advanced.md
index 5a5f6ca..96ab53a 100644
--- a/docs/guide/advanced.md
+++ b/docs/guide/advanced.md
@@ -61,12 +61,13 @@ You can pass transitions to a `` without problems. It will behave just t
```
However, if you use a `` for multiple ``s, you likely want to define the transition on the target end instead. This is also supported.
+
#### PortalTarget Transitions
```html
-
+
@@ -76,7 +77,7 @@ However, if you use a `` for multiple ``s, you likely wan
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.*`.
-Basically, you pass a transition to a slot named `wrapper` and get an array called `nodes` from its slot props.
+Basically, you pass a transition to a [slot named `outerWrapper`](../api/portal-target.md#v-slot-outerwrapper) and get an array called `nodes` from its slot props.
You can the use Vue'S `` to turn those into the content of the transition.
@@ -85,7 +86,7 @@ Here's a second example, using a `` instead:
```html
-
+
diff --git a/example/components/transitions/transitions.vue b/example/components/transitions/transitions.vue
index 3f27c59..4f5dc71 100644
--- a/example/components/transitions/transitions.vue
+++ b/example/components/transitions/transitions.vue
@@ -43,7 +43,7 @@
-
+
diff --git a/src/__tests__/__snapshots__/portal-target.spec.ts.snap b/src/__tests__/__snapshots__/portal-target.spec.ts.snap
deleted file mode 100644
index cf1a106..0000000
--- a/src/__tests__/__snapshots__/portal-target.spec.ts.snap
+++ /dev/null
@@ -1,5 +0,0 @@
-// Vitest Snapshot v1
-
-exports[`PortalTarget > renders slot content when no other content is available 1`] = `"Test
"`;
-
-exports[`PortalTarget renders slot content when no other content is available 1`] = `"Test
"`;
diff --git a/src/__tests__/__snapshots__/the-portal.spec.ts.snap b/src/__tests__/__snapshots__/the-portal.spec.ts.snap
deleted file mode 100644
index 830345a..0000000
--- a/src/__tests__/__snapshots__/the-portal.spec.ts.snap
+++ /dev/null
@@ -1,5 +0,0 @@
-// Vitest Snapshot v1
-
-exports[`Portal > renders locally when \`disabled\` prop is true 1`] = `"Test"`;
-
-exports[`Portal renders locally when \`disabled\` prop is true 1`] = `"Test"`;
diff --git a/src/__tests__/portal-target.spec.ts b/src/__tests__/portal-target.spec.ts
index cf68627..b9e1061 100644
--- a/src/__tests__/portal-target.spec.ts
+++ b/src/__tests__/portal-target.spec.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from 'vitest'
-import { type Slot, h, nextTick } from 'vue'
+import { describe, it, test, expect, vi } from 'vitest'
+import { type Slot, h, nextTick, type VNode } from 'vue'
import { mount } from '@vue/test-utils'
import PortalTarget from '../components/portal-target'
import { wormholeSymbol } from '../composables/wormhole'
@@ -35,7 +35,7 @@ function createWrapper(props = {}, options = {}) {
}
function generateSlotFn(text = '') {
- return (() => h('div', { class: 'testnode' }, text) as unknown) as Slot
+ return (() => [h('div', { class: 'testnode' }, text) as unknown]) as Slot
}
describe('PortalTarget', () => {
@@ -50,22 +50,26 @@ describe('PortalTarget', () => {
await nextTick()
- expect(wrapper.html()).toBe(
- `
-`
+ expect(wrapper.html()).toMatchInlineSnapshot(
+ `
+ "
+ "
+ `
)
})
- it('renders slot content when no other content is available', function () {
+ it('renders default slot content when no other content is available', function () {
const { wrapper } = createWrapper(
{},
{
slots: {
- default: h('p', { class: 'default' }, 'Test'),
+ default: () => [h('p', { class: 'default' }, 'Test')],
},
}
)
- expect(wrapper.html()).toMatchSnapshot()
+ expect(wrapper.html()).toMatchInlineSnapshot(
+ '"Test
"'
+ )
expect(wrapper.find('p.default').exists()).toBe(true)
})
@@ -90,4 +94,121 @@ describe('PortalTarget', () => {
},
])
})
+
+ describe('Wrapper slots', () => {
+ test('v-slot:itemWrapper', async () => {
+ const { wrapper, wh } = createWrapper(
+ {
+ multiple: true,
+ },
+ {
+ slots: {
+ itemWrapper: (nodes: VNode[]) => [
+ h('div', { class: 'itemWrapper' }, nodes),
+ ],
+ },
+ }
+ )
+ wh.open({
+ from: 'source1',
+ to: 'target',
+ content: () => [
+ h('div', { class: 'testnode' }, 'source1-1'),
+ h('div', { class: 'testnode' }, 'source1-2'),
+ ],
+ })
+ wh.open({
+ from: 'source2',
+ to: 'target',
+ content: generateSlotFn('source2'),
+ })
+
+ await nextTick()
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+ "
+
+
+ "
+ `)
+ })
+
+ test('v-slot:sourceWrapper', async () => {
+ const { wrapper, wh } = createWrapper(
+ {
+ multiple: true,
+ },
+ {
+ slots: {
+ sourceWrapper: (nodes: VNode[]) => [
+ h('div', { class: 'sourceWrapper' }, nodes),
+ ],
+ },
+ }
+ )
+ wh.open({
+ from: 'source1',
+ to: 'target',
+ content: generateSlotFn('source1'),
+ })
+ wh.open({
+ from: 'source2',
+ to: 'target',
+ content: generateSlotFn('source2'),
+ })
+
+ await nextTick()
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+ "
+
+ "
+ `)
+ })
+
+ test('v-slot:outerWrapper', async () => {
+ const { wrapper, wh } = createWrapper(
+ {
+ multiple: true,
+ },
+ {
+ slots: {
+ outerWrapper: (nodes: VNode[]) => [
+ h('div', { class: 'outerWrapper' }, nodes),
+ ],
+ },
+ }
+ )
+ wh.open({
+ from: 'source1',
+ to: 'target',
+ content: generateSlotFn('source1'),
+ })
+ wh.open({
+ from: 'source2',
+ to: 'target',
+ content: generateSlotFn('source2'),
+ })
+
+ await nextTick()
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+ "
+ "
+ `)
+ })
+ })
})
diff --git a/src/__tests__/the-portal.spec.ts b/src/__tests__/the-portal.spec.ts
index 951d506..0e725f4 100644
--- a/src/__tests__/the-portal.spec.ts
+++ b/src/__tests__/the-portal.spec.ts
@@ -76,6 +76,8 @@ describe('Portal', function () {
it('renders locally when `disabled` prop is true', () => {
const { wrapper } = createWrapper({ disabled: true })
expect(wrapper.find('span').exists()).toBe(true)
- expect(wrapper.html()).toMatchSnapshot()
+ expect(wrapper.html()).toMatchInlineSnapshot(
+ '"Test"'
+ )
})
})
diff --git a/src/__tests__/wormhole.spec.ts b/src/__tests__/wormhole.spec.ts
index 6e13854..eb9d1ef 100644
--- a/src/__tests__/wormhole.spec.ts
+++ b/src/__tests__/wormhole.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { Slot, h } from 'vue'
+import { type Slot, h } from 'vue'
import { createWormhole } from '@/wormhole'
const createSlotFn = () => (() => h('div')) as unknown as Slot
@@ -32,7 +32,10 @@ describe('Wormhole', () => {
wormhole.open(content)
expect(wormhole.transports.get('target')?.get('test-portal')).toMatchObject(
- content
+ {
+ ...content,
+ content: expect.any(Function),
+ }
)
wormhole.close({
diff --git a/src/components/portal-target.ts b/src/components/portal-target.ts
index f7f3eb0..ae43010 100644
--- a/src/components/portal-target.ts
+++ b/src/components/portal-target.ts
@@ -1,10 +1,9 @@
import {
type FunctionalComponent,
type VNode,
- computed,
defineComponent,
h,
- watch,
+ onMounted,
} from 'vue'
import { useWormhole } from '../composables/wormhole'
@@ -23,39 +22,36 @@ export default defineComponent({
emits: ['change'],
setup(props, { emit, slots }) {
const wormhole = useWormhole()
+ let mounted = false
+ const slotVnodes = () => {
+ const transports = wormhole.getContentForTarget(
+ props.name,
+ props.multiple
+ )
+ const sourceWrapperSlot = slots.sourceWrapper ?? slots.wrapper
+ let vnodes = transports
+ .map((t) => {
+ const content = t
+ .content(props.slotProps)
+ .map((vnode) => slots.itemWrapper?.(vnode)[0] ?? vnode)
+ return sourceWrapperSlot ? sourceWrapperSlot(content) : content
+ })
+ .flat(1)
+ vnodes = slots.outerWrapper ? slots.outerWrapper(vnodes) : vnodes
+ mounted && emitSlotChange(vnodes)
+ return vnodes
+ }
- const slotVnodes = computed<{ vnodes: VNode[]; vnodesFn: () => VNode[] }>(
- () => {
- const transports = wormhole.getContentForTarget(
- props.name,
- props.multiple
- )
- const wrapperSlot = slots.wrapper
- const rawNodes = transports.map((t) => t.content(props.slotProps))
- const vnodes = wrapperSlot
- ? rawNodes.flatMap((nodes) =>
- nodes.length ? wrapperSlot(nodes) : []
- )
- : rawNodes.flat(1)
- return {
- vnodes,
- vnodesFn: () => vnodes, // just to make Vue happy. raw vnodes in a slot give a DEV warning
- }
- }
- )
-
- watch(
- slotVnodes,
- ({ vnodes }) => {
- const hasContent = vnodes.length > 0
- const content = wormhole.transports.get(props.name)
- const sources = content ? [...content.keys()] : []
- emit('change', { hasContent, sources })
- },
- { flush: 'post' }
- )
+ const emitSlotChange = (vnodes: VNode[]) => {
+ const hasContent = vnodes.length > 0
+ const content = wormhole.transports.get(props.name)
+ const sources = content ? [...content.keys()] : []
+ emit('change', { hasContent, sources })
+ }
+ onMounted(() => (mounted = true))
return () => {
- const hasContent = !!slotVnodes.value.vnodes.length
+ const vnodes = slotVnodes()
+ const hasContent = !!vnodes.length
if (hasContent) {
return [
// this node is a necessary hack to force Vue to change the scoped-styles boundary
@@ -67,7 +63,7 @@ export default defineComponent({
// we wrap the slot content in a functional component
// so that transitions in the slot can properly determine first render
// for `appear` behavior to work properly
- h(PortalTargetContent, slotVnodes.value.vnodesFn),
+ h(PortalTargetContent, () => vnodes),
]
} else {
return slots.default?.()
diff --git a/src/wormhole.ts b/src/wormhole.ts
index d7dcac0..6b3b710 100644
--- a/src/wormhole.ts
+++ b/src/wormhole.ts
@@ -1,4 +1,4 @@
-import { reactive, readonly } from 'vue'
+import { reactive, readonly, type VNode } from 'vue'
import type {
Name,
Transport,
@@ -26,7 +26,7 @@ export function createWormhole(asReadonly = true): Wormhole {
const newTransport = {
to,
from,
- content,
+ content: (...args) => ([] as VNode[]).concat(content(...args)),
order,
} as Transport