Skip to content

Commit

Permalink
feat: initial live region support (#33)
Browse files Browse the repository at this point in the history
* feat: first pass live region support
* feat: implicit live region roles

---------

Co-authored-by: jlp-craigmorten <craig.morten@waitrose.co.uk>
  • Loading branch information
cmorten and jlp-craigmorten authored Nov 4, 2023
1 parent 6425855 commit f431795
Show file tree
Hide file tree
Showing 24 changed files with 2,488 additions and 724 deletions.
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @type {import("ts-jest/dist/types").InitialOptionsTsJest} */
// eslint-disable-next-line no-undef
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
Expand All @@ -13,5 +13,5 @@ module.exports = {
statements: 100,
},
},
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
};
38 changes: 19 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,26 @@
"test:coverage": "yarn test --coverage",
"prepublish": "yarn build"
},
"dependencies": {
"@guidepup/guidepup": "^0.19.0",
"@testing-library/dom": "^9.3.3",
"@testing-library/user-event": "^14.5.1",
"aria-query": "^5.3.0",
"dom-accessibility-api": "^0.6.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@types/jest": "^29.4.0",
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"eslint": "^8.32.0",
"eslint-config-prettier": "^8.6.0",
"jest": "^29.4.0",
"jest-environment-jsdom": "^29.5.0",
"rimraf": "^4.1.2",
"ts-jest": "^29.0.5",
"@testing-library/jest-dom": "^6.1.4",
"@types/jest": "^29.5.7",
"@types/node": "^20.8.10",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"rimraf": "^5.0.5",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
},
"dependencies": {
"@guidepup/guidepup": "^0.17.1",
"@testing-library/dom": "^9.3.0",
"@testing-library/user-event": "^14.4.3",
"aria-query": "^5.1.3",
"dom-accessibility-api": "^0.6.1"
"typescript": "^5.2.2"
}
}
108 changes: 31 additions & 77 deletions src/Virtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import {
ERR_VIRTUAL_MISSING_CONTAINER,
ERR_VIRTUAL_NOT_STARTED,
} from "./errors";
import { getElementFromNode } from "./getElementFromNode";
import { getItemText } from "./getItemText";
import { getLiveSpokenPhrase } from "./getLiveSpokenPhrase";
import { getSpokenPhrase } from "./getSpokenPhrase";
import { isElement } from "./isElement";
import { observeDOM } from "./observeDOM";
import { tick } from "./tick";
import userEvent from "@testing-library/user-event";
import { VirtualCommandArgs } from "./commands/types";

Expand All @@ -29,41 +32,10 @@ export interface StartOptions extends CommandOptions {
}

const defaultUserEventOptions = {
delay: null,
delay: 0,
skipHover: true,
};

/**
* TODO: handle live region roles:
*
* - alert
* - log
* - marquee
* - status
* - timer
* - alertdialog
*
* And handle live region attributes:
*
* - aria-atomic
* - aria-busy
* - aria-live
* - aria-relevant
*
* When live regions are marked as polite, assistive technologies SHOULD
* announce updates at the next graceful opportunity, such as at the end of
* speaking the current sentence or when the user pauses typing. When live
* regions are marked as assertive, assistive technologies SHOULD notify the
* user immediately.
*
* REF:
*
* - https://w3c.github.io/aria/#live_region_roles
* - https://w3c.github.io/aria/#window_roles
* - https://w3c.github.io/aria/#attrs_liveregions
* - https://w3c.github.io/aria/#aria-live
*/

/**
* TODO: When a modal element is displayed, assistive technologies SHOULD
* navigate to the element unless focus has explicitly been set elsewhere. Some
Expand All @@ -74,42 +46,6 @@ const defaultUserEventOptions = {
* REF: https://w3c.github.io/aria/#aria-modal
*/

const observeDOM = (function () {
const MutationObserver = window.MutationObserver;

return function observeDOM(
node: Node,
onChange: MutationCallback
): () => void {
if (!isElement(node)) {
return;
}

if (MutationObserver) {
const mutationObserver = new MutationObserver(onChange);

mutationObserver.observe(node, {
attributes: true,
childList: true,
subtree: true,
});

return () => {
mutationObserver.disconnect();
};
}

return () => {
// gracefully fallback to not supporting Accessibility Tree refreshes if
// the DOM changes.
};
};
})();

async function tick() {
return await new Promise<void>((resolve) => setTimeout(() => resolve()));
}

/**
* TODO: When an assistive technology reading cursor moves from one article to
* another, assistive technologies SHOULD set user agent focus on the article
Expand Down Expand Up @@ -178,11 +114,26 @@ export class Virtual implements ScreenReader {
}

#focusActiveElement() {
if (!this.#activeNode || !isElement(this.#activeNode.node)) {
return;
}
const target = getElementFromNode(this.#activeNode.node);
target?.focus();
}

this.#activeNode.node.focus();
async #announceLiveRegions(mutations: MutationRecord[]) {
await tick();

const container = this.#container;

mutations
.map((mutation) =>
getLiveSpokenPhrase({
container,
mutation,
})
)
.filter(Boolean)
.forEach((spokenPhrase) => {
this.#spokenPhraseLog.push(spokenPhrase);
});
}

#updateState(accessibilityNode: AccessibilityNode, ignoreIfNoChange = false) {
Expand Down Expand Up @@ -283,7 +234,10 @@ export class Virtual implements ScreenReader {

this.#disconnectDOMObserver = observeDOM(
container,
this.#invalidateTreeCache.bind(this)
(mutations: MutationRecord[]) => {
this.#invalidateTreeCache();
this.#announceLiveRegions(mutations);
}
);

const tree = this.#getAccessibilityTree();
Expand Down Expand Up @@ -370,7 +324,7 @@ export class Virtual implements ScreenReader {
return;
}

const target = this.#activeNode.node as HTMLElement;
const target = getElementFromNode(this.#activeNode.node);

// TODO: verify that is appropriate for all default actions
await userEvent.click(target, defaultUserEventOptions);
Expand Down Expand Up @@ -478,7 +432,7 @@ export class Virtual implements ScreenReader {
return;
}

const target = this.#activeNode.node as HTMLElement;
const target = getElementFromNode(this.#activeNode.node);
await userEvent.type(target, text, defaultUserEventOptions);
await this.#refreshState(true);

Expand Down Expand Up @@ -537,7 +491,7 @@ export class Virtual implements ScreenReader {

const key = `[Mouse${button[0].toUpperCase()}${button.slice(1)}]`;
const keys = key.repeat(clickCount);
const target = this.#activeNode.node as HTMLElement;
const target = getElementFromNode(this.#activeNode.node);

await userEvent.pointer(
[{ target }, { keys, target }],
Expand Down
10 changes: 3 additions & 7 deletions src/commands/getElementNode.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { AccessibilityNode } from "../createAccessibilityTree";
import { isElement } from "../isElement";
import { getElementFromNode } from "../getElementFromNode";

export function getElementNode(accessibilityNode: AccessibilityNode) {
export function getElementNode(accessibilityNode: AccessibilityNode): Element {
const { node } = accessibilityNode;

if (node && isElement(node)) {
return node;
}

return accessibilityNode.parent;
return getElementFromNode(node);
}
5 changes: 5 additions & 0 deletions src/getElementFromNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { isElement } from "./isElement";

export const getElementFromNode = (node: Node): HTMLElement => {
return isElement(node) ? node : node.parentElement;
};
Loading

0 comments on commit f431795

Please sign in to comment.