Skip to content

Commit

Permalink
feat: Resolve presentational role conflicts when ARIA attributes are …
Browse files Browse the repository at this point in the history
…used (#436)
  • Loading branch information
eps1lon authored Sep 21, 2020
1 parent 5357beb commit 96d4438
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 17 deletions.
8 changes: 8 additions & 0 deletions .changeset/sour-forks-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"dom-accessibility-api": patch
---

Resolve presentational role conflicts when global WAI-ARIA states or properties (ARIA attributes) are used.

`<img alt="" />` used to have no role.
[By spec](https://w3c.github.io/html-aam/#el-img-empty-alt) it should have `role="presentation"` with no ARIA attributes or `role="img"` [otherwise](https://rawgit.com/w3c/aria/stable/#conflict_resolution_presentation_none).
4 changes: 4 additions & 0 deletions sources/__tests__/accessible-name.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,10 @@ test.each([
// https://w3c.github.io/html-aam/#input-type-image
[`<input data-test alt="Select an image" type="image" />`, "Select an image"],
[`<input data-test alt="" type="image" />`, "Submit Query"],
[
`<img data-test alt="" aria-label="a logo" role="presentation" /> />`,
"a logo",
],
])(`test #%#`, testMarkup);

test("text nodes are not concatenated by space", () => {
Expand Down
7 changes: 6 additions & 1 deletion sources/__tests__/getRole.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const cases = [
["iframe", null, createElementFactory("iframe", {})],
["img with alt=\"some text\"", "img", createElementFactory("img", {alt: "text"})],
["img with missing alt", "img", createElementFactory("img", {})],
["img with alt=\"\"", null, createElementFactory("img", {alt: ""})],
["img with alt=\"\"", "presentation", createElementFactory("img", {alt: ""})],
["input type=button", "button", createElementFactory("input", {type: "button"})],
["input type=checkbox", "checkbox", createElementFactory("input", {type: "checkbox"})],
["input type=color", null, createElementFactory("input", {type: "color"})],
Expand Down Expand Up @@ -172,6 +172,11 @@ const cases = [
["track", null, createElementFactory("track", {})],
["ul", "list", createElementFactory("ul", {})],
["video", null, createElementFactory("video", {})],
// https://rawgit.com/w3c/aria/stable/#conflict_resolution_presentation_none
["presentational <img /> with accessible name", "img", createElementFactory("img", {alt: "", 'aria-label': "foo"})],
["presentational <h1 /> global aria attributes", "heading", createElementFactory("h1", {'aria-describedby': "comment-1", role: "presentation"})],
// <div /> isn't mapped to `"generic"` yet so implicit semantics are `No role`
["presentational <div /> with prohibited aria attributes", null, createElementFactory("div", {'aria-label': "hello", role: "presentation"})],
];

it.each(cases)("%s has the role %s", (name, role, elementFactory) => {
Expand Down
97 changes: 82 additions & 15 deletions sources/getRole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@

import { getLocalName } from "./util";

export default function getRole(element: Element): string | null {
const explicitRole = getExplicitRole(element);
if (explicitRole !== null) {
return explicitRole;
}

return getImplicitRole(element);
}

const localNameToRoleMappings: Record<string, string | undefined> = {
article: "article",
aside: "complementary",
Expand Down Expand Up @@ -61,6 +52,81 @@ const localNameToRoleMappings: Record<string, string | undefined> = {
ul: "list",
};

const prohibitedAttributes: Record<string, Set<string>> = {
caption: new Set(["aria-label", "aria-labelledby"]),
code: new Set(["aria-label", "aria-labelledby"]),
deletion: new Set(["aria-label", "aria-labelledby"]),
emphasis: new Set(["aria-label", "aria-labelledby"]),
generic: new Set(["aria-label", "aria-labelledby", "aria-roledescription"]),
insertion: new Set(["aria-label", "aria-labelledby"]),
paragraph: new Set(["aria-label", "aria-labelledby"]),
presentation: new Set(["aria-label", "aria-labelledby"]),
strong: new Set(["aria-label", "aria-labelledby"]),
subscript: new Set(["aria-label", "aria-labelledby"]),
superscript: new Set(["aria-label", "aria-labelledby"]),
};

/**
*
* @param element
* @param role The role used for this element. This is specified to control whether you want to use the implicit or explicit role.
*/
function hasGlobalAriaAttributes(element: Element, role: string): boolean {
// https://rawgit.com/w3c/aria/stable/#global_states
// commented attributes are deprecated
return [
"aria-atomic",
"aria-busy",
"aria-controls",
"aria-current",
"aria-describedby",
"aria-details",
// "disabled",
"aria-dropeffect",
// "errormessage",
"aria-flowto",
"aria-grabbed",
// "haspopup",
"aria-hidden",
// "invalid",
"aria-keyshortcuts",
"aria-label",
"aria-labelledby",
"aria-live",
"aria-owns",
"aria-relevant",
"aria-roledescription",
].some((attributeName) => {
return (
element.hasAttribute(attributeName) &&
!prohibitedAttributes[role]?.has(attributeName)
);
});
}

function ignorePresentationalRole(
element: Element,
implicitRole: string
): boolean {
// https://rawgit.com/w3c/aria/stable/#conflict_resolution_presentation_none
return hasGlobalAriaAttributes(element, implicitRole);
}

export default function getRole(element: Element): string | null {
const explicitRole = getExplicitRole(element);
if (explicitRole === null || explicitRole === "presentation") {
const implicitRole = getImplicitRole(element);
if (
explicitRole !== "presentation" ||
ignorePresentationalRole(element, implicitRole || "")
) {
return implicitRole;
}
}

return explicitRole;
}

function getImplicitRole(element: Element): string | null {
const mappedByTag = localNameToRoleMappings[getLocalName(element)];
if (mappedByTag !== undefined) {
Expand All @@ -75,13 +141,14 @@ function getImplicitRole(element: Element): string | null {
return "link";
}
break;
case "img": {
const alt: string | null = element.getAttribute("alt");
if (alt === null || alt.length > 0) {
return "img";
case "img":
if (
element.getAttribute("alt") === "" &&
!ignorePresentationalRole(element, "img")
) {
return "presentation";
}
break;
}
return "img";
case "input": {
const { type } = element as HTMLInputElement;
switch (type) {
Expand Down
7 changes: 6 additions & 1 deletion sources/polyfills/SetLike.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
declare global {
class Set<T> {
// es2015.collection.d.ts
constructor(items?: T[]);
add(value: T): this;
clear(): void;
delete(value: T): boolean;
Expand All @@ -18,7 +19,11 @@ declare global {

// for environments without Set we fallback to arrays with unique members
class SetLike<T> implements Set<T> {
private items: T[] = [];
private items: T[];

constructor(items: T[] = []) {
this.items = items;
}

add(value: T): this {
if (this.has(value) === false) {
Expand Down

0 comments on commit 96d4438

Please sign in to comment.