From 17442eeb9a13ef71d5b6289ad89689854b69968d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sun, 2 Feb 2025 00:28:17 +0100 Subject: [PATCH] Improve accessibility of modal dialogs (#974) * improve details modal accessibility * improve tag modal accessibility * fix overlays in archive and shared pages * update tests * use buttons for closing dialogs * replace description list * hide preview image from screen readers * update tests --- bookmarks/frontend/behaviors/details-modal.js | 75 +++++------------ bookmarks/frontend/behaviors/focus-utils.js | 59 +++++++++++++ bookmarks/frontend/behaviors/modal.js | 83 +++++++++++++++++++ bookmarks/frontend/behaviors/tag-modal.js | 62 ++++++++------ bookmarks/styles/bookmark-details.css | 11 ++- bookmarks/styles/theme/modals.css | 2 +- bookmarks/templates/bookmarks/archive.html | 18 ++-- .../templates/bookmarks/bookmark_list.html | 7 +- .../templates/bookmarks/details/form.html | 62 +++++++------- .../templates/bookmarks/details/modal.html | 28 +++---- bookmarks/templates/bookmarks/index.html | 18 ++-- bookmarks/templates/bookmarks/layout.html | 4 + bookmarks/templates/bookmarks/shared.html | 18 ++-- ...test_bookmark_archived_view_performance.py | 20 +++-- .../tests/test_bookmark_details_modal.py | 58 ++++++------- .../test_bookmark_index_view_performance.py | 22 +++-- .../test_bookmark_shared_view_performance.py | 22 +++-- .../tests/test_bookmarks_list_template.py | 17 +++- 18 files changed, 369 insertions(+), 217 deletions(-) create mode 100644 bookmarks/frontend/behaviors/focus-utils.js create mode 100644 bookmarks/frontend/behaviors/modal.js diff --git a/bookmarks/frontend/behaviors/details-modal.js b/bookmarks/frontend/behaviors/details-modal.js index 5646969..cf6c408 100644 --- a/bookmarks/frontend/behaviors/details-modal.js +++ b/bookmarks/frontend/behaviors/details-modal.js @@ -1,61 +1,28 @@ -import { Behavior, registerBehavior } from "./index"; +import { registerBehavior } from "./index"; +import { isKeyboardActive } from "./focus-utils"; +import { ModalBehavior } from "./modal"; -class DetailsModalBehavior extends Behavior { - constructor(element) { - super(element); +class DetailsModalBehavior extends ModalBehavior { + doClose() { + super.doClose(); - this.onClose = this.onClose.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); + // Navigate to close URL + const closeUrl = this.element.dataset.closeUrl; + Turbo.visit(closeUrl, { + action: "replace", + frame: "details-modal", + }); - this.overlayLink = element.querySelector("a:has(.modal-overlay)"); - this.buttonLink = element.querySelector("a:has(button.close)"); + // Try restore focus to view details to view details link of respective bookmark + const bookmarkId = this.element.dataset.bookmarkId; + const restoreFocusElement = + document.querySelector( + `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`, + ) || + document.querySelector("ul.bookmark-list") || + document.body; - this.overlayLink.addEventListener("click", this.onClose); - this.buttonLink.addEventListener("click", this.onClose); - document.addEventListener("keydown", this.onKeyDown); - } - - destroy() { - this.overlayLink.removeEventListener("click", this.onClose); - this.buttonLink.removeEventListener("click", this.onClose); - document.removeEventListener("keydown", this.onKeyDown); - } - - onKeyDown(event) { - // Skip if event occurred within an input element - const targetNodeName = event.target.nodeName; - const isInputTarget = - targetNodeName === "INPUT" || - targetNodeName === "SELECT" || - targetNodeName === "TEXTAREA"; - - if (isInputTarget) { - return; - } - - if (event.key === "Escape") { - this.onClose(event); - } - } - - onClose(event) { - event.preventDefault(); - this.element.classList.add("closing"); - this.element.addEventListener( - "animationend", - (event) => { - if (event.animationName === "fade-out") { - this.element.remove(); - - const closeUrl = this.overlayLink.href; - Turbo.visit(closeUrl, { - action: "replace", - frame: "details-modal", - }); - } - }, - { once: true }, - ); + restoreFocusElement.focus({ focusVisible: isKeyboardActive() }); } } diff --git a/bookmarks/frontend/behaviors/focus-utils.js b/bookmarks/frontend/behaviors/focus-utils.js new file mode 100644 index 0000000..ab6053a --- /dev/null +++ b/bookmarks/frontend/behaviors/focus-utils.js @@ -0,0 +1,59 @@ +let keyboardActive = false; + +window.addEventListener( + "keydown", + () => { + keyboardActive = true; + }, + { capture: true }, +); + +window.addEventListener( + "mousedown", + () => { + keyboardActive = false; + }, + { capture: true }, +); + +export function isKeyboardActive() { + return keyboardActive; +} + +export class FocusTrapController { + constructor(element) { + this.element = element; + this.focusableElements = this.element.querySelectorAll( + 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])', + ); + this.firstFocusableElement = this.focusableElements[0]; + this.lastFocusableElement = + this.focusableElements[this.focusableElements.length - 1]; + + this.onKeyDown = this.onKeyDown.bind(this); + + this.firstFocusableElement.focus({ focusVisible: keyboardActive }); + this.element.addEventListener("keydown", this.onKeyDown); + } + + destroy() { + this.element.removeEventListener("keydown", this.onKeyDown); + } + + onKeyDown(event) { + if (event.key !== "Tab") { + return; + } + if (event.shiftKey) { + if (document.activeElement === this.firstFocusableElement) { + event.preventDefault(); + this.lastFocusableElement.focus(); + } + } else { + if (document.activeElement === this.lastFocusableElement) { + event.preventDefault(); + this.firstFocusableElement.focus(); + } + } + } +} diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js new file mode 100644 index 0000000..78d788c --- /dev/null +++ b/bookmarks/frontend/behaviors/modal.js @@ -0,0 +1,83 @@ +import { Behavior } from "./index"; +import { FocusTrapController } from "./focus-utils"; + +export class ModalBehavior extends Behavior { + constructor(element) { + super(element); + + this.onClose = this.onClose.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + + this.overlay = element.querySelector(".modal-overlay"); + this.closeButton = element.querySelector(".modal-header .close"); + + this.overlay.addEventListener("click", this.onClose); + this.closeButton.addEventListener("click", this.onClose); + document.addEventListener("keydown", this.onKeyDown); + + this.setupInert(); + this.focusTrap = new FocusTrapController( + element.querySelector(".modal-container"), + ); + } + + destroy() { + this.overlay.removeEventListener("click", this.onClose); + this.closeButton.removeEventListener("click", this.onClose); + document.removeEventListener("keydown", this.onKeyDown); + + this.clearInert(); + this.focusTrap.destroy(); + } + + setupInert() { + // Inert all other elements on the page + document + .querySelectorAll("body > *:not(.modals)") + .forEach((el) => el.setAttribute("inert", "")); + } + + clearInert() { + // Clear inert attribute from all elements to allow focus outside the modal again + document + .querySelectorAll("body > *") + .forEach((el) => el.removeAttribute("inert")); + } + + onKeyDown(event) { + // Skip if event occurred within an input element + const targetNodeName = event.target.nodeName; + const isInputTarget = + targetNodeName === "INPUT" || + targetNodeName === "SELECT" || + targetNodeName === "TEXTAREA"; + + if (isInputTarget) { + return; + } + + if (event.key === "Escape") { + this.onClose(event); + } + } + + onClose(event) { + event.preventDefault(); + this.element.classList.add("closing"); + this.element.addEventListener( + "animationend", + (event) => { + if (event.animationName === "fade-out") { + this.doClose(); + } + }, + { once: true }, + ); + } + + doClose() { + this.element.remove(); + this.clearInert(); + this.element.dispatchEvent(new CustomEvent("modal:close")); + } +} diff --git a/bookmarks/frontend/behaviors/tag-modal.js b/bookmarks/frontend/behaviors/tag-modal.js index 9963bff..f515d5d 100644 --- a/bookmarks/frontend/behaviors/tag-modal.js +++ b/bookmarks/frontend/behaviors/tag-modal.js @@ -1,29 +1,31 @@ import { Behavior, registerBehavior } from "./index"; +import { ModalBehavior } from "./modal"; +import { isKeyboardActive } from "./focus-utils"; -class TagModalBehavior extends Behavior { +class TagModalTriggerBehavior extends Behavior { constructor(element) { super(element); this.onClick = this.onClick.bind(this); - this.onClose = this.onClose.bind(this); element.addEventListener("click", this.onClick); } destroy() { - this.onClose(); this.element.removeEventListener("click", this.onClick); } onClick() { + // Creates a new modal and teleports the tag cloud into it const modal = document.createElement("div"); modal.classList.add("modal", "active"); + modal.setAttribute("ld-tag-modal", ""); modal.innerHTML = ` - - @@ -39,12 +39,14 @@ {% include 'bookmarks/tag_cloud.html' %} - - {# Bookmark details #} - - {% if details %} - {% include 'bookmarks/details/modal.html' %} - {% endif %} - {% endblock %} + +{% block overlays %} + {# Bookmark details #} + + {% if details %} + {% include 'bookmarks/details/modal.html' %} + {% endif %} + +{% endblock %} diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index e115975..4014764 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -6,10 +6,12 @@ {% include 'bookmarks/empty_bookmarks.html' %} {% else %}