Skip to content

Instantly share code, notes, and snippets.

@lemnis
Created November 24, 2017 15:45
Show Gist options
  • Save lemnis/5f753515a34a8fc513bca6865c122330 to your computer and use it in GitHub Desktop.
Save lemnis/5f753515a34a8fc513bca6865c122330 to your computer and use it in GitHub Desktop.
Calculates which role should used by a screen reader following the HTML ARIA spec
/**
* Follows https://www.w3.org/TR/2017/WD-html-aria-20171013/#docconformance
*/
/**
* All aria roles
* @type {Array}
*/
var roles = [
"alert", "alertdialog", "application", "article", "banner", "button", "cell",
"checkbox", "columnheader", "combobox", "complementary", "contentinfo",
"definition", "dialog", "directory", "document", "feed", "figure", "form",
"grid", "gridcell", "group", "heading", "img", "link", "list", "listbox",
"listitem", "log", "main", "marquee", "math", "menu", "menubar", "menuitem",
"menuitemcheckbox", "menuitemradio", "navigation", "none", "note", "option",
"presentation", "progressbar", "radio", "radiogroup", "region", "row",
"rowgroup", "rowheader", "scrollbar", "search", "searchbox", "separator",
"slider", "spinbutton", "status", "switch", "tab", "table", "tablist",
"tabpanel", "term", "textbox", "timer", "toolbar", "tooltip", "tree",
"treegrid", "treeitem", "command", "composite", "input", "landmark", "range",
"roletype", "section", "sectionhead", "select", "structure", "widget", "window"
];
/**
* Stores info which is used in functions of rolePerHTMLTag,
* mostly a key as tagName with an array of allowed roles for that tag
* @type {Object}
*/
var possibleRoles = {
"aWithHref": [
"button", "checkbox", "menuitem", "menuitemcheckbox", "menuitemradio",
"option", "radio", "switch", "tab", "treeitem", "doc-backlink",
"doc-biblioref", "doc-glossref", "doc-noteref"
],
"article": [
"feed", "presentation", "none", "document", "application", "main", "region"
],
"aside": [
"feed", "note", "presentation", "none", "region", "search", "doc-example",
"doc-footnote", "doc-pullquote", "doc-tip"
],
"button": [
"checkbox", "link", "menuitem", "menuitemcheckbox", "menuitemradio",
"option", "radio", "switch", "tab"
],
"dl": ["group", "presentation", "none", "doc-glossary"],
"embed": [ "application", "document", "presentation", "none", "img" ],
"figcaption": [ "group", "presentation", "none" ],
"fieldset": [ "group", "none", "presentation" ],
"footer": [ "group", "none", "presentation", "doc-footnote" ],
"form": [ "search", "none", "presentation" ],
"h1Toh6": [ "tab", "none", "presentation", "doc-subtitle" ],
"header": [ "group", "none", "presentation", "doc-footnote" ],
"hr": [ "presentation", "doc-pagebreak" ],
"iframe": [ "application", "document", "img" ],
"imgWithEmptyAlt": [ "presentation", "none" ],
"inputTypeButton": [
"link, menuitem", "menuitemcheckbox", "menuitemradio", "radio", "switch",
"option", "tab"
],
"inputTypeImage": [
"link", "menuitem", "menuitemcheckbox", "menuitemradio", "radio", "switch"
],
"inputTypeCheckbox": [ "menuitemcheckbox", "option", "switch" ],
"li": [
"menuitem", "menuitemcheckbox", "menuitemradio", "option", "none",
"presentation", "radio", "separator", "tab", "treeitem", "doc-biblioentry",
"doc-endnote"
],
"nav": [ "doc-index", "doc-pagelist", "doc-toc" ],
"object": [ "application", "document", "img" ],
"section": [
"alert", "alertdialog", "application", "banner", "complementary",
"contentinfo", "dialog", "document", "feed", "log", "main", "marquee",
"navigation", "none", "presentation", "search", "status", "tabpanel",
"doc-abstract", "doc-acknowledgments", "doc-afterword", "doc-appendix",
"doc-bibliography", "doc-chapter", "doc-colophon", "doc-conclusion",
"doc-credit", "doc-credits", "doc-dedication", "doc-endnotes", "doc-epilogue",
"doc-errata", "doc-example", "doc-foreword", "doc-index", "doc-introduction",
"doc-notice", "doc-pagelist", "doc-part", "doc-preface", "doc-prologue",
"doc-pullquote", "doc-qna", "doc-toc"
],
"svg": [ "application", "document", "img" ],
"ul": [
"directory", "group", "listbox", "menu", "menubar", "radiogroup",
"tablist", "toolbar", "tree", "presentation"
]
}
/**
* Contains a function for each htmlTag where not all roles allowed
* @type {Object}
*/
var rolePerHTMLTag = {
a: (el, role) => {
if(el.href) {
return possibleRoles.aWithHref.indexOf(role) > -1 ? role : "link";
} else {
return role;
}
},
area: (el, role) => {
if(el.href) return role ? null : "link";
return role;
},
article: (el, role) => possibleRoles.article.indexOf(role) > -1 ? role : "article",
aside: (el, role) => possibleRoles.aside.indexOf(role) > -1 ? role : "complementary",
audio: (el, role) => role == "application" ? "application" : null,
base: () => null,
body: (el, role) => "document",
button: (el, role) => {
if(el.type == "menu") {
return role == "menuitem" ? "menuitem" : "button";
}
return possibleRoles.button.indexOf(role) > -1 ? role : "button";
},
caption: () => null,
col: () => null,
colgroup: () => null,
datalist: () => "listbox",
dd: () => "definition",
details: () => "group",
dialog: (el, role) => role == "alertdialog" ? "alertdialog" : "dialog",
dl: (el, role) => possibleRoles.dl.indexOf(role) > -1 ? role : "list",
dt: () => "listitem",
embed: (el, role) => possibleRoles.embed.indexOf(role) > -1 ? role : null,
figcaption: (el, role) => possibleRoles.figcaption.indexOf(role) > -1 ? role : null,
fieldset: (el, role) => possibleRoles.fieldset.indexOf(role) > -1 ? role : null,
figure: (el, role) => possibleRoles.figure.indexOf(role) > -1 ? role : "figure",
footer: (el, role) => {
let hasImplicitContentinfoRole = !getParentWithTagName(el, ["ARTICLE", "ASIDE", "MAIN", "NAV", "SECTION"]);
let hasAllowedRole = possibleRoles.footer.indexOf(role) > -1;
if(hasAllowedRole){
return role;
} else if (hasImplicitContentinfoRole) {
return "contentinfo";
} else {
return null;
}
},
form: (el, role) => possibleRoles.form.indexOf(role) > -1 ? role : "form",
h1: (el, role) => possibleRoles.h1Toh6.indexOf(role) > -1 ? role : "heading",
h2: (el, role) => possibleRoles.h1Toh6.indexOf(role) > -1 ? role : "heading",
h3: (el, role) => possibleRoles.h1Toh6.indexOf(role) > -1 ? role : "heading",
h4: (el, role) => possibleRoles.h1Toh6.indexOf(role) > -1 ? role : "heading",
h5: (el, role) => possibleRoles.h1Toh6.indexOf(role) > -1 ? role : "heading",
h6: (el, role) => possibleRoles.h1Toh6.indexOf(role) > -1 ? role : "heading",
head: () => null,
header: (el, role) => {
let hasImplicitBannerRole = !getParentWithTagName(el, ["ARTICLE", "ASIDE", "MAIN", "NAV", "SECTION"]);
let hasAllowedRole = possibleRoles.header.indexOf(role) > -1;
if(hasAllowedRole){
return role;
} else if (hasImplicitBannerRole) {
return "banner";
} else {
return null;
}
},
hr: (el, role) => possibleRoles.hr.indexOf(role) > -1 ? role : "seperator",
html: () => null,
iframe: (el, role) => possibleRoles.iframe.indexOf(role) > -1 ? role : null,
img: (el, role) => {
var hasAllowedEmptyAltRole = possibleRoles.imgWithAlt.indexOf(role) > -1
if(el.alt) {
// any role exept the roles used by empty alt values
return hasAllowedEmptyAltRole ? "img" : role;
} else {
return hasAllowedEmptyAltRole ? role : null;
}
},
input: (el, role) => {
switch(el.type) {
case "button":
return possibleRoles.inputTypeButton.indexOf(role) > -1 ? role : "button";
case "checkbox":
if(role == "button" && el.hasAttribute("aria-pressed")) return "button";
return possibleRoles.inputTypeCheckbox.indexOf(role) > -1 ? role : "checkbox";
case "image":
return possibleRoles.inputTypeImage.indexOf(role) > -1 ? role : "button";
case "number":
return "spinbutton";
case "radio":
return role == "menuitemradio" ? "menuitemradio" : "radio";
case "range":
return "slider";
case "search":
return el.list ? "combobox" : "searchbox";
case "reset":
case "submit":
return "button";
case "email":
case "tel":
case "text":
case "url":
return el.list ? "combobox" : "textbox";
default:
return null;
}
},
keygen: () => null,
label: () => null,
legend: () => null,
li: (el, role) => {
let hasImplicitListitemRole = getParentWithTagName(el, ["OL", "UL"]);
if(hasImplicitListitemRole) {
return possibleRoles.li.indexOf(role) > -1 ? role : "listitem";
} else {
return null;
}
},
link: (el, role) => {
if(el.href) return role ? null : "link";
return role;
},
main: () => "main",
map: () => null,
math: () => "math",
menu: (el, role) => el.type == "context" ? "menu" : role,
menuitem: (el, role) => {
switch (el.type) {
case "command":
return "menuitem";
case "checkbox":
return "menuitemcheckbox";
case "radio":
return "menuitemradio";
default:
return role;
}
},
meta: () => null,
meter: () => null,
nav: (el, role) => possibleRoles.nav.indexOf(role) > -1 ? role : "navigation",
noscript: () => null,
object: (el, role) => possibleRoles.object.indexOf(role) > -1 ? role : null,
"ol": "list",
optgroup: () => "group",
option: (el, role) => {
let withinOptionList = ["select", "optgroup", "datalist"].indexOf(el.parentNode);
return withinOptionList ? "option" : null;
},
output: (el, role) => role ? role : "status",
param: () => null,
picture: () => null,
progress: (el, role) => "progressbar",
script: () => null,
section: (el, role) => {
let hasValidRole = possibleRoles.section.indexOf(role) > -1;
if(hasValidRole) return role;
// only if accessible name
if(el.title || el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby")){
return "section";
} else {
return null;
}
},
select: (el, role) => {
if(el.multiple && el.size > 1){
return "listbox";
} else if(!el.multiple && el.size <= 1) {
return role == "menu" ? role : "combobox";
}
return role;
},
source: () => null,
style: () => null,
svg: (el, role) => possibleRoles.svg.indexOf(role) > -1 ? role : null,
summary: () => "button",
table: (el, role) => role ? role : "table",
template: () => null,
textarea: () => "textbox",
thead: (el, role) => role ? role : "rowgroup",
tbody: (el, role) => role ? role : "rowgroup",
tfoot: (el, role) => role ? role : "rowgroup",
title: () => null,
td: (el, role) => getParentWithTagName(el, ["TABLE"]) ? "cell" : role,
th: () => getParentWithTagName(el, ["THEAD"]) ? "columnheader" : "rowheader",
tr: (el, role) => {
// role=row, may be explicitly declared when child of a table element with role=grid
return role ? role : "row";
},
track: () => null,
ul: (el, role) => possibleRoles.ul.indexOf(role) > -1 ? role : "list",
video: (el, role) => role == "application" ? "application" : null
};
/**
* Finds nearest parent with a specifig tagName
* @param {HTMLElement} el child - starting pointer
* @param {Array<String>} tagName Array containg capatilized tagnames
* @return {HTMLElement} Parent that matches one of the tagnames
*/
function getParentWithTagName(el, tagName){
while (el.parentNode){
if(tagName.indexOf(el.tagName) > -1) return el;
el = el.parentNode
}
}
export function getComputedRole(el) {
var role = el.getAttribute("role");
// check if given role exist
if(role) role = roles.indexOf(role) > - 1 ? role : null;
var tagName = el.tagName.toLowerCase();
// call possible custom function if tag has any
if (rolePerHTMLTag[tagName]) return rolePerHTMLTag[tagName](el, role);
// default behavior a.k.a. set role
return role;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment