From f86326fc19528d67fe4171e2c27801d47879a569 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Sun, 7 Aug 2022 16:38:24 -0400 Subject: [PATCH] New rule: prefer-tag-over-role --- .../src/rules/prefer-tag-over-role-test.js | 66 ++++++++++++++ docs/rules/element-roles.md | 30 +++++++ src/rules/prefer-tag-over-role.js | 87 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 __tests__/src/rules/prefer-tag-over-role-test.js create mode 100644 docs/rules/element-roles.md create mode 100644 src/rules/prefer-tag-over-role.js diff --git a/__tests__/src/rules/prefer-tag-over-role-test.js b/__tests__/src/rules/prefer-tag-over-role-test.js new file mode 100644 index 000000000..1b6b898fb --- /dev/null +++ b/__tests__/src/rules/prefer-tag-over-role-test.js @@ -0,0 +1,66 @@ +import { RuleTester } from 'eslint'; +import parserOptionsMapper from '../../__util__/parserOptionsMapper'; +import rule from '../../../src/rules/prefer-tag-over-role'; + +const ruleTester = new RuleTester(); + +const expectedError = (role, tag) => ({ + message: `Use ${tag} instead of the "${role}" role to ensure accessibility across all devices.`, + type: 'JSXOpeningElement', +}); + +ruleTester.run('element-role', rule, { + valid: [ + { code: '
;' }, + { code: '
;' }, + { code: '
;' }, + { code: '' }, + { code: '' }, + { code: '' }, + ].map(parserOptionsMapper), + invalid: [ + { + code: '
', + errors: [expectedError('checkbox', '')], + }, + { + code: '
', + errors: [expectedError('checkbox', '')], + }, + { + code: '
', + errors: [ + expectedError('heading', '

,

,

,

,

, or
'), + ], + }, + { + code: '
', + errors: [ + expectedError( + 'link', + ', , or ', + ), + ], + }, + { + code: '
', + errors: [expectedError('rowgroup', ', , or ')], + }, + { + code: '', + errors: [expectedError('checkbox', '')], + }, + { + code: '', + errors: [expectedError('checkbox', '')], + }, + { + code: '', + errors: [expectedError('checkbox', '')], + }, + { + code: '
', + errors: [expectedError('banner', '
')], + }, + ].map(parserOptionsMapper), +}); diff --git a/docs/rules/element-roles.md b/docs/rules/element-roles.md new file mode 100644 index 000000000..3c1309ed8 --- /dev/null +++ b/docs/rules/element-roles.md @@ -0,0 +1,30 @@ +# prefer-tag-over-role + +Enforces using semantic DOM elements over the ARIA `role` property. + +## Rule details + +This rule takes no arguments. + +### Succeed + +```jsx +
...
+
...
+ +``` + +### Fail + +```jsx +
+
+``` + +## Accessibility guidelines + +- [WAI-ARIA Roles model](https://www.w3.org/TR/wai-aria-1.0/roles) + +### Resources + +- [MDN WAI-ARIA Roles](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles) diff --git a/src/rules/prefer-tag-over-role.js b/src/rules/prefer-tag-over-role.js new file mode 100644 index 000000000..0f6d00b3c --- /dev/null +++ b/src/rules/prefer-tag-over-role.js @@ -0,0 +1,87 @@ +import { roleElements } from 'aria-query'; +import { getProp, getPropValue } from 'jsx-ast-utils'; + +import getElementType from '../util/getElementType'; +import { generateObjSchema } from '../util/schemas'; + +const errorMessage = 'Use {{tag}} instead of the "{{role}}" role to ensure accessibility across all devices.'; + +const schema = generateObjSchema(); + +const formatTag = (tag) => { + if (!tag.attributes) { + return `<${tag.name}>`; + } + + const [attribute] = tag.attributes; + const value = attribute.value ? `"${attribute.value}"` : '...'; + + return `<${tag.name} ${attribute.name}=${value}>`; +}; + +const getLastPropValue = (rawProp) => { + const propValue = getPropValue(rawProp); + if (!propValue) { + return propValue; + } + + const lastSpaceIndex = propValue.lastIndexOf(' '); + + return lastSpaceIndex === -1 + ? propValue + : propValue.substring(lastSpaceIndex + 1); +}; + +export default { + meta: { + docs: { + url: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/prefer-tag-over-role.md', + }, + schema: [schema], + }, + + create: (context) => { + const elementType = getElementType(context); + + return { + JSXOpeningElement: (node) => { + const role = getLastPropValue(getProp(node.attributes, 'role')); + if (!role) { + return; + } + + const matchedTagsSet = roleElements.get(role); + if (!matchedTagsSet) { + return; + } + + const matchedTags = Array.from(matchedTagsSet); + if ( + matchedTags.some( + (matchedTag) => matchedTag.name === elementType(node), + ) + ) { + return; + } + + context.report({ + data: { + tag: + matchedTags.length === 1 + ? formatTag(matchedTags[0]) + : [ + matchedTags + .slice(0, matchedTags.length - 1) + .map(formatTag) + .join(', '), + formatTag(matchedTags[matchedTags.length - 1]), + ].join(', or '), + role, + }, + node, + message: errorMessage, + }); + }, + }; + }, +};