Skip to content

Commit 1d678e0

Browse files
committed
click-events-have-key-events
1 parent 55a2200 commit 1d678e0

14 files changed

+362
-52
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ I'm currently working on getting parity between this project and `eslint-plugin-
5555
- [x] aria-props
5656
- [x] aria-role
5757
- [x] aria-unsupported-elements
58-
- [ ] click-events-have-key-events
58+
- [x] click-events-have-key-events
5959
- [ ] form-has-label
6060
- [x] heading-has-content
6161
- [x] iframe-has-title
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# click-events-have-key-events
2+
3+
Enforce `@click` is accompanied by at least one of the following: `@keyup`, `@keydown`, `@keypress`. Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, AT compatibility, and screenreader users.
4+
5+
## Rule details
6+
7+
This rule takes no arguments.
8+
9+
### Succeed
10+
11+
```vue
12+
<div @click="foo" @keydown="bar" />
13+
<div @click="foo" @keyup="bar" />
14+
<div @click="foo" @keypress="bar" />
15+
```
16+
17+
### Fail
18+
19+
```vue
20+
<div @click="foo" />
21+
```
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const rule = require("../click-events-have-key-events");
2+
const makeRuleTester = require("./makeRuleTester");
3+
4+
makeRuleTester("click-events-have-key-events", rule, {
5+
valid: [
6+
"<div @click='foo' @keydown='bar' />",
7+
"<div @click='foo' @keyup='bar' />",
8+
"<div @click='foo' @keypress='bar' />",
9+
"<div @click='foo' @keydown='bar' @keydown='baz' />",
10+
"<div class='foo' />",
11+
"<div @click='foo' aria-hidden />",
12+
"<div @click='foo' aria-hidden='true' />",
13+
"<div @click='foo' aria-hidden='false' @keydown='bar' />",
14+
"<div @click='foo' @keydown='bar' aria-hidden='undefined' />",
15+
"<input type='text' @click='foo' />",
16+
"<input @click='foo' />",
17+
"<button @click='foo' class='foo' />",
18+
"<option @click='foo' class='foo' />",
19+
"<select @click='foo' class='foo' />",
20+
"<textarea @click='foo' class='foo' />",
21+
"<a @click='foo' href='http://x.y.z' />",
22+
"<a @click='foo' href='http://x.y.z' tabIndex='0' />",
23+
"<input @click='foo' type='hidden' />",
24+
"<div @click='foo' role='presentation' />",
25+
"<div @click='foo' role='none' />",
26+
"<TestComponent @click='foo' />",
27+
"<Button @click='foo' />"
28+
],
29+
invalid: [
30+
"<div @click='foo' />",
31+
"<div @click='foo' role='undefined' />",
32+
"<section @click='foo' />",
33+
"<main @click='foo' />",
34+
"<article @click='foo' />",
35+
"<header @click='foo' />",
36+
"<footer @click='foo' />",
37+
"<div @click='foo' aria-hidden='false' />",
38+
"<a @click='foo' />",
39+
"<a tabIndex='0' @click='foo' />"
40+
]
41+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const vueEslintParser = require("vue-eslint-parser");
2+
3+
const {
4+
defineTemplateBodyVisitor,
5+
getElementAttribute,
6+
getElementAttributeValue,
7+
hasOnDirective,
8+
isHiddenFromScreenReader,
9+
isInteractiveElement,
10+
makeDocsURL
11+
} = require("../utils");
12+
const htmlElements = require("../utils/htmlElements.json");
13+
14+
const message = `Visible, non-interactive elements with click handlers must \
15+
have at least one keyboard listener.`;
16+
17+
const isHtmlElementNode = (node) =>
18+
node.namespace === vueEslintParser.AST.NS.HTML;
19+
20+
const isCustomComponent = (node) =>
21+
(isHtmlElementNode(node) && !htmlElements.includes(node.rawName)) ||
22+
getElementAttribute(node, "is");
23+
24+
const isPresentationRole = (node) => {
25+
const role = getElementAttributeValue(node, "role");
26+
return role && ["presentation", "none"].includes(role);
27+
};
28+
29+
const hasKeyEvent = (node) =>
30+
hasOnDirective(node, "keydown") ||
31+
hasOnDirective(node, "keyup") ||
32+
hasOnDirective(node, "keypress");
33+
34+
module.exports = {
35+
meta: {
36+
docs: {
37+
url: makeDocsURL("click-events-have-key-events")
38+
}
39+
},
40+
create(context) {
41+
return defineTemplateBodyVisitor(context, {
42+
VElement(node) {
43+
if (
44+
!isCustomComponent(node) &&
45+
hasOnDirective(node, "click") &&
46+
!isHiddenFromScreenReader(node) &&
47+
!isPresentationRole(node) &&
48+
!isInteractiveElement(node) &&
49+
!hasKeyEvent(node)
50+
) {
51+
context.report({ node, message });
52+
}
53+
}
54+
});
55+
},
56+
message
57+
};

src/rules/no-onchange.js

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
11
const {
22
defineTemplateBodyVisitor,
3-
makeDocsURL,
4-
getElementType
3+
getElementType,
4+
hasOnDirective,
5+
makeDocsURL
56
} = require("../utils");
67

78
const message = `@blur must be used instead of @change, unless absolutely \
89
necessary and it causes no negative consequences for keyboard only or screen \
910
reader users.`;
1011

11-
const hasOnDirective = (node, name) =>
12-
node.startTag.attributes.some(
13-
(attribute) =>
14-
attribute.directive &&
15-
attribute.key.name.name === "on" &&
16-
attribute.key.argument.name === name
17-
);
18-
1912
module.exports = {
2013
meta: {
2114
docs: {

src/rules/no-redundant-roles.js

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,51 +3,26 @@ const {
33
defineTemplateBodyVisitor,
44
getElementAttributeValue,
55
getElementType,
6-
makeDocsURL
6+
makeDocsURL,
7+
matchesElementRole
78
} = require("../utils");
89

910
const exceptions = { nav: ["navigation"] };
1011
const makeMessage = (type, role) => `\
1112
The element ${type} has an implicit role of ${role}. Defining this \
1213
explicitly is redundant and should be avoided.`;
1314

14-
const hasRoleAttributes = (node, attributes) =>
15-
attributes.every((attribute) => {
16-
const value = getElementAttributeValue(node, attribute.name);
17-
18-
if (attribute.value) {
19-
return value === attribute.value;
20-
}
21-
22-
if (attribute.constraints) {
23-
switch (attribute.constraints[0]) {
24-
case "set":
25-
return value;
26-
case "undefined":
27-
return !value;
28-
default:
29-
return null;
30-
}
31-
}
32-
33-
return value;
34-
});
35-
3615
const getImplicitRoleSet = (node) => {
37-
const elementType = getElementType(node);
38-
39-
for (const [key, value] of elementRoles) {
40-
if (key.name === elementType) {
41-
if (!key.attributes || hasRoleAttributes(node, key.attributes)) {
42-
return value;
43-
}
16+
for (const [elementRole, roleSet] of elementRoles) {
17+
if (matchesElementRole(node, elementRole)) {
18+
return roleSet;
4419
}
4520
}
21+
4622
return null;
4723
};
4824

4925
module.exports = {
50-
getImplicitRoleSet,
5126
meta: {
5227
docs: {
5328
url: makeDocsURL("no-redundant-roles")

src/utils.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ const getAttributeName = require("./utils/getAttributeName");
33
const getAttributeValue = require("./utils/getAttributeValue");
44
const getElementAttribute = require("./utils/getElementAttribute");
55
const getElementAttributeValue = require("./utils/getElementAttributeValue");
6+
const getElementType = require("./utils/getElementType");
67
const hasContent = require("./utils/hasContent");
8+
const hasOnDirective = require("./utils/hasOnDirective");
79
const isAttribute = require("./utils/isAttribute");
10+
const isHiddenFromScreenReader = require("./utils/isHiddenFromScreenReader");
11+
const isInteractiveElement = require("./utils/isInteractiveElement");
812
const makeDocsURL = require("./utils/makeDocsURL");
13+
const matchesElementRole = require("./utils/matchesElementRole");
914

1015
const isPlainValue = (attribute) => !attribute.directive && attribute.value;
1116
const isBoundValue = (attribute) =>
@@ -34,9 +39,6 @@ const getLiteralAttributeValue = (node, name) => {
3439
return null;
3540
};
3641

37-
const getElementType = (node) =>
38-
getLiteralAttributeValue(node, "is") || node.rawName;
39-
4042
const isAttributeWithValue = (node, name) => {
4143
const { key } = node;
4244

@@ -55,7 +57,11 @@ module.exports = {
5557
getElementType,
5658
getLiteralAttributeValue,
5759
hasContent,
60+
hasOnDirective,
5861
isAttribute,
5962
isAttributeWithValue,
60-
makeDocsURL
63+
isHiddenFromScreenReader,
64+
isInteractiveElement,
65+
makeDocsURL,
66+
matchesElementRole
6167
};

src/utils/getElementType.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const getElementAttributeValue = require("./getElementAttributeValue");
2+
3+
const getElementType = (node) =>
4+
getElementAttributeValue(node, "is") || node.rawName;
5+
6+
module.exports = getElementType;

src/utils/hasContent.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
const getElementAttributeValue = require("./getElementAttributeValue");
2-
3-
const isHiddenFromScreenReader = (node) => {
4-
const ariaHidden = getElementAttributeValue(node, "aria-hidden");
5-
return ariaHidden && ariaHidden.toString() === "true";
6-
};
1+
const isHiddenFromScreenReader = require("./isHiddenFromScreenReader");
72

83
const hasAccessibleChild = (node) =>
94
node.children.some((child) => {

src/utils/hasOnDirective.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const hasOnDirective = (node, name) =>
2+
node.startTag.attributes.some(
3+
(attribute) =>
4+
attribute.directive &&
5+
attribute.key.name.name === "on" &&
6+
attribute.key.argument.name === name
7+
);
8+
9+
module.exports = hasOnDirective;

0 commit comments

Comments
 (0)