Skip to content

Commit

Permalink
Merge pull request jsx-eslint#833 from JoshuaKGoldberg/prefer-tag-ove…
Browse files Browse the repository at this point in the history
…r-role

New rule: prefer-tag-over-role
  • Loading branch information
jessebeach committed Aug 7, 2022
2 parents 7f6463e + f86326f commit 7ad61b7
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 0 deletions.
66 changes: 66 additions & 0 deletions __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: '<div />;' },
{ code: '<div role="unknown" />;' },
{ code: '<div role="also unknown" />;' },
{ code: '<other />' },
{ code: '<img role="img" />' },
{ code: '<input role="checkbox" />' },
].map(parserOptionsMapper),
invalid: [
{
code: '<div role="checkbox" />',
errors: [expectedError('checkbox', '<input type="checkbox">')],
},
{
code: '<div role="button checkbox" />',
errors: [expectedError('checkbox', '<input type="checkbox">')],
},
{
code: '<div role="heading" />',
errors: [
expectedError('heading', '<h1>, <h2>, <h3>, <h4>, <h5>, or <h6>'),
],
},
{
code: '<div role="link" />',
errors: [
expectedError(
'link',
'<a href=...>, <area href=...>, or <link href=...>',
),
],
},
{
code: '<div role="rowgroup" />',
errors: [expectedError('rowgroup', '<tbody>, <tfoot>, or <thead>')],
},
{
code: '<span role="checkbox" />',
errors: [expectedError('checkbox', '<input type="checkbox">')],
},
{
code: '<other role="checkbox" />',
errors: [expectedError('checkbox', '<input type="checkbox">')],
},
{
code: '<other role="checkbox" />',
errors: [expectedError('checkbox', '<input type="checkbox">')],
},
{
code: '<div role="banner" />',
errors: [expectedError('banner', '<header>')],
},
].map(parserOptionsMapper),
});
30 changes: 30 additions & 0 deletions 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
<div>...</div>
<header>...</header>
<img alt="" src="image.jpg" />
```

### Fail

```jsx
<div role="checkbox">
<div role="img">
```

## 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)
87 changes: 87 additions & 0 deletions 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,
});
},
};
},
};

0 comments on commit 7ad61b7

Please sign in to comment.