Skip to main content

Wizards

When you have a large or complex form, the best user experience is to turn it into a multi step wizard so they can fill out bits of information at a time. Follow these best practices to streamline wizards.

Component structure

There should be one parent component, and a child component for each step/nested view. The job of the parent is to handle the routing between steps, and the children can focus on their individual step's form logic.

In the above file structure, step 1 has nested views. These views will still be managed by the parent however, so we keep them at the same level as the main steps even though they will appear nested in step 1's breadcrumbs.

One Form per Step

Rather than trying to connect all the steps together under 1 BwForm, give each step it's own form and emit the final object on form submission. This avoids complications with trying to validate steps when they are not visible in the dom.

State Management

Utilize BwSession's for keeping track of the user's progress through the wizard. The parent component will open a session, and will wait to close it until the user completes the final step. Some benefits to this approach is:

  1. Avoids spaghetti code from trying to keep all the values in memory.
  2. Users progress will not be lost if they refresh the page or leave and come back later. You have to explicitly close the session for the progress to be lost.
  3. BwSession's are not permanent, they are tied to sessionStorage. You get a little bit of persistence without needing to mantain storage.

View Manager

All the steps should be wrapped in a BwViewManager, and every step should be a BwView. If you need a wizard, you can set the steps on the view manager and it will handle displaying them. If you have nested views and you need to represent the hierarchy with breadcrumbs, you can configure them via data attributes. If you need the wizard to maintain it's state even if they refresh the page, you should store the steps and crumbs properties on the view manager in your BwSession, and make sure you set the properties on the view manager on startup.

You can navigate between views using data-view-link attributes. The value should be set to the label of the next view.

<bw-button data-view-link="Step 2">Go to step 2</bw-button>

Animation

If the next view you are navigating to is backwards in the hierarchy, add data-view-dir="backwards" to the button triggering the navigation to cause a leaving animation to occur. The attribute defaults to forwards, which causes entering animations.

<bw-button data-view-link="Step 1" data-view-dir="backwards">Go to back to step 1</bw-button>

Wizard

To control the wizard, add data-view-wizard attributes to the necessary buttons that trigger the changes. Possible values are:

  • next
  • previous
  • reset
  • skip
<bw-button data-view-link="Step 2" data-view-wizard="next">Go to step 2</bw-button>

To control the breadcrumbs, add data-view-crumbs to the necessary buttons that link to the other views. The data-view-dir attribute determines if it will add or remove the view from the breadcrumbs.

<bw-button data-view-link="Nested Step" data-view-crumbs>Go to nested step</bw-button>
<bw-button data-view-link="Nested Step" data-view-crumbs data-view-dir="backwards">Go backwards</bw-button>

Discard Changes

If a particular view needs to show a discard changes prompt before navigating anywhere, set data-discard-form to the id of the form in the view to do the check on. You can also set the form property on BwView to get the auto rendered close button to trigger the prompt.

<bw-button data-view-link="Step 1" data-view-dir="backwards" data-discard-form="step2Form">Go to back to step 1</bw-button>

Full Example

Note: For brevity, all the logic for each step is in the same file, but in production you should put them in their own components to keep the parent clean.

<bw-overlay side="right" backdrop-dismiss="false" id="productsWizardOverlay">
<bw-button slot="trigger">+ Add Product</bw-button>
<template>
<bw-view-manager value="Details">
<bw-view label="Details" show-wizard form="detailsForm">
<template>
<bw-form id="detailsForm">
<bw-input name="name" label="Name" required></bw-input>
<bw-input name="description" label="Description"></bw-input>
<bw-input name="manufacturer" label="Manufacturer" required>
<bw-menu slot="dropdown" type="select"></bw-menu>
<bw-icon-button slot="after" size="xs" icon="add" color="gray" variant="secondary" data-view-link="Add Manufacturer" data-view-crumbs></bw-icon-button>
</bw-input>
</bw-form>
<bw-button slot="footer" type="submit" form="detailsForm">Next</bw-button>
</template>
</bw-view>
<bw-view label="Add Manufacturer" closable="false">
<template>
<bw-icon-button data-view-link="Details" data-discard-form="manufacturerForm" data-view-dir="backwards" data-view-crumbs slot="header-start" icon="chevron_backward"></bw-icon-button>
<bw-form id="manufacturerForm">
<bw-input name="name" label="Manufacturer Name" required></bw-input>
</bw-form>
<bw-button data-view-link="Details" data-discard-form="manufacturerForm" data-view-dir="backwards" data-view-crumbs slot="footer" color="gray" variant="secondary">Cancel</bw-button>
<bw-button type="submit" form="manufacturerForm" slot="footer">Save</bw-button>
</template>
</bw-view>
<bw-view label="Pricing" show-wizard form="pricingForm">
<template>
<bw-option slot="options">Example Option</bw-option>
<bw-form id="pricingForm">
<bw-input name="price" label="Price" required type="currency"></bw-input>
</bw-form>
<bw-button data-view-link="Details" data-view-dir="backwards" data-view-wizard="previous" slot="footer" color="gray" variant="secondary">Cancel</bw-button>
<bw-button type="submit" form="pricingForm" slot="footer">Save</bw-button>
</template>
</bw-view>
<bw-view label="Release" show-wizard>
<template>
<pre id="wizardContent"></pre>
<bw-button data-overlay="close" slot="footer">Release Product</bw-button>
</template>
</bw-view>
</bw-view-manager>
</template>
</bw-overlay>

<script>
const overlay = document.querySelector('#productsWizardOverlay')
let session;

function initWizard() {
session = window.BwSession.openSession('products-wizard')
session.clear().subscribe()
const viewManager = overlay.querySelector('bw-view-manager')
viewManager.steps = [{label: 'Details',state:'active'},{label:'Pricing'},{label:'Release'}]
function initManufacturers() {
const menu = viewManager.querySelector('bw-menu')
session.get('manufacturers').subscribe(val => {
val ??= []
menu.options = val.map(t => ({
value: t.name,
text: t.name
}))
})
const form = viewManager.querySelector('#detailsForm')
form.addEventListener('dataSubmit', ({detail}) => {
session.set('step1', detail).subscribe()
viewManager.navigate('Pricing', 'forwards', false, 'next')
})
}
viewManager.addEventListener('viewMount', ({detail}) => {
if (detail == 'Details') {
initManufacturers()
}
})
viewManager.addEventListener('viewChange', ({detail}) => {
if (detail.to === 'Details') {
initManufacturers()
} else if (detail.to === 'Add Manufacturer') {
const form = document.querySelector('#manufacturerForm')
form.addEventListener('dataSubmit', ({detail}) => {
session.update('manufacturers', curr => {
curr ??= []
return [...curr, detail]
}).subscribe(() => {
viewManager.navigate('Details', 'backwards', true, undefined)
})
})
} else if (detail.to === 'Pricing') {
const form = document.querySelector('#pricingForm')
form.addEventListener('dataSubmit', ({detail}) => {
session.set('step2', detail).subscribe()
viewManager.navigate('Release', 'forwards', false, 'next')
})
} else if (detail.to === 'Release') {
const content = viewManager.querySelector('#wizardContent')
session.getAll().subscribe(vals => {
content.innerHTML = JSON.stringify(vals, null, 2)
})
}
})
}
overlay.addEventListener('overlayPresent', initWizard)

overlay.addEventListener('overlayDismiss', () => {
if (session) window.BwSession.closeSession(session)
})
</script>