Last active
July 24, 2020 13:56
-
-
Save mdwheele/ed197a88d29a3b6d0749ccc6ed75443e to your computer and use it in GitHub Desktop.
A renderless Stepper component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script> | |
export default { | |
name: 'Step', | |
props: ['title', 'description'], | |
render(h) { | |
return h('div', this.$slots.default) | |
} | |
} | |
</script> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 offattrs
, 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.