diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 1dc2ae6b39..1491de42d8 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -3,6 +3,7 @@ | accesskeys | Ensures every accesskey attribute value is unique | Serious | best-practice, cat.keyboard | true | | area-alt | Ensures <area> elements of image maps have alternate text | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | | aria-allowed-attr | Ensures ARIA attributes are allowed for an element's role | Critical | cat.aria, wcag2a, wcag412 | true | +| aria-allowed-role | Ensures role attribute has an appropriate value for the element | Minor | cat.aria, best-practice | true | | aria-dpub-role-fallback | Ensures unsupported DPUB roles are only used on elements with implicit fallback roles | Moderate | cat.aria, wcag2a, wcag131 | true | | aria-hidden-body | Ensures aria-hidden='true' is not present on the document body. | Critical | cat.aria, wcag2a, wcag412 | true | | aria-required-attr | Ensures elements with ARIA roles have all required ARIA attributes | Critical | cat.aria, wcag2a, wcag412 | true | diff --git a/lib/checks/aria/aria-allowed-role.js b/lib/checks/aria/aria-allowed-role.js new file mode 100644 index 0000000000..e221ef34c1 --- /dev/null +++ b/lib/checks/aria/aria-allowed-role.js @@ -0,0 +1,23 @@ +/** + * Implements allowed roles defined at: + * https://www.w3.org/TR/html-aria/#docconformance + * https://www.w3.org/TR/SVG2/struct.html#implicit-aria-semantics + */ +const { allowImplicit = true, ignoredTags = [] } = options || {}; +const tagName = node.nodeName.toUpperCase(); + +// check if the element should be ignored, by an user setting +if (ignoredTags.map(t => t.toUpperCase()).includes(tagName)) { + return true; +} + +const unallowedRoles = axe.commons.aria.getElementUnallowedRoles( + node, + allowImplicit +); + +if (unallowedRoles.length) { + this.data(unallowedRoles); + return false; +} +return true; diff --git a/lib/checks/aria/aria-allowed-role.json b/lib/checks/aria/aria-allowed-role.json new file mode 100644 index 0000000000..f0a09782dd --- /dev/null +++ b/lib/checks/aria/aria-allowed-role.json @@ -0,0 +1,15 @@ +{ + "id": "aria-allowed-role", + "evaluate": "aria-allowed-role.js", + "options": { + "allowImplicit": true, + "ignoredTags": [] + }, + "metadata": { + "impact": "minor", + "messages": { + "pass": "ARIA role is allowed for given element", + "fail": "role{{=it.data && it.data.length > 1 ? 's' : ''}} {{=it.data.join(', ')}} {{=it.data && it.data.length > 1 ? 'are' : ' is'}} not allowed for given element" + } + } +} \ No newline at end of file diff --git a/lib/commons/aria/get-element-unallowed-roles.js b/lib/commons/aria/get-element-unallowed-roles.js new file mode 100644 index 0000000000..2fbb6dd337 --- /dev/null +++ b/lib/commons/aria/get-element-unallowed-roles.js @@ -0,0 +1,80 @@ +/* global aria */ +/** + * gets all unallowed roles for a given node + * @method getElementUnallowedRoles + * @param {Object} node HTMLElement to validate + * @param {String} tagName tag name of a node + * @param {String} allowImplicit option to allow implicit roles, defaults to true + * @return {Array} retruns an array of roles that are not allowed on the given node + */ +aria.getElementUnallowedRoles = function getElementUnallowedRoles( + node, + allowImplicit +) { + /** + * Get roles applied to a given node + * @param {HTMLElement} node HTMLElement + * @return {Array} return an array of roles applied to the node, if no roles, return an empty array. + */ + // TODO: not moving this to outer namespace yet, work with wilco to see overlap with his PR(WIP) - aria.getRole + function getRoleSegments(node) { + let roles = []; + if (!node) { + return roles; + } + if (node.hasAttribute('role')) { + const nodeRoles = axe.utils.tokenList( + node.getAttribute('role').toLowerCase() + ); + roles = roles.concat(nodeRoles); + } + if (node.hasAttributeNS('http://www.idpf.org/2007/ops', 'type')) { + const epubRoles = axe.utils + .tokenList( + node + .getAttributeNS('http://www.idpf.org/2007/ops', 'type') + .toLowerCase() + ) + .map(role => `doc-${role}`); + roles = roles.concat(epubRoles); + } + return roles; + } + + const tagName = node.nodeName.toUpperCase(); + + // by pass custom elements + if (!axe.utils.isHtmlElement(node)) { + return []; + } + + const roleSegments = getRoleSegments(node); + const implicitRole = axe.commons.aria.implicitRole(node); + + // stores all roles that are not allowed for a specific element most often an element only has one explicit role + const unallowedRoles = roleSegments.filter(role => { + if (!axe.commons.aria.isValidRole(role)) { + // do not check made-up/ fake roles + return false; + } + + // check if an implicit role may be set explicit following a setting + if (!allowImplicit && role === implicitRole) { + // edge case: setting implicit role row on tr element is allowed when child of table[role='grid'] + if ( + !( + role === 'row' && + tagName === 'TR' && + axe.utils.matchesSelector(node, 'table[role="grid"] > tr') + ) + ) { + return true; + } + } + if (!aria.isAriaRoleAllowedOnElement(node, role)) { + return true; + } + }); + + return unallowedRoles; +}; diff --git a/lib/commons/aria/index.js b/lib/commons/aria/index.js index 1b2e22bb7c..3d9f86bf4e 100644 --- a/lib/commons/aria/index.js +++ b/lib/commons/aria/index.js @@ -192,7 +192,67 @@ lookupTable.globalAttributes = [ 'aria-relevant' ]; +const elementConditions = { + CANNOT_HAVE_LIST_ATTRIBUTE: node => { + const nodeAttrs = Array.from(node.attributes).map(a => + a.name.toUpperCase() + ); + if (nodeAttrs.includes('LIST')) { + return false; + } + return true; + }, + CANNOT_HAVE_HREF_ATTRIBUTE: node => { + const nodeAttrs = Array.from(node.attributes).map(a => + a.name.toUpperCase() + ); + if (nodeAttrs.includes('HREF')) { + return false; + } + return true; + }, + MUST_HAVE_HREF_ATTRIBUTE: node => { + if (!node.href) { + return false; + } + return true; + }, + MUST_HAVE_SIZE_ATTRIBUTE_WITH_VALUE_GREATER_THAN_1: node => { + const attr = 'SIZE'; + const nodeAttrs = Array.from(node.attributes).map(a => + a.name.toUpperCase() + ); + if (!nodeAttrs.includes(attr)) { + return false; + } + return Number(node.getAttribute(attr)) > 1; + }, + MUST_HAVE_ALT_ATTRIBUTE: node => { + const attr = 'ALT'; + const nodeAttrs = Array.from(node.attributes).map(a => + a.name.toUpperCase() + ); + if (!nodeAttrs.includes(attr)) { + return false; + } + return true; + }, + MUST_HAVE_ALT_ATTRIBUTE_WITH_VALUE: node => { + const attr = 'ALT'; + const nodeAttrs = Array.from(node.attributes).map(a => + a.name.toUpperCase() + ); + if (!nodeAttrs.includes(attr)) { + return false; + } + const attrValue = node.getAttribute(attr); + // ensure attrValue is defined and have a length (empty string is not allowed) + return attrValue && attrValue.length > 0; + } +}; + lookupTable.role = { + // valid roles below alert: { type: 'widget', attributes: { @@ -201,7 +261,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, alertdialog: { type: 'widget', @@ -211,7 +272,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['DIALOG', 'SECTION'] }, application: { type: 'landmark', @@ -221,7 +283,17 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + 'ARTICLE', + 'AUDIO', + 'EMBED', + 'IFRAME', + 'OBJECT', + 'SECTION', + 'SVG', + 'VIDEO' + ] }, article: { type: 'structure', @@ -248,7 +320,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['header'], - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, button: { type: 'widget', @@ -266,7 +339,13 @@ lookupTable.role = { 'input[type="submit"]', 'summary' ], - unsupported: false + unsupported: false, + allowedElements: [ + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, cell: { type: 'structure', @@ -299,7 +378,8 @@ lookupTable.role = { nameFrom: ['author', 'contents'], context: null, implicit: ['input[type="checkbox"]'], - unsupported: false + unsupported: false, + allowedElements: ['BUTTON'] }, columnheader: { type: 'structure', @@ -356,7 +436,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['aside'], - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, composite: { nameFrom: ['author'], @@ -372,7 +453,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['footer'], - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, definition: { type: 'structure', @@ -394,7 +476,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['dialog'], - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, directory: { type: 'structure', @@ -404,7 +487,8 @@ lookupTable.role = { owned: null, nameFrom: ['author', 'contents'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, document: { type: 'structure', @@ -415,7 +499,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['body'], - unsupported: false + unsupported: false, + allowedElements: ['ARTICLE', 'EMBED', 'IFRAME', 'SECTION', 'SVG', 'OBJECT'] }, 'doc-abstract': { type: 'section', @@ -425,7 +510,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-acknowledgments': { type: 'landmark', @@ -435,7 +521,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-afterword': { type: 'landmark', @@ -445,7 +532,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-appendix': { type: 'landmark', @@ -455,7 +543,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-backlink': { type: 'link', @@ -465,7 +554,13 @@ lookupTable.role = { owned: null, nameFrom: ['author', 'contents'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, 'doc-biblioentry': { type: 'listitem', @@ -481,7 +576,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: ['doc-bibliography'], - unsupported: false + unsupported: false, + allowedElements: ['LI'] }, 'doc-bibliography': { type: 'landmark', @@ -491,7 +587,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-biblioref': { type: 'link', @@ -501,7 +598,13 @@ lookupTable.role = { owned: null, nameFrom: ['author', 'contents'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, 'doc-chapter': { type: 'landmark', @@ -511,7 +614,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-colophon': { type: 'section', @@ -521,7 +625,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-conclusion': { type: 'landmark', @@ -531,7 +636,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-cover': { type: 'img', @@ -551,7 +657,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-credits': { type: 'landmark', @@ -561,7 +668,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-dedication': { type: 'section', @@ -571,7 +679,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-endnote': { type: 'listitem', @@ -587,7 +696,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: ['doc-endnotes'], - unsupported: false + unsupported: false, + allowedElements: ['LI'] }, 'doc-endnotes': { type: 'landmark', @@ -597,7 +707,8 @@ lookupTable.role = { owned: ['doc-endnote'], namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-epigraph': { type: 'section', @@ -617,7 +728,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-errata': { type: 'landmark', @@ -627,7 +739,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-example': { type: 'section', @@ -637,7 +750,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['ASIDE', 'SECTION'] }, 'doc-footnote': { type: 'section', @@ -647,7 +761,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['ASIDE', 'FOOTER', 'HEADER'] }, 'doc-foreword': { type: 'landmark', @@ -657,7 +772,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-glossary': { type: 'landmark', @@ -667,7 +783,8 @@ lookupTable.role = { owned: ['term', 'definition'], namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['DL'] }, 'doc-glossref': { type: 'link', @@ -677,7 +794,13 @@ lookupTable.role = { owned: null, namefrom: ['author', 'contents'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, 'doc-index': { type: 'navigation', @@ -687,7 +810,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['NAV', 'SECTION'] }, 'doc-introduction': { type: 'landmark', @@ -697,7 +821,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-noteref': { type: 'link', @@ -707,7 +832,13 @@ lookupTable.role = { owned: null, namefrom: ['author', 'contents'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, 'doc-notice': { type: 'note', @@ -717,7 +848,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-pagebreak': { type: 'separator', @@ -727,7 +859,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['HR'] }, 'doc-pagelist': { type: 'navigation', @@ -737,7 +870,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['NAV', 'SECTION'] }, 'doc-part': { type: 'landmark', @@ -747,7 +881,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-preface': { type: 'landmark', @@ -757,7 +892,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-prologue': { type: 'landmark', @@ -767,7 +903,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-pullquote': { type: 'none', @@ -777,7 +914,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['ASIDE', 'SECTION'] }, 'doc-qna': { type: 'section', @@ -787,7 +925,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, 'doc-subtitle': { type: 'sectionhead', @@ -797,7 +936,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'] }, 'doc-tip': { type: 'note', @@ -807,7 +947,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['ASIDE'] }, 'doc-toc': { type: 'navigation', @@ -817,7 +958,8 @@ lookupTable.role = { owned: null, namefrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['NAV', 'SECTION'] }, feed: { type: 'structure', @@ -829,7 +971,8 @@ lookupTable.role = { }, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['ARTICLE', 'ASIDE', 'SECTION'] }, figure: { type: 'structure', @@ -898,7 +1041,17 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['details', 'optgroup'], - unsupported: false + unsupported: false, + allowedElements: [ + 'DL', + 'FIGCAPTION', + 'FIELDSET', + 'FIGURE', + 'FOOTER', + 'HEADER', + 'OL', + 'UL' + ] }, heading: { type: 'structure', @@ -921,7 +1074,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['img'], - unsupported: false + unsupported: false, + allowedElements: ['EMBED', 'IFRAME', 'OBJECT', 'SVG'] }, input: { nameFrom: ['author'], @@ -942,7 +1096,22 @@ lookupTable.role = { nameFrom: ['author', 'contents'], context: null, implicit: ['a[href]'], - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + { + tagName: 'INPUT', + attributes: { + TYPE: 'IMAGE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'IMAGE' + } + } + ] }, list: { type: 'structure', @@ -975,7 +1144,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['select'], - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, listitem: { type: 'structure', @@ -1002,7 +1172,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, main: { type: 'landmark', @@ -1013,7 +1184,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['main'], - unsupported: false + unsupported: false, + allowedElements: ['ARTICLE', 'SECTION'] }, marquee: { type: 'widget', @@ -1023,7 +1195,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, math: { type: 'structure', @@ -1052,7 +1225,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['menu[type="context"]'], - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, menubar: { type: 'composite', @@ -1067,7 +1241,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, menuitem: { type: 'widget', @@ -1083,7 +1258,27 @@ lookupTable.role = { nameFrom: ['author', 'contents'], context: ['menu', 'menubar'], implicit: ['menuitem[type="command"]'], - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + 'LI', + { + tagName: 'INPUT', + attributes: { + TYPE: 'IMAGE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'BUTTON' + } + }, + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, menuitemcheckbox: { type: 'widget', @@ -1099,7 +1294,33 @@ lookupTable.role = { nameFrom: ['author', 'contents'], context: ['menu', 'menubar'], implicit: ['menuitem[type="checkbox"]'], - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + 'LI', + { + tagName: 'INPUT', + attributes: { + TYPE: 'CHECKBOX' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'IMAGE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'BUTTON' + } + }, + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, menuitemradio: { type: 'widget', @@ -1116,7 +1337,27 @@ lookupTable.role = { nameFrom: ['author', 'contents'], context: ['menu', 'menubar'], implicit: ['menuitem[type="radio"]'], - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + 'LI', + { + tagName: 'INPUT', + attributes: { + TYPE: 'IMAGE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'BUTTON' + } + }, + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, navigation: { type: 'landmark', @@ -1127,7 +1368,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['nav'], - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, none: { type: 'structure', @@ -1135,7 +1377,32 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + 'ARTICLE', + 'ASIDE', + 'DL', + 'EMBED', + 'FIGCAPTION', + 'FIELDSET', + 'FIGURE', + 'FOOTER', + 'FORM', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEADER', + 'LI', + 'SECTION', + 'OL', + { + tagName: 'IMG', + condition: elementConditions.MUST_HAVE_ALT_ATTRIBUTE + } + ] }, note: { type: 'structure', @@ -1145,7 +1412,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['ASIDE'] }, option: { type: 'widget', @@ -1162,7 +1430,27 @@ lookupTable.role = { nameFrom: ['author', 'contents'], context: ['listbox'], implicit: ['option'], - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + 'LI', + { + tagName: 'INPUT', + attributes: { + TYPE: 'CHECKBOX' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'BUTTON' + } + }, + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, presentation: { type: 'structure', @@ -1170,7 +1458,34 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + 'ARTICLE', + 'ASIDE', + 'DL', + 'EMBED', + 'FIGCAPTION', + 'FIELDSET', + 'FIGURE', + 'FOOTER', + 'FORM', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEADER', + 'HR', + 'LI', + 'OL', + 'SECTION', + 'UL', + { + tagName: 'IMG', + condition: elementConditions.MUST_HAVE_ALT_ATTRIBUTE + } + ] }, progressbar: { type: 'widget', @@ -1206,7 +1521,23 @@ lookupTable.role = { nameFrom: ['author', 'contents'], context: null, implicit: ['input[type="radio"]'], - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + 'LI', + { + tagName: 'INPUT', + attributes: { + TYPE: 'IMAGE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'BUTTON' + } + } + ] }, radiogroup: { type: 'composite', @@ -1224,11 +1555,17 @@ lookupTable.role = { }, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, range: { nameFrom: ['author'], - type: 'abstract' + type: 'abstract', + // @marcysutton, @wilco + // - there is no unsupported here (noticed when resolving conflicts) from PR - https://github.com/dequelabs/axe-core/pull/1064 + // - https://github.com/dequelabs/axe-core/pull/1064/files#diff-ec67bb6113bfd9a900ee27ecef942f74R1229 + // - adding unsupported flag (false) + unsupported: false }, region: { type: 'landmark', @@ -1243,7 +1580,8 @@ lookupTable.role = { 'section[aria-labelledby]', 'section[title]' ], - unsupported: false + unsupported: false, + allowedElements: ['ARTICLE', 'ASIDE'] }, roletype: { type: 'abstract', @@ -1329,7 +1667,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['ASIDE', 'FORM', 'SECTION'] }, searchbox: { type: 'widget', @@ -1382,7 +1721,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['hr'], - unsupported: false + unsupported: false, + allowedElements: ['LI'] }, slider: { type: 'widget', @@ -1427,7 +1767,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['output'], - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, structure: { type: 'abstract', @@ -1442,7 +1783,32 @@ lookupTable.role = { owned: null, nameFrom: ['author', 'contents'], context: null, - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + { + tagName: 'INPUT', + attributes: { + TYPE: 'CHECKBOX' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'IMAGE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'BUTTON' + } + }, + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, tab: { type: 'widget', @@ -1458,7 +1824,27 @@ lookupTable.role = { owned: null, nameFrom: ['author', 'contents'], context: ['tablist'], - unsupported: false + unsupported: false, + allowedElements: [ + 'BUTTON', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'LI', + { + tagName: 'INPUT', + attributes: { + TYPE: 'BUTTON' + } + }, + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, table: { type: 'structure', @@ -1490,7 +1876,8 @@ lookupTable.role = { }, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, tabpanel: { type: 'widget', @@ -1500,7 +1887,8 @@ lookupTable.role = { owned: null, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['SECTION'] }, term: { type: 'structure', @@ -1571,7 +1959,8 @@ lookupTable.role = { nameFrom: ['author'], context: null, implicit: ['menu[type="toolbar"]'], - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, tooltip: { type: 'widget', @@ -1600,7 +1989,8 @@ lookupTable.role = { }, nameFrom: ['author'], context: null, - unsupported: false + unsupported: false, + allowedElements: ['OL', 'UL'] }, treegrid: { type: 'composite', @@ -1641,7 +2031,14 @@ lookupTable.role = { owned: null, nameFrom: ['author', 'contents'], context: ['group', 'tree'], - unsupported: false + unsupported: false, + allowedElements: [ + 'LI', + { + tagName: 'A', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + } + ] }, widget: { type: 'abstract', @@ -1653,3 +2050,369 @@ lookupTable.role = { unsupported: false } }; + +// Source: https://www.w3.org/TR/html-aria/ +lookupTable.elementsAllowedNoRole = [ + { + tagName: 'AREA', + condition: elementConditions.MUST_HAVE_HREF_ATTRIBUTE + }, + 'BASE', + 'BODY', + 'CAPTION', + 'COL', + 'COLGROUP', + 'DATALIST', + 'DD', + 'DETAILS', + 'DT', + 'HEAD', + 'HTML', + { + tagName: 'INPUT', + attributes: { + TYPE: 'COLOR' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'DATE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'DATETIME' + } + }, + { + tagName: 'INPUT', + condition: elementConditions.CANNOT_HAVE_LIST_ATTRIBUTE, + attributes: { + TYPE: 'EMAIL' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'FILE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'HIDDEN' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'MONTH' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'NUMBER' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'PASSWORD' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'RANGE' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'RESET' + } + }, + { + tagName: 'INPUT', + condition: elementConditions.CANNOT_HAVE_LIST_ATTRIBUTE, + attributes: { + TYPE: 'SEARCH' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'SUBMIT' + } + }, + { + tagName: 'INPUT', + condition: elementConditions.CANNOT_HAVE_LIST_ATTRIBUTE, + attributes: { + TYPE: 'TEL' + } + }, + { + tagName: 'INPUT', + condition: elementConditions.CANNOT_HAVE_LIST_ATTRIBUTE, + attributes: { + TYPE: 'TEXT' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'TIME' + } + }, + { + tagName: 'INPUT', + condition: elementConditions.CANNOT_HAVE_LIST_ATTRIBUTE, + attributes: { + TYPE: 'URL' + } + }, + { + tagName: 'INPUT', + attributes: { + TYPE: 'WEEK' + } + }, + 'KEYGEN', + 'LABEL', + 'LEGEND', + { + tagName: 'LINK', + attributes: { + TYPE: 'HREF' + } + }, + 'MAIN', + 'MAP', + 'MATH', + { + tagName: 'MENU', + attributes: { + TYPE: 'CONTEXT' + } + }, + { + tagName: 'MENUITEM', + attributes: { + TYPE: 'COMMAND' + } + }, + { + tagName: 'MENUITEM', + attributes: { + TYPE: 'CHECKBOX' + } + }, + { + tagName: 'MENUITEM', + attributes: { + TYPE: 'RADIO' + } + }, + 'META', + 'METER', + 'NOSCRIPT', + 'OPTGROUP', + 'PARAM', + 'PICTURE', + 'PROGRESS', + 'SCRIPT', + { + tagName: 'SELECT', + condition: + elementConditions.MUST_HAVE_SIZE_ATTRIBUTE_WITH_VALUE_GREATER_THAN_1, + attributes: { + TYPE: 'MULTIPLE' + } + }, + 'SOURCE', + 'STYLE', + 'TEMPLATE', + 'TEXTAREA', + 'TITLE', + 'TRACK', + // svg elements (below) + 'CLIPPATH', + 'CURSOR', + 'DEFS', + 'DESC', + 'FEBLEND', + 'FECOLORMATRIX', + 'FECOMPONENTTRANSFER', + 'FECOMPOSITE', + 'FECONVOLVEMATRIX', + 'FEDIFFUSELIGHTING', + 'FEDISPLACEMENTMAP', + 'FEDISTANTLIGHT', + 'FEDROPSHADOW', + 'FEFLOOD', + 'FEFUNCA', + 'FEFUNCB', + 'FEFUNCG', + 'FEFUNCR', + 'FEGAUSSIANBLUR', + 'FEIMAGE', + 'FEMERGE', + 'FEMERGENODE', + 'FEMORPHOLOGY', + 'FEOFFSET', + 'FEPOINTLIGHT', + 'FESPECULARLIGHTING', + 'FESPOTLIGHT', + 'FETILE', + 'FETURBULENCE', + 'FILTER', + 'HATCH', + 'HATCHPATH', + 'LINEARGRADIENT', + 'MARKER', + 'MASK', + 'MESHGRADIENT', + 'MESHPATCH', + 'MESHROW', + 'METADATA', + 'MPATH', + 'PATTERN', + 'RADIALGRADIENT', + 'SOLIDCOLOR', + 'STOP', + 'SWITCH', + 'VIEW' +]; + +// Source: https://www.w3.org/TR/html-aria/ +lookupTable.elementsAllowedAnyRole = [ + { + tagName: 'A', + condition: elementConditions.CANNOT_HAVE_HREF_ATTRIBUTE + }, + 'ABBR', + 'ADDRESS', + 'CANVAS', + 'DIV', + 'P', + 'PRE', + 'BLOCKQUOTE', + 'INS', + 'DEL', + 'OUTPUT', + 'SPAN', + 'TABLE', + 'TBODY', + 'THEAD', + 'TFOOT', + 'TD', + 'EM', + 'STRONG', + 'SMALL', + 'S', + 'CITE', + 'Q', + 'DFN', + 'ABBR', + 'TIME', + 'CODE', + 'VAR', + 'SAMP', + 'KBD', + 'SUB', + 'SUP', + 'I', + 'B', + 'U', + 'MARK', + 'RUBY', + 'RT', + 'RP', + 'BDI', + 'BDO', + 'BR', + 'WBR', + 'TH', + 'TR' +]; + +lookupTable.evaluateRoleForElement = { + A: ({ node, out }) => { + if (node.namespaceURI === 'http://www.w3.org/2000/svg') { + return true; + } + if (node.href.length) { + return out; + } + return true; + }, + AREA: ({ node }) => !node.href, + BUTTON: ({ node, role, out }) => { + if (node.getAttribute('type') === 'menu') { + return role === 'menuitem'; + } + return out; + }, + IMG: ({ node, out }) => { + if (node.alt) { + return !out; + } + return out; + }, + INPUT: ({ node, role, out }) => { + switch (node.type) { + case 'button': + case 'image': + return out; + case 'checkbox': + if (role === 'button' && node.hasAttribute('aria-pressed')) { + return true; + } + return out; + case 'radio': + return role === 'menuitemradio'; + default: + return false; + } + }, + LI: ({ node, out }) => { + const hasImplicitListitemRole = axe.utils.matchesSelector( + node, + 'ol li, ul li' + ); + if (hasImplicitListitemRole) { + return out; + } + return true; + }, + LINK: ({ node }) => !node.href, + MENU: ({ node }) => { + if (node.getAttribute('type') === 'context') { + return false; + } + return true; + }, + OPTION: ({ node }) => { + const withinOptionList = axe.utils.matchesSelector( + node, + 'select > option, datalist > option, optgroup > option' + ); + return !withinOptionList; + }, + SELECT: ({ node, role }) => + !node.multiple && node.size <= 1 && role === 'menu', + SVG: ({ node, out }) => { + // if in svg context it all roles may be used + if ( + node.parentNode && + node.parentNode.namespaceURI === 'http://www.w3.org/2000/svg' + ) { + return true; + } + return out; + } +}; diff --git a/lib/commons/aria/is-aria-role-allowed-on-element.js b/lib/commons/aria/is-aria-role-allowed-on-element.js new file mode 100644 index 0000000000..2a2fd376eb --- /dev/null +++ b/lib/commons/aria/is-aria-role-allowed-on-element.js @@ -0,0 +1,58 @@ +/* global aria */ +/** + * @description validate if a given role is an allowed ARIA role for the supplied node + * @method isAriaRoleAllowedOnElement + * @param {HTMLElement} node the node to verify + * @param {String} role aria role to check + * @return {Boolean} retruns true/false + */ +aria.isAriaRoleAllowedOnElement = function isAriaRoleAllowedOnElement( + node, + role +) { + const tagName = node.nodeName.toUpperCase(); + const lookupTable = axe.commons.aria.lookupTable; + + // if given node can have no role - return false + if (aria.validateNodeAndAttributes(node, lookupTable.elementsAllowedNoRole)) { + return false; + } + + // if given node allows any role - return true + if ( + aria.validateNodeAndAttributes(node, lookupTable.elementsAllowedAnyRole) + ) { + return true; + } + + // get role value (if exists) from lookupTable.role + const roleValue = lookupTable.role[role]; + + // if given role does not exist in lookupTable - return false + if (!roleValue) { + return false; + } + + // check if role has allowedElements - if not return false + if ( + !( + roleValue.allowedElements && + Array.isArray(roleValue.allowedElements) && + roleValue.allowedElements.length + ) + ) { + return false; + } + + let out = false; + // validate attributes and conditions (if any) from allowedElement to given node + out = aria.validateNodeAndAttributes(node, roleValue.allowedElements); + + // if given node type has complex condition to evaluate a given aria-role, execute the same + if (Object.keys(lookupTable.evaluateRoleForElement).includes(tagName)) { + out = lookupTable.evaluateRoleForElement[tagName]({ node, role, out }); + } + + // return + return out; +}; diff --git a/lib/commons/aria/roles.js b/lib/commons/aria/roles.js index 6039f65b99..ee55770335 100644 --- a/lib/commons/aria/roles.js +++ b/lib/commons/aria/roles.js @@ -5,8 +5,8 @@ * @method isValidRole * @memberof axe.commons.aria * @instance - * @param {String} role The role to check - * @param {Object} options Use `allowAbstract` if you want abstracts, and `flagUnsupported: true` to report unsupported roles + * @param {String} role The role to check + * @param {Object} options Use `allowAbstract` if you want abstracts, and `flagUnsupported: true` to report unsupported roles * @return {Boolean} */ aria.isValidRole = function( @@ -42,8 +42,8 @@ aria.getRolesWithNameFromContents = function() { * @method getRolesByType * @memberof axe.commons.aria * @instance - * @param {String} roleType The roletype to check - * @return {Array} Array of roles that match the type + * @param {String} roleType The roletype to check + * @return {Array} Array of roles that match the type */ aria.getRolesByType = function(roleType) { return Object.keys(aria.lookupTable.role).filter(function(r) { @@ -56,12 +56,11 @@ aria.getRolesByType = function(roleType) { * @method getRoleType * @memberof axe.commons.aria * @instance - * @param {String} role The role to check - * @return {Mixed} String if a matching role and its type are found, otherwise `null` + * @param {String} role The role to check + * @return {Mixed} String if a matching role and its type are found, otherwise `null` */ aria.getRoleType = function(role) { var r = aria.lookupTable.role[role]; - return (r && r.type) || null; }; @@ -70,8 +69,8 @@ aria.getRoleType = function(role) { * @method requiredOwned * @memberof axe.commons.aria * @instance - * @param {String} role The role to check - * @return {Mixed} Either an Array of required owned elements or `null` if there are none + * @param {String} role The role to check + * @return {Mixed} Either an Array of required owned elements or `null` if there are none */ aria.requiredOwned = function(role) { 'use strict'; @@ -89,8 +88,8 @@ aria.requiredOwned = function(role) { * @method requiredContext * @memberof axe.commons.aria * @instance - * @param {String} role The role to check - * @return {Mixed} Either an Array of required context elements or `null` if there are none + * @param {String} role The role to check + * @return {Mixed} Either an Array of required context elements or `null` if there are none */ aria.requiredContext = function(role) { 'use strict'; @@ -108,8 +107,8 @@ aria.requiredContext = function(role) { * @method implicitNodes * @memberof axe.commons.aria * @instance - * @param {String} role The role to check - * @return {Mixed} Either an Array of CSS selectors or `null` if there are none + * @param {String} role The role to check + * @return {Mixed} Either an Array of CSS selectors or `null` if there are none */ aria.implicitNodes = function(role) { 'use strict'; @@ -128,10 +127,9 @@ aria.implicitNodes = function(role) { * @method implicitRole * @memberof axe.commons.aria * @instance - * @param {HTMLElement} node The node to test - * @return {Mixed} Either the role or `null` if there is none + * @param {HTMLElement} node The node to test + * @return {Mixed} Either the role or `null` if there is none */ - aria.implicitRole = function(node) { 'use strict'; diff --git a/lib/commons/aria/validate-node-and-attributes.js b/lib/commons/aria/validate-node-and-attributes.js new file mode 100644 index 0000000000..1ebd4b81ee --- /dev/null +++ b/lib/commons/aria/validate-node-and-attributes.js @@ -0,0 +1,89 @@ +/* global aria */ +/** + * @description Method that validates a given node against a list of constraints. + * @param {HTMLElement} node node to verify attributes against constraints + * @param {Array} constraintsArray an array containing TAGNAME or an OBJECT abstraction with conditions and attributes + * @return {Boolean} true/ false based on if node passes the constraints expected + */ +aria.validateNodeAndAttributes = function validateNodeAndAttributes( + node, + constraintsArray +) { + const tagName = node.nodeName.toUpperCase(); + + // get all constraints from the list that are of type string + // these string are tag names which can then be validated against the node's tag name + const stringConstraints = constraintsArray.filter(c => typeof c === 'string'); + + // if tag name of the node is part of listed constraints - return true + if (stringConstraints.includes(tagName)) { + return true; + } + + // get all constraints from the list that are of type object + // the further filter the constraints to those that match the given nodes tag name + const objectConstraints = constraintsArray + .filter(c => typeof c === 'object') + .filter(c => { + return c.tagName === tagName; + }); + + // get all attrubutes that are applied on the given node + const nodeAttrs = Array.from(node.attributes).map(a => a.name.toUpperCase()); + + // iterate through all object constraints + // to filter only constraints that have valid attributes and or conditions that are applicable to the given node + const validConstraints = objectConstraints.filter(c => { + // if the constraints does not have any attribtues return false + if (!c.attributes) { + // edge case, where constraints does not have attribute + // but has condition - keep the object - return true + if (c.condition) { + return true; + } + return false; + } + + // get all attributes from constraints + const keys = Object.keys(c.attributes); + if (!keys.length) { + return false; + } + + let keepConstraint = false; + // iterate through each attribute and validate the same on the node + keys.forEach(k => { + if (!nodeAttrs.includes(k)) { + return; + } + // get value of attribute on the given node + const attrValue = node + .getAttribute(k) + .trim() + .toUpperCase(); + // validate a match in the value + if (attrValue === c.attributes[k]) { + keepConstraint = true; + } + }); + return keepConstraint; + }); + + // if not valid constraints to validate against, return + if (!validConstraints.length) { + return false; + } + + // at this juncture there is a match + // only thing to evaluate is a condition on the constraint against the node + let out = true; + + validConstraints.forEach(c => { + if (c.condition && typeof c.condition === 'function') { + out = c.condition(node); + } + }); + + // return + return out; +}; diff --git a/lib/commons/utils/is-html-element.js b/lib/commons/utils/is-html-element.js new file mode 100644 index 0000000000..5532b6377c --- /dev/null +++ b/lib/commons/utils/is-html-element.js @@ -0,0 +1,136 @@ +const htmlTags = [ + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'bdi', + 'bdo', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'keygen', + 'label', + 'legend', + 'li', + 'link', + 'main', + 'map', + 'mark', + 'math', + 'menu', + 'menuitem', + 'meta', + 'meter', + 'nav', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'param', + 'picture', + 'pre', + 'progress', + 'q', + 'rb', + 'rp', + 'rt', + 'rtc', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'slot', + 'small', + 'source', + 'span', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'svg', + 'table', + 'tbody', + 'td', + 'template', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'u', + 'ul', + 'var', + 'video', + 'wbr' +]; + +/** + * Verifies that if a given html tag is valid + * @method isHtmlElement + * @memberof axe.commons.utils + * @instance + * @param htmlTag htmlTag to check if valid + * @return {Boolean} true/ false + */ +axe.utils.isHtmlElement = function isHtmlElement(node) { + const tagName = node.nodeName.toLowerCase(); + return ( + htmlTags.includes(tagName) && + node.namespaceURI !== 'http://www.w3.org/2000/svg' + ); +}; diff --git a/lib/rules/aria-allowed-role.json b/lib/rules/aria-allowed-role.json new file mode 100644 index 0000000000..ad71e25d1e --- /dev/null +++ b/lib/rules/aria-allowed-role.json @@ -0,0 +1,18 @@ +{ + "id": "aria-allowed-role", + "excludeHidden": false, + "selector": "[role]", + "tags": [ + "cat.aria", + "best-practice" + ], + "metadata": { + "description": "Ensures role attribute has an appropriate value for the element", + "help": "ARIA role must be appropriate for the element" + }, + "all": [], + "any": [ + "aria-allowed-role" + ], + "none": [] +} \ No newline at end of file diff --git a/test/checks/aria/aria-allowed-role.js b/test/checks/aria/aria-allowed-role.js new file mode 100644 index 0000000000..181266f3a6 --- /dev/null +++ b/test/checks/aria/aria-allowed-role.js @@ -0,0 +1,193 @@ +describe('aria-allowed-role', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var checkContext = axe.testUtils.MockCheckContext(); + + afterEach(function() { + fixture.innerHTML = ''; + checkContext.reset(); + }); + + it('returns true if given element is an ignoredTag in options', function() { + var node = document.createElement('article'); + node.setAttribute('role', 'presentation'); + var options = { + ignoredTags: ['article'] + }; + var actual = checks['aria-allowed-role'].evaluate.call( + checkContext, + node, + options + ); + var expected = true; + assert.equal(actual, expected); + assert.isNull(checkContext._data, null); + }); + + it('returns false with implicit role of row for TR when allowImplicit is set to false via options', function() { + var node = document.createElement('table'); + node.setAttribute('role', 'grid'); + var row = document.createElement('tr'); + row.setAttribute('role', 'row'); + var options = { + allowImplicit: false + }; + var actual = checks['aria-allowed-role'].evaluate.call( + checkContext, + row, + options + ); + var expected = false; + assert.equal(actual, expected); + assert.deepEqual(checkContext._data, ['row']); + }); + + it('returns true when A has namespace as svg', function() { + var node = document.createElementNS('http://www.w3.org/2000/svg', 'a'); + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + }); + + it('returns true when BUTTON has type menu and role as menuitem', function() { + var node = document.createElement('button'); + node.setAttribute('type', 'menu'); + node.setAttribute('role', 'menuitem'); + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + }); + + it('returns false when img has no alt', function() { + var node = document.createElement('img'); + node.setAttribute('role', 'presentation'); + fixture.appendChild(node); + assert.isFalse( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + assert.deepEqual(checkContext._data, ['presentation']); + }); + + it('returns true when input of type image and no role', function() { + var node = document.createElement('img'); + node.setAttribute('type', 'image'); + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + assert.isNull(checkContext._data, null); + }); + + it('returns true when INPUT type is checkbox and has aria-pressed attribute', function() { + var node = document.createElement('input'); + node.setAttribute('type', 'checkbox'); + node.setAttribute('aria-pressed', ''); + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + }); + + it('returns false when MENU has type context', function() { + var node = document.createElement('menu'); + node.setAttribute('type', 'context'); + node.setAttribute('role', 'navigation'); + fixture.appendChild(node); + assert.isFalse( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + assert.deepEqual(checkContext._data, ['navigation']); + }); + + it('returns false when a role is set on an element that does not allow any role', function() { + var node = document.createElement('dd'); + node.setAttribute('role', 'link'); + fixture.appendChild(node); + assert.isFalse( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + assert.deepEqual(checkContext._data, ['link']); + }); + + it('returns true when a role is set on an element that can have any role', function() { + var node = document.createElement('div'); + node.setAttribute('role', 'link'); + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + }); + + it('returns true an without a href to have any role', function() { + var node = document.createElement('a'); + node.setAttribute('role', 'presentation'); + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + }); + + it('returns true with a empty href to have any valid role', function() { + var node = document.createElement('a'); + node.setAttribute('role', 'link'); + node.href = ''; + fixture.appendChild(node); + assert.isFalse( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + assert.deepEqual(checkContext._data, ['link']); + }); + + it('returns true with a non-empty alt', function() { + var node = document.createElement('img'); + node.setAttribute('role', 'banner'); + node.alt = 'some text'; + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + }); + + it('returns false for with a non-empty alt and role `presentation`', function() { + var node = document.createElement('img'); + node.setAttribute('role', 'presentation'); + node.alt = 'some text'; + fixture.appendChild(node); + assert.isFalse( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + // assert.deepEqual(checkContext._data, ['presentation']); + }); + + it('should not allow a with a href to have any invalid role', function() { + var node = document.createElement('link'); + node.setAttribute('role', 'invalid-role'); + node.href = '\\example.com'; + fixture.appendChild(node); + assert.isTrue( + checks['aria-allowed-role'].evaluate.call(checkContext, node) + ); + }); + + it('should allow + +

+ + + + +
+ +
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/test/integration/rules/aria-allowed-role/aria-allowed-role.json b/test/integration/rules/aria-allowed-role/aria-allowed-role.json new file mode 100644 index 0000000000..a18890b583 --- /dev/null +++ b/test/integration/rules/aria-allowed-role/aria-allowed-role.json @@ -0,0 +1,57 @@ +{ + "description": "aria-allowed-role integration tests", + "rule": "aria-allowed-role", + "passes": [ + ["#pass-section-role-alert"], + ["#pass-dialog-role-alertdialog"], + ["#pass-section-role-banner"], + ["#pass-section-role-complementary"], + ["#pass-object-role-document"], + ["#pass-section-role-doc-afterword"], + ["#pass-section-role-doc-bib"], + ["#pass-li-role-doc-biblioentry"], + ["#pass-aside-doc-example"], + ["#pass-div-has-any-role"], + ["#pass-div-valid-role"], + ["#pass-ol-valid-role"], + ["#pass-nav-role-doc-index"], + ["#pass-h1-role-doc-subtitle"], + ["#pass-video-valid-role"], + ["#pass-a-valid-role-tree-item"], + ["#pass-a-valid-role-button"], + ["#pass-a-valid-role-doc-backlink"], + ["#pass-ul-valid-role"], + ["#pass-ol-role-listbox"], + ["#pass-section-role-marquee"], + ["#pass-figure-role-group"], + ["#pass-section-valid-role-application"], + ["#pass-section-valid-role-content-info"], + ["#pass-section-valid-role-dialog"], + ["#pass-button-valid-role-checkbox"], + ["#pass-header-valid-role"], + ["#pass-footer-valid-role"], + ["#pass-embed-valid-role"], + ["#pass-input-image-valid-role"], + ["#pass-input-checkbox-valid-role"], + ["#pass-h1-valid-role"], + ["#pass-img-valid-role"], + ["#pass-button-role-radio"], + ["#pass-aside-role-region"], + ["#pass-form-role-search"], + ["#pass-li-role-sep"], + ["#pass-custom-element-any-role"] + ], + "violations": [ + ["#fail-dd-no-role"], + ["#fail-dt-no-role"], + ["#fail-label-no-role"], + ["#fail-ol-invalid-role"], + ["#fail-a-invalid-role"], + ["#fail-section-invalid-role"], + ["#fail-embed-invalid-role"], + ["#fail-input-image-invalid-role"], + ["#fail-button-role-cell"], + ["#fail-aside-doc-foreword"], + ["#fail-aside-role-tab"] + ] +} \ No newline at end of file