From 213ed42c3b42ec2e5f1a4fb13c34356b3209a53d Mon Sep 17 00:00:00 2001 From: Craig Morten <124147726+jlp-craigmorten@users.noreply.github.com> Date: Sun, 18 Feb 2024 15:25:01 +0000 Subject: [PATCH] feat: trap navigation when aria-modal=true (#54) --- src/Virtual.ts | 38 +- src/createAccessibilityTree.ts | 10 +- .../getLabelFromHtmlEquivalentAttribute.ts | 3 + test/int/ariaModal.int.test.ts | 259 ++++++++ test/int/ariaModal.js | 589 ++++++++++++++++++ 5 files changed, 889 insertions(+), 10 deletions(-) create mode 100644 test/int/ariaModal.int.test.ts create mode 100644 test/int/ariaModal.js diff --git a/src/Virtual.ts b/src/Virtual.ts index d959acc..0495c33 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -49,10 +49,7 @@ const defaultUserEventOptions = { /** * TODO: When a modal element is displayed, assistive technologies SHOULD - * navigate to the element unless focus has explicitly been set elsewhere. Some - * assistive technologies limit navigation to the modal element's contents. If - * focus moves to an element outside the modal element, assistive technologies - * SHOULD NOT limit navigation to the modal element. + * navigate to the element unless focus has explicitly been set elsewhere. * * REF: https://www.w3.org/TR/wai-aria-1.2/#aria-modal */ @@ -165,6 +162,31 @@ export class Virtual implements ScreenReader { return this.#treeCache; } + #getModalAccessibilityTree() { + const tree = this.#getAccessibilityTree(); + + if (!this.#activeNode) { + return tree; + } + + const isModal = + this.#activeNode.parentDialog?.getAttribute("aria-modal") === "true"; + + if (!isModal) { + return tree; + } + + /** + * Assistive technologies MAY limit navigation to the modal element's + * contents. + * + * REF: https://www.w3.org/TR/wai-aria-1.2/#aria-modal + */ + return tree.filter( + ({ parentDialog }) => this.#activeNode.parentDialog === parentDialog + ); + } + #invalidateTreeCache() { this.#detachFocusListeners(); this.#treeCache = null; @@ -249,7 +271,7 @@ export class Virtual implements ScreenReader { */ if ( accessibilityNode.parentDialog !== null && - accessibilityNode.parentDialog !== this.#activeNode.parentDialog + accessibilityNode.parentDialog !== this.#activeNode?.parentDialog ) { // One of the few cases where you will get two logs for a single // interaction. @@ -551,7 +573,7 @@ export class Virtual implements ScreenReader { this.#checkContainer(); await tick(); - const tree = this.#getAccessibilityTree(); + const tree = this.#getModalAccessibilityTree(); if (!tree.length) { return; @@ -593,7 +615,7 @@ export class Virtual implements ScreenReader { this.#checkContainer(); await tick(); - const tree = this.#getAccessibilityTree(); + const tree = this.#getModalAccessibilityTree(); if (!tree.length) { return; @@ -826,7 +848,7 @@ export class Virtual implements ScreenReader { this.#checkContainer(); await tick(); - const tree = this.#getAccessibilityTree(); + const tree = this.#getModalAccessibilityTree(); if (!tree.length) { return; diff --git a/src/createAccessibilityTree.ts b/src/createAccessibilityTree.ts index 3434ee2..119d42a 100644 --- a/src/createAccessibilityTree.ts +++ b/src/createAccessibilityTree.ts @@ -20,7 +20,7 @@ export interface AccessibilityNode { childrenPresentational: boolean; node: Node; parent: Node | null; - parentDialog: Node | null; + parentDialog: HTMLElement | null; role: string; spokenRole: string; } @@ -223,7 +223,13 @@ function growTree( visitedNodes.add(node); - const parentDialog = isDialogRole(tree.role) ? tree.node : tree.parentDialog; + const parentDialog = isDialogRole(tree.role) + ? (tree.node as HTMLElement) + : tree.parentDialog; + + if (parentDialog) { + tree.parentDialog = parentDialog; + } node.childNodes.forEach((childNode) => { if (isHiddenFromAccessibilityTree(childNode)) { diff --git a/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromHtmlEquivalentAttribute.ts b/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromHtmlEquivalentAttribute.ts index 1d67821..b961d25 100644 --- a/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromHtmlEquivalentAttribute.ts +++ b/src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromHtmlEquivalentAttribute.ts @@ -75,6 +75,9 @@ const ariaToHTMLAttributeMapping: Record< // REF: https://www.w3.org/TR/html-aam-1.0/#att-open-details // "aria-expanded": [{ elements: ["details"], name: "open" }], + // TODO: Set properties on the dialog element. + // REF: https://www.w3.org/TR/html-aam-1.0/#att-open-dialog + // Not announced, indeed it will be hidden from the accessibility tree. // "aria-hidden": [{ name: "hidden" }], diff --git a/test/int/ariaModal.int.test.ts b/test/int/ariaModal.int.test.ts new file mode 100644 index 0000000..720deff --- /dev/null +++ b/test/int/ariaModal.int.test.ts @@ -0,0 +1,259 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { screen } from "@testing-library/dom"; +import { setupAriaModal } from "./ariaModal.js"; +import { virtual } from "../../src/index.js"; + +/** + * REF: + * - https://www.w3.org/TR/wai-aria-1.2/#aria-modal + * - https://a11ysupport.io/tech/aria/aria-modal_attribute + */ +describe("Aria Modal", () => { + let teardown; + + describe("when the dialog is modal", () => { + beforeEach(async () => { + teardown = setupAriaModal("true"); + + await virtual.start({ container: document.body }); + await virtual.next(); + await virtual.next(); + await virtual.act(); + }); + + afterEach(async () => { + await virtual.stop(); + teardown(); + }); + + it('should convey the presence of aria-modal="true" - applied to the dialog role', async () => { + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "heading, Non-modal heading, level 1", + "button, Add Delivery Address", + "dialog, Add Delivery Address, modal", + "textbox, Street:", + ]); + }); + + it('should limit reading of children of aria-modal="true" - applied to the dialog role - moving to the next item', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + while ((await virtual.lastSpokenPhrase()) !== "textbox, Street:") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "City:", + "textbox, City:", + "State:", + "textbox, State:", + "Zip:", + "textbox, Zip:", + "Special instructions:", + "textbox, Special instructions:, For example, gate code or other information to help the driver find you", + "For example, gate code or other information to help the driver find you", + "button, Verify Address", + "button, Add", + "button, Cancel", + "end of dialog, Add Delivery Address, modal", + "dialog, Add Delivery Address, modal", + "heading, Add Delivery Address, level 2", + "Street:", + "textbox, Street:", + ]); + }); + + it('should limit reading of children of aria-modal="true" - applied to the dialog role - moving to the previous item', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + while ((await virtual.lastSpokenPhrase()) !== "textbox, Street:") { + await virtual.previous(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "Street:", + "heading, Add Delivery Address, level 2", + "dialog, Add Delivery Address, modal", + "end of dialog, Add Delivery Address, modal", + "button, Cancel", + "button, Add", + "button, Verify Address", + "For example, gate code or other information to help the driver find you", + "textbox, Special instructions:, For example, gate code or other information to help the driver find you", + "Special instructions:", + "textbox, Zip:", + "Zip:", + "textbox, State:", + "State:", + "textbox, City:", + "City:", + "textbox, Street:", + ]); + }); + + it('should remove outside content from navigational shortcuts when aria-modal="true" - applied to the dialog role - move to next heading', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + await virtual.perform(virtual.commands.moveToNextHeading); + await virtual.perform(virtual.commands.moveToNextHeading); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "heading, Add Delivery Address, level 2", + "heading, Add Delivery Address, level 2", + ]); + }); + + it('should remove outside content from navigational shortcuts when aria-modal="true" - applied to the dialog role - move to previous heading', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + await virtual.perform(virtual.commands.moveToPreviousHeading); + await virtual.perform(virtual.commands.moveToPreviousHeading); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "heading, Add Delivery Address, level 2", + "heading, Add Delivery Address, level 2", + ]); + }); + + it('should remove outside content from navigational shortcuts when aria-modal="true" - applied to the dialog role - move to next heading level 1', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + await virtual.perform(virtual.commands.moveToNextHeadingLevel1); + + expect(await virtual.spokenPhraseLog()).toEqual([]); + }); + + it('should remove outside content from navigational shortcuts when aria-modal="true" - applied to the dialog role - move to previous heading level 1', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + await virtual.perform(virtual.commands.moveToPreviousHeadingLevel1); + + expect(await virtual.spokenPhraseLog()).toEqual([]); + }); + + it("should not limit navigation to the modal element when focus moves to an element outside the modal element", async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + // Make sure the APG example "focusTrap" doesn't try to keep focus within + // the modal and complicate the focus shift. + (window as any).aria.Utils.IgnoreUtilFocusChanges = true; + + // Move the focus back to the button on the main page. + screen.getByRole("button", { name: "Add Delivery Address" }).focus(); + + // Reset the APG example "focusTrap". + (window as any).aria.Utils.IgnoreUtilFocusChanges = false; + + await virtual.previous(); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "button, Add Delivery Address", + "heading, Non-modal heading, level 1", + ]); + }); + }); + + describe("when the dialog is not modal", () => { + beforeEach(async () => { + teardown = setupAriaModal("false"); + + await virtual.start({ container: document.body }); + await virtual.next(); + await virtual.next(); + await virtual.act(); + }); + + afterEach(async () => { + await virtual.stop(); + teardown(); + }); + + it('should convey the presence of aria-modal="false" - applied to the dialog role', async () => { + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "heading, Non-modal heading, level 1", + "button, Add Delivery Address", + "dialog, Add Delivery Address, not modal", + "textbox, Street:", + ]); + }); + + it('should not limit reading of children of aria-modal="false" - applied to the dialog role - moving to the previous item', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + while ((await virtual.lastSpokenPhrase()) !== "document") { + // Make sure the APG example "focusTrap" doesn't try to keep focus + // within the modal so we can assert the virtual cursor would escape + // the modal. + (window as any).aria.Utils.IgnoreUtilFocusChanges = true; + + await virtual.previous(); + + // Reset the APG example "focusTrap". + (window as any).aria.Utils.IgnoreUtilFocusChanges = false; + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "Street:", + "heading, Add Delivery Address, level 2", + "dialog, Add Delivery Address, not modal", + "button, Add Delivery Address", + "heading, Non-modal heading, level 1", + "document", + ]); + }); + + it('should not remove outside content from navigational shortcuts when aria-modal="false" - applied to the dialog role - move to previous heading', async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + await virtual.perform(virtual.commands.moveToPreviousHeading); + + // Make sure the APG example "focusTrap" doesn't try to keep focus + // within the modal so we can assert the virtual cursor would escape + // the modal. + (window as any).aria.Utils.IgnoreUtilFocusChanges = true; + + await virtual.perform(virtual.commands.moveToPreviousHeading); + + // Reset the APG example "focusTrap". + (window as any).aria.Utils.IgnoreUtilFocusChanges = false; + + expect(await virtual.spokenPhraseLog()).toEqual([ + "heading, Add Delivery Address, level 2", + "heading, Non-modal heading, level 1", + ]); + }); + + it("should not limit navigation to the modal element when focus moves to an element outside the modal element", async () => { + await virtual.clearItemTextLog(); + await virtual.clearSpokenPhraseLog(); + + // Make sure the APG example "focusTrap" doesn't try to keep focus within + // the modal and complicate the focus shift. + (window as any).aria.Utils.IgnoreUtilFocusChanges = true; + + // Move the focus back to the button on the main page. + screen.getByRole("button", { name: "Add Delivery Address" }).focus(); + + // Reset the APG example "focusTrap". + (window as any).aria.Utils.IgnoreUtilFocusChanges = false; + + await virtual.previous(); + + expect(await virtual.spokenPhraseLog()).toEqual([ + "button, Add Delivery Address", + "heading, Non-modal heading, level 1", + ]); + }); + }); +}); diff --git a/test/int/ariaModal.js b/test/int/ariaModal.js new file mode 100644 index 0000000..9af6245 --- /dev/null +++ b/test/int/ariaModal.js @@ -0,0 +1,589 @@ +/* eslint-disable no-undef */ + +/** + * @namespace aria + */ + +var aria = aria || {}; +window.aria = aria; + +/** + * @description + * Key code constants + */ +aria.KeyCode = { + BACKSPACE: 8, + TAB: 9, + RETURN: 13, + SHIFT: 16, + ESC: 27, + SPACE: 32, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + DELETE: 46, +}; + +aria.Utils = aria.Utils || {}; + +// Polyfill src https://developer.mozilla.org/en-US/docs/Web/API/Element/matches +aria.Utils.matches = function (element, selector) { + if (!Element.prototype.matches) { + Element.prototype.matches = + Element.prototype.matchesSelector || + Element.prototype.mozMatchesSelector || + Element.prototype.msMatchesSelector || + Element.prototype.oMatchesSelector || + Element.prototype.webkitMatchesSelector || + function (s) { + var matches = element.parentNode.querySelectorAll(s); + var i = matches.length; + while (--i >= 0 && matches.item(i) !== this) { + // empty + } + return i > -1; + }; + } + + return element.matches(selector); +}; + +aria.Utils.remove = function (item) { + if (item.remove && typeof item.remove === "function") { + return item.remove(); + } + if ( + item.parentNode && + item.parentNode.removeChild && + typeof item.parentNode.removeChild === "function" + ) { + return item.parentNode.removeChild(item); + } + return false; +}; + +aria.Utils.isFocusable = function (element) { + if (element.tabIndex < 0) { + return false; + } + + if (element.disabled) { + return false; + } + + switch (element.nodeName) { + case "A": + return !!element.href && element.rel != "ignore"; + case "INPUT": + return element.type != "hidden"; + case "BUTTON": + case "SELECT": + case "TEXTAREA": + return true; + default: + return false; + } +}; + +aria.Utils.getAncestorBySelector = function (element, selector) { + if (!aria.Utils.matches(element, selector + " " + element.tagName)) { + // Element is not inside an element that matches selector + return null; + } + + // Move up the DOM tree until a parent matching the selector is found + var currentNode = element; + var ancestor = null; + while (ancestor === null) { + if (aria.Utils.matches(currentNode.parentNode, selector)) { + ancestor = currentNode.parentNode; + } else { + currentNode = currentNode.parentNode; + } + } + + return ancestor; +}; + +aria.Utils.hasClass = function (element, className) { + return new RegExp("(\\s|^)" + className + "(\\s|$)").test(element.className); +}; + +aria.Utils.addClass = function (element, className) { + if (!aria.Utils.hasClass(element, className)) { + element.className += " " + className; + } +}; + +aria.Utils.removeClass = function (element, className) { + var classRegex = new RegExp("(\\s|^)" + className + "(\\s|$)"); + element.className = element.className.replace(classRegex, " ").trim(); +}; + +aria.Utils.bindMethods = function (object /* , ...methodNames */) { + var methodNames = Array.prototype.slice.call(arguments, 1); + methodNames.forEach(function (method) { + object[method] = object[method].bind(object); + }); +}; +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + */ + +aria.Utils = aria.Utils || {}; + +(function () { + /* + * When util functions move focus around, set this true so the focus listener + * can ignore the events. + */ + aria.Utils.IgnoreUtilFocusChanges = false; + + aria.Utils.dialogOpenClass = "has-dialog"; + + /** + * @description Set focus on descendant nodes until the first focusable element is + * found. + * @param element + * DOM node for which to find the first focusable descendant. + * @returns {boolean} + * true if a focusable element is found and focus is set. + */ + aria.Utils.focusFirstDescendant = function (element) { + for (var i = 0; i < element.childNodes.length; i++) { + var child = element.childNodes[i]; + if ( + aria.Utils.attemptFocus(child) || + aria.Utils.focusFirstDescendant(child) + ) { + return true; + } + } + return false; + }; // end focusFirstDescendant + + /** + * @description Find the last descendant node that is focusable. + * @param element + * DOM node for which to find the last focusable descendant. + * @returns {boolean} + * true if a focusable element is found and focus is set. + */ + aria.Utils.focusLastDescendant = function (element) { + for (var i = element.childNodes.length - 1; i >= 0; i--) { + var child = element.childNodes[i]; + if ( + aria.Utils.attemptFocus(child) || + aria.Utils.focusLastDescendant(child) + ) { + return true; + } + } + return false; + }; // end focusLastDescendant + + /** + * @description Set Attempt to set focus on the current node. + * @param element + * The node to attempt to focus on. + * @returns {boolean} + * true if element is focused. + */ + aria.Utils.attemptFocus = function (element) { + if (!aria.Utils.isFocusable(element)) { + return false; + } + + aria.Utils.IgnoreUtilFocusChanges = true; + try { + element.focus(); + } catch (e) { + // continue regardless of error + } + aria.Utils.IgnoreUtilFocusChanges = false; + return document.activeElement === element; + }; // end attemptFocus + + /* Modals can open modals. Keep track of them with this array. */ + aria.OpenDialogList = aria.OpenDialogList || new Array(0); + + /** + * @returns {object} the last opened dialog (the current dialog) + */ + aria.getCurrentDialog = function () { + if (aria.OpenDialogList && aria.OpenDialogList.length) { + return aria.OpenDialogList[aria.OpenDialogList.length - 1]; + } + }; + + aria.closeCurrentDialog = function () { + var currentDialog = aria.getCurrentDialog(); + if (currentDialog) { + currentDialog.close(); + return true; + } + + return false; + }; + + aria.handleEscape = function (event) { + var key = event.which || event.keyCode; + + if (key === aria.KeyCode.ESC && aria.closeCurrentDialog()) { + event.stopPropagation(); + } + }; + + document.addEventListener("keyup", aria.handleEscape); + + /** + * @class + * @description Dialog object providing modal focus management. + * + * Assumptions: The element serving as the dialog container is present in the + * DOM and hidden. The dialog container has role='dialog'. + * @param dialogId + * The ID of the element serving as the dialog container. + * @param focusAfterClosed + * Either the DOM node or the ID of the DOM node to focus when the + * dialog closes. + * @param focusFirst + * Optional parameter containing either the DOM node or the ID of the + * DOM node to focus when the dialog opens. If not specified, the + * first focusable element in the dialog will receive focus. + */ + aria.Dialog = function (dialogId, focusAfterClosed, focusFirst) { + this.dialogNode = document.getElementById(dialogId); + if (this.dialogNode === null) { + throw new Error('No element found with id="' + dialogId + '".'); + } + + var validRoles = ["dialog", "alertdialog"]; + var isDialog = (this.dialogNode.getAttribute("role") || "") + .trim() + .split(/\s+/g) + .some(function (token) { + return validRoles.some(function (role) { + return token === role; + }); + }); + if (!isDialog) { + throw new Error( + "Dialog() requires a DOM element with ARIA role of dialog or alertdialog." + ); + } + + // Wrap in an individual backdrop element if one doesn't exist + // Native elements use the ::backdrop pseudo-element, which + // works similarly. + var backdropClass = "dialog-backdrop"; + if (this.dialogNode.parentNode.classList.contains(backdropClass)) { + this.backdropNode = this.dialogNode.parentNode; + } else { + this.backdropNode = document.createElement("div"); + this.backdropNode.className = backdropClass; + this.dialogNode.parentNode.insertBefore( + this.backdropNode, + this.dialogNode + ); + this.backdropNode.appendChild(this.dialogNode); + } + this.backdropNode.classList.add("active"); + + // Disable scroll on the body element + document.body.classList.add(aria.Utils.dialogOpenClass); + + if (typeof focusAfterClosed === "string") { + this.focusAfterClosed = document.getElementById(focusAfterClosed); + } else if (typeof focusAfterClosed === "object") { + this.focusAfterClosed = focusAfterClosed; + } else { + throw new Error( + "the focusAfterClosed parameter is required for the aria.Dialog constructor." + ); + } + + if (typeof focusFirst === "string") { + this.focusFirst = document.getElementById(focusFirst); + } else if (typeof focusFirst === "object") { + this.focusFirst = focusFirst; + } else { + this.focusFirst = null; + } + + // Bracket the dialog node with two invisible, focusable nodes. + // While this dialog is open, we use these to make sure that focus never + // leaves the document even if dialogNode is the first or last node. + var preDiv = document.createElement("div"); + this.preNode = this.dialogNode.parentNode.insertBefore( + preDiv, + this.dialogNode + ); + this.preNode.tabIndex = 0; + var postDiv = document.createElement("div"); + this.postNode = this.dialogNode.parentNode.insertBefore( + postDiv, + this.dialogNode.nextSibling + ); + this.postNode.tabIndex = 0; + + // If this modal is opening on top of one that is already open, + // get rid of the document focus listener of the open dialog. + if (aria.OpenDialogList.length > 0) { + aria.getCurrentDialog().removeListeners(); + } + + this.addListeners(); + aria.OpenDialogList.push(this); + this.clearDialog(); + this.dialogNode.className = "default_dialog"; // make visible + + if (this.focusFirst) { + this.focusFirst.focus(); + } else { + aria.Utils.focusFirstDescendant(this.dialogNode); + } + + this.lastFocus = document.activeElement; + }; // end Dialog constructor + + aria.Dialog.prototype.clearDialog = function () { + Array.prototype.map.call( + this.dialogNode.querySelectorAll("input"), + function (input) { + input.value = ""; + } + ); + }; + + /** + * @description + * Hides the current top dialog, + * removes listeners of the top dialog, + * restore listeners of a parent dialog if one was open under the one that just closed, + * and sets focus on the element specified for focusAfterClosed. + */ + aria.Dialog.prototype.close = function () { + aria.OpenDialogList.pop(); + this.removeListeners(); + aria.Utils.remove(this.preNode); + aria.Utils.remove(this.postNode); + this.dialogNode.className = "hidden"; + this.backdropNode.classList.remove("active"); + this.focusAfterClosed.focus(); + + // If a dialog was open underneath this one, restore its listeners. + if (aria.OpenDialogList.length > 0) { + aria.getCurrentDialog().addListeners(); + } else { + document.body.classList.remove(aria.Utils.dialogOpenClass); + } + }; // end close + + /** + * @description + * Hides the current dialog and replaces it with another. + * @param newDialogId + * ID of the dialog that will replace the currently open top dialog. + * @param newFocusAfterClosed + * Optional ID or DOM node specifying where to place focus when the new dialog closes. + * If not specified, focus will be placed on the element specified by the dialog being replaced. + * @param newFocusFirst + * Optional ID or DOM node specifying where to place focus in the new dialog when it opens. + * If not specified, the first focusable element will receive focus. + */ + aria.Dialog.prototype.replace = function ( + newDialogId, + newFocusAfterClosed, + newFocusFirst + ) { + aria.OpenDialogList.pop(); + this.removeListeners(); + aria.Utils.remove(this.preNode); + aria.Utils.remove(this.postNode); + this.dialogNode.className = "hidden"; + this.backdropNode.classList.remove("active"); + + var focusAfterClosed = newFocusAfterClosed || this.focusAfterClosed; + new aria.Dialog(newDialogId, focusAfterClosed, newFocusFirst); + }; // end replace + + aria.Dialog.prototype.addListeners = function () { + document.addEventListener("focus", this.trapFocus, true); + }; // end addListeners + + aria.Dialog.prototype.removeListeners = function () { + document.removeEventListener("focus", this.trapFocus, true); + }; // end removeListeners + + aria.Dialog.prototype.trapFocus = function (event) { + if (aria.Utils.IgnoreUtilFocusChanges) { + return; + } + var currentDialog = aria.getCurrentDialog(); + if (currentDialog.dialogNode.contains(event.target)) { + currentDialog.lastFocus = event.target; + } else { + aria.Utils.focusFirstDescendant(currentDialog.dialogNode); + if (currentDialog.lastFocus == document.activeElement) { + aria.Utils.focusLastDescendant(currentDialog.dialogNode); + } + currentDialog.lastFocus = document.activeElement; + } + }; // end trapFocus + + window.openDialog = function (dialogId, focusAfterClosed, focusFirst) { + new aria.Dialog(dialogId, focusAfterClosed, focusFirst); + }; + + window.closeDialog = function (closeButton) { + var topDialog = aria.getCurrentDialog(); + if (topDialog.dialogNode.contains(closeButton)) { + topDialog.close(); + } + }; // end closeDialog + + window.replaceDialog = function ( + newDialogId, + newFocusAfterClosed, + newFocusFirst + ) { + var topDialog = aria.getCurrentDialog(); + if (topDialog.dialogNode.contains(document.activeElement)) { + topDialog.replace(newDialogId, newFocusAfterClosed, newFocusFirst); + } + }; // end replaceDialog +})(); + +function setupAriaModal(ariaModal = "true") { + document.body.innerHTML = ` +

Non-modal heading

+ +
+ + + + + + + +
`; + + return () => { + document.body.innerHTML = ""; + }; +} + +module.exports = { setupAriaModal };