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 98e2159a..756e2d59 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", @@ -601,11 +601,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": { @@ -1101,6 +1101,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", @@ -4819,11 +4827,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": { @@ -5173,6 +5181,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 653bc80c..5d4c29bf 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: 'foobar'}, @@ -344,20 +317,25 @@ ruleTester.run('role-supports-aria-props', rule, { {code: ''}, {code: '
'}, {code: '
'}, + {code: ''}, // link maps to nothing ], invalid: [ // implicit basic checks { - code: '', - errors: [getErrorMessage('aria-checked', 'link')], + code: '', + errors: [getErrorMessage('aria-checked', 'generic')], }, { - code: '', + code: '', + errors: [getErrorMessage('aria-checked', 'generic')], + }, + { + code: '', errors: [getErrorMessage('aria-checked', 'link')], }, { - code: '', + code: '', errors: [getErrorMessage('aria-checked', 'link')], }, { @@ -394,7 +372,7 @@ ruleTester.run('role-supports-aria-props', rule, { }, { code: '', - errors: [getErrorMessage('aria-expanded', 'document')], + errors: [getErrorMessage('aria-expanded', 'generic')], }, { code: '
  • ', @@ -414,6 +392,10 @@ ruleTester.run('role-supports-aria-props', rule, { }, { code: '
    ', + errors: [getErrorMessage('aria-expanded', 'generic')], + }, + { + code: '
    ', errors: [getErrorMessage('aria-expanded', 'region')], }, { @@ -480,10 +462,6 @@ ruleTester.run('role-supports-aria-props', rule, { code: '', errors: [getErrorMessage('aria-expanded', 'toolbar')], }, - { - code: '', - errors: [getErrorMessage('aria-invalid', 'link')], - }, { code: '', errors: [getErrorMessage('aria-invalid', 'link')], diff --git a/tests/utils/get-element-type.js b/tests/utils/get-element-type.js index 91720588..004bef70 100644 --- a/tests/utils/get-element-type.js +++ b/tests/utils/get-element-type.js @@ -1,34 +1,11 @@ const {getElementType} = require('../../lib/utils/get-element-type') +const {mockJSXAttribute, mockJSXOpeningElement} = require('./mocks') + const mocha = require('mocha') const describe = mocha.describe const it = mocha.it const expect = require('chai').expect -function mockJSXAttribute(prop, propValue) { - return { - type: 'JSXAttribute', - name: { - type: 'JSXIdentifier', - name: prop, - }, - value: { - type: 'Literal', - value: propValue, - }, - } -} - -function mockJSXOpeningElement(tagName, attributes = []) { - return { - type: 'JSXOpeningElement', - name: { - type: 'JSXIdentifier', - name: tagName, - }, - attributes, - } -} - function mockSetting(componentSetting = {}) { return { settings: { diff --git a/tests/utils/get-role.js b/tests/utils/get-role.js new file mode 100644 index 00000000..6ee41ec4 --- /dev/null +++ b/tests/utils/get-role.js @@ -0,0 +1,201 @@ +const {getRole} = require('../../lib/utils/get-role') +const {mockJSXAttribute, mockJSXOpeningElement} = require('./mocks') +const mocha = require('mocha') +const describe = mocha.describe +const it = mocha.it +const expect = require('chai').expect + +describe('getRole', function () { + it('returns generic role for regardless of attribute', function () { + const node = mockJSXOpeningElement('span', [mockJSXAttribute('aria-label', 'something')]) + expect(getRole({}, node)).to.equal('generic') + }) + + it('returns generic role for
    regardless of attribute', function () { + const node = mockJSXOpeningElement('div', [mockJSXAttribute('aria-describedby', 'something')]) + expect(getRole({}, node)).to.equal('generic') + }) + + it('returns generic role for without href', function () { + const node = mockJSXOpeningElement('a') + expect(getRole({}, node)).to.equal('generic') + }) + + it('returns link role for with href', function () { + const node = mockJSXOpeningElement('a', [mockJSXAttribute('href', '#')]) + expect(getRole({}, node)).to.equal('link') + }) + + it('returns region role for
    with aria-label', function () { + const node = mockJSXOpeningElement('section', [mockJSXAttribute('aria-label', 'something')]) + expect(getRole({}, node)).to.equal('region') + }) + + it('returns region role for
    with aria-labelledby', function () { + const node = mockJSXOpeningElement('section', [mockJSXAttribute('aria-labelledby', 'something')]) + expect(getRole({}, node)).to.equal('region') + }) + + it('returns complementary role for