Skip to content

Instantly share code, notes, and snippets.

@mdwheele
Last active July 24, 2020 13:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mdwheele/ed197a88d29a3b6d0749ccc6ed75443e to your computer and use it in GitHub Desktop.
Save mdwheele/ed197a88d29a3b6d0749ccc6ed75443e to your computer and use it in GitHub Desktop.
A renderless Stepper component
<template>
<Stepper v-model="step" v-slot="{ title, description, total, progress, next, prev, hasNext, hasPrev, nextStep, prevStep }">
<p>There are {{ total }} steps.</p>
<p>You are {{ progress }}% complete.</p>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
<Step title="First" description="The first step...">
1
</Step>
<p>If you have interwoven content...</p>
<Step title="Second" description="The second step...">
2
</Step>
<p>...it will show up AFTER the first occurrence of a Step.</p>
<Step title="Third" description="The third step...">
3
</Step>
<h2>Controls</h2>
<button @click="prev" v-if="hasPrev">Previous: {{ prevStep.title }}</button>
<button @click="next" v-if="hasNext">Next: {{ nextStep.title }}</button>
</Stepper>
</template>
<script>
import Stepper from './components/Stepper.vue'
import Step from './components/Step.vue'
export default {
name: 'App',
components: { Stepper, Step },
data() {
return {
step: 1
}
}
}
</script>
<script>
export default {
name: 'Step',
props: ['title', 'description'],
render(h) {
return h('div', this.$slots.default)
}
}
</script>
<script>
import Vue from 'vue'
function defaultSlot(vm, scope) {
return vm.$slots.default ? vm.$slots.default : vm.$scopedSlots.default(scope)
}
function isComponent(vnode, name) {
return vnode.componentOptions && vnode.componentOptions.Ctor.options.name === name;
}
export default {
name: 'Stepper',
props: {
value: {
type: Number,
default: 1
}
},
data() {
return {
current: 0,
total: 0,
steps: []
}
},
watch: {
value(value) {
this.current = value - 1
}
},
computed: {
step() {
return this.steps[this.current]
},
nextStep() {
return this.hasNext && this.steps[this.current + 1]
},
prevStep() {
return this.hasPrev && this.steps[this.current - 1]
},
title() {
return this.step && this.step.title
},
description() {
return this.step && this.step.description
},
hasNext() {
return this.current < this.total - 1
},
hasPrev() {
return this.current > 0
},
progress() {
return this.current / (this.total - 1) * 100
}
},
methods: {
next() {
this.current = Math.min(this.current + 1, this.total - 1)
this.$emit('input', this.current + 1)
},
prev() {
this.current = Math.max(this.current - 1, 0)
this.$emit('input', this.current + 1)
},
},
mounted() {
this.current = this.value - 1
},
render(h) {
// Grab all our slot children
let slots = defaultSlot(this, {
title: this.title,
description: this.description,
total: this.total,
progress: this.progress,
hasNext: this.hasNext,
hasPrev: this.hasPrev,
nextStep: this.nextStep,
prevStep: this.prevStep,
next: this.next,
prev: this.prev
})
// Find all the <Step> components
const steps = slots.filter(vnode => isComponent(vnode, 'Step'))
this.total = steps.length
// Update steps (which hold our props)
const components = steps.map(step => {
const component = new step.componentOptions.Ctor(step.componentOptions)
component.$mount()
return component
})
// Only update steps IF props hashes change.
const stepsHash = JSON.stringify(this.steps.map(component => component.$options.propsData))
const componentsHash = JSON.stringify(components.map(component => component.$options.propsData))
if (stepsHash != componentsHash) {
this.steps = components
}
// Find the indexOf <Step> in $slots/$scopedSlots
const firstStepOccurrence = slots.indexOf(slots.find(vnode => isComponent(vnode, 'Step')))
// Remove all <Step> from children and splice current into indexOf
slots = slots.filter(vnode => !isComponent(vnode, 'Step'))
slots.splice(firstStepOccurrence, 0, steps[this.current])
// Render
return h('div', slots)
}
}
</script>
@mdwheele
Copy link
Author

I'm like 99% sure there is a better way to get props updates that doesn't have you creating component instances from the Step vnodes. I know you can straight rip it off attrs, so I bet as long as you pass a ref to component state, it'll stay reactive. Without doing this, I was having issues with the component template not "picking up" changes until a render was forced. So, could make a change, HMR would happen; yet the step titles/descriptions would not update until I clicked next/previous (easiest way to force a re-render).

This was because I was getting new vnodes and breaking reactivity by naively assigning nextStep/prevStep state. Now, I've got everything working as I expect and it's time to refactor to see if we can get rid of some of the complexity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment