DEV Community

loading...
Cover image for It's time to get SAD: Self-destructing Awaitable Dialogs

It's time to get SAD: Self-destructing Awaitable Dialogs

straversi profile image Steven Traversi ・3 min read
const answer = await MyDialog.ask();
console.log(`The user clicked: ${answer}`);

Lately I've been using a self-contained Dialog component I made that:

⏱ Awaits user input.
🚿 Cleans itself up.
🌎 Can be requested from anywhere.

It provides an elegant solution to a common problem. You're deep in your app's UI hierarchy, and you need a user input. The user needs to confirm their purchase. Or they need to name their document before saving. And you need a dialog that somehow both (a) covers the whole app and (b) makes the user input available to the deep UI logic.

My solution's API is an async one-liner that you can await:

const answer = await MyDialog.ask();

It creates the dialog, returns the user's input, and removes the dialog. It's a joy to use.

If you want to jump straight to the full implementation, check out the simple solution or the extended solution.

The component

I'm going to make the dialog as a web component. This provides a couple of advantages:

  • The dialog's styles and markup are encapsulated. I won't affect other elements in the DOM, which is easy to do accidentally when reaching into a different part of my UI.
  • Web components are standardized, so I won't need any new dependencies, and the dialog will be compatible with other frameworks.
// Make a template with the markup for the dialog.
// The styles will only apply to these elements.
const template = document.createElement('template');
template.innerHTML = `
  <style>
    :host, #overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; }
    #overlay { background: rgba(0,0,0,0.5); }
    #box { background: powderblue; width: 80%; max-width: 200px; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 10px; }
    #buttons { text-align: right; }
  </style>
  <div id="overlay"></div>
  <div id="box">
    <div>Do a thing?</div>
    <div style="text-align: right">
      <button id="yes">Yes</button>
      <button id="no">No</button>
    </div>
  </div>
`;

// Make and register the MyDialog class using the template.
class MyDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}
customElements.define('my-dialog', MyDialog);

Now we can attach a <my-dialog> to the document body without worrying about accidentally applying styles to other elements.

The user input

We'll add the 'self-destructing', and 'awaitable'-ness in a static method on this class.

Here's the signature of the method we'll be working in:

class MyDialog extends HTMLElement {
  // We'll be filling in this method.
  static async ask() { }
}

The rest of the code will go in the ask method.

First, we should ensure my-dialog has been registered as a custom element. Once that's been established, let's proceed with creating a my-dialog element and adding it to the DOM. I put it in the body, but we could give the developer more control by parameterizing the parent element.

await customElements.whenDefined('my-dialog');
const dialog = document.createElement('my-dialog');
document.body.prepend(dialog);

Now, let's get references to the input targets we want to listen for. These grab the "yes" and "no" buttons in the Dialog.

const yes = dialog.shadowRoot.querySelector('#yes');
const no = dialog.shadowRoot.querySelector('#no');

Now, construct a Promise that resolves once a 'Yes' or 'No' button is clicked. Take this opportunity to remove the dialog instance as well.

return new Promise(resolve => {
  yes.addEventListener('click', () => {
    document.body.removeChild(dialog);
    resolve('yes');
  });
  no.addEventListener('click', () => {
    document.body.removeChild(dialog);
    resolve('no');
  });
});

And there you have it. You can now say

await MyDialog.ask();

anywhere in your UI logic.

Extension

I've implemented the self-destructing awaitable dialog, along with some extensions, in this glitch.me demo. The following extension ideas are included in the implementation. Check it out!

More resolution states

Usually when the user clicks outside of the dialog box, the dialog closes. In this case, we could reject the promise with the reasoning "user cancelled". keydown listeners could also be added that resolve the promise in predictable manners.

disconnectedCallback

What would happen if the my-dialog DOM node is removed by some other part of our program? The promise never resolves. To handle this, add a disconnectedCallback that fires a CustomEvent. Add a listener for that event in the promise, and reject if received.

Discussion (1)

pic
Editor guide
Collapse
maxart2501 profile image
Massimo Artizzu

Nice and easy. This basically mimicks the old window.confirm modal dialog.
But what about extending HTMLDialogElement instead? Granted, it's not available in Safari...