-
Notifications
You must be signed in to change notification settings - Fork 235
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds new rule:
no-duplicate-landmark-elements
(#1550)
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
1 parent
cfea604
commit d4b509a
Showing
6 changed files
with
240 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}, | ||
], | ||
}); |