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

Revisiting modal dialogs

Sukima31st October 2022 at 11:17pm

A while back I wrote pretty heavily about modal dialog boxes in my post “Modals: the answer no one wants to a problem everyone has” were I attempted to highlight some of the tensions we have between markup and code logic and I offered a solution via means of the NPM module confirmed that I wrote. I also dove into styling with my simplistic CSS Modal Dialogs solution. This culminated with a modal dialogs guide that I wrote for Ember to experiment with this solution.

Recently in my post “Easy dirty change confirmations in Ember” I wrote a demo which had need of one of those now infamous modal dialog boxes. This demo written in a much newer version of Ember took a similar approach but much simpler and easier to implement and use compared to previous solutions. This is thanks to the wonderful patterns that emerge when using @tracked but then the same pattern can be used without the autotracking stuff.

I call this new modal dialog pattern: The ModalManager pattern. I think it is worth diving into it and revisit modal dialog boxes.

Since this pattern can be implemented in many different environments I'd like to cover some common ground. What are the requirements for this pattern? More specifically what do we want from our code when it comes to managing modal dialogs? To answer that we need to look at how the typical dialog functions.

A dialog has two base concepts. The code that interacts with it (input/output) and the presentation (the actual box, focus traps, etc.) and the glue between them is usually done through events. Focusing on the code logic the act of prompting (input) and receiving a result (output) is by definition asychronous as it needs to perform a round trip via user interaction. In essence the user themselves becomes like a remote server where the dialog being presented is the request and the user clicking a button to dismiss is the response. However most frameworks and also the Spec DOM API for <dialog> only offer hooks and not an actual asynchronous API (like a promise). The ModalManager pattern I will describe is that glue that converts the many hooks into a Promise with a result.

The pattern should…

  1. trigger the opening of a modal dialog
  2. provide a promise to be awaited on
  3. trigger closing the dialog on completion
  4. resolve the promise to a result of the interaction

And here is a simplified usage example:

let { reason } = await modalManager.open();
switch (reason) {
  case 'cancelled': …;
  case 'confirmed': …;
  case 'rejected': …;
}

modalManager.open() will show the dialog to the user and hook up any triggers needed to know when the dialog is done. The caller waits for the result. The promise resolves with a reason which the caller can use to perform some control flow.

A simplified implementation might look like this:

class ModalManager {
  #resolve = () => {};
  #reject = () => {};
  isOpen = false;
  open() {
    return new Promise((resolve, reject) => {
      this.#resolve = resolve;
      this.#reject = reject;
      this.isOpen = true;
    }).finally(() => (this.isOpen = false));
  }
  cancel() {
    this.#resolve({ reason: 'cancelled' });
  }
  confirm(value) {
    this.#resolve({ reason: 'confirmed', value });
  }
  reject(value) {
    this.#resolve({ reason: 'confirmed', value });
  }
  error(error) {
    this.#reject(error);
  }
}

In this example the isOpen flag is the trigger but as we will get into the trigger can be a lot of things. In the following tacks we will show you how the presentation layer taps into the ModalManager pattern.

In plain HTML/JS we have the <dialog> element. It has two modes: non-modal and modal. Luckily, the code I’ll demonstrate will work for both. The dialog element has two methods .show() and showModal(). It can produce the following events: cancel, click, and submit (for when we use <form method="dialog">). We will need something that will listen for those events and correctly show/hide the dialog.

Taking inspiration from good OOP design we can expand the ModalManager implementation we had earlier to delegate the open and close to a Controller.

class ModalManager {
  isOpen = false;
  #openModal = () => {};
  #closeModal = () => {};
  #resolve = () => {};
  #reject = () => {};

  open() {
    return new Promise((resolve, reject) => {
      this.#resolve = resolve;
      this.#reject = reject;
      this.#openModal();
      this.isOpen = true;
    }).finally(() => {
      this.isOpen = false;
      this.#closeModal();
    });
  }

  cancel() { … }
  confirm(value) { … }
  reject(value) { … }
  error(error) { … }

  delegateTo(controller) {
    this.#openModal = () => controller.open();
    this.#closeModal = () => controller.close();
  }
}

With this we can construct any object that has an open() and close() method and when we use our modal manager it will properly instruct the controller to handle opening and closing.

For the HTML5 <dialog> this means attaching event listeners and calling the showModal() and close() methods of the element. Before we attach meaning to the different ways to manage a modal the initial class will start off like this:

class ModalDialogController {
  constructor(element, manager) {
    this.element = element;
    this.manager = manager;
  }

  open() {
    // … attach event listeners here …
    this.element.showModal();
  }

  close() {
    // … remove event listeners here …
    this.element.close();
  }
}

And invoke it with this:

let manager = new ModalManager();
let controller = new ModalDialogController(
  document.querySelector('dialog'),
  manager,
);
manager.delegateTo(controller);
let { reason } = await manager.open();

To make it a little easier you can make a factory function:

// Factory function
static for(
  element,
  factory = (element, manager) =>
    new ModalDialogController(element, manager),
) {
  let manager = new ModalManager();
  manager.delegateTo(factory(element, manager));
  return manager;
}
…
let myManager = ModalManager.for(
  document.querySelector('dialog'),
);

Now we will hook up events to properly trigger different ways of closing the dialog. There are three main ways to close a <dialog> via events. But first we need to make sure we can remove those events. Since JavaScript event handling needs the original function reference and they also need binding to properly handle the this keyword. In the latest versions of JavaScript we can do that easily with arrow functions assigned to private methods of a class.

class ModalDialogController {
  #cancel = () => this.manager.cancel();
  #confirm = (value) => this.manager.confirm(value);
  #reject = (value) => this.manager.reject(value);
  …
}

We can attach to these private methods and later remove them:

this.element.addEventListener('cancel', this.#cancel);

this.element.removeEventListener('cancel', this.#cancel);

cancel is an event that a modal dialog triggers when the user presses the Escape key to close it. This is only available when a <dialog> is opened via the .showModal() method.

Other ways to close the modal is via buttons we add. For example say we have the following markup:

<dialog>
  <button type="button" data-action="cancel">Cancel</button>
  <button type="button" data-action="confirm">Yes</button>
  <button type="button" data-action="reject">No</button>
</dialog>

Then we can attach a click event to the <dialog> and use the data-action attribute to know how best to resolve the modal manager.

#handleClick = (event) => {
  let value =
    event.target.dataset.value
    ?? this.element.returnValue;
  switch (event.target.dataset.action) {
    case 'cancel': return this.#cancel();
    case 'confirm': return this.#confirm(value);
    case 'reject': return this.#reject(value);
    default: // no-op
  }
};
…
this.element.addEventListener('click', this.#handleClick);

You can pick any way you want to handle a value for confirm/reject or none at all. I choose to allow something via data-value or the dialog’s own returnValue for API compatibility and flexibility.

If we want to capture the clicking of the ::backdrop pseudo-element the click event will be triggered on the <dialog> itself which makes it easy to delegate by checking it event.target is the same as this.element as long as we place an inner <div> container.

<dialog>
  <div></div>
</dialog>
#handleClick = (event) => {
  if (event.target === this.element) return this.#cancel();
  …
};

The specs also talk about placing forms inside dialogs. We can also capture that use case by adding a submit event listener. The cool part about this option is that unlike the returnValue we get from the API in the submit event we can convert the form values into a FormData object and confirm the modal manager with a full on object instead of a string value.

<dialog>
  <div>
    <form method="dialog">
      <label for="my-dialog-foobar">Foobar</label>
      <input id="my-dialog-foobar" name="foobar">
      <button type="submit">Save</button>
    </form>
  </div>
</dialog>
#handleSubmit = (event) =>
  this.#confirm(new FormData(event.target));
…
this.element.addEventListener('submit', this.#handleSubmit);

Nice thing about this is that the submit event doesn't happen till the form is considered valid. method="dialog" prevents any sending to the server (client side only form) and now you get the full breadth of options as FormData provides.

let { reason, value: formData } = await manager.open();
if (reason === 'confirmed') {
  console.log(`Hello ${formData.get('foobar')}!`);
}

You probably want to see this in action. Well I’ve developed a working DEMO.

Discuss this article