This page is part of a static HTML representation of TriTarget.org at https://tritarget.org

Modal dialog wizards

Sukima8th August 2023 at 5:08am

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.

Relationships between ModalManager reasons and Wizard actions
Manager reasonWizard action
confirmedNext button
rejectedPrevious button
cancelledDialog 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.

View the full demo

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"
      >&cross;</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.

  1. a loop that requests the next modal in the flow and adjusts the state according to the modal’s result
  2. an async iterator which provides a modal result specific for the current state
  3. 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.

Discuss this article