A while back I wrote about Modal dialogs
and their use of a ModalManager
to help make the complexities of managing modal dialogs easier
and interface with JavaScript async/await semantics.
In this article
I wanted to expand on that idea
in presenting a series of dialogs
that combined follow the Wizard design pattern.
For a quick recap,
the ModalManager is a class that knows how to
open a modal dialog,
react to events to close it,
and resolves a promise with one of three reasons:
confirmed,
rejected,
and cancelled.
In a typical confirmation dialog
confirmed
and rejected
corresponding to a yes/no question;
cancelled corresponding to the ✗ button
or other UI dismissal.
With these logic flows we can correlate these to the paths each wizard step would take.
| Manager reason | Wizard action |
|---|---|
confirmed | Next button |
rejected | Previous button |
cancelled | Dialog cancelled (✗ button) |
What this looks like is
we establish a <dialog> for each wizard step.
Use a ModalManager to open each step one at a time.
As each step is dismissed
we track the progress through the wizard
via a state machine.
And finally resolve a promise to the full result of all the steps combined.
Our fist step is to define the dialogs.
Each dialog element will have a data-state value
to correspond to a state
which we can use for a little bit of meta-programming
and make our ModalWizard class more decoupled.
HTML of step two dialog
<dialog data-state="stepTwo">
<div>
<header>
<h1>Wizard step two</h1>
<button
type="button"
data-action="cancel"
aria-label="Cancel wizard"
>✗</button>
</header>
<article>
<p>What is your favorite color?</p>
<p>
<label for="color">Favorite color:</label>
<input
id="color"
name="Favorite color"
form="example-form"
>
</p>
</article>
<footer>
<button type="button" data-action="reject">
Back
</button>
<button type="button" data-action="confirm">
Next
</button>
</footer>
</div>
</dialog>
Usage
async function eventHanlder() { let result = await new ModalWizard().start(); if (result.isCancelled) return; // Do something with result }
The idea here is that
the ModalWizard initializes an internal state.
The .start() begins the wizard flow
and returns a promise.
This promise resolves to a result object.
The result object might contain getters that represent the final state and possibly any other needed data.
In the demo above I used a single <form>
to collect data from each <dialog>
which made the demo easier to read
and allowed it to focus on the ModalWizard
more than the implementation details of the demo.
ModalWizard
The implementation will hold a state
and provide an .start() method.
class ModalWizard { state = 'inactive'; get result() { return new WizardResult(this.state); } async start() { this.state = this.transition('start'); for await (let { reason } of this.modals()) this.state = this.transition(reason); return this.result; } }
Example WizardResult class
class WizardResult {
constructor(state) {
this.state = state;
}
get isDone() {
return this.state === 'done';
}
get isCancelled() {
return this.state === 'cancelled';
}
get isFinished() {
return this.isDone || this.isCancelled;
}
get isRunning() {
return !(this.isFinished || this.state === 'inactive');
}
}
If you notice the .start() transitions the state machine
with a start event.
Then it loops over a generator called modals()
which based on the state
will yield the promise provided by ModalManager.open()
for that specific state.
In short, we’ve separated the concerns into three parts.
- a loop that requests the next modal in the flow and adjusts the state according to the modal’s result
- an async iterator which provides a modal result specific for the current state
- a transition method that determines the next state based on the event/previous modal result
class ModalWizard { … async *modals() { while (true) { // When the state machine is done stop the iteration if (this.result.isFinished) return; // Find the dialog for this state let dialog = document.querySelector( `dialog[data-state=${this.state}]` ); // Protect from a missing dialog which is // a programming error but without the exception would // induce an infinite loop if (!dialog) throw new Error( `Missing dialog for state ${this.state}` ); // Use the ModalManager and open the dialog yielding // the promise so its fulfillment can be awaited in // the start() method yield new ModalManager(dialog).open(); } } }
In the transition() example
I used a reducer pattern as the state machine.
I think that if the logic of the wizard became more complex
― perhaps with branching steps ―
I would turn to something like XState
instead of a reducer function.
class ModalWizard { … transition(event) { // Handle any universal events // These events will always result in a new state switch (event) { case 'cancelled': return 'cancelled'; case 'start': return 'stepOne'; // No default as we want to fall through to the next // switch statement } // These are state changes based on both the current // state and the event. // // It goes state first to lower the amount of code to // change as states are added and removed. States change // more often than events. This is a trade off choice // from maintenance experience. // // All state machines when recived an unknown or // unhandled event should result in the same state as it // is currently in. switch (this.state) { case 'stepOne': switch (event) { case 'confirmed': return 'stepTwo'; // next case 'rejected': return 'cancelled'; // back default: return this.state; } case 'stepTwo': switch (event) { case 'confirmed': return 'stepOne'; // next case 'rejected': return 'stepThree'; // back default: return this.state; } case 'stepThree': switch (event) { case 'confirmed': return 'stepTwo'; // next case 'rejected': return 'done'; // back default: return this.state; } default: return this.state; } } }
My hope is that I’ve been able to demonstrate some alternative ways to leverage the JavaScrtipt language itself to accomplish a fairly complex idea like multiple modal dialogs working together in a wizard style UI.
I’ve shown how async genreators can be used to provide promises from derived state. How for await…of can be used to manage a wait then react cycle. And how to funnel the decision making logic into a state machine ― in this case a reducer function.
And finally, I hope I’ve manged to demonstrate how separating concerns between specialized and generalized classes can benefit both understanding and maintainability.