Skip to content

Commit 4d1f885

Browse files
author
Todd Kloots
committed
[fixed] bug where placeholder links required a tabindex and an ARIA role (closes #62)
[fixed] bug where elements with role=none required a label (closes #63) [fixed] bug where elements with aria-hidden required a label (closes #64) [added] test to ensure interactive elements hidden using aria-hidden are removed from the tab flow
1 parent 9b90134 commit 4d1f885

3 files changed

Lines changed: 142 additions & 22 deletions

File tree

lib/__tests__/index-test.js

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ describe('props', () => {
6161
<div onClick={k} role="button"/>;
6262
});
6363
});
64+
65+
it('does not warn with no role and `aria-hidden="true"`', () => {
66+
doNotExpectWarning(assertions.props.onClick.NO_ROLE.msg, () => {
67+
<a aria-hidden="true" onClick={k}/>;
68+
});
69+
});
6470
});
6571

6672
describe('tabIndex', () => {
@@ -98,6 +104,48 @@ describe('props', () => {
98104
});
99105
});
100106
});
107+
108+
describe('aria-hidden', () => {
109+
describe('when set to `true`', () => {
110+
it('warns when applied to an interactive element without `tabIndex="-1"`', () => {
111+
expectWarning(assertions.props['aria-hidden'].TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN.msg, () => {
112+
<a aria-hidden="true" href="/foo"/>;
113+
});
114+
});
115+
116+
it('warns when applied to an interactive element with `tabIndex="0"`', () => {
117+
expectWarning(assertions.props['aria-hidden'].TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN.msg, () => {
118+
<a aria-hidden="true" tabIndex="0"/>;
119+
});
120+
});
121+
122+
it('does not warn when applied to a placeholder link', () => {
123+
expectWarning(assertions.props['aria-hidden'].TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN.msg, () => {
124+
<a aria-hidden="true"/>;
125+
});
126+
});
127+
128+
it('does not warn when applied to an interactive element with `tabIndex="-1"`', () => {
129+
doNotExpectWarning(assertions.props['aria-hidden'].TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN.msg, () => {
130+
<a aria-hidden="true" tabIndex="-1"/>;
131+
});
132+
});
133+
134+
it('does not warn when applied to a non-interactive element', () => {
135+
doNotExpectWarning(assertions.props['aria-hidden'].TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN.msg, () => {
136+
<div aria-hidden="true"/>;
137+
});
138+
});
139+
});
140+
141+
describe('when set to `false`', () =>{
142+
it('does not warn when applied to an interactive element with `tabIndex="-1"`', () => {
143+
doNotExpectWarning(assertions.props['aria-hidden'].TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN.msg, () => {
144+
<a aria-hidden="false" tabIndex="-1"/>;
145+
});
146+
});
147+
});
148+
});
101149
});
102150

103151
describe('tags', () => {
@@ -132,6 +180,22 @@ describe('tags', () => {
132180
});
133181

134182
describe('a', () => {
183+
describe('placeholder links without href', () => {
184+
it('does not warn', () => {
185+
doNotExpectWarning(assertions.tags.a.HASH_HREF_NEEDS_BUTTON.msg, () => {
186+
<a class="foo" />;
187+
});
188+
});
189+
});
190+
191+
describe('placeholder links without tabindex', () => {
192+
it('does not warn', () => {
193+
doNotExpectWarning(assertions.tags.a.TABINDEX_NEEDS_BUTTON.msg, () => {
194+
<a class="foo" />;
195+
});
196+
});
197+
});
198+
135199
describe('with [href="#"]', () => {
136200
it('warns', () => {
137201
expectWarning(assertions.tags.a.HASH_HREF_NEEDS_BUTTON.msg, () => {
@@ -194,9 +258,27 @@ describe('labels', () => {
194258
});
195259
});
196260

197-
it('does not warn when the ARIA role is presentation', () => {
261+
it('does not warn when `role="presentation"`', () => {
198262
doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
199-
<span role="presentation" />;
263+
<img role="presentation" />;
264+
});
265+
});
266+
267+
it('does not warn when `role="none"`', () => {
268+
doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
269+
<img role="none" />;
270+
});
271+
});
272+
273+
it('does not warn when `aria-hidden="true"`', () => {
274+
doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
275+
<button aria-hidden="true" />;
276+
});
277+
});
278+
279+
it('warns when `aria-hidden="false"`', () => {
280+
expectWarning(assertions.render.NO_LABEL.msg, () => {
281+
<button aria-hidden="false" />;
200282
});
201283
});
202284

lib/assertions.js

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ var INTERACTIVE = {
2121
}
2222
};
2323

24+
const presentationRoles = new Set(['presentation', 'none']);
25+
26+
var isHiddenFromAT = (props) => {
27+
return props['aria-hidden'] == 'true';
28+
};
29+
2430
var hasAlt = (props) => {
2531
return typeof props.alt === 'string';
2632
};
@@ -105,18 +111,31 @@ var hasChildTextNode = (props, children, failureCB) => {
105111
return hasText;
106112
};
107113

114+
exports.mobileExclusions = [
115+
'NO_TABINDEX',
116+
'BUTTON_ROLE_SPACE',
117+
'BUTTON_ROLE_ENTER',
118+
'TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN'
119+
];
120+
108121
exports.props = {
109122
onClick: {
110123
NO_ROLE: {
111124
msg: 'You have a click handler on a non-interactive element but no `role` DOM property. It will be unclear what this element is supposed to do to a screen-reader user. http://www.w3.org/TR/wai-aria/roles#role_definitions',
112125
test (tagName, props, children) {
126+
if (isHiddenFromAT(props))
127+
return true;
128+
113129
return !(!isInteractive(tagName, props) && !props.role);
114130
}
115131
},
116132

117133
NO_TABINDEX: {
118134
msg: 'You have a click handler on a non-interactive element but no `tabIndex` DOM property. The element will not be navigable or interactive by keyboard users. http://www.w3.org/TR/wai-aria-practices/#focus_tabindex',
119135
test (tagName, props, children) {
136+
if (isHiddenFromAT(props))
137+
return true;
138+
120139
return !(
121140
!isInteractive(tagName, props) &&
122141
props.tabIndex == null // tabIndex={0} is valid
@@ -127,17 +146,35 @@ exports.props = {
127146
BUTTON_ROLE_SPACE: {
128147
msg: 'You have `role="button"` but did not define an `onKeyDown` handler. Add it, and have the "Space" key do the same thing as an `onClick` handler.',
129148
test (tagName, props, children) {
149+
if (isHiddenFromAT(props))
150+
return true;
151+
130152
return !(props.role === 'button' && !props.onKeyDown);
131153
}
132154
},
133155

134156
BUTTON_ROLE_ENTER: {
135157
msg: 'You have `role="button"` but did not define an `onKeyDown` handler. Add it, and have the "Enter" key do the same thing as an `onClick` handler.',
136158
test (tagName, props, children) {
159+
if (isHiddenFromAT(props))
160+
return true;
161+
137162
return !(props.role === 'button' && !props.onKeyDown);
138163
}
139164
}
165+
},
140166

167+
'aria-hidden': {
168+
'TABINDEX_REQUIRED_WHEN_ARIA_HIDDEN': {
169+
msg: 'You have `aria-hidden="true"` applied to an interactive element but have not removed it from the tab flow. This could result in a hidden tab stop for users of screen readers.',
170+
test (tagName, props, children) {
171+
return !(
172+
(isInteractive(tagName, props) || (tagName == 'a' && !props.href)) &&
173+
props['aria-hidden'] == 'true' &&
174+
props.tabIndex != '-1'
175+
);
176+
}
177+
}
141178
}
142179
};
143180

@@ -146,13 +183,19 @@ exports.tags = {
146183
HASH_HREF_NEEDS_BUTTON: {
147184
msg: 'You have an anchor with `href="#"` and no `role` DOM property. Add `role="button"` or better yet, use a `<button/>`.',
148185
test (tagName, props, children) {
186+
if (isHiddenFromAT(props))
187+
return true;
188+
149189
return !(!props.role && props.href === '#');
150190
}
151191
},
152192
TABINDEX_NEEDS_BUTTON: {
153193
msg: 'You have an anchor with a tabIndex, no `href` and no `role` DOM property. Add `role="button"` or better yet, use a `<button/>`.',
154194
test (tagName, props, children) {
155-
return !(!props.role && props.tabIndex !== null && !props.href);
195+
if (isHiddenFromAT(props))
196+
return true;
197+
198+
return !(!props.role && props.tabIndex != null && !props.href);
156199
}
157200
}
158201
},
@@ -161,6 +204,9 @@ exports.tags = {
161204
MISSING_ALT: {
162205
msg: 'You forgot an `alt` DOM property on an image. Screen-reader users will not know what it is.',
163206
test (tagName, props, children) {
207+
if (isHiddenFromAT(props))
208+
return true;
209+
164210
return hasAlt(props);
165211
}
166212
},
@@ -169,6 +215,9 @@ exports.tags = {
169215
// TODO: have some way to set localization strings to match against
170216
msg: 'Screen-readers already announce `img` tags as an image, you don\'t need to use the word "image" in the description',
171217
test (tagName, props, children) {
218+
if (isHiddenFromAT(props))
219+
return true;
220+
172221
return !(hasAlt(props) && props.alt.match('image'));
173222
}
174223
}
@@ -179,22 +228,17 @@ exports.render = {
179228
NO_LABEL: {
180229
msg: 'You have an unlabled element or control. Add `aria-label` or `aria-labelled-by` attribute, or put some text in the element.',
181230
test (tagName, props, children, failureCB) {
182-
var labelRequired = (
183-
isInteractive(tagName, props) ||
184-
props.role && props.role != 'presentation'
185-
);
231+
if (isHiddenFromAT(props) || presentationRoles.has(props.role))
232+
return;
186233

187-
if (!labelRequired)
234+
if (!(isInteractive(tagName, props) || props.role))
188235
return;
189236

190237
var failed = !(
191-
labelRequired &&
192-
(
193-
props['aria-label'] ||
194-
props['aria-labelled-by'] ||
195-
(tagName === 'img' && props.alt) ||
196-
hasChildTextNode(props, children, failureCB)
197-
)
238+
props['aria-label'] ||
239+
props['aria-labelled-by'] ||
240+
(tagName === 'img' && props.alt) ||
241+
hasChildTextNode(props, children, failureCB)
198242
);
199243

200244
if (failed)

lib/index.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
var assertions = require('./assertions');
22
var after = require('./after');
33

4-
const mobileExclusions = [
5-
'NO_TABINDEX',
6-
'BUTTON_ROLE_SPACE',
7-
'BUTTON_ROLE_ENTER'
8-
];
9-
104
var shouldRunTest = (testName, options) => {
115
var exclude = options.exclude || [];
126

137
if (options.device == 'mobile') {
14-
exclude = new Set(exclude.concat(mobileExclusions));
8+
exclude = new Set(exclude.concat(assertions.mobileExclusions));
159
exclude = [...exclude];
1610
}
1711

0 commit comments

Comments
 (0)