Skip to content

Commit

Permalink
feat: trap navigation when aria-modal=true (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
jlp-craigmorten authored Feb 18, 2024
1 parent 356ac88 commit 213ed42
Show file tree
Hide file tree
Showing 5 changed files with 889 additions and 10 deletions.
38 changes: 30 additions & 8 deletions src/Virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/createAccessibilityTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface AccessibilityNode {
childrenPresentational: boolean;
node: Node;
parent: Node | null;
parentDialog: Node | null;
parentDialog: HTMLElement | null;
role: string;
spokenRole: string;
}
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],

Expand Down
259 changes: 259 additions & 0 deletions test/int/ariaModal.int.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
});
Loading

0 comments on commit 213ed42

Please sign in to comment.