Skip to content

Commit 46a7182

Browse files
committed
interactive-supports-focus
1 parent c6a8b32 commit 46a7182

File tree

7 files changed

+346
-1
lines changed

7 files changed

+346
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ I'm currently working on getting parity between this project and `eslint-plugin-
5959
- [x] form-control-has-label
6060
- [x] heading-has-content
6161
- [x] iframe-has-title
62-
- [ ] interactive-supports-focus
62+
- [x] interactive-supports-focus
6363
- [ ] label-has-for
6464
- [x] media-has-caption
6565
- [x] mouse-events-have-key-events
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# interactive-supports-focus
2+
3+
Elements with an interactive role and interaction handlers (mouse or key press) must be focusable.
4+
5+
## How do I resolve this error?
6+
7+
### Case: I got the error "Elements with the '\${role}' interactive role must be tabbable". How can I fix this?
8+
9+
This element is a stand-alone control like a button, a link or a form element. A user should be able to reach this element by pressing the tab key on their keyboard.
10+
11+
Replace the component with one that renders semantic html element like `<button>`, `<a href>` or `<input>`. Generally buttons, links and form elements should be reachable via tab key presses. An element that can be tabbed to is said to be in the _tab ring_.
12+
13+
-- or --
14+
15+
Add the `tabindex` property to your component. A value of zero indicates that this element can be tabbed to.
16+
17+
```
18+
<div role="button" @click="foo" tabindex="0" />
19+
```
20+
21+
### Case: I got the error "Elements with the '\${role}' interactive role must be focusable". How can I fix this?
22+
23+
This element is part of a group of buttons, links, menu items, etc. Or this element is part of a composite widget. Composite widgets prescribe standard [keyboard interaction patterns](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_generalnav). Within a group of similar elements -- like a button bar -- or within a composite widget, elements that can be focused are given a tabindex of -1. This makes the element _focusable_ but not _tabbable_. Generally one item in a group should have a tabindex of zero so that a user can tab to the component. Once an element in the component has focus, your key management behaviors will control traversal within the component's pieces. As the UI author, you will need to implement the key handling behaviors such as listening for traversal key (up/down/left/right) presses and moving the page focus between the focusable elements in your widget.
24+
25+
```
26+
<div role="menu">
27+
<div role="menuitem" tabindex="0">Open</div>
28+
<div role="menuitem" tabindex="-1">Save</div>
29+
<div role="menuitem" tabindex="-1">Close</div>
30+
</div>
31+
```
32+
33+
In the example above, the first item in the group can be tabbed to. The developer provides the ability to traverse to the subsequent items via the up/down/left/right arrow keys. Traversing via arrow keys is not provided by the browser or the assistive technology. See [Fundamental Keyboard Navigation Conventions](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_generalnav) for information about established traversal behaviors for various UI widgets.
34+
35+
_References:_
36+
37+
1. [AX_FOCUS_02](https://github.com/GoogleChrome/accessibility-developer-tools/wiki/Audit-Rules#ax_focus_02)
38+
2. [Mozilla Developer Network - ARIA Techniques](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_button_role#Keyboard_and_focus)
39+
3. [Fundamental Keyboard Navigation Conventions](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_generalnav)
40+
4. [WAI-ARIA Authoring Practices Guide - Design Patterns and Widgets](https://www.w3.org/TR/wai-aria-practices-1.1/#aria_ex)
41+
42+
## Rule details
43+
44+
This rule takes an options object with the key `tabbable`. The value is an array of interactive ARIA roles that should be considered tabbable, not just focusable. Any interactive role not included in this list will be flagged as needing to be focusable (tabindex of -1).
45+
46+
```json
47+
{
48+
"vue-accessibility/interactive-supports-focus": ["error", {
49+
"tabbable": [
50+
"button",
51+
"checkbox",
52+
"link",
53+
"searchbox",
54+
"spinbutton",
55+
"switch",
56+
"textbox"
57+
]
58+
}
59+
]
60+
```
61+
62+
### Succeed
63+
64+
```vue
65+
<!-- Good: div with @click attribute is hidden from screen reader -->
66+
<div aria-hidden @click="() => void 0" />
67+
68+
<!-- Good: span with @click attribute is in the tab order -->
69+
<span @click="doSomething" tabindex="0" role="button">Click me!</span>
70+
71+
<!-- Good: span with @click attribute may be focused programmatically -->
72+
<span @click="doSomething" tabindex="-1" role="menuitem">Click me too!</span>
73+
74+
<!-- Good: anchor element with href is inherently focusable -->
75+
<a href="javascript:void(0)" @click="doSomething">Click ALL the things!</a>
76+
77+
<!-- Good: buttons are inherently focusable -->
78+
<button @click="doSomething">Click the button</button>
79+
```
80+
81+
### Fail
82+
83+
```vue
84+
<!-- Bad: span with @click attribute has no tabindex -->
85+
<span @click="submitForm" role="button">Submit</span>
86+
87+
<!-- Bad: anchor element without href is not focusable -->
88+
<a @click="showNextPage" role="button">Next page</a>
89+
```

src/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module.exports = {
99
"form-control-has-label": require("./rules/form-control-has-label"),
1010
"heading-has-content": require("./rules/heading-has-content"),
1111
"iframe-has-title": require("./rules/iframe-has-title"),
12+
"interactive-supports-focus": require("./rules/interactive-supports-focus"),
1213
"media-has-caption": require("./rules/media-has-caption"),
1314
"mouse-events-have-key-events": require("./rules/mouse-events-have-key-events"),
1415
"no-access-key": require("./rules/no-access-key"),
@@ -38,6 +39,20 @@ module.exports = {
3839
"vue-accessibility/form-control-has-label": "error",
3940
"vue-accessibility/heading-has-content": "error",
4041
"vue-accessibility/iframe-has-title": "error",
42+
"vue-accessibility/interactive-supports-focus": [
43+
"error",
44+
{
45+
tabbable: [
46+
"button",
47+
"checkbox",
48+
"link",
49+
"searchbox",
50+
"spinbutton",
51+
"switch",
52+
"textbox"
53+
]
54+
}
55+
],
4156
"vue-accessibility/media-has-caption": "error",
4257
"vue-accessibility/mouse-events-have-key-events": "error",
4358
"vue-accessibility/no-access-key": "error",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
const rule = require("../interactive-supports-focus");
2+
const makeRuleTester = require("./makeRuleTester");
3+
4+
makeRuleTester("interactive-supports-focus", rule, {
5+
valid: [
6+
"<div />",
7+
"<div aria-hidden @click='void 0' />",
8+
"<div aria-hidden='true' @click='void 0' />",
9+
"<div aria-hidden='hidden !== false' @click='void 0' />",
10+
"<div aria-hidden='1 < 2' @click='void 0' />",
11+
"<div aria-hidden='1 <= 2' @click='void 0' />",
12+
"<div @click='void 0' />",
13+
"<div @click='void 0' :tabindex='undefined' />",
14+
"<div @click='void 0' tabindex='bad' />",
15+
"<div @click='void 0' :role='undefined' />",
16+
"<div role='section' @click='void 0' />",
17+
"<div @click='void 0' :aria-hidden='false' />",
18+
"<input type='text' @click='void 0' />",
19+
"<input type='hidden' @click='void 0' tabindex='-1' />",
20+
"<input type='hidden' @click='void 0' :tabindex='-1' />",
21+
"<input @click='void 0' />",
22+
"<input @click='void 0' role='combobox' />",
23+
"<button @click='void 0' class='foo' />",
24+
"<option @click='void 0' class='foo' />",
25+
"<select @click='void 0' class='foo' />",
26+
"<area href='#' @click='void 0' class='foo' />",
27+
"<area @click='void 0' class='foo' />",
28+
"<textarea @click='void 0' class='foo' />",
29+
"<a @click='showNextPage'>Next page</a>",
30+
"<a @click='showNextPage' :tabindex='undefined'>Next page</a>",
31+
"<a @click='showNextPage()' tabindex='bad'>Next page</a>",
32+
"<a @click='void 0' />",
33+
"<a tabindex='0' @click='void 0' />",
34+
"<a :tabindex='dynamicTabIndex' @click='void 0' />",
35+
"<a :tabindex='0' @click='void 0' />",
36+
"<a role='button' href='#' @click='void 0' />",
37+
"<a @click='void 0' href='http://x.y.z' />",
38+
"<a @click='void 0' href='http://x.y.z' tabindex='0' />",
39+
"<a @click='void 0' href='http://x.y.z' :tabindex='0' />",
40+
"<a @click='void 0' href='http://x.y.z' role='button' />",
41+
"<TestComponent @click='foo' />",
42+
"<input @click='void 0' type='hidden' />",
43+
"<span @click='submitForm'>Submit</span>",
44+
"<span @click='submitForm' tabindex='undefined'>Submit</span>",
45+
"<span @click='submitForm' tabindex='bad'>Submit</span>",
46+
"<span @click='doSomething' tabindex='0'>Click me!</span>",
47+
"<span @click='doSomething' :tabindex='0'>Click me!</span>",
48+
"<span @click='doSomething' tabindex='-1'>Click me too!</span>",
49+
"<a href='javascript:void(0);' @click='doSomething'>Click ALL the things!</a>",
50+
"<section @click='void 0' />",
51+
"<main @click='void 0' />",
52+
"<article @click='void 0' />",
53+
"<header @click='void 0' />",
54+
"<footer @click='void 0' />",
55+
...rule.interactiveRoles.map((role) => ({
56+
code: `<div role='${role}' tabindex='0' @click='void 0' />`,
57+
options: [{ tabbable: rule.interactiveRoles }]
58+
})),
59+
"<div role='tab' tabindex='0' @click='void 0' />",
60+
"<div role='textbox' tabindex='0' @click='void 0' />",
61+
"<div role='textbox' aria-disabled='true' @click='void 0' />",
62+
"<Foo.Bar @click='void 0' aria-hidden='false' />",
63+
"<Input @click='void 0' type='hidden' />"
64+
],
65+
invalid: [
66+
...rule.interactiveRoles.flatMap((role) =>
67+
rule.interactiveHandlers.map((handler) => ({
68+
code: `<div role='${role}' @${handler}='void 0' />`,
69+
options: [{ tabbable: rule.interactiveRoles }],
70+
errors: [{ message: rule.makeTabbableErrorMessage(role) }]
71+
}))
72+
),
73+
...rule.interactiveRoles.flatMap((role) =>
74+
rule.interactiveHandlers.map((handler) => ({
75+
code: `<div role='${role}' @${handler}='void 0' />`,
76+
options: [{ tabbable: [] }],
77+
errors: [{ message: rule.makeFocusableErrorMessage(role) }]
78+
}))
79+
)
80+
]
81+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
const { dom, roles } = require("aria-query");
2+
3+
const {
4+
defineTemplateBodyVisitor,
5+
getAttributeValue,
6+
getElementAttribute,
7+
getElementAttributeValue,
8+
getElementType,
9+
hasOnDirectives,
10+
isHiddenFromScreenReader,
11+
isInteractiveElement,
12+
isPresentationRole,
13+
makeDocsURL
14+
} = require("../utils");
15+
16+
const interactiveRoles = [];
17+
18+
for (const [role, definition] of roles) {
19+
if (
20+
!definition.abstract &&
21+
definition.superClass.some((classes) => classes.includes("widget"))
22+
) {
23+
interactiveRoles.push(role);
24+
}
25+
}
26+
27+
const interactiveHandlers = [
28+
"click",
29+
"contextmenu",
30+
"dblclick",
31+
"doubleclick",
32+
"drag",
33+
"dragend",
34+
"dragenter",
35+
"dragexit",
36+
"dragleave",
37+
"dragover",
38+
"dragstart",
39+
"drop",
40+
"keydown",
41+
"keypress",
42+
"keyup",
43+
"mousedown",
44+
"mouseenter",
45+
"mouseleave",
46+
"mousemove",
47+
"mouseout",
48+
"mouseover",
49+
"mouseup"
50+
];
51+
52+
const isDisabledElement = (node) =>
53+
getElementAttributeValue(node, "disabled") ||
54+
(getElementAttributeValue(node, "aria-disabled") || "").toString() === "true";
55+
56+
const hasInteractiveRole = (node) => {
57+
const roleValue = getElementAttributeValue(node, "role");
58+
if (typeof roleValue !== "string") {
59+
return false;
60+
}
61+
62+
return roleValue
63+
.toLowerCase()
64+
.split(" ")
65+
.some((role) => roles.has(role) && interactiveRoles.includes(role));
66+
};
67+
68+
const hasTabIndex = (node) => {
69+
const attribute = getElementAttribute(node, "tabindex");
70+
71+
if (!attribute) {
72+
return false;
73+
}
74+
75+
const value = getAttributeValue(attribute);
76+
77+
if (["string", "number"].includes(typeof value)) {
78+
if (typeof value === "string" && value.length === 0) {
79+
return false;
80+
}
81+
return Number.isInteger(Number(value));
82+
}
83+
84+
if (value === true || value === false) {
85+
return false;
86+
}
87+
88+
return value === null;
89+
};
90+
91+
const makeTabbableErrorMessage = (role) =>
92+
`Elements with the "${role}" interactive role must be tabbable.`;
93+
94+
const makeFocusableErrorMessage = (role) =>
95+
`Elements with the "${role}" interactive role must be focusable.`;
96+
97+
module.exports = {
98+
meta: {
99+
docs: {
100+
url: makeDocsURL("interactive-supports-focus")
101+
},
102+
schema: [
103+
{
104+
type: "object",
105+
properties: {
106+
tabbable: {
107+
type: "array",
108+
items: {
109+
type: "string",
110+
enum: interactiveRoles
111+
},
112+
uniqueItems: true,
113+
additionalItems: false
114+
}
115+
}
116+
}
117+
]
118+
},
119+
create(context) {
120+
return defineTemplateBodyVisitor(context, {
121+
VElement(node) {
122+
if (
123+
dom.has(getElementType(node)) &&
124+
hasOnDirectives(node, interactiveHandlers) &&
125+
!hasTabIndex(node) &&
126+
!isDisabledElement(node) &&
127+
!isHiddenFromScreenReader(node) &&
128+
!isPresentationRole(node) &&
129+
hasInteractiveRole(node) &&
130+
!isInteractiveElement(node)
131+
) {
132+
const role = getElementAttributeValue(node, "role");
133+
const { tabbable = [] } = context.options[0] || {};
134+
135+
if (role && tabbable.includes(role)) {
136+
// Always tabbable, tabIndex = 0
137+
context.report({ node, message: makeTabbableErrorMessage(role) });
138+
} else {
139+
// Focusable, tabIndex = -1 or 0
140+
context.report({ node, message: makeFocusableErrorMessage(role) });
141+
}
142+
}
143+
}
144+
});
145+
},
146+
interactiveHandlers,
147+
interactiveRoles,
148+
makeTabbableErrorMessage,
149+
makeFocusableErrorMessage
150+
};

src/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const hasOnDirectives = require("./utils/hasOnDirectives");
1111
const isAttribute = require("./utils/isAttribute");
1212
const isHiddenFromScreenReader = require("./utils/isHiddenFromScreenReader");
1313
const isInteractiveElement = require("./utils/isInteractiveElement");
14+
const isPresentationRole = require("./utils/isPresentationRole");
1415
const makeDocsURL = require("./utils/makeDocsURL");
1516
const matchesElementRole = require("./utils/matchesElementRole");
1617

@@ -66,6 +67,7 @@ module.exports = {
6667
isAttributeWithValue,
6768
isHiddenFromScreenReader,
6869
isInteractiveElement,
70+
isPresentationRole,
6971
makeDocsURL,
7072
matchesElementRole
7173
};

src/utils/isPresentationRole.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const getElementAttributeValue = require("./getElementAttributeValue");
2+
3+
const isPresentationRole = (node) => {
4+
const role = getElementAttributeValue(node, "role");
5+
return role && ["presentation", "none"].includes(role);
6+
};
7+
8+
module.exports = isPresentationRole;

0 commit comments

Comments
 (0)