a11y-dialog is a lightweight (1.6Kb) yet flexible script to create accessible dialog windows.
✔︎ No dependencies
✔︎ Leveraging the native <dialog>
element
✔︎ Closing dialog on overlay click and ESC
✔︎ Toggling aria-*
attributes
✔︎ Trapping and restoring focus
✔︎ Firing events
✔︎ DOM and JS APIs
✔︎ Fast and tiny
You can try the live demo ↗.
Installation
npm install a11y-dialog --save
Usage
You will find a concrete demo in the example folder of this repository, but basically here is the gist:
Expected DOM structure
Here is the basic markup, which can be enhanced. Pay extra attention to the comments.
<div id="main">
</div>
<div class="dialog-container" id="my-accessible-dialog" aria-hidden="true">
<div tabindex="-1" data-a11y-dialog-hide></div>
<div role="dialog" aria-labelledby="dialog-title">
<div role="document">
<button
type="button"
data-a11y-dialog-hide
aria-label="Close this dialog window"
>
×
</button>
<h1 id="dialog-title">Dialog Title</h1>
</div>
</div>
</div>
Styling layer
The script itself does not take care of any styling whatsoever, not even the display
property. It basically mostly toggles the aria-hidden
attribute on the dialog itself and its counterpart containers.
In browsers supporting the <dialog>
element, its visibility will be handled by the user-agent itself. Until support gets better across the board, the styling layer is up to the implementor (you).
We recommend using at least the following styles to make everything work on both supporting and non-supporting user-agents:
[data-a11y-dialog-native] > :first-child {
display: none;
}
dialog[open] {
display: block;
}
.dialog-container[aria-hidden='true'] {
display: none;
}
Instantiation
By default, any dialog container having the data-a11y-dialog
attribute will be automatically instantiated. This is so that there is no need for any JavaScript (besides loading the script). The value of the attribute, if given, should be a selector, serving the same purpose as the 2nd attribute of the A11yDialog
constructor (see below).
<div
class="dialog-container"
id="my-accessible-dialog"
aria-hidden="true"
data-a11y-dialog="#root"
>
…
</div>
If automatic loading is not an option because the expected dialog markup is not present in the DOM on script execution (or the dialog instance is needed to do more complicated things), it can be instantiated through JavaScript.
const el = document.getElementById('my-accessible-dialog')
const dialog = new A11yDialog(el)
As recommended in the HTML section of this documentation, the dialog element is supposed to be on the same level as your content container(s). Therefore, the script will toggle the aria-hidden
attribute of the siblings of the dialog element as a default. You can change this behaviour by passing a NodeList
, an Element
or a selector as second argument to the A11yDialog
constructor:
const container = document.querySelector('#root')
const dialog = new A11yDialog(el, container)
DOM API
The DOM API relies on data-*
attributes. They all live under the data-a11y-dialog-*
namespace for consistency, clarity and robustness. Two attributes are recognised:
data-a11y-dialog-show
: the id
of the dialog element is expected as a valuedata-a11y-dialog-hide
: the id
of the dialog element is expected as a value; if omitted, the closest parent dialog element (if any) will be the target
The following button will open the dialog with the my-accessible-dialog
id when interacted with.
<button type="button" data-a11y-dialog-show="my-accessible-dialog">
Open the dialog
</button>
The following button will close the dialog in which it lives when interacted with.
<button type="button" data-a11y-dialog-hide aria-label="Close the dialog">
×
</button>
The following button will close the dialog with the my-accessible-dialog
id when interacted with. Given that the only focusable elements when the dialog is open are the focusable children of the dialog itself, it seems rather unlikely that you will ever need this but in case you do, well you can.
<button
type="button"
data-a11y-dialog-hide="my-accessible-dialog"
aria-label="Close the dialog"
>
×
</button>
In addition, the library adds a data-a11y-dialog-native
attribute (with no value) when the <dialog>
element is natively supported. This attribute is essentially used to customise the styling layer based on user-agent support (or lack thereof).
JS API
Regarding the JS API, it simply consists on show()
and hide()
methods on the dialog instance.
dialog.show()
dialog.hide()
When the <dialog>
element is natively supported, the argument passed to show()
and hide()
is being passed to the native call to showModal()
and close()
. If necessary, the returnValue
can be read using dialog.dialog.returnValue
.
For advanced usages, there are create()
and destroy()
methods. These are responsible for attaching click event listeners to dialog openers and closers. Note that the create()
method is automatically called on instantiation so there is no need to call it again directly.
dialog.destroy()
dialog.create()
If necessary, the create()
method also accepts the targets
containers (the one toggled along with the dialog element) in the same form as the second argument from the constructor. If omitted, the one given to the constructor (or default) will be used.
Advanced
Events
When shown, hidden and destroyed, the instance will emit certain events. It is possible to subscribe to these with the on()
method which will receive the dialog DOM element and the event object (if any).
The event object can be used to know which trigger (opener / closer) has been used in case of a show
or hide
event.
dialog.on('show', function (dialogEl, event) {
})
dialog.on('hide', function (dialogEl, event) {
})
dialog.on('destroy', function (dialogEl) {
})
dialog.on('create', function (dialogEl) {
})
You can unregister these handlers with the off()
method.
dialog.on('show', doSomething)
dialog.off('show', doSomething)
Usage as a “modal”
By default, a11y-dialog behaves as a dialog: it is closable with the ESC key, and by clicking the backdrop. However, it is possible to make it work like a “modal”, which would remove these features.
To do so:
- Replace
role="dialog"
with role="alertdialog"
. This will make sure ESC doesn’t close the modal. Note that this role does not work properly with the native <dialog>
element so make sure to use <div role="alertdialog">
. - Remove
data-a11y-dialog-hide
from the overlay element. This makes sure it is not possible to close the modal by clicking outside of it. - In case the user actively needs to operate with the modal, you might consider removing the close button from it. Be sure to still offer a way to eventually close the modal.
For more information about modals, refer to the WAI ARIA recommendations.
Nested dialogs
Nested dialogs is a questionable design pattern that is not referenced anywhere in the HTML 5.2 Dialog specification. Therefore it is discouraged and not supported by default by the library. That being said, if you still want to run with it, Renato de Leão explains how in issue #80.
Further reading
Known issues
-
It has been reported that the focus restoration to the formerly active element when closing the dialog does not always work properly on iOS. It is unclear what causes this or even if it happens consistently. Refer to issue #102 as a reference.
-
Content with aria-hidden
appears to be sometimes read by VoiceOver on iOS and macOS. It is unclear in which case this happens, and does not appear to be an issue directly related to the library. Refer to this WebKit bug for reference.
Implementations
If you happen to work with React or Vue in your project, you’re lucky! There are already great light-weight wrapper implemented for a11y-dialog:
Disclaimer & credits
Originally, this repository was a fork from accessible-modal-dialog ↗ by Greg Kraus. It has gone through various stages since the initial implementation and both packages are no longer similar in the way they work.