Skip to content

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:

js
export default {
  render() {
    // Options API render function
  }
}
js
export default {
  setup() {
    return () => {
      // Composition API render function
    }
  }
}
js
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.

js
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:

vue
<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>
Live Example
Top
1 - Upper
1 - Lower
2 - Upper
2 - Lower
3 - Upper
3 - Lower
Bottom

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:

vue
<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:

vue
<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:

vue
<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:

Live Example
First
Second
Third

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>:

vue
<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:

vue
<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>
Live Example
Top
1 - Upper
1 - Lower
2 - Upper
2 - Lower
3 - Upper
3 - Lower
Bottom

Inserting between children

The betweenChildren helper can be used to insert separators between children. For example:

js
import { h } from 'vue'
import { betweenChildren } from '@skirtle/vue-vnode-utils'

export default function InsertSeparators(_, { slots }) {
  return betweenChildren(slots.default(), () => h('hr'))
}

With usage:

vue
<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>
Live Example
Top

1 - Upper

1 - Lower

2 - Upper

2 - Lower

3 - Upper

3 - Lower

Bottom

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.

js
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:

vue
<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>
Live Example
[]
No results
['red', 'green', 'blue']
  • red
  • green
  • blue

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.

js
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:

vue
<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.

Live Example
Item 1
outerHTML: undefined

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:

js
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:

vue
<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:

Live Example

See it on the SFC Playground: Composition API | Options API