Skip to content

Commit

Permalink
Improve accessibility of modal dialogs (#974)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
sissbruecker authored Feb 1, 2025
1 parent 2973812 commit 17442ee
Show file tree
Hide file tree
Showing 18 changed files with 369 additions and 217 deletions.
83 changes: 25 additions & 58 deletions bookmarks/frontend/behaviors/details-modal.js
Original file line number Diff line number Diff line change
@@ -1,61 +1,28 @@
import { Behavior, registerBehavior } from "./index";

class DetailsModalBehavior extends Behavior {
constructor(element) {
super(element);

this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);

this.overlayLink = element.querySelector("a:has(.modal-overlay)");
this.buttonLink = element.querySelector("a:has(button.close)");

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 },
);
import { registerBehavior } from "./index";
import { isKeyboardActive } from "./focus-utils";
import { ModalBehavior } from "./modal";

class DetailsModalBehavior extends ModalBehavior {
doClose() {
super.doClose();

// Navigate to close URL
const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});

// 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;

restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}

Expand Down
59 changes: 59 additions & 0 deletions bookmarks/frontend/behaviors/focus-utils.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
}
83 changes: 83 additions & 0 deletions bookmarks/frontend/behaviors/modal.js
Original file line number Diff line number Diff line change
@@ -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"));
}
}
54 changes: 31 additions & 23 deletions bookmarks/frontend/behaviors/tag-modal.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Tags</h2>
<button class="close" aria-label="Close">
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
Expand All @@ -37,32 +39,38 @@ class TagModalBehavior extends Behavior {
</div>
</div>
`;
modal.addEventListener("modal:close", this.onClose);

const tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement;
modal.tagCloud = document.querySelector(".tag-cloud");
modal.tagCloudContainer = modal.tagCloud.parentElement;

const content = modal.querySelector(".content");
content.appendChild(tagCloud);
content.appendChild(modal.tagCloud);

const overlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
document.body.querySelector(".modals").appendChild(modal);
}
}

this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
class TagModalBehavior extends ModalBehavior {
destroy() {
super.destroy();
// Always close on destroy to restore tag cloud to original parent before
// turbo caches DOM
this.doClose();
}

onClose() {
if (!this.modal) {
return;
}
doClose() {
super.doClose();

// Restore tag cloud to original parent
this.element.tagCloudContainer.appendChild(this.element.tagCloud);

this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
// Try restore focus to tag cloud trigger
const restoreFocusElement =
document.querySelector("[ld-tag-modal-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}

registerBehavior("ld-tag-modal-trigger", TagModalTriggerBehavior);
registerBehavior("ld-tag-modal", TagModalBehavior);
11 changes: 9 additions & 2 deletions bookmarks/styles/bookmark-details.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,15 @@
}
}

& dl {
margin-bottom: 0;

& .sections section {
margin-top: var(--unit-4);
}

& .sections h3 {
margin-bottom: var(--unit-2);
font-size: var(--font-size);
font-weight: bold;
}

& .assets {
Expand Down
2 changes: 1 addition & 1 deletion bookmarks/styles/theme/modals.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
margin: 0;
}

& button.close {
& .close {
background: none;
border: none;
padding: 0;
Expand Down
18 changes: 10 additions & 8 deletions bookmarks/templates/bookmarks/archive.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ <h2>Archived bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
<button ld-tag-modal-trigger class="btn ml-2 show-md">Tags
</button>
</div>
</div>
Expand All @@ -39,12 +39,14 @@ <h2>Tags</h2>
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>

{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}

{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}
Loading

0 comments on commit 17442ee

Please sign in to comment.