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
This commit is contained in:
Tuukka Ojala 2023-07-02 23:28:02 +03:00 committed by Frédéric Guillot
parent bfb4fc1c36
commit 29a06511a9
3 changed files with 77 additions and 6 deletions

View file

@ -116,8 +116,8 @@
</main> </main>
<template id="keyboard-shortcuts"> <template id="keyboard-shortcuts">
<div id="modal-left"> <div id="modal-left">
<a href="#" class="btn-close-modal">x</a> <button class="btn-close-modal" aria-label="Close">x</button>
<h3>{{ t "page.keyboard_shortcuts.title" }}</h3> <h3 tabindex="-1" id="dialog-title">{{ t "page.keyboard_shortcuts.title" }}</h3>
<div class="keyboard-shortcuts"> <div class="keyboard-shortcuts">
<p>{{ t "page.keyboard_shortcuts.subtitle.sections" }}</p> <p>{{ t "page.keyboard_shortcuts.subtitle.sections" }}</p>

View file

@ -94,7 +94,7 @@ function setFocusToSearchInput(event) {
function showKeyboardShortcuts() { function showKeyboardShortcuts() {
let template = document.getElementById("keyboard-shortcuts"); let template = document.getElementById("keyboard-shortcuts");
if (template !== null) { if (template !== null) {
ModalHandler.open(template.content); ModalHandler.open(template.content, "dialog-title");
} }
} }

View file

@ -3,29 +3,100 @@ class ModalHandler {
return document.getElementById("modal-container") !== null; 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()) { if (ModalHandler.exists()) {
return; return;
} }
this.activeElement = document.activeElement;
let container = document.createElement("div"); let container = document.createElement("div");
container.id = "modal-container"; container.id = "modal-container";
container.setAttribute("role", "dialog");
container.appendChild(document.importNode(fragment, true)); container.appendChild(document.importNode(fragment, true));
document.body.appendChild(container); document.body.appendChild(container);
let closeButton = document.querySelector("a.btn-close-modal"); let closeButton = document.querySelector("button.btn-close-modal");
if (closeButton !== null) { if (closeButton !== null) {
closeButton.onclick = (event) => { closeButton.onclick = (event) => {
event.preventDefault(); event.preventDefault();
ModalHandler.close(); ModalHandler.close();
}; };
} }
let initialFocusElement;
if (initialFocusElementId !== undefined) {
initialFocusElement = document.getElementById(initialFocusElementId);
} else {
initialFocusElement = this.getFocusableElements()[0];
}
initialFocusElement.focus();
this.setupFocusTrap();
} }
static close() { static close() {
let container = document.getElementById("modal-container"); let container = this.getModalContainer();
if (container !== null) { if (container !== null) {
container.parentNode.removeChild(container); container.parentNode.removeChild(container);
} }
if (this.activeElement !== undefined) {
this.activeElement.focus();
}
} }
} }