Skip to content

Commit

Permalink
Adds new rule: no-duplicate-landmark-elements (#1550)
Browse files Browse the repository at this point in the history
Co-authored-by: Melanie Sumner <melaniersumner@gmail.com>
Co-authored-by: Rajasegar Chandran <rajasegar@users.noreply.github.com>
Co-authored-by: Robert Jackson <rjackson@linkedin.com>
  • Loading branch information
3 people committed Oct 7, 2020
1 parent cfea604 commit d4b509a
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ Each rule has emojis denoting:
| :white_check_mark: | [no-debugger](./docs/rule/no-debugger.md) |
| :white_check_mark: | [no-duplicate-attributes](./docs/rule/no-duplicate-attributes.md) |
| | [no-duplicate-id](./docs/rule/no-duplicate-id.md) |
| | [no-duplicate-landmark-elements](./docs/rule/no-duplicate-landmark-elements.md) |
| | [no-element-event-actions](./docs/rule/no-element-event-actions.md) |
| :white_check_mark: | [no-extra-mut-helper-argument](./docs/rule/no-extra-mut-helper-argument.md) |
| | [no-forbidden-elements](./docs/rule/no-forbidden-elements.md) |
Expand Down
66 changes: 66 additions & 0 deletions docs/rule/no-duplicate-landmark-elements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# no-duplicate-landmark-elements

If multiple landmark elements of the same type are found on a page, they must each have a unique label (provided by aria-label or aria-labelledby).

List of elements & their corresponding roles:

- header (banner)
- main (main)
- aside (complementary)
- form (form, search)
- main (main)
- nav (navigation)
- footer (contentinfo)

## Examples

This rule **forbids** the following:

```hbs
<nav></nav>
<nav></nav>
```

```hbs
<nav></nav>
<div role="navigation"></div>
```

```hbs
<nav aria-label="site navigation"></nav>
<nav aria-label="site navigation"></nav>
```

```hbs
<form aria-label="search-form"></form>
<form aria-label="search-form"></form>
```

This rule **allows** the following:

```hbs
<nav aria-label="primary site navigation"></nav>
<nav aria-label="secondary site navigation within home page"></nav>
```

```hbs
<nav aria-label="primary site navigation"></nav>
<div role="navigation" aria-label="secondary site navigation within home page"></div>
```

```hbs
<form aria-label="shipping address"></form>
<form aria-label="billing address"></form>
```

```hbs
<form role="search" aria-label="search"></form>
<form aria-labelledby="form-title"><div id="form-title">Meaningful Form Title</div></form>
```

## References

- [WAI-ARIA specification: Landmark Roles](https://www.w3.org/WAI/PF/aria/roles#landmark_roles)
- [Understanding Success Criterion 1.3.1: Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
- [Using aria-labelledby to name regions and landmarks](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA13.html)
- [Using aria-label to provide labels for objects](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA6)
1 change: 1 addition & 0 deletions lib/config/a11y.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module.exports = {
'no-abstract-roles': 'error',
'no-duplicate-attributes': 'error',
'no-duplicate-id': 'error',
'no-duplicate-landmark-elements': 'error',
'no-heading-inside-button': 'error',
'no-invalid-interactive': 'error',
'no-invalid-link-text': 'error',
Expand Down
1 change: 1 addition & 0 deletions lib/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ module.exports = {
'no-debugger': require('./no-debugger'),
'no-duplicate-attributes': require('./no-duplicate-attributes'),
'no-duplicate-id': require('./no-duplicate-id'),
'no-duplicate-landmark-elements': require('./no-duplicate-landmark-elements'),
'no-element-event-actions': require('./no-element-event-actions'),
'no-extra-mut-helper-argument': require('./no-extra-mut-helper-argument'),
'no-forbidden-elements': require('./no-forbidden-elements'),
Expand Down
82 changes: 82 additions & 0 deletions lib/rules/no-duplicate-landmark-elements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use strict';

const AstNodeInfo = require('../helpers/ast-node-info');
const Matcher = require('../helpers/node-matcher');
const Rule = require('./base');

const ERROR_MESSAGE =
'If multiple landmark elements (or elements with an equivalent role) of the same type are found on a page, they must each have a unique label.';

// from https://www.w3.org/WAI/PF/aria/roles#landmark_roles
const DEFAULT_ROLE_FOR_ELEMENT = new Map([
['header', 'banner'],
['main', 'main'],
['aside', 'complementary'],
['form', 'form'],
['nav', 'navigation'],
['footer', 'contentinfo'],
]);

module.exports = class NoDuplicateLandmarkElements extends Rule {
constructor(options) {
super(options);
this._landmarksSeen = new Map();
}

visitor() {
return {
ElementNode(node) {
const roleAttribute = AstNodeInfo.findAttribute(node, 'role');

if (roleAttribute && !Matcher.match(roleAttribute, { value: { type: 'TextNode' } })) {
// dynamic role value; nothing we can infer/do about it
return;
}

if (!roleAttribute && !DEFAULT_ROLE_FOR_ELEMENT.has(node.tag)) {
// no role override, and not a landmark element
return;
}

const role = roleAttribute
? roleAttribute.value.chars
: DEFAULT_ROLE_FOR_ELEMENT.get(node.tag);

// check for accessible label via aria-label or aria-labelledby
const labelAttribute =
AstNodeInfo.findAttribute(node, 'aria-label') ||
AstNodeInfo.findAttribute(node, 'aria-labelledby');

if (labelAttribute && !Matcher.match(labelAttribute, { value: { type: 'TextNode' } })) {
// can't make inference about dynamic label
return;
}

const label = labelAttribute ? labelAttribute.value.chars : undefined;

let labelsForRole = this._landmarksSeen.get(role);
if (labelsForRole === undefined) {
labelsForRole = new Map();
this._landmarksSeen.set(role, labelsForRole);
}

let hasUnlabeledForRole = labelsForRole.has(undefined);
if (hasUnlabeledForRole || labelsForRole.has(label)) {
let problematicNode =
hasUnlabeledForRole && label !== undefined ? labelsForRole.get(undefined) : node;

this.log({
message: ERROR_MESSAGE,
line: problematicNode.loc && problematicNode.loc.start.line,
column: problematicNode.loc && problematicNode.loc.start.column,
source: this.sourceForNode(problematicNode),
});
}

labelsForRole.set(label, node);
},
};
}
};

module.exports.ERROR_MESSAGE = ERROR_MESSAGE;
89 changes: 89 additions & 0 deletions test/unit/rules/no-duplicate-landmark-elements-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use strict';

const { ERROR_MESSAGE } = require('../../../lib/rules/no-duplicate-landmark-elements');
const generateRuleTests = require('../../helpers/rule-test-harness');

generateRuleTests({
name: 'no-duplicate-landmark-elements',

config: true,

good: [
'<nav aria-label="primary site navigation"></nav><nav aria-label="secondary site navigation within home page"></nav>',
'<nav aria-label="primary site navigation"></nav><div role="navigation" aria-label="secondary site navigation within home page"></div>',
'<nav aria-label={{siteNavigation}}></nav><nav aria-label={{siteNavigation}}></nav>',
// since we can't confirm what the role of the div is, we have to let it pass
'<nav aria-label="primary site navigation"></nav><div role={{role}} aria-label="secondary site navigation within home page"></div>',
'<form aria-labelledby="form-title"><div id="form-title">Shipping Address</div></form><form aria-label="meaningful title of second form"></form>',
'<form role="search"></form><form></form>',
'<header></header><main></main><footer></footer>',
'<nav aria-label="primary navigation"></nav><nav aria-label={{this.something}}></nav>',
],

bad: [
{
template: '<nav></nav><nav></nav>',
result: {
message: ERROR_MESSAGE,
source: '<nav></nav>',
line: 1,
column: 11,
},
},
{
template: '<nav></nav><div role="navigation"></div>',
result: {
message: ERROR_MESSAGE,
source: '<div role="navigation"></div>',
line: 1,
column: 11,
},
},
{
template: '<nav></nav><nav aria-label="secondary navigation"></nav>',
result: {
message: ERROR_MESSAGE,
source: '<nav></nav>',
line: 1,
column: 0,
},
},
{
template: '<main></main><div role="main"></div>',
result: {
message: ERROR_MESSAGE,
source: '<div role="main"></div>',
line: 1,
column: 13,
},
},
{
template: '<nav aria-label="site navigation"></nav><nav aria-label="site navigation"></nav>',
result: {
message: ERROR_MESSAGE,
source: '<nav aria-label="site navigation"></nav>',
line: 1,
column: 40,
},
},
{
template: '<form aria-label="search-form"></form><form aria-label="search-form"></form>',
result: {
message: ERROR_MESSAGE,
source: '<form aria-label="search-form"></form>',
line: 1,
column: 38,
},
},
{
template:
'<form aria-labelledby="form-title"></form><form aria-labelledby="form-title"></form>',
result: {
message: ERROR_MESSAGE,
source: '<form aria-labelledby="form-title"></form>',
line: 1,
column: 42,
},
},
],
});

0 comments on commit d4b509a

Please sign in to comment.