Skip to content

Commit

Permalink
feat: Fork elementToRole from aria-query (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored Oct 21, 2019
1 parent 057d723 commit fe4fab5
Show file tree
Hide file tree
Showing 4 changed files with 316 additions and 37 deletions.
180 changes: 180 additions & 0 deletions sources/__tests__/getRole.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import getRole from "../getRole";

it("prioritizes explicit roles", () => {
const element = document.createElement("div");
element.setAttribute("role", "textbox");

expect(getRole(element)).toBe("textbox");
});

it("ignores whitespace", () => {
const element = document.createElement("div");
element.setAttribute("role", " textbox ");

expect(getRole(element)).toBe("textbox");
});

it("uses the first role", () => {
const element = document.createElement("div");
element.setAttribute("role", "textbox input");

expect(getRole(element)).toBe("textbox");
});

it("ignores empty roles", () => {
const element = document.createElement("div");
element.setAttribute("role", " ");

expect(getRole(element)).toBeNull();
});

function createElementFactory(tagName, attributes) {
return () => {
const element = document.createElement(tagName);

for (const [name, value] of Object.entries(attributes)) {
element.setAttribute(name, value);
}

return element;
};
}

// prettier-ignore
const cases = [
["a element with a href", "link", createElementFactory("a", { href: "any" })],
["a element without a href", null, createElementFactory("a", {})],
["abbr", null, createElementFactory("abbr", {})],
["address", null, createElementFactory("address", {})],
["area with a href", "link", createElementFactory("area", { href: "any" })],
["area without a href", null, createElementFactory("area", {})],
["article", "article", createElementFactory("article", {})],
["aside", "complementary", createElementFactory("aside", {})],
["audio", null, createElementFactory("audio", {})],
["base", null, createElementFactory("base", {})],
["blockquote", null, createElementFactory("blockquote", {})],
["body", "document", createElementFactory("body", {})],
["button", "button", createElementFactory("button", {})],
["canvas", null, createElementFactory("canvas", {})],
["caption", null, createElementFactory("caption", {})],
["col", null, createElementFactory("col", {})],
["colgroup", null, createElementFactory("colgroup", {})],
["datalist", "listbox", createElementFactory("datalist", {})],
["dd", "definition", createElementFactory("dd", {})],
["del", null, createElementFactory("del", {})],
["details", "group", createElementFactory("details", {})],
["dialog", "dialog", createElementFactory("dialog", {})],
["div", null, createElementFactory("div", {})],
["dl", null, createElementFactory("dl", {})],
["dt", "term", createElementFactory("dt", {})],
["em", null, createElementFactory("em", {})],
["embed", null, createElementFactory("embed", {})],
["figcaption", null, createElementFactory("figcaption", {})],
["fieldset", "group", createElementFactory("fieldset", {})],
["figure", "figure", createElementFactory("figure", {})],
// WARNING: Only in certain context
["footer", "contentinfo", createElementFactory("footer", {})],
// WARNING: only with a name
["form", "form", createElementFactory("form", {})],
["h1", "heading", createElementFactory("h1", {})],
["h2", "heading", createElementFactory("h2", {})],
["h3", "heading", createElementFactory("h3", {})],
["h4", "heading", createElementFactory("h4", {})],
["h5", "heading", createElementFactory("h5", {})],
["h6", "heading", createElementFactory("h6", {})],
// WARNING: Only in certain context
["header", "banner", createElementFactory("header", {})],
["hgroup", null, createElementFactory("hgroup", {})],
["hr", "separator", createElementFactory("hr", {})],
["html", null, createElementFactory("html", {})],
["iframe", null, createElementFactory("iframe", {})],
["img with alt=\"some text\"", "img", createElementFactory("img", {alt: "text"})],
["img with alt=\"\"", null, 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"})],
["input type=date", null, createElementFactory("input", {type: "date"})],
["input type=datetime", null, createElementFactory("input", {type: "datetime"})],
["input type=email", "textbox", createElementFactory("input", {type: "email"})],
["input type=file", null, createElementFactory("input", {type: "file"})],
["input type=hidden", null, createElementFactory("input", {type: "hidden"})],
["input type=image", "button", createElementFactory("input", {type: "image"})],
["input type=month", null, createElementFactory("input", {type: "month"})],
["input type=radio", "radio", createElementFactory("input", {type: "radio"})],
["input type=range", "slider", createElementFactory("input", {type: "range"})],
["input type=reset", "button", createElementFactory("input", {type: "reset"})],
["input type=search", "searchbox", createElementFactory("input", {type: "search"})],
["input type=submit", "button", createElementFactory("input", {type: "submit"})],
["input type=tel", "textbox", createElementFactory("input", {type: "tel"})],
["input type=text, with no list attribute", "textbox", createElementFactory("input", {type: "text"})],
["input type=text with a list attribute", "combobox", createElementFactory("input", {list: "",type: "text"})],
["input type=search with a list attribute", "combobox", createElementFactory("input", {list: "",type: "search"})],
["input type=tel with a list attribute", "combobox", createElementFactory("input", {list: "",type: "tel"})],
["input type=url with a list attribute", "combobox", createElementFactory("input", {list: "",type: "url"})],
["input type=email with a list attribute", "combobox", createElementFactory("input", {list: "",type: "email"})],
["input type=time", null, createElementFactory("input", {type: "time"})],
["input type=url", "textbox", createElementFactory("input", {type: "url"})],
["input type=week", null, createElementFactory("input", {type: "week"})],
["ins", null, createElementFactory("ins", {})],
["label", null, createElementFactory("label", {})],
["legend", null, createElementFactory("legend", {})],
// WARNING: Only in certain context
["li", "listitem", createElementFactory("li", {})],
["link element with a href", "link", createElementFactory("link", {href: "some"})],
["main", "main", createElementFactory("main", {})],
["map", null, createElementFactory("map", {})],
["math", "math", createElementFactory("math", {})],
["menu", "list", createElementFactory("menu", {})],
["meta", null, createElementFactory("meta", {})],
["meter", null, createElementFactory("meter", {})],
["nav", "navigation", createElementFactory("nav", {})],
["noscript", null, createElementFactory("noscript", {})],
["object", null, createElementFactory("object", {})],
["ol", "list", createElementFactory("ol", {})],
["optgroup", "group", createElementFactory("optgroup", {})],
// Warning: Only in certain context
["option", "option", createElementFactory("option", {})],
["output", "status", createElementFactory("output", {})],
["p", null, createElementFactory("p", {})],
["param", null, createElementFactory("param", {})],
["picture", null, createElementFactory("picture", {})],
["pre", null, createElementFactory("pre", {})],
["progress", "progressbar", createElementFactory("progress", {})],
["script", null, createElementFactory("script", {})],
// WARNING: Only with a name
["section", "region", createElementFactory("section", {})],
["select, no multiple, no size", "combobox", createElementFactory("select", {})],
["select, no multiple, no size greater 1", "combobox", createElementFactory("select", {size: 1})],
["select, size greater 1", "listbox", createElementFactory("select", {size: 2})],
["select, multiple", "listbox", createElementFactory("select", {multiple: true})],
["slot", null, createElementFactory("slot", {})],
["source", null, createElementFactory("source", {})],
["span", null, createElementFactory("span", {})],
["strong", null, createElementFactory("strong", {})],
["style", null, createElementFactory("style", {})],
["svg", null, createElementFactory("svg", {})],
["sub", null, createElementFactory("sub", {})],
["summary", "button", createElementFactory("summary", {})],
["sup", null, createElementFactory("sup", {})],
["table", "table", createElementFactory("table", {})],
["tbody", "rowgroup", createElementFactory("tbody", {})],
["template", null, createElementFactory("template", {})],
["textarea", "textbox", createElementFactory("textarea", {})],
["tfoot", "rowgroup", createElementFactory("tfoot", {})],
["thead", "rowgroup", createElementFactory("thead", {})],
["time", null, createElementFactory("time", {})],
["title", null, createElementFactory("title", {})],
// WARNING: Only in certain contexts
["td", "cell", createElementFactory("td", {})],
["th", "columnheader", createElementFactory("th", {})],
["tr", "row", createElementFactory("tr", {})],
["track", null, createElementFactory("track", {})],
["ul", "list", createElementFactory("ul", {})],
["video", null, createElementFactory("video", {})],
];

it.each(cases)("%s has the role %s", (name, role, elementFactory) => {
const element = elementFactory();

expect(getRole(element)).toEqual(role);
});
40 changes: 3 additions & 37 deletions sources/accessible-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* implements https://w3c.github.io/accname/
*/

import getRole from "./getRole";

/**
* A string of characters where all carriage returns, newlines, tabs, and form-feeds are replaced with a single space, and multiple spaces are reduced to a single space. The string contains only character data; it does not contain any markup.
*/
Expand Down Expand Up @@ -150,43 +152,7 @@ function hasAbstractRole(node: Node, role: string): node is Element {

function hasAnyConcreteRoles(node: Node, roles: string[]): node is Element {
if (isElement(node)) {
if (node.hasAttribute("role")) {
return node
.getAttribute("role")!
.split(" ")
.some(role => roles.indexOf(role) !== -1);
}

// https://w3c.github.io/html-aria/

if (isHTMLInputElement(node)) {
if (
["email", "tel", "text", "url"].indexOf(node.type) !== -1 &&
!node.hasAttribute("list")
) {
return roles.indexOf("textbox") !== -1;
}
}

switch (node.tagName) {
case "A":
return roles.indexOf("link") !== -1;
case "BUTTON":
return roles.indexOf("button") !== -1;
case "H1":
case "H2":
case "H3":
case "H4":
case "H5":
case "H6":
return roles.indexOf("heading") !== -1;
case "SELECT":
return roles.indexOf("listbox") !== -1;
case "OPTION":
return roles.indexOf("option") !== -1;
case "TEXTAREA":
return roles.indexOf("textbox") !== -1;
}
return roles.indexOf(getRole(node)!) !== -1;
}
return false;
}
Expand Down
132 changes: 132 additions & 0 deletions sources/getRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// https://w3c.github.io/html-aria/#document-conformance-requirements-for-use-of-aria-attributes-in-html

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

return getImplicitRole(element);
}

const tagToRoleMappings: Record<string, string | undefined> = {
ARTICLE: "article",
ASIDE: "complementary",
BODY: "document",
BUTTON: "button",
DATALIST: "listbox",
DD: "definition",
DETAILS: "group",
DIALOG: "dialog",
DT: "term",
FIELDSET: "group",
FIGURE: "figure",
// WARNING: Only with an accessible name
FORM: "form",
FOOTER: "contentinfo",
H1: "heading",
H2: "heading",
H3: "heading",
H4: "heading",
H5: "heading",
H6: "heading",
HEADER: "banner",
HR: "separator",
LI: "listitem",
MATH: "math",
MAIN: "main",
MENU: "list",
NAV: "navigation",
OL: "list",
OPTGROUP: "group",
// WARNING: Only in certain context
OPTION: "option",
OUTPUT: "status",
PROGRESS: "progressbar",
// WARNING: Only with an accessible name
SECTION: "region",
SUMMARY: "button",
TABLE: "table",
TBODY: "rowgroup",
TEXTAREA: "textbox",
TFOOT: "rowgroup",
// WARNING: Only in certain context
TD: "cell",
TH: "columnheader",
THEAD: "rowgroup",
TR: "row",
UL: "list"
};

function getImplicitRole(element: Element): string | null {
const mappedByTag = tagToRoleMappings[element.tagName];
if (mappedByTag !== undefined) {
return mappedByTag;
}

switch (element.tagName) {
case "A":
case "AREA":
case "LINK":
if (element.hasAttribute("href")) {
return "link";
}
case "IMG":
if ((element.getAttribute("alt") || "").length > 0) {
return "img";
}
case "INPUT":
const { type } = element as HTMLInputElement;
switch (type) {
case "button":
case "image":
case "reset":
case "submit":
return "button";
case "checkbox":
case "radio":
return type;
case "range":
return "slider";
case "email":
case "tel":
case "text":
case "url":
if (element.hasAttribute("list")) {
return "combobox";
}
return "textbox";

case "search":
if (element.hasAttribute("list")) {
return "combobox";
}
return "searchbox";
default:
return null;
}
case "SELECT":
if (
element.hasAttribute("multiple") ||
(element as HTMLSelectElement).size > 1
) {
return "listbox";
}
return "combobox";
}
return null;
}

function getExplicitRole(element: Element): string | null {
if (element.hasAttribute("role")) {
const [explicitRole] = element
.getAttribute("role")!
.trim()
.split(" ");
if (explicitRole !== undefined && explicitRole.length > 0) {
return explicitRole;
}
}

return null;
}
1 change: 1 addition & 0 deletions sources/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { computeAccessibleName } from "./accessible-name";
export { default as getRole } from "./getRole";

0 comments on commit fe4fab5

Please sign in to comment.