Skip to content

Commit 654297c

Browse files
authored
feat: swap to html-aria for implicit role algorithm (#113)
* feat: swap out `dom-accessibility-api` for `html-aria` for implicit role calculation * test: update WPT suite new passes, failures, and reasons * feat: support (some) context based roles * test: fix `banner` --> `generic` for `header` element * fix: dual run aria-query and html-aria for dpub spec support * docs: update WPT coverage stats * fix: synonym roles * chore: remove wpt-deprecated
1 parent e19630a commit 654297c

File tree

56 files changed

+242
-2213
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+242
-2213
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The current status of the WPT coverage is:
5656

5757
| Passing | Failing | Skipped |
5858
| :-----: | :-----: | :-----: |
59-
| 402 | 129 | 340 |
59+
| 404 | 119 | 338 |
6060

6161
The included tests, skipped tests, and expected failures can be found in the [WPT configuration file](./test/wpt-jsdom/to-run.yaml) with reasons as to skips and expected failures.
6262

examples/web-test-runner/test/virtual.test.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,10 @@ it("renders a heading and a paragraph", async () => {
2323
"Section Text",
2424
"end of paragraph",
2525
"article",
26-
"banner",
2726
"heading, Article Header Heading 1, level 1",
2827
"paragraph",
2928
"Article Header Text",
3029
"end of paragraph",
31-
"end of banner",
3230
"paragraph",
3331
"Article Text",
3432
"end of paragraph",

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ module.exports = {
1515
},
1616
},
1717
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
18-
testPathIgnorePatterns: ["<rootDir>/test/wpt"],
18+
testPathIgnorePatterns: ["<rootDir>/test/wpt/", "<rootDir>/test/wpt-jsdom/"],
1919
transform: {
2020
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "./tsconfig.test.json" }],
2121
},

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
"@testing-library/dom": "^10.4.0",
6464
"@testing-library/user-event": "^14.5.2",
6565
"aria-query": "^5.3.0",
66-
"dom-accessibility-api": "^0.7.0"
66+
"dom-accessibility-api": "^0.7.0",
67+
"html-aria": "^0.1.6"
6768
},
6869
"devDependencies": {
6970
"@arethetypeswrong/cli": "^0.16.2",

src/getLiveSpokenPhrase.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ enum Relevant {
4949
TEXT = "text",
5050
}
5151

52-
const RELEVANT_VALUES = Object.values(Relevant);
52+
const RELEVANT_VALUES = new Set(Object.values(Relevant));
5353
const DEFAULT_ATOMIC = false;
5454
const DEFAULT_LIVE = Live.OFF;
5555
const DEFAULT_RELEVANT = [Relevant.ADDITIONS, Relevant.TEXT];
@@ -60,7 +60,7 @@ function getSpokenPhraseForNode(node: Node) {
6060
getAccessibleValue(node) ||
6161
// `node.textContent` is only `null` if the `node` is a `document` or a
6262
// `doctype`. We don't consider either.
63-
63+
6464
sanitizeString(node.textContent!)
6565
);
6666
}
@@ -229,12 +229,12 @@ function getLiveRegionAttributes(
229229
if (typeof relevant === "undefined" && target.hasAttribute("aria-relevant")) {
230230
// The `target.hasAttribute("aria-relevant")` check is sufficient to guard
231231
// against the `target.getAttribute("aria-relevant")` being null.
232-
232+
233233
relevant = target
234234
.getAttribute("aria-relevant")!
235235
.split(" ")
236236
.filter(
237-
(token) => !!RELEVANT_VALUES.includes(token as Relevant)
237+
(token) => !!RELEVANT_VALUES.has(token as Relevant)
238238
) as Relevant[];
239239

240240
if (relevant.includes(Relevant.ALL)) {

src/getNodeAccessibilityData/getAccessibleAttributeLabels/getAttributesByRole.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ARIARoleDefinitionKey, roles } from "aria-query";
2-
import { globalStatesAndProperties } from "../getRole";
2+
import { globalStatesAndProperties, reverseSynonymRolesMap } from "../getRole";
33

4-
const ignoreAttributesWithAccessibleValue = ["aria-placeholder"];
4+
const ignoreAttributesWithAccessibleValue = new Set(["aria-placeholder"]);
55

66
export const getAttributesByRole = ({
77
accessibleValue,
@@ -10,10 +10,18 @@ export const getAttributesByRole = ({
1010
accessibleValue: string;
1111
role: string;
1212
}): [string, string | null][] => {
13+
// TODO: temporary solution until aria-query is updated with WAI-ARIA 1.3
14+
// synonym roles, or the html-aria package supports implicit attribute
15+
// values.
16+
const reverseSynonymRole = (reverseSynonymRolesMap[role] ??
17+
role) as ARIARoleDefinitionKey;
18+
19+
// TODO: swap out with the html-aria package if implicit role attributes
20+
// become supported.
1321
const {
1422
props: implicitRoleAttributes = {},
1523
prohibitedProps: prohibitedAttributes = [],
16-
} = (roles.get(role as ARIARoleDefinitionKey) ?? {}) as {
24+
} = (roles.get(reverseSynonymRole) ?? {}) as {
1725
props: Record<string, string | undefined>;
1826
prohibitedProps: string[];
1927
};
@@ -27,8 +35,7 @@ export const getAttributesByRole = ({
2735
.filter((attribute) => !prohibitedAttributes.includes(attribute))
2836
.filter(
2937
(attribute) =>
30-
!accessibleValue ||
31-
!ignoreAttributesWithAccessibleValue.includes(attribute)
38+
!accessibleValue || !ignoreAttributesWithAccessibleValue.has(attribute)
3239
);
3340

3441
return uniqueAttributes.map((attribute) => [

src/getNodeAccessibilityData/getAccessibleAttributeLabels/getLabelFromImplicitHtmlElementValue/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const getNodeSet = ({
6363
});
6464
};
6565

66+
const levelItemRoles = new Set(["listitem", "treeitem"]);
67+
6668
type Mapper = ({
6769
node,
6870
tree,
@@ -112,7 +114,7 @@ const mapHtmlElementAriaToImplicitValue: Record<string, Mapper> = {
112114
});
113115
}
114116

115-
if (["listitem", "treeitem"].includes(role)) {
117+
if (levelItemRoles.has(role)) {
116118
return getLevelFromDocumentStructure({
117119
role,
118120
tree,

src/getNodeAccessibilityData/getAccessibleAttributeLabels/postProcessAriaValueNow.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const percentageBasedValueRoles = ["progressbar", "scrollbar"];
1+
const percentageBasedValueRoles = new Set(["progressbar", "scrollbar"]);
22

33
const isNumberLike = (value: string) => {
44
return !isNaN(parseFloat(value));
@@ -24,7 +24,7 @@ export const postProcessAriaValueNow = ({
2424
role: string;
2525
value: string;
2626
}) => {
27-
if (!percentageBasedValueRoles.includes(role)) {
27+
if (!percentageBasedValueRoles.has(role)) {
2828
return value;
2929
}
3030

src/getNodeAccessibilityData/getAccessibleValue.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export type HTMLElementWithValue =
1111
| HTMLProgressElement
1212
| HTMLParamElement;
1313

14-
const ignoredInputTypes = ["checkbox", "radio"];
15-
const allowedLocalNames = [
14+
const ignoredInputTypes = new Set(["checkbox", "radio"]);
15+
const allowedLocalNames = new Set([
1616
"button",
1717
"data",
1818
"input",
@@ -21,7 +21,7 @@ const allowedLocalNames = [
2121
"option",
2222
"progress",
2323
"param",
24-
];
24+
]);
2525

2626
function getSelectValue(node: HTMLSelectElement) {
2727
const selectedOptions = [...node.options].filter(
@@ -42,7 +42,7 @@ function getSelectValue(node: HTMLSelectElement) {
4242
}
4343

4444
function getInputValue(node: HTMLInputElement) {
45-
if (ignoredInputTypes.includes(node.type)) {
45+
if (ignoredInputTypes.has(node.type)) {
4646
return "";
4747
}
4848

@@ -55,7 +55,7 @@ function getValue(node: HTMLElementWithValue) {
5555
// TODO: handle use of explicit roles where a value taken from content is
5656
// expected, e.g. combobox.
5757
// See core-aam/combobox-value-calculation-manual.html
58-
if (!allowedLocalNames.includes(localName)) {
58+
if (!allowedLocalNames.has(localName)) {
5959
return "";
6060
}
6161

src/getNodeAccessibilityData/getRole.ts

Lines changed: 113 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
1-
import { getRole as getImplicitRole } from "dom-accessibility-api";
1+
import {
2+
type AncestorList,
3+
getRole as getImplicitRole,
4+
roles,
5+
type TagName,
6+
type VirtualElement,
7+
} from "html-aria";
8+
import { roles as backupRoles } from "aria-query";
29
import { getLocalName } from "../getLocalName";
3-
import { getRoles } from "@testing-library/dom";
410
import { isElement } from "../isElement";
5-
import { roles } from "aria-query";
611

7-
export const presentationRoles = ["presentation", "none"];
12+
export const presentationRoles = new Set(["presentation", "none"]);
813

9-
const allowedNonAbstractRoles = roles
10-
.entries()
11-
.filter(([, { abstract }]) => !abstract)
12-
.map(([key]) => key) as string[];
14+
export const synonymRolesMap: Record<string, string> = {
15+
img: "image",
16+
presentation: "none",
17+
directory: "list",
18+
};
19+
20+
export const reverseSynonymRolesMap: Record<string, string> =
21+
Object.fromEntries(
22+
Object.entries(synonymRolesMap).map(([key, value]) => [value, key])
23+
);
1324

14-
const rolesRequiringName = ["form", "region"];
25+
const allowedNonAbstractRoles = new Set([
26+
...(Object.entries(roles)
27+
.filter(([, { type }]) => !type.includes("abstract"))
28+
.map(([key]) => key) as string[]),
29+
// TODO: remove once the `html-aria` package supports `dpub-aam` /
30+
// `dpub-aria` specifications.
31+
...(backupRoles
32+
.entries()
33+
.filter(([, { abstract }]) => !abstract)
34+
.map(([key]) => key) as string[]),
35+
]);
36+
37+
const rolesRequiringName = new Set(["form", "region"]);
1538

1639
export const globalStatesAndProperties = [
1740
"aria-atomic",
@@ -54,13 +77,8 @@ function hasGlobalStateOrProperty(node: HTMLElement) {
5477
return globalStatesAndProperties.some((global) => node.hasAttribute(global));
5578
}
5679

57-
const aliasedRolesMap: Record<string, string> = {
58-
img: "image",
59-
presentation: "none",
60-
};
61-
6280
function mapAliasedRoles(role: string) {
63-
const canonical = aliasedRolesMap[role];
81+
const canonical = synonymRolesMap[role];
6482

6583
return canonical ?? role;
6684
}
@@ -87,7 +105,7 @@ function getExplicitRole({
87105
*
88106
* REF: https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
89107
*/
90-
.filter((role) => allowedNonAbstractRoles.includes(role))
108+
.filter((role) => allowedNonAbstractRoles.has(role))
91109
/**
92110
* Certain landmark roles require names from authors. In situations where
93111
* an author has not specified names for these landmarks, it is
@@ -102,7 +120,7 @@ function getExplicitRole({
102120
*
103121
* REF: https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
104122
*/
105-
.filter((role) => !!accessibleName || !rolesRequiringName.includes(role));
123+
.filter((role) => !!accessibleName || !rolesRequiringName.has(role));
106124

107125
/**
108126
* If an allowed child element has an explicit non-presentational role, user
@@ -149,7 +167,7 @@ function getExplicitRole({
149167
* REF: https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
150168
*/
151169
.filter((role) => {
152-
if (!presentationRoles.includes(role)) {
170+
if (!presentationRoles.has(role)) {
153171
return true;
154172
}
155173

@@ -163,6 +181,73 @@ function getExplicitRole({
163181
return filteredRoles?.[0] ?? "";
164182
}
165183

184+
// TODO: upstream update to `html-aria` to support supplying a jsdom element in
185+
// a Node environment. Appears their check for `element instanceof HTMLElement`
186+
// fails the `test/int/nodeEnvironment.int.test.ts` suite.
187+
function virtualizeElement(element: HTMLElement): VirtualElement {
188+
const tagName = getLocalName(element) as TagName;
189+
const attributes: Record<string, string | null> = {};
190+
191+
for (let i = 0; i < element.attributes.length; i++) {
192+
const { name } = element.attributes[i]!;
193+
194+
attributes[name] = element.getAttribute(name);
195+
}
196+
197+
return { tagName, attributes };
198+
}
199+
200+
const rolesDependentOnHierarchy = new Set([
201+
"footer",
202+
"header",
203+
"li",
204+
"td",
205+
"th",
206+
"tr",
207+
]);
208+
const ignoredAncestors = new Set(["body", "document"]);
209+
210+
// TODO: Thought needed if the `getAncestors()` can limit the number of parents
211+
// it enumerates? Presumably as ancestors only matter for a limited number of
212+
// roles, there might be a ceiling to the amount of nesting that is even valid,
213+
// and therefore put an upper bound on how far to backtrack without having to
214+
// stop at the document level for every single element.
215+
//
216+
// Another thought is that we special case each element so the backtracking can
217+
// exit early if an ancestor with a relevant role has already been found.
218+
//
219+
// Alternatively see if providing an element that is part of a DOM can be
220+
// traversed by the `html-aria` library itself so these concerns are
221+
// centralised.
222+
function getAncestors(node: HTMLElement): AncestorList | undefined {
223+
if (!rolesDependentOnHierarchy.has(getLocalName(node))) {
224+
return undefined;
225+
}
226+
227+
const ancestors: AncestorList = [];
228+
229+
let target: HTMLElement | null = node;
230+
let targetLocalName: string;
231+
232+
while (true) {
233+
target = target.parentElement;
234+
235+
if (!target) {
236+
break;
237+
}
238+
239+
targetLocalName = getLocalName(target);
240+
241+
if (ignoredAncestors.has(targetLocalName)) {
242+
break;
243+
}
244+
245+
ancestors.push({ tagName: targetLocalName as TagName });
246+
}
247+
248+
return ancestors;
249+
}
250+
166251
export function getRole({
167252
accessibleName,
168253
allowedAccessibilityRoles,
@@ -179,12 +264,13 @@ export function getRole({
179264
}
180265

181266
const target = node.cloneNode() as HTMLElement;
182-
const explicitRole = getExplicitRole({
267+
const baseExplicitRole = getExplicitRole({
183268
accessibleName,
184269
allowedAccessibilityRoles,
185270
inheritedImplicitPresentational,
186271
node: target,
187272
});
273+
const explicitRole = mapAliasedRoles(baseExplicitRole);
188274

189275
// Feature detect AOM support
190276
// TODO: this isn't quite right, computed role might not be the implicit
@@ -198,19 +284,16 @@ export function getRole({
198284

199285
target.removeAttribute("role");
200286

201-
let implicitRole = getImplicitRole(target) ?? "";
287+
// Backwards compatibility
288+
const isBodyElement = getLocalName(target) === "body";
202289

203-
if (!implicitRole) {
204-
// Backwards compatibility for when was using [email protected]
205-
if (getLocalName(target) === "body") {
206-
implicitRole = "document";
207-
} else {
208-
// TODO: remove this fallback post https://github.com/eps1lon/dom-accessibility-api/pull/937
209-
implicitRole = Object.keys(getRoles(target))?.[0] ?? "";
210-
}
211-
}
290+
const baseImplicitRole = isBodyElement
291+
? "document"
292+
: getImplicitRole(virtualizeElement(target), {
293+
ancestors: getAncestors(node),
294+
}) ?? "";
212295

213-
implicitRole = mapAliasedRoles(implicitRole);
296+
const implicitRole = mapAliasedRoles(baseImplicitRole);
214297

215298
if (explicitRole) {
216299
return { explicitRole, implicitRole, role: explicitRole };

0 commit comments

Comments
 (0)