{{ t "page.keyboard_shortcuts.subtitle.sections" }}
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(); + } } }