lib/role-from-tag.js
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2025 Ruslan Sagitov
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
'use strict';
const {isFunction, capitalize} = require('./util');
const ROLE_LOCAL_ATTRS = {
alert: ['expanded'],
alertdialog: ['expanded'],
application: ['expanded'],
article: ['expanded'],
banner: ['expanded'],
button: ['expanded', 'pressed'],
checkbox: ['checked'],
columnheader: ['expanded', 'required', 'readonly', 'selected', 'sort'],
combobox: ['expanded', 'autocomplete', 'required'],
complementary: ['expanded'],
contentinfo: ['expanded'],
definition: ['expanded'],
dialog: ['expanded'],
directory: ['expanded'],
document: ['expanded'],
form: ['expanded'],
grid: ['expanded', 'activedescendant', 'level', 'multiselectable', 'readonly'],
gridcell: ['expanded', 'readonly', 'required', 'selected'],
group: ['expanded', 'activedescendant'],
heading: ['expanded', 'level'],
img: ['expanded'],
link: ['expanded'],
list: ['expanded'],
listbox: ['expanded', 'required', 'activedescendant', 'multiselectable'],
listitem: ['expanded', 'level', 'posinset', 'setsize'],
log: ['expanded'],
main: ['expanded'],
marquee: ['expanded'],
math: ['expanded'],
menu: ['expanded', 'activedescendant'],
menubar: ['expanded', 'activedescendant'],
menuitem: [],
menuitemcheckbox: ['checked'],
menuitemradio: ['checked', 'posinset', 'setsize'],
navigation: ['expanded'],
note: ['expanded'],
option: ['checked', 'selected', 'posinset', 'setsize'],
progressbar: ['valuetext', 'valuenow', 'valuemin', 'valuemax'],
radio: ['checked', 'posinset', 'setsize'],
radiogroup: ['expanded', 'required', 'activedescendant'],
region: ['expanded'],
row: ['expanded', 'activedescendant', 'level', 'selected'],
rowgroup: ['expanded', 'activedescendant'],
rowheader: ['expanded', 'required', 'selected', 'readonly', 'sort'],
search: ['expanded'],
separator: ['expanded', 'orientation'],
scrollbar: ['valuetext', 'valuenow', 'valuemin', 'valuemax', 'orientation', 'controls'],
slider: ['valuetext', 'valuenow', 'valuemin', 'valuemax', 'orientation'],
spinbutton: ['valuetext', 'valuenow', 'valuemin', 'valuemax', 'required'],
status: ['expanded'],
tab: ['expanded', 'selected'],
tablist: ['expanded', 'level', 'multiselectable', 'activedescendant'],
tabpanel: ['expanded'],
textbox: ['required', 'readonly', 'activedescendant', 'autocomplete', 'multiline'],
timer: ['expanded'],
toolbar: ['expanded', 'activedescendant'],
tooltip: ['expanded'],
tree: ['expanded', 'required', 'activedescendant', 'multiselectable'],
treegrid: ['expanded', 'required', 'readonly', 'activedescendant', 'multiselectable'],
treeitem: ['expanded', 'selected', 'posinset', 'setsize']
};
const DEFAULT_FOR = {
alert: {
live: 'assertive',
atomic: true
},
checkbox: {
checked: false
},
combobox: {
haspopup: true,
expanded: false
},
log: {
live: 'polite'
},
menuitemcheckbox: {
checked: false
},
menuitemradio: {
checked: false
},
/*progressbar: {
readonly: true
},*/
radio: {
checked: false
},
scrollbar: {
orientation: 'vertical'
},
status: {
live: 'polite',
atomic: true
}
};
function roleToObject(roleData) {
if (typeof roleData === 'string') {
return {role: roleData};
}
return {...roleData};
}
function getTextboxRole(node) {
let listId = node.getAttribute('list'),
datalist;
/* istanbul ignore if: untestable */
if (listId) {
datalist = this.getElementById(listId);
}
/* istanbul ignore next: untestable */
if (datalist && datalist.tag === 'datalist') {
return {
role: 'combobox',
owns: listId
};
}
return 'textbox';
}
function range(role) {
return function(node) {
return {
role,
valuenow: node.getAttribute('value'),
valuemin: node.getAttribute('min'),
valuemax: node.getAttribute('max')
};
};
}
const TAG_INPUT_GET_ROLE = {
checkbox: 'checkbox',
email: getTextboxRole,
image: 'button',
number: range('spinbutton'),
password: {role: 'textbox', password: true},
radio: 'radio',
range: range('slider'),
reset: 'button',
search: getTextboxRole,
submit: 'button',
tel: getTextboxRole,
text: getTextboxRole,
url: getTextboxRole
};
const TAG_TO_ROLE = {
a: 'link',
address: 'contentinfo',
area: 'link',
article: 'article',
aside: 'complementary',
body: 'document',
button: 'button',
caption(node) {
/* istanbul ignore else */
if (node.hasParent('table')) {
return {
part: true,
hidden: node.hasOnlyTextChilds()
};
}
},
colgroup(node) {
/* istanbul ignore else */
if (node.hasParent('table')) {
return {part: true};
}
},
datalist: {role: 'listbox', multiselectable: false},
dialog: 'dialog',
dd: 'listitem',
dl: 'list',
dt: 'listitem',
fieldset: 'group',
figcaption(node) {
if (node.hasParent('figure') &&
node.hasOnlyTextChilds()) {
return {hidden: true};
}
},
footer(node) {
if (!node.hasParent('article') &&
!node.hasParent('section')) {
return 'contentinfo';
}
return '';
},
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
h5: 'heading',
h6: 'heading',
header(node) {
if (!node.hasParent('article') &&
!node.hasParent('section')) {
return 'banner';
}
return '';
},
hr: 'separator',
img(node) {
let alt = node.getAttribute('alt');
if (alt === '') {
return 'presentation';
}
return 'img';
},
input(node) {
if (node.mustNoRole()) {
return '';
}
let type = node.getAttribute('type') || 'text',
getRole = TAG_INPUT_GET_ROLE[type],
roleData = isFunction(getRole) ?
getRole.call(this, node) : getRole;
return roleToObject(roleData);
},
legend(node) {
if (node.hasParent('fieldset') &&
node.hasOnlyTextChilds()) {
return {hidden: true};
}
},
li: 'listitem',
link(node) {
/* istanbul ignore if: untestable */
if (node.isHyperlink()) {
return 'link';
}
return '';
},
main: 'main',
menu(node) {
let type = node.getAttribute('type');
return type === 'toolbar' ? 'toolbar' : 'menu';
},
menuitem: 'menuitem',
nav: 'navigation',
ol: 'list',
optgroup(node) {
/* istanbul ignore else: untestable */
if (node.hasParent('select')) {
return 'group';
}
/* istanbul ignore next: untestable */
return '';
},
option(node) {
return {
role: 'option',
selected: node.isSelected()
};
},
progress: range('progressbar'),
section: 'region',
select(node) {
return {
role: 'listbox',
multiselectable: node.hasAttribute('multiple')
};
},
summary(node) {
if (node.hasParent('details') &&
node.hasOnlyTextChilds()) {
return {hidden: true};
}
},
table: {role: 'grid', table: true},
tbody: 'rowgroup',
td: 'gridcell',
textarea: {role: 'textbox', multiline: true},
tfoot: 'rowgroup',
th(node) {
if (node.getAttribute('scope') === 'row') {
return 'rowheader';
}
return 'columnheader';
},
thead: 'rowgroup',
tr: 'row',
ul: {role: 'list', numbered: true}
};
function setGlobalAttrs(node) {
node.describedby = node.getAttribute('aria-describedby');
node.controls = node.getAttribute('aria-controls');
node.owns = node.getAttribute('aria-owns');
node.flowto = node.getAttribute('aria-flowto');
node.haspopup = node.getAttribute('aria-haspopup') === 'true';
node.dropeffect = node.getDropeffect();
node.grabbed = node.getGrabbed();
node.busy = node.getAttribute('aria-busy') === 'true';
node.atomic = node.getAttribute('aria-atomic') === 'true';
node.live = node.getLive();
node.relevant = node.getRelevant();
node.disabled = node.isDisabled();
node.invalid = node.isInvalid();
}
function setLocalAttrs(node) {
let role = node.role,
attrs = ROLE_LOCAL_ATTRS[role] || [];
for (let attr of attrs) {
if (typeof node[attr] !== 'undefined') {
return;
}
let funcName = `get${capitalize(attr)}`,
value;
if (isFunction(node[funcName])) {
value = node[funcName]();
if (value !== null) {
node[attr] = value;
}
} else {
if (node.hasAttribute(`aria-${attr}`)) {
node[attr] = node.getAttribute(`aria-${attr}`);
}
}
}
}
function setDefaults(node) {
let role = node.role,
data = DEFAULT_FOR[role];
if (data) {
for (let key of Object.keys(data)) {
if (!node[key]) {
node[key] = data[key];
}
}
}
}
function setRole(node) {
let getRole = TAG_TO_ROLE[node.tag] || '',
roleData, role;
roleData = isFunction(getRole) ? getRole.call(this, node) : getRole;
roleData = roleToObject(roleData);
if (!node.mustNoRole()) {
role = node.getRoleFromAttr();
if (role === 'presentation') {
if (node.mayBePresentation()) {
roleData.role = role;
}
} else if (role) {
if (!node.isStrongRole()) {
if (node.mayTransitionToRole(role)) {
roleData.role = role;
}
}
}
}
role = roleData.role;
node.part = roleData.part;
node.hidden = roleData.hidden || node.isHidden();
if (role && role !== 'presentation') {
Object.assign(node, roleData);
node.role = role;
setGlobalAttrs.call(this, node);
}
}
function fixRole(node) {
let role = node.role;
if (role && role !== 'presentation') {
if (!node.ownsValidRolesFor(role)) {
this.warn(`Element with role "${role
}" does not own elements with valid roles`);
if (this.forceValidMarkup) {
delete node.role;
}
} else if (!node.ownedByValidRolesFor(role)) {
this.warn(`Element with role "${role
}" is not owned by elements with valid roles`);
if (this.forceValidMarkup) {
delete node.role;
}
}
setLocalAttrs.call(this, node);
setDefaults.call(this, node);
}
}
exports.setRole = setRole;
exports.fixRole = fixRole;