LITERAT

Fullstack Developer & Whitewater Kayaker & Scout

Dialog Element - Don't reinvent the wheel with modals!

We see them everywhere. They pop up with cookie confirmations, we send tweets through them on Twitter, menus open in them, or they are simply used for notifications. On the web, they are ubiquitous as an integral part of user interaction. Offcanvas, Drawer, Modal, or Popup - each library implements them in its own way, even though there is already a native solution in the form of the dialog element. Let's take a closer look at it together.

Dialog Element - Don't reinvent the wheel with modals!

Every UI library names components a little differently. As for anatomy and implementation, components are often similar to each other (although each may be suitable for different purposes). However, we encounter one variant of such a component almost everywhere. And that is the Modal. Sometimes it is called Dialog or Popup. In various mutations, it also appears as Drawer or Offcanvas (dialog that slides from the side). But they all have one thing in common. They appear above the current interface and prioritize a specific action or information for the user. And in all cases, their implementation is quite complex. Let's take a look at how to simplify and improve such implementation using the native element <dialog>.

Meet the dialog element

Anatomy of Modal component from Adobe - https://react-spectrum.adobe.com/react-spectrum/Dialog.html Anatomy of Modal component - https://uxmovement.com/content/anatomy-of-an-optimally-designed-modal/

Some of us have surely struggled with creating and styling modals. It's always the same:

  • build the dialog and its content,
  • create an overlay with a darkened backdrop,
  • create an API for opening and closing the entire modal,
  • then solve closing when pressing the Escape key, clicking outside the <dialog> (on the backdrop), and other edge cases (such as preventing scrolling on the page).

So many things that repeat themselves, and yet we have the dialog element that serves all of this on a silver platter. And at the same time, it solves accessibility.

The first glimpse of the dialog element was shown to us by Tomáš Pustelník in his lecture at WebExpo - HTML can do that. Let's recap.

The dialog element itself is a relatively simple element. It doesn't have any additional properties except for the "open" attribute, which determines whether the dialog is initially open or not. However, be aware that a dialog opened via the "open" attribute is not modal and there is no method to reopen it. Controlling it through JavaScript is the preferred method, but more on that later.

<dialog open>
  <p>Greetings, one and all!</p>
  <form method="dialog">
    <button>OK</button>
  </form>
</dialog>
Non-modal dailog Non-modal dailog from Modalz - https://modalzmodalzmodalz.com/

It becomes interesting only when combined with the <form> element, if it has the attribute method="dialog" or if the submit button has formmethod="dialog". In such a case, the form state is saved, the dialog is closed, and the return value dialog.returnValue is set to the value of the button.

<main>
  <button id="open">Open dialog</button>
  <dialog>
    <form method="dialog">
    <h1>Hello, would you like to do something cool?<h1/>
    <button id="close" value="cancel">Go to hell!</button>
    <button id="confirm" value="confirm">Let's go!</button>
  </dialog>
</main>

However, that's not all. The dialog element also adds a new pseudo-element, ::backdrop, which allows easy styling of the backdrop that appears behind the dialog. For example, to achieve a darkening effect on the inaccessible content.

.Modal::backdrop {
  background-color: #333333;
  visibility: visible;
  opacity: 1;
}

Close me and open me again, with JavaScript of course

And now for the controls, because not everything works automatically and a bit of JavaScript is always needed. However, even here, <dialog> saves us some typing and offers us several options for interacting with it.

Methods

.show()

I will start with a method that may initially give the impression of an inconsistent API. The show() method does not display the dialog as the developer would expect, in Modal state. This method opens the Dialog in so-called non-modal state, where interaction with content outside the dialog is still allowed. It's similar to the open attribute that I described at the beginning.

.showModal()

This is the real method you want to use to open the dialog. The dialog opens as we all know, over all other dialogs, to the top layer. The ::backdrop pseudo-element is activated and interaction with content outside the dialog is blocked.

.close()

Closing <dialog> is already easy, there is only one method for that, which also accepts a return value as an argument that we want to get into dialog.returnValue.

Events

The <dialog> element also triggers two events.

close

→ The close event is triggered when the dialog is closed by a button.

cancel

→ The cancel event is triggered when the dialog is canceled, for example by pressing the Escape key. This automatically closes the dialog and eliminates the need for further implementation.

<script>
  (() => {
    const updateButton = document.getElementById("updateDetails");
    const closeButton = document.getElementById("close");
    const dialog = document.getElementById("favDialog");
    dialog.returnValue = "favAnimal";

    function openCheck(dialog) {
      if (dialog.open) {
        console.log("Dialog open");
      } else {
        console.log("Dialog closed");
      }
    }

    // Update button opens a modal dialog
    updateButton.addEventListener("click", () => {
      dialog.showModal();
      openCheck(dialog);
    });

    // Form close button closes the dialog box
    closeButton.addEventListener("click", () => {
      dialog.close("animalNotChosen");
      openCheck(dialog);
    });
  })();
</script>

Then all you have to do is handle closing when clicking on the backdrop and you're done. But even here the solution is simple, because clicking on the backdrop passes event.target as the dialog element. Therefore, it is enough to compare whether the click was on the content or on the dialog element itself and close it accordingly.

Content + Backdrop Dialog element - Top layer

And what about React?

The implementation in React has been significantly simplified.

Let's start with the Modal component itself using the dialog element and a reference to it.

function Modal({ children }) {
  const dialogRef = React.useRef(null);

  return <dialogref={dialogRef}>{children}</dialog>;
}

Next, we need to control the open or closed state using the methods described above.

function Modal({ children, open }) {
  const dialogRef = React.useRef(null);

  React.useEffect(() => {
    const dialogNode = dialogRef.current;

    if (open) {
      dialogNode.showModal();
    } else {
      dialogNode.close();
    }
  }, [open]);

  return <dialog ref={dialogRef}>{children}</dialog>;
}

Then it's just a matter of handling what should happen when someone presses Escape and the cancel event is triggered.

function Modal({ children, open, onRequestClose }) {
  const dialogRef = React.useRef(null);

  React.useEffect(() => {
    const dialogNode = dialogRef.current;

    if (open) {
      dialogNode.showModal();
    } else {
      dialogNode.close();
    }
  }, [open]);

  React.useEffect(() => {
    const dialogNode = dialogRef.current;
    const handleCancel = (event) => {
      event.preventDefault();
      onRequestClose();
    };

    dialogNode.addEventListener('cancel', handleCancel);

    return () => {
      dialogNode.removeEventListener('cancel', handleCancel);
    };
  }, [onRequestClose]);

  return <dialog ref={dialogRef}>{children}</dialog>;
}

And as a cherry on top, we can also add the focus returning to the element that opened the modal, as recommended by WAI-ARIA.

function Modal({ children, open, onRequestClose }) {
  //  ...
  const lastActiveElement = React.useRef(null);

  React.useEffect(() => {
    const node = ref.current;

    if (open) {
      lastActiveElement.current = document.activeElement;
      node.showModal();
    } else {
      node.close();
      lastActiveElement.current.focus();
    }
  }, [open]);
  //  ...
}

Or you can break down the entire implementation into hooks and the Dialog component itself, and then assemble it like LEGO, as we did in the Spirit Design System.

What to take away from this

Advantages:

  • Minimal JavaScript is needed.
  • Accessibility is almost solved except for a few problems with autofocus.
  • There is a ::backdrop pseudo-element that can be easily styled.
  • The form attribute method="dialog" exposes form values and buttons when used in a dialog.
  • Pressing the Escape key automatically closes the dialog.

Disadvantages:

  • More advanced functionality requires more JavaScript, but not that much more ;-)
  • Clicking on the backdrop does not close the dialog automatically, so a little bit of JavaScript is needed.
  • Some minor accessibility issues, see autofocus.

Accessibility

As shown in the following table, the dialog element can already be used in the vast majority of browsers, and work is underway on solutions to some of the remaining issues.

Support of the <dialog> element in the browsers | https://caniuse.com/?search=dialog

Therefore, if your website does not require any special features such as support for very old browsers or if you do not have extremely limited resources or time, I recommend migrating to the dialog element. There will always be cases where role="dialog" is preferred over the <dialog> element, but there will be fewer and fewer of them.

However, if you do not want to add additional dependencies that would solve any Dialog-related issues, even if they are very small, use the <dialog> element. After all, you don't want to reinvent the wheel, or rather the Dialog :-)

References

I code on

Literat © 2008 — 2024