Building accessible-app.com: Modal and non-modal dialogs (and Vue)
After having established a briefing document in the last post and after having decided which framework to tackle first (VueJS), now it's time to start with the first important component of Accessible App: dialogs and modals.
This is part 6 of an article series on building "accessible-app.com".
Read the other posts:
At first, let's take a step back and do some explanation of terms: A dialog box is a piece of UI that asks or requests the user for a response. This process of asking for the users' attention is non-obstrusive, they could ignore the request altogether. As Brian Dys puts it on Quora:
The option to cancel the action is the same as ignoring the Dialog. And that makes it a Non-modal Dialog.
A modal dialog, on the other hand, requires a users' response and makes it mandatory to interact with it. Focus, both literally and proverbially, can't be moved away from the modal. That makes "modal" a type of dialog and also an - something is a modal (meaning: a modal dialog), or something is modal (meaning: blocking). Content "behind" a modal dialog is inert, which means de-activated for any interaction. Users that are presented with an open modal dialog must not be able to interact with inert content. Very often you'll see an half-transparent overlay on inert content
Strategies for building a (modal) dialog
As always, WAI-ARIA's Authoring Practices are a great resource on how to implement this abstract explanation into tangible code.
- At first, it is advised to distribute the role of either
dialog(in case of non-modal dialogs) or
alertdialogto the element that contains all elements of the dialog
- After that, you have to make sure that the keyboard focus is trapped within the modal dialog
- You have to supply at least two options to close the modal. One being a visible close button, the other being the ESC key. Often times, a click on a modal overlay (the one that is more or less hiding, sometimes darkens inert content) closes it as well.
- Up until now, the (modal) dialog has no accessible name. We can solve that by using either
aria-labelledbyand referring to a present headline that will label the dialog, or
aria-label. Read more about both strategies at Accessibility Developer Guide.
- Finally, once the modal closes, keyboard focus must return to the triggering button.
Right now, we still lack a strategy to inform users of, e.g. screen readers, that once a modal is open, interaction and access should be limited to its content and interactions alone, and that navigating outside of the modal is prohibited. ARIA 1.1 has a solution for that - placing
aria-modal="true" on the dialog container. The theory is that this won't allow access to inert content. In reality, there is a Webkit bug (affecting Voice Over on iOS and Mac which is preventing that using aria-modal suffices and becomes best practice.) So, for now, we have to rely on another strategy.
That strategy was actually a recommendation in ARIA 1.0, and it goes like this:
- At first, once a modal is open, set
aria-hidden="true"on the "background", meaning: The part of the site that you want to render inaccessible or inert
- You have to make sure that your modal dialog element is not a descendant of said inert background, because once you apply
aria-hidden="true"on an element, all of its content and children will be removed from the accessibility tree. The catch is: You can't markup exceptions from this, for example placing an element with
aria-hidden="false"within it and expect it to somehow override the parents removal from the tree - unfortunalety, that is not how it works.
Which leads us to the required general markup (at least, for now):
<div aria-hidden="true" inert> <main role="main"> <p>content</p> </main> </div> <div aria-hidden="false" role="alertdialog" aria-labelledby="myModal-title"> <h1 id="myModal-title">Supermodal!</h1> ... </div> <!-- In short: Inert content and modals are siblings, not nested! -->
Now, how do we solve this in Vue (and actually, in React as well)? Actually I gave the answer to this in the last blog post: Portals. These aptly named constructs give us the possibility to render our components anywhere in the DOM. And this freedom lets us markup the overall app structure exactly like mentioned above. I considered this already in the "Schema" part of Accessible Apps brief.
In the case of Vue, the tool of choice is PortalVue. It works like this:
<portal to="destination"> <p>This slot content will be rendered wherever the <portal-target> with name 'destination' is located. </p> </portal> <portal-target name="destination"> <!-- This component can be located anwhere in your App. The slot content of the above portal component will be rendered here. --> </portal-target>
This means that we can use a yet-to-be-selected dialog element in any component we want (let's say: ProductListing from our schema) and we can still place it "outside" (or better: neighboring) the content container. More or less like this:
<some-wrapper> <product-listing> ... <portal to="modals"> <fancy-dialog-thingy>Hello!</fancy-dialog-thingy> </portal> </product-listing> <some-wrapper> <portal-target name="modals"> <!-- <fancy-dialog-thingy /> will be rendered here! --> </portal-target>
Choosing the right component
Now that our app's structure adheres to the current recommendation of building accessible modal dialogs, we can put the spotlight on the modal component itself. As you read below it has to comply to WAI-ARIA's Authoring Practices in terms of:
✔︎ Leveraging the native
✔︎ Closing dialog on overlay click and ESC
✔︎ Toggling aria-* attributes
✔︎ Trapping and restoring focus
Probably you now the list mentioned above. It comes from the readme of Hugo Giraudel's a11y-dialog script, which takes care of all these things, and more. And luckily implementations for React and Vue do exist.
All together now
This leads us to following basic app structure:
<template> <div id="app"> <div id="wrapper"> <router-view/> </div> <portal-target name="dialog"> </portal-target> </div> </template>
Let's assume we were using routing (with focus management) and the component
<ProductListing /> is a route target. ProductListing harbours a button component, that in turn opens a modal dialog - and that dialog is part of ProductListing and uses modals. By doing this, we can place different modal dialogs in the established place in the DOM.
One thing to notice: When you look into the examples provided in the readme of vue-ally-dialog you'll stumble over the
<a11y-dialog app-root="#app"> <!-- ... --> </a11y-dialog> <!-- Description: The selector(s) a11y-dialog needs to disable when the dialog is open. -->
#app is actually a very bad example (
and I will open an issue or PR soon regarding this done), because disabling (which means: setting
<div id="app" /> would remove the whole Vue App from the accessibility tree, given your app lives in a div with the id of "app". Remember, you cannot stop the "downward inheritance" of
aria-hidden="true". A better setting for app-root would be, at least in our scenario,
#wrapper. Therefore, the
app-root property should better be named
inert-element, or similar.
Accessible modals with React
In the last paragraph I mentioned that there's a React implementation of "a11y dialog", but it is even better: There is a Reach UI component for modal dialogs available. And if you open your browser's developer tools you will see that Reach's modal dialog implementation works architecturally the same way as the Vue construct I sketched above - rendering the main wrapper inert with
aria-hidden="true", placing the modal in a portal outside of it, giving it necessary roles and focus traps, and labelling it property.
This way of building accessible-app.com is actually quite fascinating: At first, you refer to ARIA Authoring practices, then you look into other framework's solutions built my seasoned accessibility experts (like the Reach project by @ryanflorence, and then you try to apply everything you have learned so far in Vue. And in a further step, you reflect on what you have learned by writing a blog post about it.