Tag: Dialog

Dialog Components: Go Native HTML or Roll Your Own?

As the author of a library called AgnosticUI, I’m always on the lookout for new components. And recently, I decided to dig in and start work on a new dialog (aka modal) component. That’s something many devs like to have in their toolset and my goal was to make the best one possible, with an extra special focus on making it inclusive and accessible.

My first thought was that I would avoid any dependencies and bite the bullet to build my own dialog component. As you may know, there’s a new <dialog> element making the rounds and I figured using it as a starting point would be the right thing, especially in the inclusiveness and accessibilities departments.

But, after doing some research, I instead elected to leverage a11y-dialog by Kitty Giraudel. I even wrote adapters so it integrates smoothly with Vue 3, Svelte, and Angular. Kitty has long offered a React adapter as well.

Why did I go that route? Let me take you through my thought process.

First question: Should I even use the native <dialog> element?

The native <dialog> element is being actively improved and will likely be the way forward. But, it still has some issues at the moment that Kitty pointed out quite well:

  1. Clicking the backdrop overlay does not close the dialog by default
  2. The alertdialog ARIA role used for alerts simply does not work with the native <dialog> element. We’re supposed to use that role when a dialog requires a user’s response and shouldn’t be closed by clicking the backdrop, or by pressing ESC.
  3. The <dialog> element comes with a ::backdrop pseudo-element but it is only available when a dialog is programmatically opened with dialog.showModal().

And as Kitty also points out, there are general issues with the element’s default styles, like the fact they are left to the browser and will require JavaScript. So, it’s sort of not 100% HTML anyway.

Here’s a pen demonstrating these points:

Now, some of these issues may not affect you or whatever project you’re working on specifically, and you may even be able to work around things. If you still would like to utilize the native dialog you should see Adam Argyle’s wonderful post on building a dialog component with native dialog.

OK, let’s discuss what actually are the requirements for an accessible dialog component…

What I’m looking for

I know there are lots of ideas about what a dialog component should or should not do. But as far as what I was personally going after for AgnosticUI hinged on what I believe make for an accessible dialog experience:

  1. The dialog should close when clicking outside the dialog (on the backdrop) or when pressing the ESC key.
  2. It should trap focus to prevent tabbing out of the component with a keyboard.
  3. It should allow forwarding tabbing with TAB and backward tabbing with SHIFT+TAB.
  4. It should return focus back to the previously focused element when closed.
  5. It should correctly apply aria-* attributes and toggles.
  6. It should provide Portals (only if we’re using it within a JavaScript framework).
  7. It should support the alertdialog ARIA role for alert situations.
  8. It should prevent the underlying body from scrolling, if needed.
  9. It would be great if our implementation could avoid the common pitfalls that come with the native <dialog> element.
  10. It would ideally provide a way to apply custom styling while also taking the prefers-reduced-motion user preference query as a further accessibility measure.

I’m not the only one with a wish list. You might want to see Scott O’Hara’s article on the topic as well as Kitty’s full write-up on creating an accessible dialog from scratch for more in-depth coverage.

It should be clear right about now why I nixed the native <dialog> element from my component library. I believe in the work going into it, of course, but my current needs simply outweigh the costs of it. That’s why I went with Kitty’s a11y-dialog as my starting point.

Auditing <dialog> accessibility

Before trusting any particular dialog implementation, it’s worth making sure it fits the bill as far as your requirements go. With my requirements so heavily leaning on accessibility, that meant auditing a11y-dialog.

Accessibility audits are a profession of their own. And even if it’s not my everyday primary focus, I know there are some things that are worth doing, like:

This is quite a lot of work, as you might imagine (or know from experience). It’s tempting to take a path of less resistance and try automating things but, in a study conducted by Deque Systems, automated tooling can only catch about 57% of accessibility issues. There’s no substitute for good ol’ fashioned hard work.

The auditing environment

The dialog component can be tested in lots of places, including Storybook, CodePen, CodeSandbox, or whatever. For this particular test, though, I prefer instead to make a skeleton page and test locally. This way I’m preventing myself from having to validate the validators, so to speak. Having to use, say, a Storybook-specific add-on for a11y verification is fine if you’re already using Storybook on your own components, but it adds another layer of complexity when testing the accessibility of an external component.

A skeleton page can verify the dialog with manual checks, existing a11y tooling, and screen readers. If you’re following along, you’ll want to run this page via a local server. There are many ways to do that; one is to use a tool called serve, and npm even provides a nice one-liner npx serve <DIRECTORY> command to fire things up.

Let’s do an example audit together!

I’m obviously bullish on a11y-dialog here, so let’s put it to the test and verify it using some of the the recommended approaches we’ve covered.

Again, all I’m doing here is starting with an HTML. You can use the same one I am (complete with styles and scripts baked right in).

View full code
<!DOCTYPE html> <html lang="en">   <head>     <meta charset="UTF-8">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <meta http-equiv="X-UA-Compatible" content="ie=edge">     <title>A11y Dialog Test</title>     <style>       .dialog-container {         display: flex;         position: fixed;         top: 0;         left: 0;         bottom: 0;         right: 0;         z-index: 2;       }              .dialog-container[aria-hidden='true'] {         display: none;       }              .dialog-overlay {         position: fixed;         top: 0;         left: 0;         bottom: 0;         right: 0;         background-color: rgb(43 46 56 / 0.9);         animation: fade-in 200ms both;       }              .dialog-content {         background-color: rgb(255, 255, 255);         margin: auto;         z-index: 2;         position: relative;         animation: fade-in 400ms 200ms both, slide-up 400ms 200ms both;         padding: 1em;         max-width: 90%;         width: 600px;         border-radius: 2px;       }              @media screen and (min-width: 700px) {         .dialog-content {           padding: 2em;         }       }              @keyframes fade-in {         from {           opacity: 0;         }       }              @keyframes slide-up {         from {           transform: translateY(10%);         }       }        /* Note, for brevity we haven't implemented prefers-reduced-motion */              .dialog h1 {         margin: 0;         font-size: 1.25em;       }              .dialog-close {         position: absolute;         top: 0.5em;         right: 0.5em;         border: 0;         padding: 0;         background-color: transparent;         font-weight: bold;         font-size: 1.25em;         width: 1.2em;         height: 1.2em;         text-align: center;         cursor: pointer;         transition: 0.15s;       }              @media screen and (min-width: 700px) {         .dialog-close {           top: 1em;           right: 1em;         }       }              * {         box-sizing: border-box;       }              body {         font: 125% / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif;         padding: 2em 0;       }              h1 {         font-size: 1.6em;         line-height: 1.1;         font-family: 'ESPI Slab', sans-serif;         margin-bottom: 0;       }              main {         max-width: 700px;         margin: 0 auto;         padding: 0 1em;       }     </style>     <script defer src="https://cdn.jsdelivr.net/npm/a11y-dialog@7/dist/a11y-dialog.min.js"></script>   </head>    <body>     <main>       <div class="dialog-container" id="my-dialog" aria-hidden="true" aria-labelledby="my-dialog-title" role="dialog">         <div class="dialog-overlay" data-a11y-dialog-hide></div>         <div class="dialog-content" role="document">           <button data-a11y-dialog-hide class="dialog-close" aria-label="Close this dialog window">             ×           </button>           <a href="https://www.yahoo.com/" target="_blank">Rando Yahoo Link</a>              <h1 id="my-dialog-title">My Title</h1>           <p id="my-dialog-description">             Some description of what's inside this dialog…           </p>         </div>       </div>       <button type="button" data-a11y-dialog-show="my-dialog">         Open the dialog       </button>     </main>     <script>       // We need to ensure our deferred A11yDialog has       // had a chance to do its thing ;-)       window.addEventListener('DOMContentLoaded', (event) => {         const dialogEl = document.getElementById('my-dialog')         const dialog = new A11yDialog(dialogEl)       });     </script>   </body>  </html>

I know, we’re ignoring a bunch of best practices (what, styles in the <head>?!) and combined all of the HTML, CSS, and JavaScript in one file. I won’t go into the details of the code as the focus here is testing for accessibility, but know that this test requires an internet connection as we are importing a11y-dialog from a CDN.

First, the manual checks

I served this one-pager locally and here are my manual check results:

Feature Result
It should close when clicking outside the dialog (on the backdrop) or when pressing the ESC key.
It ought to trap focus to prevent tabbing out of the component with a keyboard.
It should allow forwarding tabbing with TAB and backward tabbing with SHIFT+TAB.
It should return focus back to the previously focused element when closed.
It should correctly apply aria-* attributes and toggles.
I verified this one “by eye” after inspecting the elements in the DevTools Elements panel.
It should provide Portals. Not applicable.
This is only useful when implementing the element with React, Svelte, Vue, etc. We’ve statically placed it on the page with aria-hidden for this test.
It should support for the alertdialog ARIA role for alert situations.
You’ll need to do two things:

First, remove data-a11y-dialog-hide from the overlay in the HTML so that it is <div class="dialog-overlay"></div>. Replace the dialog role with alertdialog so that it becomes:

<div class="dialog-container" id="my-dialog" aria-hidden="true" aria-labelledby="my-dialog-title" aria-describedby="my-dialog-description" role="alertdialog">

Now, clicking on the overlay outside of the dialog box does not close the dialog, as expected.

It should prevent the underlying body from scrolling, if needed.
I didn’t manually test but this, but it is clearly available per the documentation.
It should avoid the common pitfalls that come with the native <dialog> element.
This component does not rely on the native <dialog> which means we’re good here.

Next, let’s use some a11y tooling

I used Lighthouse to test the component both on a desktop computer and a mobile device, in two different scenarios where the dialog is open by default, and closed by default.

a11y-dialog Lighthouse testing, score 100.

I’ve found that sometimes the tooling doesn’t account for DOM elements that are dynamically shown or hidden DOM elements, so this test ensures I’m getting full coverage of both scenarios.

I also tested with IBM Equal Access Accessibility Checker. Generally, this tool will give you a red violation error if there’s anything egregious wrong. It will also ask you to manually review certain items. As seen here, there a couple of items for manual review, but no red violations.

a11y-dialog — tested with IBM Equal Access Accessibility Checker

Moving on to screen readers

Between my manual and tooling checks, I’m already feeling relatively confident that a11y-dialog is an accessible option for my dialog of choice. However, we ought to do our due diligence and consult a screen reader.

VoiceOver is the most convenient screen reader for me since I work on a Mac at the moment, but JAWS and NVDA are big names to look at as well. Like checking for UI consistency across browsers, it’s probably a good idea to test on more than one screen reader if you can.

VoiceOver caption over the a11y-modal example.

Here’s how I conducted the screen reader part of the audit with VoiceOver. Basically, I mapped out what actions needed testing and confirmed each one, like a script:

Step Result
The dialog component’s trigger button is announced. “Entering A11y Dialog Test, web content.”
The dialog should open when pressing CTRL+ALT +Space should show the dialog. “Dialog. Some description of what’s inside this dialog. You are currently on a dialog, inside of web content.”
The dialog should TAB to and put focus on the component’s Close button. “Close this dialog button. You are currently on a button, inside of web content.”
Tab to the link element and confirm it is announced. “Link, Rando Yahoo Link”
Pressing the SPACE key while focused on the Close button should close the dialog component and return to the last item in focus.

Testing with people

If you’re thinking we’re about to move on to testing with real people, I was unfortunately unable to find someone. If I had done this, though, I would have used a similar set of steps for them to run through while I observe, take notes, and ask a few questions about the general experience.

As you can see, a satisfactory audit involves a good deal of time and thought.

Fine, but I want to use a framework’s dialog component

That’s cool! Many frameworks have their own dialog component solution, so there’s lots to choose from. I don’t have some amazing spreadsheet audit of all the frameworks and libraries in the wild, and will spare you the work of evaluating them all.

Instead, here are some resources that might be good starting points and considerations for using a dialog component in some of the most widely used frameworks.

Disclaimer: I have not tested these personally. This is all stuff I found while researching.

Angular dialog options

In 2020, Deque published an article that audits Angular component libraries and the TL;DR was that Material (and its Angular/CDK library) and ngx-bootstrap both appear to provide decent dialog accessibility.

React dialog options

Reakit offers a dialog component that they claim is compliant with WAI-ARIA dialog guidelines, and chakra-ui appears to pay attention to its accessibility. Of course, Material is also available for React, so that’s worth a look as well. I’ve also heard good things about reach/dialog and Adobe’s @react-aria/dialog.

Vue dialog options

I’m a fan of Vuetensils, which is Austin Gil’s naked (aka headless) components library, which just so happens to have a dialog component. There’s also Vuetify, which is a popular Material implementation with a dialog of its own. I’ve also crossed paths with PrimeVue, but was surprised that its dialog component failed to return focus to the original element.

Svelte dialog options

You might want to look at svelte-headlessui. Material has a port in svelterial that is also worth a look. It seems that many current SvelteKit users prefer to build their own component sets as SvelteKit’s packaging idiom makes it super simple to do. If this is you, I would definitely recommend considering svelte-a11y-dialog as a convenient means to build custom dialogs, drawers, bottom sheets, etc.

I’ll also point out that my AgnosticUI library wraps the React, Vue, Svelte and Angular a11y-dialog adapter implementations we’ve been talking about earlier.

Bootstrap, of course

Bootstrap is still something many folks reach for, and unsurprisingly, it offers a dialog component. It requires you to follow some steps in order to make the modal accessible.

If you have other inclusive and accessible library-based dialog components that merit consideration, I’d love to know about them in the comments!

But I’m creating a custom design system

If you’re creating a design system or considering some other roll-your-own dialog approach, you can see just how many things need to be tested and taken into consideration… all for one component! It’s certainly doable to roll your own, of course, but I’d say it’s also extremely prone to error. You might ask yourself whether the effort is worthwhile when there are already battle-tested options to choose from.

I’ll simply leave you with something Scott O’Hara — co-editor of ARIA in HTML and HTML AAM specifications in addition to just being super helpful with all things accessibility — points out:

You could put in the effort to add in those extensions, or you could use a robust plugin like a11y-dialog and ensure that your dialogs will have a pretty consistent experience across all browsers.

Back to my objective…

I need that dialog to support React, Vue, Svelte, and Angular implementations.

I mentioned earlier that a11y-dialog already has ports for Vue and React. But the Vue port hasn’t yet been updated for Vue 3. Well, I was quite happy to spend the time I would have spent creating what likely would have been a buggy hand-rolled dialog component toward helping update the Vue port. I also added a Svelte port and one for Angular too. These are both very new and I would consider them experimental beta software at time of writing. Feedback welcome, of course!

It can support other components, too!

I think it’s worth pointing out that a dialog uses the same underlying concept for hiding and showing that can be used for a drawer (aka off-canvas) component. For example, if we borrow the CSS we used in our dialog accessibility audit and add a few additional classes, then a11y-dialog can be transformed into a working and effective drawer component:

.drawer-start { right: initial; } .drawer-end { left: initial; } .drawer-top { bottom: initial; } .drawer-bottom { top: initial; }  .drawer-content {   margin: initial;   max-width: initial;   width: 25rem;   border-radius: initial; }  .drawer-top .drawer-content, .drawer-bottom .drawer-content {   width: 100%; }

These classes are used in an additive manner, essentially extending the base dialog component. This is exactly what I have started to do as I add my own drawer component to AgnosticUI. Saving time and reusing code FTW!

Wrapping up

Hopefully I’ve given you a good idea of the thinking process that goes into the making and maintenance of a component library. Could I have hand-rolled my own dialog component for the library? Absolutely! But I doubt it would have yielded better results than what a resource like Kitty’s a11y-dialog does, and the effort is daunting. There’s something cool about coming up with your own solution — and there may be good situations where you want to do that — but probably not at the cost of sacrificing something like accessibility.

Anyway, that’s how I arrived at my decision. I learned a lot about the native HTML <dialog> and its accessibility along the way, and I hope my journey gave you some of those nuggets too.


Dialog Components: Go Native HTML or Roll Your Own? originally published on CSS-Tricks. You should get the newsletter.

CSS-Tricks

, , , ,

Replace JavaScript Dialogs With the New HTML Dialog Element

You know how there are JavaScript dialogs for alerting, confirming, and prompting user actions? Say you want to replace JavaScript dialogs with the new HTML dialog element.

Let me explain.

I recently worked on a project with a lot of API calls and user feedback gathered with JavaScript dialogs. While I was waiting for another developer to code the <Modal /> component, I used alert(), confirm() and prompt() in my code. For instance:

const deleteLocation = confirm('Delete location'); if (deleteLocation) {   alert('Location deleted'); }

Then it hit me: you get a lot of modal-related features for free with alert(), confirm(), and prompt() that often go overlooked:

  • It’s a true modal. As in, it will always be on top of the stack — even on top of that <div> with z-index: 99999;.
  • It’s accessible with the keyboard. Press Enter to accept and Escape to cancel.
  • It’s screen reader-friendly. It moves focus and allows the modal content to be read aloud.
  • It traps focus. Pressing Tab will not reach any focusable elements on the main page, but in Firefox and Safari it does indeed move focus to the browser UI. What’s weird though is that you can’t move focus to the “accept” or “cancel” buttons in any browser using the Tab key.
  • It supports user preferences. We get automatic light and dark mode support right out of the box.
  • It pauses code-execution., Plus, it waits for user input.

These three JavaScripts methods work 99% of the time when I need any of these functionalities. So why don’t I — or really any other web developer — use them? Probably because they look like system errors that cannot be styled. Another big consideration: there has been movement toward their deprecation. First removal from cross-domain iframes and, word is, from the web platform entirely, although it also sounds like plans for that are on hold.

With that big consideration in mind, what are alert(), confirm() and prompt() alternatives do we have to replace them? You may have already heard about the <dialog> HTML element and that’s what I want to look at in this article, using it alongside a JavaScript class.

It’s impossible to completely replace Javascript dialogs with identical functionality, but if we use the showModal() method of <dialog> combined with a Promise that can either resolve (accept) or reject (cancel) — then we have something almost as good. Heck, while we’re at it, let’s add sound to the HTML dialog element — just like real system dialogs!

If you’d like to see the demo right away, it’s here.

A dialog class

First, we need a basic JavaScript Class with a settings object that will be merged with the default settings. These settings will be used for all dialogs, unless you overwrite them when invoking them (but more on that later).

export default class Dialog { constructor(settings = {}) {   this.settings = Object.assign(     {       /* DEFAULT SETTINGS - see description below */     },     settings   )   this.init() }

The settings are:

  • accept: This is the “Accept” button’s label.
  • bodyClass: This is a CSS class that is added to <body> element when the dialog is open and <dialog> is unsupported by the browser.
  • cancel: This is the “Cancel” button’s label.
  • dialogClass: This is a custom CSS class added to the <dialog> element.
  • message: This is the content inside the <dialog>.
  • soundAccept: This is the URL to the sound file we’ll play when the user hits the “Accept” button.
  • soundOpen: This is the URL to the sound file we’ll play when the user opens the dialog.
  • template: This is an optional, little HTML template that’s injected into the <dialog>.

The initial template to replace JavaScript dialogs

In the init method, we’ll add a helper function for detecting support for the HTML dialog element in browsers, and set up the basic HTML:

init() {   // Testing for <dialog> support   this.dialogSupported = typeof HTMLDialogElement === 'function'   this.dialog = document.createElement('dialog')   this.dialog.dataset.component = this.dialogSupported ? 'dialog' : 'no-dialog'   this.dialog.role = 'dialog'      // HTML template   this.dialog.innerHTML = `   <form method="dialog" data-ref="form">     <fieldset data-ref="fieldset" role="document">       <legend data-ref="message" id="$ {(Math.round(Date.now())).toString(36)}">       </legend>       <div data-ref="template"></div>     </fieldset>     <menu>       <button data-ref="cancel" value="cancel"></button>       <button data-ref="accept" value="default"></button>     </menu>     <audio data-ref="soundAccept"></audio>     <audio data-ref="soundOpen"></audio>   </form>`    document.body.appendChild(this.dialog)    // ... }

Checking for support

The road for browsers to support <dialog> has been long. Safari picked it up pretty recently. Firefox even more recently, though not the <form method="dialog"> part. So, we need to add type="button" to the “Accept” and “Cancel” buttons we’re mimicking. Otherwise, they’ll POST the form and cause a page refresh and we want to avoid that.

<button$ {this.dialogSupported ? '' : ` type="button"`}...></button>

DOM node references

Did you notice all the data-ref-attributes? We’ll use these for getting references to the DOM nodes:

this.elements = {} this.dialog.querySelectorAll('[data-ref]').forEach(el => this.elements[el.dataset.ref] = el)

So far, this.elements.accept is a reference to the “Accept” button, and this.elements.cancel refers to the “Cancel” button.

Button attributes

For screen readers, we need an aria-labelledby attribute pointing to the ID of the tag that describes the dialog — that’s the <legend> tag and it will contain the message.

this.dialog.setAttribute('aria-labelledby', this.elements.message.id)

That id? It’s a unique reference to this part of the <legend> element:

this.dialog.setAttribute('aria-labelledby', this.elements.message.id)

The “Cancel” button

Good news! The HTML dialog element has a built-in cancel() method making it easier to replace JavaScript dialogs calling the confirm() method. Let’s emit that event when we click the “Cancel” button:

this.elements.cancel.addEventListener('click', () => {    this.dialog.dispatchEvent(new Event('cancel'))  })

That’s the framework for our <dialog> to replace alert(), confirm(), and prompt().

Polyfilling unsupported browsers

We need to hide the HTML dialog element for browsers that do not support it. To do that, we’ll wrap the logic for showing and hiding the dialog in a new method, toggle():

toggle(open = false) {   if (this.dialogSupported && open) this.dialog.showModal()   if (!this.dialogSupported) {     document.body.classList.toggle(this.settings.bodyClass, open)     this.dialog.hidden = !open     /* If a `target` exists, set focus on it when closing */     if (this.elements.target && !open) {       this.elements.target.focus()     }   } } /* Then call it at the end of `init`: */ this.toggle()

Keyboard navigation

Next up, let’s implement a way to trap focus so that the user can tab between the buttons in the dialog without inadvertently exiting the dialog. There are many ways to do this. I like the CSS way, but unfortunately, it’s unreliable. Instead, let’s grab all focusable elements from the dialog as a NodeList and store it in this.focusable:

getFocusable() {   return [...this.dialog.querySelectorAll('button,[href],select,textarea,input:not([type=&quot;hidden&quot;]),[tabindex]:not([tabindex=&quot;-1&quot;])')] }

Next, we’ll add a keydown event listener, handling all our keyboard navigation logic:

this.dialog.addEventListener('keydown', e => {   if (e.key === 'Enter') {     if (!this.dialogSupported) e.preventDefault()     this.elements.accept.dispatchEvent(new Event('click'))   }   if (e.key === 'Escape') this.dialog.dispatchEvent(new Event('cancel'))   if (e.key === 'Tab') {     e.preventDefault()     const len =  this.focusable.length - 1;     let index = this.focusable.indexOf(e.target);     index = e.shiftKey ? index-1 : index+1;     if (index < 0) index = len;     if (index > len) index = 0;     this.focusable[index].focus();   } })

For Enter, we need to prevent the <form> from submitting in browsers where the <dialog> element is unsupported. Escape will emit a cancel event. Pressing the Tab key will find the current element in the node list of focusable elements, this.focusable, and set focus on the next item (or the previous one if you hold down the Shift key at the same time).

Displaying the <dialog>

Now let’s show the dialog! For this, we need a small method that merges an optional settings object with the default values. In this object — exactly like the default settings object — we can add or change the settings for a specific dialog.

open(settings = {}) {   const dialog = Object.assign({}, this.settings, settings)   this.dialog.className = dialog.dialogClass || ''    /* set innerText of the elements */   this.elements.accept.innerText = dialog.accept   this.elements.cancel.innerText = dialog.cancel   this.elements.cancel.hidden = dialog.cancel === ''   this.elements.message.innerText = dialog.message    /* If sounds exists, update `src` */   this.elements.soundAccept.src = dialog.soundAccept || ''   this.elements.soundOpen.src = dialog.soundOpen || ''    /* A target can be added (from the element invoking the dialog */   this.elements.target = dialog.target || ''    /* Optional HTML for custom dialogs */   this.elements.template.innerHTML = dialog.template || ''    /* Grab focusable elements */   this.focusable = this.getFocusable()   this.hasFormData = this.elements.fieldset.elements.length > 0   if (dialog.soundOpen) {     this.elements.soundOpen.play()   }   this.toggle(true)   if (this.hasFormData) {     /* If form elements exist, focus on that first */     this.focusable[0].focus()     this.focusable[0].select()   }   else {     this.elements.accept.focus()   } }

Phew! That was a lot of code. Now we can show the <dialog> element in all browsers. But we still need to mimic the functionality that waits for a user’s input after execution, like the native alert(), confirm(), and prompt() methods. For that, we need a Promise and a new method I’m calling waitForUser():

waitForUser() {   return new Promise(resolve => {     this.dialog.addEventListener('cancel', () => {        this.toggle()       resolve(false)     }, { once: true })     this.elements.accept.addEventListener('click', () => {       let value = this.hasFormData ?          this.collectFormData(new FormData(this.elements.form)) : true;       if (this.elements.soundAccept.src) this.elements.soundAccept.play()       this.toggle()       resolve(value)     }, { once: true })   }) }

This method returns a Promise. Within that, we add event listeners for “cancel” and “accept” that either resolve false (cancel), or true (accept). If formData exists (for custom dialogs or prompt), these will be collected with a helper method, then returned in an object:

collectFormData(formData) {   const object = {};   formData.forEach((value, key) => {     if (!Reflect.has(object, key)) {       object[key] = value       return     }     if (!Array.isArray(object[key])) {       object[key] = [object[key]]     }     object[key].push(value)   })   return object }

We can remove the event listeners immediately, using { once: true }.

To keep it simple, I don’t use reject() but rather simply resolve false.

Hiding the <dialog>

Earlier on, we added event listeners for the built-in cancel event. We call this event when the user clicks the “cancel” button or presses the Escape key. The cancel event removes the open attribute on the <dialog>, thus hiding it.

Where to :focus?

In our open() method, we focus on either the first focusable form field or the “Accept” button:

if (this.hasFormData) {   this.focusable[0].focus()   this.focusable[0].select() } else {   this.elements.accept.focus() }

But is this correct? In the W3’s “Modal Dialog” example, this is indeed the case. In Scott Ohara’s example though, the focus is on the dialog itself — which makes sense if the screen reader should read the text we defined in the aria-labelledby attribute earlier. I’m not sure which is correct or best, but if we want to use Scott’s method. we need to add a tabindex="-1" to the <dialog> in our init method:

this.dialog.tabIndex = -1

Then, in the open() method, we’ll replace the focus code with this:

this.dialog.focus()

We can check the activeElement (the element that has focus) at any given time in DevTools by clicking the “eye” icon and typing document.activeElement in the console. Try tabbing around to see it update:

Showing the eye icon in DevTools, highlighted in bright green.
Clicking the “eye” icon

Adding alert, confirm, and prompt

We’re finally ready to add alert(), confirm() and prompt() to our Dialog class. These will be small helper methods that replace JavaScript dialogs and the original syntax of those methods. All of them call the open()method we created earlier, but with a settings object that matches the way we trigger the original methods.

Let’s compare with the original syntax.

alert() is normally triggered like this:

window.alert(message);

In our Dialog, we’ll add an alert() method that’ll mimic this:

/* dialog.alert() */ alert(message, config = { target: event.target }) {   const settings = Object.assign({}, config, { cancel: '', message, template: '' })   this.open(settings)   return this.waitForUser() }

We set cancel and template to empty strings, so that — even if we had set default values earlier — these will not be hidden, and only message and accept are shown.

confirm() is normally triggered like this:

window.confirm(message);

In our version, similar to alert(), we create a custom method that shows the message, cancel and accept items:

/* dialog.confirm() */ confirm(message, config = { target: event.target }) {   const settings = Object.assign({}, config, { message, template: '' })   this.open(settings)   return this.waitForUser() }

prompt() is normally triggered like this:

window.prompt(message, default);

Here, we need to add a template with an <input> that we’ll wrap in a <label>:

/* dialog.prompt() */ prompt(message, value, config = { target: event.target }) {   const template = `   <label aria-label="$ {message}">     <input name="prompt" value="$ {value}">   </label>`   const settings = Object.assign({}, config, { message, template })   this.open(settings)   return this.waitForUser() }

{ target: event.target } is a reference to the DOM element that calls the method. We’ll use that to refocus on that element when we close the <dialog>, returning the user to where they were before the dialog was fired.

We ought to test this

It’s time to test and make sure everything is working as expected. Let’s create a new HTML file, import the class, and create an instance:

<script type="module">   import Dialog from './dialog.js';   const dialog = new Dialog(); </script>

Try out the following use cases one at a time!

/* alert */ dialog.alert('Please refresh your browser') /* or */ dialog.alert('Please refresh your browser').then((res) => {  console.log(res) })  /* confirm */ dialog.confirm('Do you want to continue?').then((res) => { console.log(res) })  /* prompt */ dialog.prompt('The meaning of life?', 42).then((res) => { console.log(res) })

Then watch the console as you click “Accept” or “Cancel.” Try again while pressing the Escape or Enter keys instead.

Async/Await

We can also use the async/await way of doing this. We’re replacing JavaScript dialogs even more by mimicking the original syntax, but it requires the wrapping function to be async, while the code within requires the await keyword:

document.getElementById('promptButton').addEventListener('click', async (e) => {   const value = await dialog.prompt('The meaning of life?', 42);   console.log(value); });

Cross-browser styling

We now have a fully-functional cross-browser and screen reader-friendly HTML dialog element that replaces JavaScript dialogs! We’ve covered a lot. But the styling could use a lot of love. Let’s utilize the existing data-component and data-ref-attributes to add cross-browser styling — no need for additional classes or other attributes!

We’ll use the CSS :where pseudo-selector to keep our default styles free from specificity:

:where([data-component*="dialog"] *) {     box-sizing: border-box;   outline-color: var(--dlg-outline-c, hsl(218, 79.19%, 35%)) } :where([data-component*="dialog"]) {   --dlg-gap: 1em;   background: var(--dlg-bg, #fff);   border: var(--dlg-b, 0);   border-radius: var(--dlg-bdrs, 0.25em);   box-shadow: var(--dlg-bxsh, 0px 25px 50px -12px rgba(0, 0, 0, 0.25));   font-family:var(--dlg-ff, ui-sansserif, system-ui, sans-serif);   min-inline-size: var(--dlg-mis, auto);   padding: var(--dlg-p, var(--dlg-gap));   width: var(--dlg-w, fit-content); } :where([data-component="no-dialog"]:not([hidden])) {   display: block;   inset-block-start: var(--dlg-gap);   inset-inline-start: 50%;   position: fixed;   transform: translateX(-50%); } :where([data-component*="dialog"] menu) {   display: flex;   gap: calc(var(--dlg-gap) / 2);   justify-content: var(--dlg-menu-jc, flex-end);   margin: 0;   padding: 0; } :where([data-component*="dialog"] menu button) {   background-color: var(--dlg-button-bgc);   border: 0;   border-radius: var(--dlg-bdrs, 0.25em);   color: var(--dlg-button-c);   font-size: var(--dlg-button-fz, 0.8em);   padding: var(--dlg-button-p, 0.65em 1.5em); } :where([data-component*="dialog"] [data-ref="accept"]) {   --dlg-button-bgc: var(--dlg-accept-bgc, hsl(218, 79.19%, 46.08%));   --dlg-button-c: var(--dlg-accept-c, #fff); } :where([data-component*="dialog"] [data-ref="cancel"]) {   --dlg-button-bgc: var(--dlg-cancel-bgc, transparent);   --dlg-button-c: var(--dlg-cancel-c, inherit); } :where([data-component*="dialog"] [data-ref="fieldset"]) {   border: 0;   margin: unset;   padding: unset; } :where([data-component*="dialog"] [data-ref="message"]) {   font-size: var(--dlg-message-fz, 1.25em);   margin-block-end: var(--dlg-gap); } :where([data-component*="dialog"] [data-ref="template"]:not(:empty)) {   margin-block-end: var(--dlg-gap);   width: 100%; }

You can style these as you’d like, of course. Here’s what the above CSS will give you:

Showing how to replace JavaScript dialogs that use the alert method. The modal is white against a gray background. The content reads please refresh your browser and is followed by a blue button with a white label that says OK.
alert()

Showing how to replace JavaScript dialogs that use the confirm method. The modal is white against a gray background. The content reads please do you want to continue? and is followed by a black link that says cancel, and a blue button with a white label that says OK.
confirm()

Showing how to replace JavaScript dialogs that use the prompt method. The modal is white against a gray background. The content reads the meaning of life, and is followed by a a text input filled with the number 42, which is followed by a black link that says cancel, and a blue button with a white label that says OK.
prompt()

To overwrite these styles and use your own, add a class in dialogClass,

dialogClass: 'custom'

…then add the class in CSS, and update the CSS custom property values:

.custom {   --dlg-accept-bgc: hsl(159, 65%, 75%);   --dlg-accept-c: #000;   /* etc. */ }

A custom dialog example

What if the standard alert(), confirm() and prompt() methods we are mimicking won’t do the trick for your specific use case? We can actually do a bit more to make the <dialog> more flexible to cover more than the content, buttons, and functionality we’ve covered so far — and it’s not much more work.

Earlier, I teased the idea of adding a sound to the dialog. Let’s do that.

You can use the template property of the settings object to inject more HTML. Here’s a custom example, invoked from a <button> with id="btnCustom" that triggers a fun little sound from an MP3 file:

document.getElementById('btnCustom').addEventListener('click', (e) => {   dialog.open({     accept: 'Sign in',     dialogClass: 'custom',     message: 'Please enter your credentials',     soundAccept: 'https://assets.yourdomain.com/accept.mp3',     soundOpen: 'https://assets.yourdomain.com/open.mp3',     target: e.target,     template: `     <label>Username<input type="text" name="username" value="admin"></label>     <label>Password<input type="password" name="password" value="password"></label>`   })   dialog.waitForUser().then((res) => {  console.log(res) }) });

Live demo

Here’s a Pen with everything we built! Open the console, click the buttons, and play around with the dialogs, clicking the buttons and using the keyboard to accept and cancel.

So, what do you think? Is this a good way to replace JavaScript dialogs with the newer HTML dialog element? Or have you tried doing it another way? Let me know in the comments!


Replace JavaScript Dialogs With the New HTML Dialog Element originally published on CSS-Tricks. You should get the newsletter and become a supporter.

CSS-Tricks

, , , , ,
[Top]

How to Implement and Style the Dialog Element

A look from Christian Kozalla on the <dialog> HTML element and using it to create a nice-looking and accessible modal.

I’m attracted to the <dialog> element as it’s one of those “you get a lot for free” elements (even more so than the beloved <details> element) and it’s so easy to get modal accessibility wrong (e.g. focus trapping) that having this sort of thing provided by a native element seems… great. ::backdrop is especially cool.

But is it too good to be true? Solid support isn’t there yet, with Safari not picking it up. Christian mentions the polyfill from Google, which definitely helps bring basic functionality to otherwise non-supporting browsers.

The main problem is actual testing on a screen reader. Scott O’Hara has an article, “Having an open dialog,” which has been updated as recently as this very month (October 2021), in which he ultimately says, “[…] the dialog element and its polyfill are not suitable for use in production.” I don’t doubt Scott’s testing, but because most people just roll-their-own modal experiences paying little mind to accessibility at all, I wonder if the web would be better off if more people just used <dialog> (and the polyfill) anyway. Higher usage would likely trigger more browser attention and improvements.


The post How to Implement and Style the Dialog Element appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , ,
[Top]

Some Hands-On with the HTML Dialog Element

This is me looking at the HTML <dialog> element for the first time. I’ve been aware of it for a while, but haven’t taken it for a spin yet. It has some pretty cool and compelling features. I can’t decide for you if you should use it in production on your sites, but I’d think it’s starting to be possible.

It’s not just a semantic element, it has APIs and special CSS.

We’ll get to that stuff in a moment, but it’s notable because it makes the browser support stuff significant.

When we first got HTML5 elements like <article>, it pretty much didn’t matter if the browser supported it or not because nothing was worse-off in those scenarios if you used it. You could make it block-level and it was just like a meaningless div you would have used anyway.

That said, I wouldn’t just use <dialog> as a “more semantic <div> replacement.” It’s got too much functionality for that.

Let’s do the browser support thing.

As I write:

  • Chrome’s got it (37+), so Edge is about to get it.
  • Firefox has the User-Agent (UA) styles in place (69+), but the functionality is behind a dom.dialog_element.enabled flag. Even with the flag, it doesn’t look like we get the CSS stuff yet.
  • No support from Safari.

It’s polyfillable.

It’s certainly more compelling to use features with a better support than this, but I’d say it’s close and it might just cross the line if you’re the polyfilling type anyway.

On to the juicy stuff.

A dialog is either open or not.

<dialog>   I'm closed. </dialog>  <dialog open>   I'm open. </dialog>

There is some UA styling to them.

It seems to match in Chrome and Firefox. The UA stylesheet has it centered with auto margins, thick black lines, sized to content.

See the Pen
Basic Open Dialog
by Chris Coyier (@chriscoyier)
on CodePen.

Like any UA styles, you’ll almost surely override them with your own fancy dialog styles — shadows and typography and whatever else matches your site’s style.

There is a JavaScript API for opening and closing them.

Say you have a reference to the element named dialog:

dialog.show(); dialog.hide();

See the Pen
Basic Togglable Dialog
by Chris Coyier (@chriscoyier)
on CodePen.

You should probably use this more explicit command though:

dialog.showModal();

That’s what makes the backdrop work (and we’ll get to that soon). I’m not sure I quite grok it, but the the spec talks about a “pending dialog stack” and this API will open the modal pending that stack. Here’s a modal that can open a second stacking modal:

See the Pen
Basic Open Dialog
by Chris Coyier (@chriscoyier)
on CodePen.

There’s also an HTML-based way close them.

If you put a special kind of form in there, submitting the form will close the modal.

<form method="dialog">   <button>Close</button> </form>

See the Pen
Dialog with Form that Closes Dialog
by Chris Coyier (@chriscoyier)
on CodePen.

Notice that if you programmatically open the dialog, you get a backdrop cover.

This has always been one of the more finicky things about building your own dialogs. A common UI pattern is to darken the background behind the dialog to focus attention on the dialog.

We get that for free with <dialog>, assuming you open it via JavaScript. You control the look of it with the ::backdrop pseudo-element. Instead of the low-opacity black default, let’s do red with stripes:

See the Pen
Custom Dialog Backdrop
by Chris Coyier (@chriscoyier)
on CodePen.

Cool bonus: you can use backdrop-filter to do stuff like blur the background.

See the Pen
Native Dialog
by Chris Coyier (@chriscoyier)
on CodePen.

It moves focus for you

I don’t know much about this stuff, but I can fire up VoiceOver on my Mac and see the dialog come into focus see that when I trigger the button that opens the modal.

It doesn’t trap focus there, and I hear that’s ideal for modals. We have a clever idea for that utilizing CSS you could explore.

Rob Dodson said: “modals are actually the boss battle at the end of web accessibility.” Kinda nice that the native browser version helps with a lot of that. You even automatically get the Escape key closing functionality, which is great. There’s no click outside to close, though. Perhaps someday pending user feedback.

Ire’s article is a go-to resource for building your own dialogs and a ton of accessibility considerations when using them.

The post Some Hands-On with the HTML Dialog Element appeared first on CSS-Tricks.

CSS-Tricks

, , , ,
[Top]