diff options
author | 2023-07-02 23:28:02 +0300 | |
---|---|---|
committer | 2023-07-07 15:44:44 -0700 | |
commit | 29a06511a9af8639da3134c4703a4b993b7534eb (patch) | |
tree | d3dd973e867160cffc5e6d18188cd5e20504d9b9 | |
parent | bfb4fc1c3638b25837bf189e6c45d3cea3df8d73 (diff) | |
download | v2-29a06511a9af8639da3134c4703a4b993b7534eb.tar.gz v2-29a06511a9af8639da3134c4703a4b993b7534eb.tar.zst v2-29a06511a9af8639da3134c4703a4b993b7534eb.zip |
Fix accessibility issues in modal component
* Fix modal aria role
* Trap focusing with tab / shift+tab inside the modal
* Restore keyboard focus when closing modal
* Automatically move keyboard focus to first focusable element unless specified otherwise
* Keyboard shortcut help modal: move keyboard focus to modal title
* Keyboard shortcut help modal: change close control from link to button
-rw-r--r-- | template/templates/common/layout.html | 4 | ||||
-rw-r--r-- | ui/static/js/app.js | 2 | ||||
-rw-r--r-- | ui/static/js/modal_handler.js | 77 |
3 files changed, 77 insertions, 6 deletions
diff --git a/template/templates/common/layout.html b/template/templates/common/layout.html index c63577a0..9ad15a6d 100644 --- a/template/templates/common/layout.html +++ b/template/templates/common/layout.html @@ -116,8 +116,8 @@ </main> <template id="keyboard-shortcuts"> <div id="modal-left"> - <a href="#" class="btn-close-modal">x</a> - <h3>{{ t "page.keyboard_shortcuts.title" }}</h3> + <button class="btn-close-modal" aria-label="Close">x</button> + <h3 tabindex="-1" id="dialog-title">{{ t "page.keyboard_shortcuts.title" }}</h3> <div class="keyboard-shortcuts"> <p>{{ t "page.keyboard_shortcuts.subtitle.sections" }}</p> diff --git a/ui/static/js/app.js b/ui/static/js/app.js index 308a22b7..e75c2ddf 100644 --- a/ui/static/js/app.js +++ b/ui/static/js/app.js @@ -94,7 +94,7 @@ function setFocusToSearchInput(event) { function showKeyboardShortcuts() { let template = document.getElementById("keyboard-shortcuts"); if (template !== null) { - ModalHandler.open(template.content); + ModalHandler.open(template.content, "dialog-title"); } } diff --git a/ui/static/js/modal_handler.js b/ui/static/js/modal_handler.js index c0e8b137..d6e6a446 100644 --- a/ui/static/js/modal_handler.js +++ b/ui/static/js/modal_handler.js @@ -3,29 +3,100 @@ class ModalHandler { return document.getElementById("modal-container") !== null; } - static open(fragment) { + static getModalContainer() { + let container = document.getElementById("modal-container"); + + if (container === undefined) { + return; + } + + return container; + } + + static getFocusableElements() { + let container = this.getModalContainer(); + + if (container === undefined) { + return; + } + + return container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + } + + static setupFocusTrap() { + let focusableElements = this.getFocusableElements(); + + if (focusableElements === undefined) { + return; + } + + let firstFocusableElement = focusableElements[0]; + let lastFocusableElement = focusableElements[focusableElements.length - 1]; + + this.getModalContainer().onkeydown = (e) => { + if (e.key !== 'Tab') { + return; + } + + // If there is only one focusable element in the dialog we always want to focus that one with the tab key. + // This handles the special case of having just one focusable element in a dialog where keyboard focus is placed on an element that is not in the tab order. + if (focusableElements.length === 1) { + firstFocusableElement.focus(); + e.preventDefault(); + return; + } + + if (e.shiftKey && document.activeElement === firstFocusableElement) { + lastFocusableElement.focus(); + e.preventDefault(); + } else if (!e.shiftKey && document.activeElement === lastFocusableElement) { + firstFocusableElement.focus(); + e.preventDefault(); + } + } + } + + static open(fragment, initialFocusElementId) { if (ModalHandler.exists()) { return; } + this.activeElement = document.activeElement; + let container = document.createElement("div"); container.id = "modal-container"; + container.setAttribute("role", "dialog"); container.appendChild(document.importNode(fragment, true)); document.body.appendChild(container); - let closeButton = document.querySelector("a.btn-close-modal"); + let closeButton = document.querySelector("button.btn-close-modal"); if (closeButton !== null) { closeButton.onclick = (event) => { event.preventDefault(); ModalHandler.close(); }; } + + let initialFocusElement; + if (initialFocusElementId !== undefined) { + initialFocusElement = document.getElementById(initialFocusElementId); + } else { + initialFocusElement = this.getFocusableElements()[0]; + } + + initialFocusElement.focus(); + + this.setupFocusTrap(); } static close() { - let container = document.getElementById("modal-container"); + let container = this.getModalContainer(); if (container !== null) { container.parentNode.removeChild(container); } + + if (this.activeElement !== undefined) { + this.activeElement.focus(); + } } } |