@guidepup/virtual-screen-reader
Advanced tools
Comparing version 0.1.0 to 0.1.1
export interface AccessibilityNode { | ||
accessibleDescription: string; | ||
accessibleName: string; | ||
childrenPresentational: boolean; | ||
node: Node; | ||
role: string; | ||
} | ||
export declare function isHiddenFromAccessibilityTree(node: Node): boolean; | ||
export declare function createAccessibilityTree(node: Node): AccessibilityNode[]; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.createAccessibilityTree = exports.isHiddenFromAccessibilityTree = void 0; | ||
const getAccessibleName_1 = require("./getAccessibleName"); | ||
const getRole_1 = require("./getRole"); | ||
exports.createAccessibilityTree = void 0; | ||
const getNodeAccessibilityData_1 = require("./getNodeAccessibilityData"); | ||
const isElement_1 = require("./isElement"); | ||
const dom_accessibility_api_1 = require("dom-accessibility-api"); | ||
// TODO: This isn't fully compliant, see "Children Presentational" point | ||
// See https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion | ||
function isHiddenFromAccessibilityTree(node) { | ||
@@ -16,8 +13,18 @@ if (node.nodeType === Node.TEXT_NODE && !!node.textContent.trim()) { | ||
} | ||
exports.isHiddenFromAccessibilityTree = isHiddenFromAccessibilityTree; | ||
function shouldIgnoreChildren(tree) { | ||
const { accessibleName, node } = tree; | ||
return ( | ||
// TODO: improve comparison on whether the children are superfluous | ||
// to include. | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
accessibleName === (node.textContent || node.value)?.trim()); | ||
} | ||
function flattenTree(tree) { | ||
const { children, ...treeNode } = tree; | ||
const flattenedTree = [...children.flatMap((child) => flattenTree(child))]; | ||
const isAnnounced = treeNode.accessibleName || treeNode.role; | ||
const isRoleContainer = !treeNode.accessibleName && treeNode.role; | ||
const ignoreChildren = shouldIgnoreChildren(tree); | ||
const flattenedTree = ignoreChildren | ||
? [] | ||
: [...children.flatMap((child) => flattenTree(child))]; | ||
const isRoleContainer = flattenedTree.length && !ignoreChildren && treeNode.role; | ||
if (isAnnounced) { | ||
@@ -28,3 +35,5 @@ flattenedTree.unshift(treeNode); | ||
flattenedTree.push({ | ||
accessibleName: "", | ||
accessibleDescription: "", | ||
accessibleName: treeNode.accessibleName, | ||
childrenPresentational: treeNode.childrenPresentational, | ||
node: treeNode.node, | ||
@@ -37,5 +46,2 @@ role: `end of ${treeNode.role}`, | ||
function growTree(node, tree) { | ||
if (tree.accessibleName) { | ||
return tree; | ||
} | ||
node.childNodes.forEach((childNode) => { | ||
@@ -45,8 +51,13 @@ if (isHiddenFromAccessibilityTree(childNode)) { | ||
} | ||
const accessibleName = (0, getAccessibleName_1.getAccessibleName)(childNode); | ||
const { accessibleDescription, accessibleName, childrenPresentational, role, } = (0, getNodeAccessibilityData_1.getNodeAccessibilityData)({ | ||
node: childNode, | ||
inheritedImplicitPresentational: tree.childrenPresentational, | ||
}); | ||
tree.children.push(growTree(childNode, { | ||
accessibleDescription, | ||
accessibleName, | ||
children: [], | ||
childrenPresentational, | ||
node: childNode, | ||
role: (0, getRole_1.getRole)(childNode, accessibleName), | ||
role, | ||
})); | ||
@@ -60,8 +71,13 @@ }); | ||
} | ||
const accessibleName = (0, getAccessibleName_1.getAccessibleName)(node); | ||
const { accessibleDescription, accessibleName, childrenPresentational, role, } = (0, getNodeAccessibilityData_1.getNodeAccessibilityData)({ | ||
node, | ||
inheritedImplicitPresentational: false, | ||
}); | ||
const tree = growTree(node, { | ||
accessibleDescription, | ||
accessibleName, | ||
children: [], | ||
node: node, | ||
role: (0, getRole_1.getRole)(node, accessibleName), | ||
childrenPresentational, | ||
node, | ||
role, | ||
}); | ||
@@ -68,0 +84,0 @@ return flattenTree(tree); |
@@ -1,1 +0,7 @@ | ||
export declare function getRole(node: Node, accessibleName: string): string; | ||
export declare const presentationRoles: string[]; | ||
export declare const childrenPresentationalRoles: string[]; | ||
export declare function getRole({ accessibleName, inheritedImplicitPresentational, node, }: { | ||
accessibleName: string; | ||
inheritedImplicitPresentational: boolean; | ||
node: Node; | ||
}): string; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.getRole = void 0; | ||
exports.getRole = exports.childrenPresentationalRoles = exports.presentationRoles = void 0; | ||
const dom_accessibility_api_1 = require("dom-accessibility-api"); | ||
const dom_1 = require("@testing-library/dom"); | ||
const isElement_1 = require("./isElement"); | ||
const aria_query_1 = require("aria-query"); | ||
const ignoredRoles = ["presentation", "none"]; | ||
exports.presentationRoles = ["presentation", "none"]; | ||
const allowedNonAbstractRoles = aria_query_1.roles | ||
@@ -12,10 +13,57 @@ .entries() | ||
.map(([key]) => key); | ||
exports.childrenPresentationalRoles = aria_query_1.roles | ||
.entries() | ||
.filter(([, { childrenPresentational }]) => childrenPresentational) | ||
.map(([key]) => key); | ||
const rolesRequiringName = ["form", "region"]; | ||
function getExplicitRole(node, accessibleName) { | ||
const rawRole = node.getAttribute("role")?.trim(); | ||
if (!rawRole) { | ||
const globalStatesAndProperties = [ | ||
"aria-atomic", | ||
"aria-braillelabel", | ||
"aria-brailleroledescription", | ||
"aria-busy", | ||
"aria-controls", | ||
"aria-describedby", | ||
"aria-description", | ||
"aria-details", | ||
"aria-dropeffect", | ||
"aria-flowto", | ||
"aria-grabbed", | ||
"aria-hidden", | ||
"aria-keyshortcuts", | ||
"aria-label", | ||
"aria-labelledby", | ||
"aria-live", | ||
"aria-owns", | ||
"aria-relevant", | ||
"aria-roledescription", | ||
]; | ||
const FOCUSABLE_SELECTOR = [ | ||
"input:not([type=hidden]):not([disabled])", | ||
"button:not([disabled])", | ||
"select:not([disabled])", | ||
"textarea:not([disabled])", | ||
'[contenteditable=""]', | ||
'[contenteditable="true"]', | ||
"a[href]", | ||
"[tabindex]:not([disabled])", | ||
].join(", "); | ||
function isFocusable(node) { | ||
return node.matches(FOCUSABLE_SELECTOR); | ||
} | ||
function hasGlobalStateOrProperty(node) { | ||
return globalStatesAndProperties.some((global) => node.hasAttribute(global)); | ||
} | ||
function getExplicitRole({ accessibleName, inheritedImplicitPresentational, node, }) { | ||
const rawRoles = node.getAttribute("role")?.trim().split(" ") ?? []; | ||
// "Children Presentational: True" | ||
// https://w3c.github.io/aria/#tree_exclusion | ||
if (inheritedImplicitPresentational) { | ||
rawRoles.unshift("none"); | ||
} | ||
if (!rawRoles?.length) { | ||
return ""; | ||
} | ||
const filteredRoles = rawRole | ||
.split(" ") | ||
// TODO: allowed child element exceptions for | ||
// https://w3c.github.io/aria/#conflict_resolution_presentation_none | ||
const filteredRoles = rawRoles | ||
/** | ||
@@ -44,12 +92,38 @@ * As stated in the Definition of Roles section, it is considered an | ||
*/ | ||
.filter((role) => !!accessibleName || !rolesRequiringName.includes(role)); | ||
.filter((role) => !!accessibleName || !rolesRequiringName.includes(role)) | ||
/** | ||
* If an element is focusable, user agents MUST ignore the | ||
* presentation/none role and expose the element with its implicit role, in | ||
* order to ensure that the element is operable. | ||
* | ||
* If an element has global WAI-ARIA states or properties, user agents MUST | ||
* ignore the presentation role and instead expose the element's implicit | ||
* role. However, if an element has only non-global, role-specific WAI-ARIA | ||
* states or properties, the element MUST NOT be exposed unless the | ||
* presentational role is inherited and an explicit non-presentational role | ||
* is applied. | ||
* | ||
* https://w3c.github.io/aria/#conflict_resolution_presentation_none | ||
*/ | ||
.filter((role) => { | ||
if (!exports.presentationRoles.includes(role)) { | ||
return true; | ||
} | ||
if (hasGlobalStateOrProperty(node) || isFocusable(node)) { | ||
return false; | ||
} | ||
return true; | ||
}); | ||
return filteredRoles?.[0] ?? ""; | ||
} | ||
// TODO: https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none | ||
function getRole(node, accessibleName) { | ||
function getRole({ accessibleName, inheritedImplicitPresentational, node, }) { | ||
if (!(0, isElement_1.isElement)(node)) { | ||
return ""; | ||
} | ||
const target = node.cloneNode(true); | ||
const explicitRole = getExplicitRole(target, accessibleName); | ||
const target = node.cloneNode(); | ||
const explicitRole = getExplicitRole({ | ||
accessibleName, | ||
inheritedImplicitPresentational, | ||
node: target, | ||
}); | ||
if (explicitRole) { | ||
@@ -61,5 +135,10 @@ target.setAttribute("role", explicitRole); | ||
} | ||
const role = (0, dom_accessibility_api_1.getRole)(target) ?? ""; | ||
return ignoredRoles.includes(role) ? "" : role; | ||
let role = (0, dom_accessibility_api_1.getRole)(target) ?? ""; | ||
if (!role) { | ||
// TODO: decide if we just replace dom-accessibility-api above with | ||
// @testing-library/dom here rather than doing this fallback | ||
role = Object.keys((0, dom_1.getRoles)(target))?.[0] ?? ""; | ||
} | ||
return role; | ||
} | ||
exports.getRole = getRole; |
@@ -8,4 +8,30 @@ "use strict"; | ||
const user_event_1 = require("@testing-library/user-event"); | ||
// TODO: monitor focus change and update the screen reader active element | ||
// TODO: handle aria-live, role="polite", role="alert", and other interruptions | ||
// TODO: monitor focus change and update the screen reader active element. | ||
// TODO: handle aria-live, role="polite", role="alert", and other interruptions. | ||
const observeDOM = (function () { | ||
const MutationObserver = | ||
// @ts-expect-error WebKitMutationObserver is non-standard WebKit fallback for old implementations | ||
window.MutationObserver || window.WebKitMutationObserver; | ||
return function observeDOM(node, onChange) { | ||
if (!node || node.nodeType !== 1) { | ||
return; | ||
} | ||
const callback = () => onChange(); | ||
if (MutationObserver) { | ||
const mutationObserver = new MutationObserver(callback); | ||
mutationObserver.observe(node, { childList: true, subtree: true }); | ||
return () => { | ||
mutationObserver.disconnect(); | ||
}; | ||
} | ||
else if (window.addEventListener) { | ||
node.addEventListener("DOMNodeInserted", callback, false); | ||
node.addEventListener("DOMNodeRemoved", callback, false); | ||
return () => { | ||
node.removeEventListener("DOMNodeInserted", callback, false); | ||
node.removeEventListener("DOMNodeRemoved", callback, false); | ||
}; | ||
} | ||
}; | ||
})(); | ||
class Virtual { | ||
@@ -16,2 +42,4 @@ #activeNode = null; | ||
#spokenPhraseLog = []; | ||
#treeCache = null; | ||
#disconnectDOMObserver = null; | ||
#checkContainer() { | ||
@@ -23,7 +51,15 @@ if (!this.#container) { | ||
#getAccessibilityTree() { | ||
return (0, createAccessibilityTree_1.createAccessibilityTree)(this.#container); | ||
if (!this.#treeCache) { | ||
this.#treeCache = (0, createAccessibilityTree_1.createAccessibilityTree)(this.#container); | ||
} | ||
return this.#treeCache; | ||
} | ||
#invalidateTreeCache() { | ||
this.#treeCache = null; | ||
} | ||
#updateState(accessibilityNode) { | ||
const { role, accessibleName } = accessibilityNode; | ||
const spokenPhrase = [role, accessibleName].filter(Boolean).join(", "); | ||
const { accessibleDescription, accessibleName, role } = accessibilityNode; | ||
const spokenPhrase = [role, accessibleName, accessibleDescription] | ||
.filter(Boolean) | ||
.join(", "); | ||
this.#activeNode = accessibilityNode; | ||
@@ -51,5 +87,9 @@ this.#itemTextLog.push(accessibleName); | ||
this.#updateState(tree[0]); | ||
const invalidateTreeCache = () => this.#invalidateTreeCache(); | ||
this.#disconnectDOMObserver = observeDOM(container, invalidateTreeCache); | ||
return; | ||
} | ||
async stop() { | ||
this.#disconnectDOMObserver?.(); | ||
this.#invalidateTreeCache(); | ||
this.#activeNode = null; | ||
@@ -56,0 +96,0 @@ this.#container = null; |
{ | ||
"name": "@guidepup/virtual-screen-reader", | ||
"version": "0.1.0", | ||
"version": "0.1.1", | ||
"description": "Virtual screen reader driver for unit test automation.", | ||
@@ -5,0 +5,0 @@ "main": "lib/index.js", |
@@ -44,10 +44,2 @@ <h1 align="center">Guidepup Virtual Screen Reader</h1> | ||
import { virtual } from "@guidepup/virtual-screen-reader"; | ||
import { | ||
getByLabelText, | ||
getByText, | ||
getByTestId, | ||
queryByTestId, | ||
waitFor, | ||
} from "@testing-library/dom"; | ||
import "@testing-library/jest-dom"; | ||
@@ -54,0 +46,0 @@ function setupBasicPage() { |
27320
23
541
119