aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Tuukka Ojala <tuukka.ojala@iki.fi> 2023-07-02 23:28:02 +0300
committerGravatar Frédéric Guillot <f@miniflux.net> 2023-07-07 15:44:44 -0700
commit29a06511a9af8639da3134c4703a4b993b7534eb (patch)
treed3dd973e867160cffc5e6d18188cd5e20504d9b9
parentbfb4fc1c3638b25837bf189e6c45d3cea3df8d73 (diff)
downloadv2-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.html4
-rw-r--r--ui/static/js/app.js2
-rw-r--r--ui/static/js/modal_handler.js77
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();
+ }
}
}