Examples
vue-vnode-utils
are intended to manipulate VNodes in render
functions, so the examples below are all using render
functions instead of templates. <script setup>
doesn't support render
functions, but they can be written in various other ways:
export default {
render() {
// Options API render function
}
}
export default {
setup() {
return () => {
// Composition API render function
}
}
}
export default () => {
// Functional components are just a render function
}
The examples use functional components where possible.
Templates are used for components that don't use vue-vnode-utils
directly, as those don't need to use render
functions.
Adding a class
Props can be added to child VNodes using addProps
. For a class
prop this will be additive, it won't remove any other classes that are already included.
In the example below, the <add-outline>
component adds the class child-outline
to each of its children. This class applies the border and spacing seen in the live demo.
import { h } from 'vue'
import { addProps } from '@skirtle/vue-vnode-utils'
export default function AddOutline(_, { slots }) {
const children = addProps(slots.default(), () => {
return {
class: 'child-outline'
}
})
return h('div', children)
}
Usage:
<script setup>
import AddOutline from './add-outline'
</script>
<template>
<add-outline>
<div>Top</div>
<template v-for="n in 3">
<div>{{ n }} - Upper</div>
<div>{{ n }} - Lower</div>
</template>
<div>Bottom</div>
</add-outline>
</template>
Adding component v-model
addProps
can also be used to add the prop/event pair used for v-model
. Here we'll use it to implement an accordion component.
TIP
This functionality would be better implemented using provide
and inject
. Example here.
The intended usage is something like this:
<script setup>
import BasicAccordion from './basic-accordion.vue'
import BasicAccordionPanel from './basic-accordion-panel.vue'
</script>
<template>
<basic-accordion>
<basic-accordion-panel title="First">
First panel content
</basic-accordion-panel>
<basic-accordion-panel title="Second">
Second panel content
</basic-accordion-panel>
<basic-accordion-panel title="Third">
Third panel content
</basic-accordion-panel>
</basic-accordion>
</template>
First we'll need a <basic-accordion-panel>
component. It implements an expanded
prop and update:expanded
event, consistent with v-model:expanded
:
<script setup>
const props = defineProps({
expanded: {
type: Boolean
},
title: {
required: true,
type: String
}
})
const emit = defineEmits(['update:expanded'])
const toggle = () => {
emit('update:expanded', !props.expanded)
}
</script>
<template>
<div class="accordion-panel" :class="{ expanded }">
<div class="header" @click="toggle">{{ title }}</div>
<div v-if="expanded" class="body"><slot /></div>
</div>
</template>
<style scoped>
.accordion-panel {
display: flex;
flex-direction: column;
}
.accordion-panel + .accordion-panel {
margin-top: 1px;
}
.header {
background-color: #e6f6ff;
border: 1px solid #ccc;
cursor: pointer;
margin: -1px;
padding: 5px;
}
.body {
background-color: #f7fcff;
border: 1px solid #ccc;
flex: auto;
margin: 0 -1px -1px;
overflow: auto;
}
.expanded {
flex: auto;
min-height: 0;
}
</style>
The <basic-accordion>
might then be implemented something like this, using addProps
to add the prop and event:
<script>
import { h, ref } from 'vue'
import { addProps } from '@skirtle/vue-vnode-utils'
export default {
setup(_, { slots }) {
const expandedPanel = ref(null)
return () => {
let count = 0
const children = addProps(slots.default?.() ?? [], () => {
const index = count++
return {
// Using the index is overly simplistic
// but suffices for this example
expanded: index === expandedPanel.value,
'onUpdate:expanded': (expanded) => {
if (expanded) {
expandedPanel.value = index
} else if (index === expandedPanel.value) {
expandedPanel.value = null
}
}
}
})
return h('div', { class : 'accordion' }, children)
}
}
}
</script>
<style scoped>
.accordion {
background: #eee;
border: 1px solid #ccc;
color: #213547;
display: flex;
flex-direction: column;
height: 300px;
width: 240px;
}
</style>
This implementation is very naive and is only intended to demonstrate the basic idea of how this component might be implemented using VNode manipulation.
All of which gives:
See it on the SFC Playground: Composition API | Options API
Wrap children
We can use the replaceChildren
helper to wrap each child node in an extra <div>
:
<script>
import { h } from 'vue'
import { replaceChildren } from '@skirtle/vue-vnode-utils'
export default {
render () {
const newChildren = replaceChildren(this.$slots.default(), (vnode) => {
return h('div', { class: 'wrapper' }, [vnode])
})
return h('div', { class: 'list' }, newChildren)
}
}
</script>
<style scoped>
.list {
background: #777;
border: 1px solid #777;
border-radius: 5px;
padding: 10px;
}
.wrapper {
background: #eee;
color: #444;
padding: 2px 10px;
}
.wrapper + .wrapper {
margin-top: 10px;
}
</style>
This might then be used something like this:
<script setup>
import WrappingList from './wrapping-list.vue'
</script>
<template>
<wrapping-list>
<div>Top</div>
<template v-for="n in 3">
<div>{{ n }} - Upper</div>
<div>{{ n }} - Lower</div>
</template>
<div>Bottom</div>
</wrapping-list>
</template>
Inserting between children
The betweenChildren
helper can be used to insert separators between children. For example:
import { h } from 'vue'
import { betweenChildren } from '@skirtle/vue-vnode-utils'
export default function InsertSeparators(_, { slots }) {
return betweenChildren(slots.default(), () => h('hr'))
}
With usage:
<script setup>
import InsertSeparators from './insert-separators'
</script>
<template>
<insert-separators>
<div>Top</div>
<template v-for="n in 3">
<div>{{ n }} - Upper</div>
<div>{{ n }} - Lower</div>
</template>
<div>Bottom</div>
</insert-separators>
</template>
Checking for empty content
The isEmpty
helper can help to check whether the child VNodes are empty. Fragment nodes aren't counted as content, neither are comments nor strings of collapsible whitespace.
import { h } from 'vue'
import { isEmpty } from '@skirtle/vue-vnode-utils'
export default function ResultsList(_, { slots }) {
const children = slots.default()
if (isEmpty(children)) {
return h('div', 'No results')
}
return h('ul', children)
}
Example usage, with an empty list followed by a non-empty list:
<script setup>
import ResultsList from './results-list'
</script>
<template>
<pre>[]</pre>
<results-list>
<li v-for="result in []">{{ result }}</li>
</results-list>
<pre>['red', 'green', 'blue']</pre>
<results-list>
<li v-for="result in ['red', 'green', 'blue']">{{ result }}</li>
</results-list>
</template>
See it on the SFC Playground: Composition API | Options API
Adding a ref
to a slot
Adding a 'template ref' to slot content can be a bit tricky. A common trick is to add the ref
attribute to a surrounding element and then walk the DOM tree to access the relevant element. But that only works if there is a surrounding element in the relevant component, and it also only allows access to elements, not components.
There are a number of ways we might attempt to implement something like this with vue-vnode-utils
.
Let's assume our component only allows a single child inside the slot, a bit like a <Transition>
component. Let's further assume that we want to skip fragment nodes, comments and empty text nodes. We could use extractSingleChild()
, which will pull out a single element or component node, with a console warning if multiple nodes are found.
import { cloneVNode, h, ref } from 'vue'
import { extractSingleChild } from '@skirtle/vue-vnode-utils'
export default {
setup(_, { slots }) {
const rootRef = ref(null)
return () => {
const child = extractSingleChild(slots.default())
// Pass `true` to avoid overriding any existing ref
const clone = cloneVNode(child, { ref: rootRef }, true)
return [
clone,
h('pre', `outerHTML: ${ rootRef.value?.outerHTML }`)
]
}
}
}
The example is using outerHTML
during rendering, which is only there so we can see the contents of the ref
. Something like that should not appear in real code.
Usage might look something like this:
<script setup>
import { ref } from 'vue'
import AddRef from './add-ref'
const index = ref(1)
</script>
<template>
<button @click="index = index % 3 + 1">Next</button>
<add-ref>
<template v-for="n in 3">
<template v-if="n === index">
<div :key="n">Item {{ n }}</div>
</template>
</template>
</add-ref>
</template>
This example is a bit silly, because in practice you're very unlikely to use a v-for
in a scenario where only one node can be rendered. But it shows how extractSingleChild()
successfully negotiates the v-for
fragments and v-if
comment nodes.
One quirk of this example is the position of the key
. There's no benefit in having a key
on the nodes we discard, as they'll never make it to the patching process anyway. Instead, we need the key
to be placed on the <div>
VNode that we keep. This will ensure that the <div>
DOM nodes are not reused for different items, updating the ref
on each rendering update.
In a real use case we'd probably be fine with reusing the same <div>
, so the key
could be omitted.
Another way we might approach this is using addProps()
. This could be used to add a ref
to a single node, like in the previous example, or it could handle the more general case with multiple top-level nodes:
import { h, reactive } from 'vue'
import { addProps } from '@skirtle/vue-vnode-utils'
export default {
setup(_, { slots }) {
// This array will hold the top-level elements/components
// rendered by the slot
const childRefs = reactive([])
let childCount
return () => {
childCount = 0
const children = addProps(slots.default(), () => {
const refIndex = childCount++
return {
ref: (item) => {
if (item) {
childRefs[refIndex] = item
} else if (childRefs.length > childCount) {
childRefs.length = childCount
}
}
}
})
return [
children,
h('pre', [
`outerHTML:\n`,
childRefs.map(
(el, i) => `${i}: ${el.outerHTML}`
).join('\n')
])
]
}
}
}
As with the previous example, this example contains a circularity because it is rendering the outerHTML
of the ref
elements, but that's just for demo purposes and in real code you would do whatever you need to do with the elements/components, not just dump out their outerHTML
.
Usage might be something like this:
<script setup>
import { ref } from 'vue'
import AddMultipleRefs from './add-multiple-refs'
const count = ref(3)
</script>
<template>
<button @click="count++">Add</button>
|
<button @click="count = Math.max(count - 1, 0)">Remove</button>
<add-multiple-refs>
<template v-for="n in count">
<div>Item {{ n }}</div>
</template>
</add-multiple-refs>
</template>
All of which gives:
See it on the SFC Playground: Composition API | Options API