Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new kinds of wrapper slots for the target #390

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 98 additions & 11 deletions docs/api/portal-target.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,29 +138,116 @@ Example:
<p>This is rendered when no other content is available.</p>
```

### `wrapper`
### `v-slot:sourceWrapper`

This slot can be used to define markup that should wrap the content received from a `<portal>`. This is usually only useful in combination with [`multiple`](#multiple), as for content from a single portal, you can just wrap the `<portal-target>` 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
<portal to="wrapped-target">
<p>Some inline content</p>
<p>Some more content</p>
</portal>

<portal to="wrapped-target">
<p>Some content from a second portal</p>
</portal>

The slot receives an array as its only prop, which contains the raw vnodes representing the content sent from the source portal(s).
<div class="flex">
<portal-vue to="wrapped-target" multiple>
<template v-slot:item-wrapper="nodes">
<div class="flex-item">
<component v-for="node in nodes" :is="node" />
</div>
</template>
</portal-vue>
</div>
```

These vnodes can be rendered with Vue's dynamic component syntax:
**Result**

```html
<div class="flex">
<div class="flex-item"> <!-- content from first portal wrapped together -->
<p>Some inline content</p>
<p>Some more content</p>
</div>
<div class="flex-item"> <!-- content from second portal in a second wrapper -->
<p>Some content from a second portal</p>
</div>

</div>
```

### `v-slot:itemWrapper`

This slot can be used to define markup that should wrap the content received from a `<portal>`. This is usually only useful in combination with [`multiple`](#multiple), as for content from a single portal, you can just wrap the `<portal-target>` as a whole.

The slot receives a single vnode as its only prop. These vnodes can be rendered with Vue's dynamic component syntax:

`<component :is="node">`

Example:
```html
<portal to="wrapped-target">
<p>Some inline content</p>
<p>Some more content</p>
</portal>

<portal to="wrapped-target">
<p>Some content from a second portal</p>
</portal>

<div class="flex">
<portal-vue to="wrapped-target" multiple>
<template v-slot:item-wrapper="node">
<div class="flex-item"> <!-- will be applied around each individual <p> !! -->
<component :is="node" />
</div>
</template>
</portal-vue>
</div>
```

**Result**

```html
<div class="flex">
<div class="flex-item">
<p>Some inline content</p>
</div>
<div class="flex-item">
<p>Some more content</p>
</div>
<div class="flex-item">
<p>Some content from a second portal</p>
</div>

</div>
```

### `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 `<portal-target>`itself. But it's useful for transition groups which would otherwie conflict with the `<portal-target>`'s own root element:

**Source**
<!-- prettier-ignore -->
```html
<portal-target name="target">
<template v-slot:wrapper="nodes">
<component :is="node" v-for="node in nodes" />
<portal-target name="wrapped-with-transition-group" multiple>
<template v-slot:outer-wrapper="{ nodes }">
<transition-group name="fade">
<component v-for="node in nodes" :is="node">
</transition-group
</template>
</portal-target>
```

This slot is also useful to [add transitions (see advanced Guide)](../guide/advanced#transitions ).
### `v-slot:wrapper` <Badge type="warning">deprecated</Badge>

::: 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`
Expand Down
7 changes: 4 additions & 3 deletions docs/guide/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ You can pass transitions to a `<portal>` without problems. It will behave just t
```

However, if you use a `<portal-target>` for multiple `<portal>`s, you likely want to define the transition on the target end instead. This is also supported.

#### PortalTarget Transitions

<!-- prettier-ignore -->
```html
<portal-target name="target">
<template v-slot:default="nodes">
<template v-slot:outerWrapper="nodes">
<transition name="fade" mode="out-in">
<component :is="nodes[0]" />
</transition>
Expand All @@ -76,7 +77,7 @@ However, if you use a `<portal-target>` for multiple `<portal>`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 `<component :is="">` to turn those into the content of the transition.

Expand All @@ -85,7 +86,7 @@ Here's a second example, using a `<transition-group>` instead:
<!-- prettier-ignore -->
```html
<portal-target name="target">
<template #default="nodes">
<template #outerWrapper="nodes">
<transition-group name="fade">
<component :is="node" v-for="node in nodes" :key="node" />
</transition-group>
Expand Down
2 changes: 1 addition & 1 deletion example/components/transitions/transitions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@

<div style="position: relative">
<portal-target name="group-transition">
<template #wrapper="nodes">
<template #sourceWrapper="nodes">
<transition-group name="fade">
<component :is="node" v-for="node in nodes" :key="node" />
</transition-group>
Expand Down
5 changes: 0 additions & 5 deletions src/__tests__/__snapshots__/portal-target.spec.ts.snap

This file was deleted.

5 changes: 0 additions & 5 deletions src/__tests__/__snapshots__/the-portal.spec.ts.snap

This file was deleted.

139 changes: 130 additions & 9 deletions src/__tests__/portal-target.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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', () => {
Expand All @@ -50,22 +50,26 @@ describe('PortalTarget', () => {

await nextTick()

expect(wrapper.html()).toBe(
`<div style="display: none;"></div>
<div class="testnode"></div>`
expect(wrapper.html()).toMatchInlineSnapshot(
`
"<div style=\\"display: none;\\"></div>
<div class=\\"testnode\\"></div>"
`
)
})

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(
'"<p class=\\"default\\">Test</p>"'
)
expect(wrapper.find('p.default').exists()).toBe(true)
})

Expand All @@ -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(`
"<div style=\\"display: none;\\"></div>
<div class=\\"itemWrapper\\">
<div class=\\"testnode\\">source1-1</div>
</div>
<div class=\\"itemWrapper\\">
<div class=\\"testnode\\">source1-2</div>
</div>
<div class=\\"itemWrapper\\">
<div class=\\"testnode\\">source2</div>
</div>"
`)
})

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(`
"<div style=\\"display: none;\\"></div>
<div class=\\"sourceWrapper\\">
<div class=\\"testnode\\">source1</div>
</div>
<div class=\\"sourceWrapper\\">
<div class=\\"testnode\\">source2</div>
</div>"
`)
})

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(`
"<div style=\\"display: none;\\"></div>
<div class=\\"outerWrapper\\">
<div class=\\"testnode\\">source1</div>
<div class=\\"testnode\\">source2</div>
</div>"
`)
})
})
})
4 changes: 3 additions & 1 deletion src/__tests__/the-portal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'"<span class=\\"test-span\\">Test</span>"'
)
})
})
7 changes: 5 additions & 2 deletions src/__tests__/wormhole.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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({
Expand Down
Loading