Most of the time, UI libraries make life very easy for us. Occasionally though, something seemingly simple ends up being an arduous journey into the deepest depths of the source code, just hoping that something you can use will be there for you.
That occasion came for us when we tried to get Inertia‘s form helper to play nice with Vuetify’s Stepper component. All we wanted was for an error on the form to display an error in the header step and navigate the stepper back to the first item in need of fixing.
Sure, we could have manually coded an index of every field in each step, but why spend 5 minutes doing an easy fix when you can spend hours automating it completely.
The Problem
Vuetify doesn’t expose anything to do with its error state or its internal registry of stepper items, so there’s a fair bit of hackery needed. General wisdom is, “If a property or method starts with an _ then don’t use it”. We ignored general wisdom. But first, here’s how we needed Vuetify to work:
- After submitting a form, we needed a collection of any inputs with a error.
- With those errors, we wanted a collection of every stepper item containing at least one errored input
- We’d need the given
value
for those items for later, we’d also need to be able to programmatically enable their error state - Finally, we update the
modelValue
of the stepper to navigate to the earliest step with an errored input.
We daresay that a couple of exposed functions in Vuetify could’ve save us a lot of time, but other people’s code rarely does 100% of what you want it to.
The Code
For the impatient, we’ll give you the entire code now and then explain it afterwards. Bear in mind that the mission was to make it as reusable as possible and with little-to-no boilerplate needed to use it in a component.
useFlow.js
1import { ref, watch, nextTick, computed, toValue } from "vue";2
3/**4 * Given a v-stepper-window-item HTML element, get its index position in the stepper5 *6 * @param { HTMLElement } target The v-stepper-window-item element7 * @returns { Number }8 */9const findStepperItemIndex = (target) => {10 const container = target.closest(".v-window__container");11 if (!container) return -1;12
13 const stepperWindowItems = Array.from(container.querySelectorAll(".v-stepper-window-item"));14 return stepperWindowItems.indexOf(target);15};16
17/**18 * Create a set of unique stepper item values whose content has an error19 * @param { Set } acc The reduce functions's accumulator20 * @param { HTMLElement } curr The HTML element of the errored input21 * @returns { Set<number|string> }22 */23const createIndexSetFromErrors = (acc, curr) => {24 const el = curr.closest(".v-stepper-window-item");25 const elIndex = findStepperItemIndex(el);26 if (elIndex >= 0) acc.add(elIndex);27 return acc;28};29
30/**31 * Using the item values, get the header item and enable error state32 *33 * @param { Map } errorValues A map of values of errored items34 * @param { Map } headerMap A map of stepper header items, keyed by value35 * @returns { void }36 */37const markErroredHeaderSteps = (errorValues, headerMap) => {38 errorValues.forEach((val, index) => {39 const headerItem = headerMap.get(val);40 headerItem.props.error = true;41 });42};43
44/**45 * @param { import("@inertiajs/vue3").InertiaForm } form The form object46 * @param { import("@vueuse/core").Ref<string|number> } step The modelValue for the stepper47 * @returns { object }48 */49export const useFlow = (form, step) => {50 const stepperRef = ref(null);51
52 const stepperInjectionKey = computed(() =>53 stepperRef.value54 ? Object.getOwnPropertySymbols(stepperRef.value._.provides).find(55 (symbol) => Symbol.keyFor(symbol) === "vuetify:v-stepper",56 )57 : null,58 );59 const stepperProps = computed(() =>60 stepperInjectionKey.value ? stepperRef.value._.provides[stepperInjectionKey.value] : {},61 );62
63 const isPaused = ref(false);64
65 /**66 * Pause the error watcher67 */68 const pause = () => {69 isPaused.value = true;70 };71 /**72 * Resume watching for errors73 */74 const resume = () => {75 isPaused.value = false;76 };77
78 watch(79 () => form.processing,80 async (isProcessing) => {81 // Reset the stepper's error state82 if (isProcessing) {83 headerMap.forEach((item) => {84 item.props.error = false;85 });86 }87 if (isPaused.value === true || isProcessing === true) return;88 // Wait for the effects of onError to complete89 await nextTick();90
91 if (form.hasErrors === false) return;92
93 // Get the HTML element of the stepper, control for dev-mode variance94 const stepperEl = stepperRef.value.$el ?? stepperRef.value._?.vnode?.el;95
96 const errorElements = stepperEl.querySelectorAll(".v-input--error");97 if (!errorElements) return;98
99 const errorIndexes = Array.from(errorElements).reduce(createIndexSetFromErrors, new Set());100
101 /**102 * Create a map of [index => value] records103 */104 const errorValues = Array.from(errorIndexes).reduce((acc, curr) => {105 acc.set(curr, toValue(stepperProps.value.items)[curr]?.value);106 return acc;107 }, new Map());108
109 markErroredHeaderSteps(errorValues, headerMap);110
111 if (errorValues.size) {112 // Get the lowest index with an error113 const min = Math.min(...errorValues.keys());114 // Set the stepper's modelValue to navigate there115 step.value = errorValues.get(min);116 }117 },118 );119
120 const headerMap = new Map();121 /**122 * Template ref callback function for <v-stepper-item> elements123 */124 const headerRefsFn = (item) => {125 headerMap.set(item._.props.value, item._);126 };127
128 return {129 pause,130 resume,131 stepperRef,132 headerRefsFn,133 };134};
SomeComponent.vue
<template> <v-stepper v-model="step" ref="stepperRef"> <v-stepper-header> <v-stepper-item :ref="headerRefsFn" title="Step One" :value="1" /> <v-stepper-item :ref="headerRefsFn" title="Step Two" :value="2" /> <v-stepper-item :ref="headerRefsFn" title="Step Three" :value="3" /> </v-stepper-header> <v-stepper-window> <v-stepper-window-item :value="1"> <v-text-field v-model="form.first_name" :error-messages="form.errors.first_name" label="First Name" /> </v-stepper-window-item> <v-stepper-window-item :value="2"> <v-text-field v-model="form.last_name" :error-messages="form.errors.last_name" label="Last Name" /> </v-stepper-window-item> <v-stepper-window-item :value="3"> <v-text-field v-model="form.email" :error-messages="form.errors.email" label="Email Address" /> </v-stepper-window-item> </v-stepper-window> <v-stepper-actions @click:prev="step--" @click:next="step++"> <template v-if="step === 3" #next> <v-btn :disabled="false" color="primary-500" @click="onSubmit">Submit</v-btn> </template> </v-stepper-actions> </v-stepper></template>
<script setup>import { useFlow } from "composables/useFlow";
const step = ref(1);
const form = useForm({ first_name: "", last_name: "", email: "",});
const onSubmit = () => { form.post(route("users.store"));};
const { pause, resume, headerRefsFn, stepperRef } = useFlow(form, step);</script>
Some commentary
So yeah, that’s a lot of code in our composable just to keep the component looking clean. But if we need to refactor our form or stepper layout at a later date, we don’t have to worry about whether we got everything synced up again, because ugly or not, it does exactly what we wanted it to do.
Accessing the provided items
Like we said before, the Stepper didn’t give our external components much to work with in accessing its inner-state, so the first task was to crack into it a little bit. We found the the <v-stepper>
component had a provide/inject relationship with its descendents, but alas, the key was a Symbol and there was no export for it.
1export const useFlow = (form, step) => {2 const stepperRef = ref(null);3
4 const stepperInjectionKey = computed(() =>5 stepperRef.value6 ? Object.getOwnPropertySymbols(stepperRef.value._.provides).find(7 (symbol) => Symbol.keyFor(symbol) === "vuetify:v-stepper",8 )9 : null,10 );11
12 const stepperProps = computed(() =>13 stepperInjectionKey.value ? stepperRef.value._.provides[stepperInjectionKey.value] : {},14 );15
16 // ...17});
So using our template ref for the root stepper, we get the injection key by iterating over its Symbol properties and matching the one we want using it constructor key. Then using that Symbol, we can then grab the entire provided object.
Finding the errors
Since we don’t really care about which form properties have errors, only which stepper item they’re inside, we opted for some old-fashioned DOM snooping for the next part.
const findStepperItemIndex = (target) => { const container = target.closest(".v-window__container"); if (!container) return -1;
const stepperWindowItems = Array.from(container.querySelectorAll(".v-stepper-window-item")); return stepperWindowItems.indexOf(target);};
const createIndexSetFromErrors = (acc, curr) => { const el = curr.closest(".v-stepper-window-item"); const elIndex = findStepperItemIndex(el); if (elIndex >= 0) acc.add(elIndex); return acc;};
// Get the HTML element of the stepper, control for dev-mode varianceconst stepperEl = stepperRef.value.$el ?? stepperRef.value._?.vnode?.el;
const errorElements = stepperEl.querySelectorAll(".v-input--error");if (!errorElements) return;
const errorIndexes = Array.from(errorElements).reduce(createIndexSetFromErrors, new Set());
First we need the HTML element of the root stepper to search it for errored inputs. Then we need to match those inputs with a stepper item index (in our case: 0, 1 or 2).
Then, using those indexes, we’re going to match them with the :value
prop attached to that item. Remember that the values could be anything, so hard-coding those would make our composable very fragile.
/*** Create a map of [index => value] records*/const errorValues = Array.from(errorIndexes).reduce((acc, curr) => { acc.set(curr, toValue(stepperProps.value.items)[curr]?.value); return acc;}, new Map());
With our indexes or all stepper items of interest, we can match them against the items
we collected from the provide
object earlier. Now we have a map of index -> value.
Brief sidenote
There’s a small section of code outside of our watcher callback like so:
const headerMap = new Map();/*** Template ref callback function for <v-stepper-item> elements*/const headerRefsFn = (item) => { headerMap.set(item._.props.value, item._);};
The headerRefsFn
is a template function ref. This allows us to manually manage how we store references to <v-stepper-item>
components so we can access them in the next step.
Getting to the point
Back to the watcher. So far, nothing’s actually happened in our UI, but as the finale of our form.processing
watcher callback, we change that.
1/**2 * Using the item values, get the header item and enable error state3 *4 * @param { Map } errorValues A map of values of errored items5 * @param { Map } headerMap A map of stepper header items, keyed by value6 * @returns { void }7 */8const markErroredHeaderSteps = (errorValues, headerMap) => {9 errorValues.forEach((val, index) => {10 const headerItem = headerMap.get(val);11 headerItem.props.error = true;12 });13};14
15markErroredHeaderSteps(errorValues, headerMap);16
17if (errorValues.size) {18 // Get the lowest index with an error19 const min = Math.min(...errorValues.keys());20 // Set the stepper's modelValue to navigate there21 step.value = errorValues.get(min);22}
On line 15 above, we pass our map of [index => value]
records and our map of header step items (also keyed by value) to activate the error state, showing us a nice error icon for any steps containing errored inputs. Then finally, we find the lowest stepper item index with errors, grab its value, and then update our stepper’s modelValue
to navigate us back to that step in the UI.
Final thoughts
The downside with using private APIs on libraries is that there’s no guarantee they won’t change, so our code isn’t exactly future-proof. For now though it does what’s needed and with a bit more error handling, we can ensure that worst-case-scenario, it simply stops working rather than breaking the page (a non-trivial event on an Inertia site, since it stops navigation).