Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: trap navigation when aria-modal=true #54

Merged
merged 3 commits into from
Feb 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading