Skip to content
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The current status of the WPT coverage is:

| Passing | Failing | Skipped |
| :-----: | :-----: | :-----: |
| 402 | 129 | 340 |
| 404 | 119 | 338 |

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.

Expand Down
2 changes: 0 additions & 2 deletions examples/web-test-runner/test/virtual.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,10 @@ it("renders a heading and a paragraph", async () => {
"Section Text",
"end of paragraph",
"article",
"banner",
"heading, Article Header Heading 1, level 1",
"paragraph",
"Article Header Text",
"end of paragraph",
"end of banner",
"paragraph",
"Article Text",
"end of paragraph",
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module.exports = {
},
},
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
testPathIgnorePatterns: ["<rootDir>/test/wpt"],
testPathIgnorePatterns: ["<rootDir>/test/wpt/", "<rootDir>/test/wpt-jsdom/"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "./tsconfig.test.json" }],
},
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/user-event": "^14.5.2",
"aria-query": "^5.3.0",
"dom-accessibility-api": "^0.7.0"
"dom-accessibility-api": "^0.7.0",
"html-aria": "^0.1.6"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.16.2",
Expand Down
8 changes: 4 additions & 4 deletions src/getLiveSpokenPhrase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ enum Relevant {
TEXT = "text",
}

const RELEVANT_VALUES = Object.values(Relevant);
const RELEVANT_VALUES = new Set(Object.values(Relevant));
const DEFAULT_ATOMIC = false;
const DEFAULT_LIVE = Live.OFF;
const DEFAULT_RELEVANT = [Relevant.ADDITIONS, Relevant.TEXT];
Expand All @@ -60,7 +60,7 @@ function getSpokenPhraseForNode(node: Node) {
getAccessibleValue(node) ||
// `node.textContent` is only `null` if the `node` is a `document` or a
// `doctype`. We don't consider either.

sanitizeString(node.textContent!)
);
}
Expand Down Expand Up @@ -229,12 +229,12 @@ function getLiveRegionAttributes(
if (typeof relevant === "undefined" && target.hasAttribute("aria-relevant")) {
// The `target.hasAttribute("aria-relevant")` check is sufficient to guard
// against the `target.getAttribute("aria-relevant")` being null.

relevant = target
.getAttribute("aria-relevant")!
.split(" ")
.filter(
(token) => !!RELEVANT_VALUES.includes(token as Relevant)
(token) => !!RELEVANT_VALUES.has(token as Relevant)
) as Relevant[];

if (relevant.includes(Relevant.ALL)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ARIARoleDefinitionKey, roles } from "aria-query";
import { globalStatesAndProperties } from "../getRole";
import { globalStatesAndProperties, reverseSynonymRolesMap } from "../getRole";

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

export const getAttributesByRole = ({
accessibleValue,
Expand All @@ -10,10 +10,18 @@ export const getAttributesByRole = ({
accessibleValue: string;
role: string;
}): [string, string | null][] => {
// TODO: temporary solution until aria-query is updated with WAI-ARIA 1.3
// synonym roles, or the html-aria package supports implicit attribute
// values.
const reverseSynonymRole = (reverseSynonymRolesMap[role] ??
role) as ARIARoleDefinitionKey;

// TODO: swap out with the html-aria package if implicit role attributes
// become supported.
const {
props: implicitRoleAttributes = {},
prohibitedProps: prohibitedAttributes = [],
} = (roles.get(role as ARIARoleDefinitionKey) ?? {}) as {
} = (roles.get(reverseSynonymRole) ?? {}) as {
props: Record<string, string | undefined>;
prohibitedProps: string[];
};
Expand All @@ -27,8 +35,7 @@ export const getAttributesByRole = ({
.filter((attribute) => !prohibitedAttributes.includes(attribute))
.filter(
(attribute) =>
!accessibleValue ||
!ignoreAttributesWithAccessibleValue.includes(attribute)
!accessibleValue || !ignoreAttributesWithAccessibleValue.has(attribute)
);

return uniqueAttributes.map((attribute) => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const getNodeSet = ({
});
};

const levelItemRoles = new Set(["listitem", "treeitem"]);

type Mapper = ({
node,
tree,
Expand Down Expand Up @@ -112,7 +114,7 @@ const mapHtmlElementAriaToImplicitValue: Record<string, Mapper> = {
});
}

if (["listitem", "treeitem"].includes(role)) {
if (levelItemRoles.has(role)) {
return getLevelFromDocumentStructure({
role,
tree,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const percentageBasedValueRoles = ["progressbar", "scrollbar"];
const percentageBasedValueRoles = new Set(["progressbar", "scrollbar"]);

const isNumberLike = (value: string) => {
return !isNaN(parseFloat(value));
Expand All @@ -24,7 +24,7 @@ export const postProcessAriaValueNow = ({
role: string;
value: string;
}) => {
if (!percentageBasedValueRoles.includes(role)) {
if (!percentageBasedValueRoles.has(role)) {
return value;
}

Expand Down
10 changes: 5 additions & 5 deletions src/getNodeAccessibilityData/getAccessibleValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export type HTMLElementWithValue =
| HTMLProgressElement
| HTMLParamElement;

const ignoredInputTypes = ["checkbox", "radio"];
const allowedLocalNames = [
const ignoredInputTypes = new Set(["checkbox", "radio"]);
const allowedLocalNames = new Set([
"button",
"data",
"input",
Expand All @@ -21,7 +21,7 @@ const allowedLocalNames = [
"option",
"progress",
"param",
];
]);

function getSelectValue(node: HTMLSelectElement) {
const selectedOptions = [...node.options].filter(
Expand All @@ -42,7 +42,7 @@ function getSelectValue(node: HTMLSelectElement) {
}

function getInputValue(node: HTMLInputElement) {
if (ignoredInputTypes.includes(node.type)) {
if (ignoredInputTypes.has(node.type)) {
return "";
}

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

Expand Down
143 changes: 113 additions & 30 deletions src/getNodeAccessibilityData/getRole.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
import { getRole as getImplicitRole } from "dom-accessibility-api";
import {
type AncestorList,
getRole as getImplicitRole,
roles,
type TagName,
type VirtualElement,
} from "html-aria";
import { roles as backupRoles } from "aria-query";
import { getLocalName } from "../getLocalName";
import { getRoles } from "@testing-library/dom";
import { isElement } from "../isElement";
import { roles } from "aria-query";

export const presentationRoles = ["presentation", "none"];
export const presentationRoles = new Set(["presentation", "none"]);

const allowedNonAbstractRoles = roles
.entries()
.filter(([, { abstract }]) => !abstract)
.map(([key]) => key) as string[];
export const synonymRolesMap: Record<string, string> = {
img: "image",
presentation: "none",
directory: "list",
};

export const reverseSynonymRolesMap: Record<string, string> =
Object.fromEntries(
Object.entries(synonymRolesMap).map(([key, value]) => [value, key])
);

const rolesRequiringName = ["form", "region"];
const allowedNonAbstractRoles = new Set([
...(Object.entries(roles)
.filter(([, { type }]) => !type.includes("abstract"))
.map(([key]) => key) as string[]),
// TODO: remove once the `html-aria` package supports `dpub-aam` /
// `dpub-aria` specifications.
...(backupRoles
.entries()
.filter(([, { abstract }]) => !abstract)
.map(([key]) => key) as string[]),
]);

const rolesRequiringName = new Set(["form", "region"]);

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

const aliasedRolesMap: Record<string, string> = {
img: "image",
presentation: "none",
};

function mapAliasedRoles(role: string) {
const canonical = aliasedRolesMap[role];
const canonical = synonymRolesMap[role];

return canonical ?? role;
}
Expand All @@ -87,7 +105,7 @@ function getExplicitRole({
*
* REF: https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
*/
.filter((role) => allowedNonAbstractRoles.includes(role))
.filter((role) => allowedNonAbstractRoles.has(role))
/**
* Certain landmark roles require names from authors. In situations where
* an author has not specified names for these landmarks, it is
Expand All @@ -102,7 +120,7 @@ function getExplicitRole({
*
* REF: https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
*/
.filter((role) => !!accessibleName || !rolesRequiringName.includes(role));
.filter((role) => !!accessibleName || !rolesRequiringName.has(role));

/**
* If an allowed child element has an explicit non-presentational role, user
Expand Down Expand Up @@ -149,7 +167,7 @@ function getExplicitRole({
* REF: https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
*/
.filter((role) => {
if (!presentationRoles.includes(role)) {
if (!presentationRoles.has(role)) {
return true;
}

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

// TODO: upstream update to `html-aria` to support supplying a jsdom element in
// a Node environment. Appears their check for `element instanceof HTMLElement`
// fails the `test/int/nodeEnvironment.int.test.ts` suite.
function virtualizeElement(element: HTMLElement): VirtualElement {
const tagName = getLocalName(element) as TagName;
const attributes: Record<string, string | null> = {};

for (let i = 0; i < element.attributes.length; i++) {
const { name } = element.attributes[i]!;

attributes[name] = element.getAttribute(name);
}

return { tagName, attributes };
}

const rolesDependentOnHierarchy = new Set([
"footer",
"header",
"li",
"td",
"th",
"tr",
]);
const ignoredAncestors = new Set(["body", "document"]);

// TODO: Thought needed if the `getAncestors()` can limit the number of parents
// it enumerates? Presumably as ancestors only matter for a limited number of
// roles, there might be a ceiling to the amount of nesting that is even valid,
// and therefore put an upper bound on how far to backtrack without having to
// stop at the document level for every single element.
//
// Another thought is that we special case each element so the backtracking can
// exit early if an ancestor with a relevant role has already been found.
//
// Alternatively see if providing an element that is part of a DOM can be
// traversed by the `html-aria` library itself so these concerns are
// centralised.
function getAncestors(node: HTMLElement): AncestorList | undefined {
if (!rolesDependentOnHierarchy.has(getLocalName(node))) {
return undefined;
}

const ancestors: AncestorList = [];

let target: HTMLElement | null = node;
let targetLocalName: string;

while (true) {
target = target.parentElement;

if (!target) {
break;
}

targetLocalName = getLocalName(target);

if (ignoredAncestors.has(targetLocalName)) {
break;
}

ancestors.push({ tagName: targetLocalName as TagName });
}

return ancestors;
}

export function getRole({
accessibleName,
allowedAccessibilityRoles,
Expand All @@ -179,12 +264,13 @@ export function getRole({
}

const target = node.cloneNode() as HTMLElement;
const explicitRole = getExplicitRole({
const baseExplicitRole = getExplicitRole({
accessibleName,
allowedAccessibilityRoles,
inheritedImplicitPresentational,
node: target,
});
const explicitRole = mapAliasedRoles(baseExplicitRole);

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

target.removeAttribute("role");

let implicitRole = getImplicitRole(target) ?? "";
// Backwards compatibility
const isBodyElement = getLocalName(target) === "body";

if (!implicitRole) {
// Backwards compatibility for when was using aria-query@5.1.3
if (getLocalName(target) === "body") {
implicitRole = "document";
} else {
// TODO: remove this fallback post https://github.com/eps1lon/dom-accessibility-api/pull/937
implicitRole = Object.keys(getRoles(target))?.[0] ?? "";
}
}
const baseImplicitRole = isBodyElement
? "document"
: getImplicitRole(virtualizeElement(target), {
ancestors: getAncestors(node),
}) ?? "";

implicitRole = mapAliasedRoles(implicitRole);
const implicitRole = mapAliasedRoles(baseImplicitRole);

if (explicitRole) {
return { explicitRole, implicitRole, role: explicitRole };
Expand Down
Loading
Loading