Integrating Vuetify's stepper with Inertia Forms

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:

  1. After submitting a form, we needed a collection of any inputs with a error.
  2. With those errors, we wanted a collection of every stepper item containing at least one errored input
  3. We’d need the given value for those items for later, we’d also need to be able to programmatically enable their error state
  4. 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 stepper
5 *
6 * @param { HTMLElement } target The v-stepper-window-item element
7 * @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 error
19 * @param { Set } acc The reduce functions's accumulator
20 * @param { HTMLElement } curr The HTML element of the errored input
21 * @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 state
32 *
33 * @param { Map } errorValues A map of values of errored items
34 * @param { Map } headerMap A map of stepper header items, keyed by value
35 * @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 object
46 * @param { import("@vueuse/core").Ref<string|number> } step The modelValue for the stepper
47 * @returns { object }
48 */
49export const useFlow = (form, step) => {
50 const stepperRef = ref(null);
51
52 const stepperInjectionKey = computed(() =>
53 stepperRef.value
54 ? 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 watcher
67 */
68 const pause = () => {
69 isPaused.value = true;
70 };
71 /**
72 * Resume watching for errors
73 */
74 const resume = () => {
75 isPaused.value = false;
76 };
77
78 watch(
79 () => form.processing,
80 async (isProcessing) => {
81 // Reset the stepper's error state
82 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 complete
89 await nextTick();
90
91 if (form.hasErrors === false) return;
92
93 // Get the HTML element of the stepper, control for dev-mode variance
94 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] records
103 */
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 error
113 const min = Math.min(...errorValues.keys());
114 // Set the stepper's modelValue to navigate there
115 step.value = errorValues.get(min);
116 }
117 },
118 );
119
120 const headerMap = new Map();
121 /**
122 * Template ref callback function for <v-stepper-item> elements
123 */
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.value
6 ? 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 variance
const 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 state
3 *
4 * @param { Map } errorValues A map of values of errored items
5 * @param { Map } headerMap A map of stepper header items, keyed by value
6 * @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 error
19 const min = Math.min(...errorValues.keys());
20 // Set the stepper's modelValue to navigate there
21 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).

Get in Touch

Get in touch with us today 01202 790300

monday 09:00 - 17:00
tuesday 09:00 - 17:00
wednesday 09:00 - 17:00
thursday 09:00 - 17:00
friday 09:00 - 16:00
saturday closed
sunday closed