diff --git a/lib/rules/role-supports-aria-props.js b/lib/rules/role-supports-aria-props.js
index 55644c1f..b6ba0710 100644
--- a/lib/rules/role-supports-aria-props.js
+++ b/lib/rules/role-supports-aria-props.js
@@ -1,28 +1,7 @@
// @ts-check
-const {aria, elementRoles, roles} = require('aria-query')
-const {getProp, getPropValue, propName} = require('jsx-ast-utils')
-const {getElementType} = require('../utils/get-element-type')
-const ObjectMap = require('../utils/object-map')
-
-// Clean-up `elementRoles` from `aria-query`
-const elementRolesMap = new ObjectMap()
-for (const [key, value] of elementRoles.entries()) {
- // - Remove unused `constraints` key
- delete key.constraints
- key.attributes = key.attributes?.filter(attribute => !('constraints' in attribute))
- // - Remove empty `attributes` key
- if (!key.attributes || key.attributes?.length === 0) {
- delete key.attributes
- }
- elementRolesMap.set(key, value)
-}
-// - Remove insufficiently-disambiguated `menuitem` entry
-elementRolesMap.delete({name: 'menuitem'})
-// - Disambiguate `menuitem` and `menu` roles by `type`
-elementRolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'command'}]}, ['menuitem'])
-elementRolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'radio'}]}, ['menuitemradio'])
-elementRolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])
-elementRolesMap.set({name: 'menu', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])
+const {aria, roles} = require('aria-query')
+const {getPropValue, propName} = require('jsx-ast-utils')
+const {getRole} = require('../utils/get-role')
module.exports = {
meta: {
@@ -37,27 +16,8 @@ module.exports = {
create(context) {
return {
JSXOpeningElement(node) {
- // Assemble a key for looking-up the element’s role in the `elementRolesMap`
- // - Get the element’s name
- const key = {name: getElementType(context, node)}
- // - Get the element’s disambiguating attributes
- for (const prop of ['aria-expanded', 'type', 'size', 'role', 'href', 'multiple', 'scope']) {
- // - Only provide `aria-expanded` when it’s required for disambiguation
- if (prop === 'aria-expanded' && key.name !== 'summary') continue
- const value = getPropValue(getProp(node.attributes, prop))
- if (value) {
- if (!('attributes' in key)) {
- key.attributes = []
- }
- if (prop === 'href') {
- key.attributes.push({name: prop})
- } else {
- key.attributes.push({name: prop, value})
- }
- }
- }
// Get the element’s explicit or implicit role
- const role = getPropValue(getProp(node.attributes, 'role')) ?? elementRolesMap.get(key)?.[0]
+ const role = getRole(context, node)
// Return early if role could not be determined
if (!role) return
diff --git a/lib/utils/get-role.js b/lib/utils/get-role.js
new file mode 100644
index 00000000..b69431d7
--- /dev/null
+++ b/lib/utils/get-role.js
@@ -0,0 +1,108 @@
+const {getProp, getPropValue} = require('jsx-ast-utils')
+const {elementRoles} = require('aria-query')
+const {getElementType} = require('./get-element-type')
+const ObjectMap = require('./object-map')
+
+const elementRolesMap = cleanElementRolesMap()
+
+/*
+ Returns an element roles map which uses `aria-query`'s elementRoles as the foundation.
+ We additionally clean the data so we're able to fetch a role using a key we construct based on the node we're looking at.
+ In a few scenarios, we stray from the roles returned by `aria-query` and hard code the mapping.
+*/
+function cleanElementRolesMap() {
+ const rolesMap = new ObjectMap()
+
+ for (const [key, value] of elementRoles.entries()) {
+ // - Remove empty `attributes` key
+ if (!key.attributes || key.attributes?.length === 0) {
+ delete key.attributes
+ }
+ rolesMap.set(key, value)
+ }
+ // Remove insufficiently-disambiguated `menuitem` entry
+ rolesMap.delete({name: 'menuitem'})
+ // Disambiguate `menuitem` and `menu` roles by `type`
+ rolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'command'}]}, ['menuitem'])
+ rolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'radio'}]}, ['menuitemradio'])
+ rolesMap.set({name: 'menuitem', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])
+ rolesMap.set({name: 'menu', attributes: [{name: 'type', value: 'toolbar'}]}, ['toolbar'])
+
+ /* These have constraints defined in aria-query's `elementRoles` which depend on knowledge of ancestor roles which we cant accurately determine in a linter context.
+ However, we benefit more from assuming the role, than assuming it's generic or undefined so we opt to hard code the mapping */
+ rolesMap.set({name: 'aside'}, ['complementary']) // `aside` still maps to `complementary` in https://www.w3.org/TR/html-aria/#docconformance.
+ rolesMap.set({name: 'li'}, ['listitem']) // `li` can be generic if it's not within a list but we would never want to render `li` outside of a list.
+
+ return rolesMap
+}
+
+/*
+ Determine role of an element, based on its name and attributes.
+ We construct a key and look up the element's role in `elementRolesMap`.
+ If there is no match, we return undefined.
+*/
+function getRole(context, node) {
+ // Early return if role is explicitly set
+ const explicitRole = getPropValue(getProp(node.attributes, 'role'))
+ if (explicitRole) {
+ return explicitRole
+ }
+
+ // Assemble a key for looking-up the element’s role in the `elementRolesMap`
+ // - Get the element’s name
+ const key = {name: getElementType(context, node)}
+
+ for (const prop of [
+ 'aria-label',
+ 'aria-labelledby',
+ 'alt',
+ 'type',
+ 'size',
+ 'role',
+ 'href',
+ 'multiple',
+ 'scope',
+ 'name',
+ ]) {
+ if ((prop === 'aria-labelledby' || prop === 'aria-label') && !['section', 'form'].includes(key.name)) continue
+ if (prop === 'name' && key.name !== 'form') continue
+ if (prop === 'href' && key.name !== 'a' && key.name !== 'area') continue
+ if (prop === 'alt' && key.name !== 'img') continue
+
+ const propOnNode = getProp(node.attributes, prop)
+
+ if (!('attributes' in key)) {
+ key.attributes = []
+ }
+ // Disambiguate "undefined" props
+ if (propOnNode === undefined && prop === 'alt' && key.name === 'img') {
+ key.attributes.push({name: prop, constraints: ['undefined']})
+ continue
+ }
+
+ const value = getPropValue(propOnNode)
+ if (value || (value === '' && prop === 'alt')) {
+ if (
+ prop === 'href' ||
+ prop === 'aria-labelledby' ||
+ prop === 'aria-label' ||
+ prop === 'name' ||
+ (prop === 'alt' && value !== '')
+ ) {
+ key.attributes.push({name: prop, constraints: ['set']})
+ } else {
+ key.attributes.push({name: prop, value})
+ }
+ }
+ }
+
+ // - Remove empty `attributes` key
+ if (!key.attributes || key.attributes?.length === 0) {
+ delete key.attributes
+ }
+
+ // Get the element’s implicit role
+ return elementRolesMap.get(key)?.[0]
+}
+
+module.exports = {getRole}
diff --git a/package-lock.json b/package-lock.json
index c6157798..93a2cbd7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@github/browserslist-config": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^5.1.0",
- "aria-query": "^5.1.3",
+ "aria-query": "^5.3.0",
"eslint-config-prettier": ">=8.0.0",
"eslint-plugin-escompat": "^3.3.3",
"eslint-plugin-eslint-comments": "^3.2.0",
@@ -682,11 +682,11 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/aria-query": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
- "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dependencies": {
- "deep-equal": "^2.0.5"
+ "dequal": "^2.0.3"
}
},
"node_modules/array-includes": {
@@ -1182,6 +1182,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/diff": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
@@ -4944,11 +4952,11 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"aria-query": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
- "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"requires": {
- "deep-equal": "^2.0.5"
+ "dequal": "^2.0.3"
}
},
"array-includes": {
@@ -5298,6 +5306,11 @@
"object-keys": "^1.1.1"
}
},
+ "dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="
+ },
"diff": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
diff --git a/package.json b/package.json
index c8f8e0d1..50151ced 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"@github/browserslist-config": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^5.1.0",
- "aria-query": "^5.1.3",
+ "aria-query": "^5.3.0",
"eslint-config-prettier": ">=8.0.0",
"eslint-plugin-escompat": "^3.3.3",
"eslint-plugin-eslint-comments": "^3.2.0",
diff --git a/tests/role-supports-aria-props.js b/tests/role-supports-aria-props.js
index c0676d6a..0192b96d 100644
--- a/tests/role-supports-aria-props.js
+++ b/tests/role-supports-aria-props.js
@@ -57,9 +57,6 @@ ruleTester.run('role-supports-aria-props', rule, {
{code: ''},
{code: ''},
- // this will have global
- {code: ''},
-
// AREA TESTS - implicit role is `link`
{code: ''},
{code: ''},
@@ -78,30 +75,6 @@ ruleTester.run('role-supports-aria-props', rule, {
{code: ''},
{code: ''},
- // this will have global
- {code: ''},
-
- // LINK TESTS - implicit role is `link`
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
- {code: ''},
-
- // this will have global
- {code: ''},
-
// this will have role of `img`
{code: ''},
@@ -344,20 +317,25 @@ ruleTester.run('role-supports-aria-props', rule, {
{code: ''},
{code: '