Просмотр исходного кода

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
Tuukka Ojala 3 лет назад
Родитель
Сommit
29a06511a9
3 измененных файлов с 77 добавлено и 6 удалено
  1. 2 2
      template/templates/common/layout.html
  2. 1 1
      ui/static/js/app.js
  3. 74 3
      ui/static/js/modal_handler.js

+ 2 - 2
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>

+ 1 - 1
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");
     }
 }
 

+ 74 - 3
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();
+        }
     }
 }