Skip to content

Commit 6399a08

Browse files
committed
Add anchor-is-valid rule
1 parent fe9bd6f commit 6399a08

File tree

5 files changed

+626
-0
lines changed

5 files changed

+626
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Add `plugin:jsx-a11y/recommended` or `plugin:jsx-a11y/strict` in `extends`:
9595
- [accessible-emoji](docs/rules/accessible-emoji.md): Enforce emojis are wrapped in <span> and provide screenreader access.
9696
- [alt-text](docs/rules/alt-text.md): Enforce all elements that require alternative text have meaningful information to relay back to end user.
9797
- [anchor-has-content](docs/rules/anchor-has-content.md): Enforce all anchors to contain accessible content.
98+
- [anchor-is-valid](docs/rules/anchor-is-valid.md): Enforce all anchors are valid, navigable elements.
9899
- [aria-activedescendant-has-tabindex](docs/rules/aria-activedescendant-has-tabindex.md): Enforce elements with aria-activedescendant are tabbable.
99100
- [aria-props](docs/rules/aria-props.md): Enforce all `aria-*` props are valid.
100101
- [aria-proptypes](docs/rules/aria-proptypes.md): Enforce ARIA state and property values are valid.
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/* eslint-env jest */
2+
/**
3+
* @fileoverview Performs validity check on anchor hrefs. Warns when anchors are used as buttons.
4+
* @author Almero Steyn
5+
*/
6+
7+
// -----------------------------------------------------------------------------
8+
// Requirements
9+
// -----------------------------------------------------------------------------
10+
11+
import { RuleTester } from 'eslint';
12+
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
13+
import rule from '../../../src/rules/anchor-is-valid';
14+
15+
// -----------------------------------------------------------------------------
16+
// Tests
17+
// -----------------------------------------------------------------------------
18+
19+
const ruleTester = new RuleTester();
20+
21+
const preferButtonErrorMessage = 'Anchor used as a button. ' +
22+
'Anchors are primarily expected to navigate. ' +
23+
'Use the button element instead.';
24+
25+
const noHrefErrorMessage = 'The href attribute is required on an anchor. ' +
26+
'Provide a valid, navigable address as the href value.';
27+
28+
const invalidHrefErrorMessage = 'The href attribute requires a valid address. ' +
29+
'Provide a valid, navigable address as the href value.';
30+
31+
const preferButtonexpectedError = {
32+
message: preferButtonErrorMessage,
33+
type: 'JSXOpeningElement',
34+
};
35+
const noHrefexpectedError = {
36+
message: noHrefErrorMessage,
37+
type: 'JSXOpeningElement',
38+
};
39+
const invalidHrefexpectedError = {
40+
message: invalidHrefErrorMessage,
41+
type: 'JSXOpeningElement',
42+
};
43+
44+
const components = [{
45+
components: ['Anchor', 'Link'],
46+
}];
47+
const specialLink = [{
48+
specialLink: ['hrefLeft', 'hrefRight'],
49+
}];
50+
const componentsAndSpecialLink = [{
51+
components: ['Anchor'],
52+
specialLink: ['hrefLeft'],
53+
}];
54+
55+
ruleTester.run('anchor-is-valid', rule, {
56+
valid: [
57+
// DEFAULT ELEMENT 'a' TESTS
58+
{ code: '<a {...props} />' },
59+
{ code: '<a href="foo" />' },
60+
{ code: '<a href={foo} />' },
61+
{ code: '<a href="/foo" />' },
62+
{ code: '<div href="foo" />' },
63+
{ code: '<a href={`${undefined}foo`}/>' },
64+
{ code: '<a href={`#${undefined}foo`}/>' },
65+
{ code: '<a href={`#foo`}/>' },
66+
{ code: '<a href={"foo"}/>' },
67+
{ code: '<a href="#foo" />' },
68+
{ code: '<UX.Layout>test</UX.Layout>' },
69+
{ code: '<a href={this} />' },
70+
71+
// CUSTOM ELEMENT TEST FOR ARRAY OPTION
72+
{ code: '<Anchor {...props} />', options: components },
73+
{ code: '<Anchor href="foo" />', options: components },
74+
{ code: '<Anchor href={foo} />', options: components },
75+
{ code: '<Anchor href="/foo" />', options: components },
76+
{ code: '<div href="foo" />', options: components },
77+
{ code: '<Anchor href={`${undefined}foo`}/>', options: components },
78+
{ code: '<Anchor href={`#${undefined}foo`}/>', options: components },
79+
{ code: '<Anchor href={`#foo`}/>', options: components },
80+
{ code: '<Anchor href={"foo"}/>', options: components },
81+
{ code: '<Anchor href="#foo" />', options: components },
82+
{ code: '<Link {...props} />', options: components },
83+
{ code: '<Link href="foo" />', options: components },
84+
{ code: '<Link href={foo} />', options: components },
85+
{ code: '<Link href="/foo" />', options: components },
86+
{ code: '<div href="foo" />', options: components },
87+
{ code: '<Link href={`${undefined}foo`}/>', options: components },
88+
{ code: '<Link href={`#${undefined}foo`}/>', options: components },
89+
{ code: '<Link href={`#foo`}/>', options: components },
90+
{ code: '<Link href={"foo"}/>', options: components },
91+
{ code: '<Link href="#foo" />', options: components },
92+
93+
// CUSTOM PROP TESTS
94+
{ code: '<a {...props} />', options: specialLink },
95+
{ code: '<a hrefLeft="foo" />', options: specialLink },
96+
{ code: '<a hrefLeft={foo} />', options: specialLink },
97+
{ code: '<a hrefLeft="/foo" />', options: specialLink },
98+
{ code: '<div hrefLeft="foo" />', options: specialLink },
99+
{ code: '<a hrefLeft={`${undefined}foo`}/>', options: specialLink },
100+
{ code: '<a hrefLeft={`#${undefined}foo`}/>', options: specialLink },
101+
{ code: '<a hrefLeft={`#foo`}/>', options: specialLink },
102+
{ code: '<a hrefLeft={"foo"}/>', options: specialLink },
103+
{ code: '<a hrefLeft="#foo" />', options: specialLink },
104+
{ code: '<UX.Layout>test</UX.Layout>', options: specialLink },
105+
{ code: '<a hrefRight={this} />', options: specialLink },
106+
{ code: '<a {...props} />', options: specialLink },
107+
{ code: '<a hrefRight="foo" />', options: specialLink },
108+
{ code: '<a hrefRight={foo} />', options: specialLink },
109+
{ code: '<a hrefRight="/foo" />', options: specialLink },
110+
{ code: '<div hrefRight="foo" />', options: specialLink },
111+
{ code: '<a hrefRight={`${undefined}foo`}/>', options: specialLink },
112+
{ code: '<a hrefRight={`#${undefined}foo`}/>', options: specialLink },
113+
{ code: '<a hrefRight={`#foo`}/>', options: specialLink },
114+
{ code: '<a hrefRight={"foo"}/>', options: specialLink },
115+
{ code: '<a hrefRight="#foo" />', options: specialLink },
116+
{ code: '<UX.Layout>test</UX.Layout>', options: specialLink },
117+
{ code: '<a hrefRight={this} />', options: specialLink },
118+
119+
// CUSTOM BOTH COMPONENTS AND SPECIALLINK TESTS
120+
{ code: '<Anchor {...props} />', options: componentsAndSpecialLink },
121+
{ code: '<Anchor hrefLeft="foo" />', options: componentsAndSpecialLink },
122+
{ code: '<Anchor hrefLeft={foo} />', options: componentsAndSpecialLink },
123+
{ code: '<Anchor hrefLeft="/foo" />', options: componentsAndSpecialLink },
124+
{ code: '<div hrefLeft="foo" />', options: componentsAndSpecialLink },
125+
{ code: '<Anchor hrefLeft={`${undefined}foo`}/>', options: componentsAndSpecialLink },
126+
{ code: '<Anchor hrefLeft={`#${undefined}foo`}/>', options: componentsAndSpecialLink },
127+
{ code: '<Anchor hrefLeft={`#foo`}/>', options: componentsAndSpecialLink },
128+
{ code: '<Anchor hrefLeft={"foo"}/>', options: componentsAndSpecialLink },
129+
{ code: '<Anchor hrefLeft="#foo" />', options: componentsAndSpecialLink },
130+
{ code: '<UX.Layout>test</UX.Layout>', options: componentsAndSpecialLink },
131+
132+
// WITH ONCLICK
133+
// DEFAULT ELEMENT 'a' TESTS
134+
{ code: '<a {...props} onClick={() => void 0} />' },
135+
{ code: '<a href="foo" onClick={() => void 0} />' },
136+
{ code: '<a href={foo} onClick={() => void 0} />' },
137+
{ code: '<a href="/foo" onClick={() => void 0} />' },
138+
{ code: '<div href="foo" onClick={() => void 0} />' },
139+
{ code: '<a href={`${undefined}foo`} onClick={() => void 0} />' },
140+
{ code: '<a href={`#${undefined}foo`} onClick={() => void 0} />' },
141+
{ code: '<a href={`#foo`} onClick={() => void 0} />' },
142+
{ code: '<a href={"foo"} onClick={() => void 0} />' },
143+
{ code: '<a href="#foo" onClick={() => void 0} />' },
144+
{ code: '<a href={this} onClick={() => void 0} />' },
145+
146+
// CUSTOM ELEMENT TEST FOR ARRAY OPTION
147+
{ code: '<Anchor {...props} onClick={() => void 0} />', options: components },
148+
{ code: '<Anchor href="foo" onClick={() => void 0} />', options: components },
149+
{ code: '<Anchor href={foo} onClick={() => void 0} />', options: components },
150+
{ code: '<Anchor href="/foo" onClick={() => void 0} />', options: components },
151+
{ code: '<Anchor href={`${undefined}foo`} onClick={() => void 0} />', options: components },
152+
{ code: '<Anchor href={`#${undefined}foo`} onClick={() => void 0} />', options: components },
153+
{ code: '<Anchor href={`#foo`} onClick={() => void 0} />', options: components },
154+
{ code: '<Anchor href={"foo"} onClick={() => void 0} />', options: components },
155+
{ code: '<Anchor href="#foo" onClick={() => void 0} />', options: components },
156+
{ code: '<Link {...props} onClick={() => void 0} />', options: components },
157+
{ code: '<Link href="foo" onClick={() => void 0} />', options: components },
158+
{ code: '<Link href={foo} onClick={() => void 0} />', options: components },
159+
{ code: '<Link href="/foo" onClick={() => void 0} />', options: components },
160+
{ code: '<div href="foo" onClick={() => void 0} />', options: components },
161+
{ code: '<Link href={`${undefined}foo`} onClick={() => void 0} />', options: components },
162+
{ code: '<Link href={`#${undefined}foo`} onClick={() => void 0} />', options: components },
163+
{ code: '<Link href={`#foo`} onClick={() => void 0} />', options: components },
164+
{ code: '<Link href={"foo"} onClick={() => void 0} />', options: components },
165+
{ code: '<Link href="#foo" onClick={() => void 0} />', options: components },
166+
167+
// CUSTOM PROP TESTS
168+
{ code: '<a {...props} onClick={() => void 0} />', options: specialLink },
169+
{ code: '<a hrefLeft="foo" onClick={() => void 0} />', options: specialLink },
170+
{ code: '<a hrefLeft={foo} onClick={() => void 0} />', options: specialLink },
171+
{ code: '<a hrefLeft="/foo" onClick={() => void 0} />', options: specialLink },
172+
{ code: '<div hrefLeft="foo" onClick={() => void 0} />', options: specialLink },
173+
{ code: '<a hrefLeft={`${undefined}foo`} onClick={() => void 0} />', options: specialLink },
174+
{ code: '<a hrefLeft={`#${undefined}foo`} onClick={() => void 0} />', options: specialLink },
175+
{ code: '<a hrefLeft={`#foo`} onClick={() => void 0} />', options: specialLink },
176+
{ code: '<a hrefLeft={"foo"} onClick={() => void 0} />', options: specialLink },
177+
{ code: '<a hrefLeft="#foo" onClick={() => void 0} />', options: specialLink },
178+
{ code: '<a hrefRight={this} onClick={() => void 0} />', options: specialLink },
179+
{ code: '<a {...props} onClick={() => void 0} />', options: specialLink },
180+
{ code: '<a hrefRight="foo" onClick={() => void 0} />', options: specialLink },
181+
{ code: '<a hrefRight={foo} onClick={() => void 0} />', options: specialLink },
182+
{ code: '<a hrefRight="/foo" onClick={() => void 0} />', options: specialLink },
183+
{ code: '<div hrefRight="foo" onClick={() => void 0} />', options: specialLink },
184+
{ code: '<a hrefRight={`${undefined}foo`} onClick={() => void 0} />', options: specialLink },
185+
{ code: '<a hrefRight={`#${undefined}foo`} onClick={() => void 0} />', options: specialLink },
186+
{ code: '<a hrefRight={`#foo`} onClick={() => void 0} />', options: specialLink },
187+
{ code: '<a hrefRight={"foo"} onClick={() => void 0} />', options: specialLink },
188+
{ code: '<a hrefRight="#foo" onClick={() => void 0} />', options: specialLink },
189+
{ code: '<a hrefRight={this} onClick={() => void 0} />', options: specialLink },
190+
191+
// CUSTOM BOTH COMPONENTS AND SPECIALLINK TESTS
192+
{ code: '<Anchor {...props} onClick={() => void 0} />', options: componentsAndSpecialLink },
193+
{ code: '<Anchor hrefLeft="foo" onClick={() => void 0} />', options: componentsAndSpecialLink },
194+
{ code: '<Anchor hrefLeft={foo} onClick={() => void 0} />', options: componentsAndSpecialLink },
195+
{ code: '<Anchor hrefLeft="/foo" onClick={() => void 0} />', options: componentsAndSpecialLink },
196+
{ code: '<Anchor hrefLeft={`${undefined}foo`} onClick={() => void 0} />', options: componentsAndSpecialLink },
197+
{ code: '<Anchor hrefLeft={`#${undefined}foo`} onClick={() => void 0} />', options: componentsAndSpecialLink },
198+
{ code: '<Anchor hrefLeft={`#foo`} onClick={() => void 0} />', options: componentsAndSpecialLink },
199+
{ code: '<Anchor hrefLeft={"foo"} onClick={() => void 0} />', options: componentsAndSpecialLink },
200+
{ code: '<Anchor hrefLeft="#foo" onClick={() => void 0} />', options: componentsAndSpecialLink },
201+
].map(parserOptionsMapper),
202+
invalid: [
203+
// DEFAULT ELEMENT 'a' TESTS
204+
// NO HREF
205+
{ code: '<a />', errors: [noHrefexpectedError] },
206+
{ code: '<a href={undefined} />', errors: [noHrefexpectedError] },
207+
{ code: '<a href={null} />', errors: [noHrefexpectedError] },
208+
// INVALID HREF
209+
{ code: '<a href="" />;', errors: [invalidHrefexpectedError] },
210+
{ code: '<a href="#" />', errors: [invalidHrefErrorMessage] },
211+
{ code: '<a href={"#"} />', errors: [invalidHrefErrorMessage] },
212+
{ code: '<a href={`#${undefined}`} />', errors: [invalidHrefErrorMessage] },
213+
{ code: '<a href={`${undefined}`} />', errors: [invalidHrefErrorMessage] },
214+
{ code: '<a href="javascript:void(0)" />', errors: [invalidHrefexpectedError] },
215+
{ code: '<a href={"javascript:void(0)"} />', errors: [invalidHrefexpectedError] },
216+
// SHOULD BE BUTTON
217+
{ code: '<a onClick={() => void 0} />', errors: [preferButtonexpectedError] },
218+
{ code: '<a href="#" onClick={() => void 0} />', errors: [preferButtonexpectedError] },
219+
{ code: '<a href="javascript:void(0)" onClick={() => void 0} />', errors: [preferButtonexpectedError] },
220+
{
221+
code: '<a href={"javascript:void(0)"} onClick={() => void 0} />',
222+
errors: [preferButtonexpectedError],
223+
},
224+
225+
// CUSTOM ELEMENT TEST FOR ARRAY OPTION
226+
// NO HREF
227+
{ code: '<Link />', errors: [noHrefexpectedError], options: components },
228+
{ code: '<Link href={undefined} />', errors: [noHrefexpectedError], options: components },
229+
{ code: '<Link href={null} />', errors: [noHrefexpectedError], options: components },
230+
// INVALID HREF
231+
{ code: '<Link href="" />', errors: [invalidHrefexpectedError], options: components },
232+
{ code: '<Link href="#" />', errors: [invalidHrefErrorMessage], options: components },
233+
{ code: '<Link href={"#"} />', errors: [invalidHrefErrorMessage], options: components },
234+
{
235+
code: '<Link href={`#${undefined}`} />',
236+
errors: [invalidHrefErrorMessage],
237+
options: components,
238+
},
239+
{ code: '<Link href="javascript:void(0)" />', errors: [invalidHrefexpectedError], options: components },
240+
{ code: '<Link href={"javascript:void(0)"} />', errors: [invalidHrefexpectedError], options: components },
241+
{ code: '<Anchor href="" />', errors: [invalidHrefexpectedError], options: components },
242+
{ code: '<Anchor href="#" />', errors: [invalidHrefErrorMessage], options: components },
243+
{ code: '<Anchor href={"#"} />', errors: [invalidHrefErrorMessage], options: components },
244+
{
245+
code: '<Anchor href={`#${undefined}`} />',
246+
errors: [invalidHrefErrorMessage],
247+
options: components,
248+
},
249+
{ code: '<Anchor href="javascript:void(0)" />', errors: [invalidHrefexpectedError], options: components },
250+
{ code: '<Anchor href={"javascript:void(0)"} />', errors: [invalidHrefexpectedError], options: components },
251+
// SHOULD BE BUTTON
252+
{ code: '<Link onClick={() => void 0} />', errors: [preferButtonexpectedError], options: components },
253+
{ code: '<Link href="#" onClick={() => void 0} />', errors: [preferButtonexpectedError], options: components },
254+
{
255+
code: '<Link href="javascript:void(0)" onClick={() => void 0} />',
256+
errors: [preferButtonexpectedError],
257+
options: components,
258+
},
259+
{
260+
code: '<Link href={"javascript:void(0)"} onClick={() => void 0} />',
261+
errors: [preferButtonexpectedError],
262+
options: components,
263+
},
264+
{ code: '<Anchor onClick={() => void 0} />', errors: [preferButtonexpectedError], options: components },
265+
{ code: '<Anchor href="#" onClick={() => void 0} />', errors: [preferButtonexpectedError], options: components },
266+
{
267+
code: '<Anchor href="javascript:void(0)" onClick={() => void 0} />',
268+
errors: [preferButtonexpectedError],
269+
options: components,
270+
},
271+
{
272+
code: '<Anchor href={"javascript:void(0)"} onClick={() => void 0} />',
273+
errors: [preferButtonexpectedError],
274+
options: components,
275+
},
276+
277+
// CUSTOM PROP TESTS
278+
// NO HREF
279+
{ code: '<a hrefLeft={undefined} />', errors: [noHrefexpectedError], options: specialLink },
280+
{ code: '<a hrefLeft={null} />', errors: [noHrefexpectedError], options: specialLink },
281+
// INVALID HREF
282+
{ code: '<a hrefLeft="" />;', errors: [invalidHrefexpectedError], options: specialLink },
283+
{ code: '<a hrefLeft="#" />', errors: [invalidHrefErrorMessage], options: specialLink },
284+
{ code: '<a hrefLeft={"#"} />', errors: [invalidHrefErrorMessage], options: specialLink },
285+
{ code: '<a hrefLeft={`#${undefined}`} />', errors: [invalidHrefErrorMessage], options: specialLink },
286+
{ code: '<a hrefLeft={`${undefined}`} />', errors: [invalidHrefErrorMessage], options: specialLink },
287+
{ code: '<a hrefLeft="javascript:void(0)" />', errors: [invalidHrefexpectedError], options: specialLink },
288+
{ code: '<a hrefLeft={"javascript:void(0)"} />', errors: [invalidHrefexpectedError], options: specialLink },
289+
// SHOULD BE BUTTON
290+
{ code: '<a hrefLeft="#" onClick={() => void 0} />', errors: [preferButtonexpectedError], options: specialLink },
291+
{
292+
code: '<a hrefLeft="javascript:void(0)" onClick={() => void 0} />',
293+
errors: [preferButtonexpectedError],
294+
options: specialLink,
295+
},
296+
{
297+
code: '<a hrefLeft={"javascript:void(0)"} onClick={() => void 0} />',
298+
errors: [preferButtonexpectedError],
299+
options: specialLink,
300+
},
301+
302+
// CUSTOM BOTH COMPONENTS AND SPECIALLINK TESTS
303+
// NO HREF
304+
{ code: '<Anchor Anchor={undefined} />', errors: [noHrefexpectedError], options: componentsAndSpecialLink },
305+
{ code: '<Anchor hrefLeft={null} />', errors: [noHrefexpectedError], options: componentsAndSpecialLink },
306+
// INVALID HREF
307+
{ code: '<Anchor hrefLeft="" />;', errors: [invalidHrefexpectedError], options: componentsAndSpecialLink },
308+
{ code: '<Anchor hrefLeft="#" />', errors: [invalidHrefErrorMessage], options: componentsAndSpecialLink },
309+
{ code: '<Anchor hrefLeft={"#"} />', errors: [invalidHrefErrorMessage], options: componentsAndSpecialLink },
310+
{
311+
code: '<Anchor hrefLeft={`#${undefined}`} />',
312+
errors: [invalidHrefErrorMessage],
313+
options: componentsAndSpecialLink,
314+
},
315+
{
316+
code: '<Anchor hrefLeft={`${undefined}`} />',
317+
errors: [invalidHrefErrorMessage],
318+
options: componentsAndSpecialLink,
319+
},
320+
{
321+
code: '<Anchor hrefLeft="javascript:void(0)" />',
322+
errors: [invalidHrefexpectedError],
323+
options: componentsAndSpecialLink,
324+
},
325+
{
326+
code: '<Anchor hrefLeft={"javascript:void(0)"} />',
327+
errors: [invalidHrefexpectedError],
328+
options: componentsAndSpecialLink,
329+
},
330+
// SHOULD BE BUTTON
331+
{
332+
code: '<Anchor hrefLeft="#" onClick={() => void 0} />',
333+
errors: [preferButtonexpectedError],
334+
options: componentsAndSpecialLink,
335+
},
336+
{
337+
code: '<Anchor hrefLeft="javascript:void(0)" onClick={() => void 0} />',
338+
errors: [preferButtonexpectedError],
339+
options: componentsAndSpecialLink,
340+
},
341+
{
342+
code: '<Anchor hrefLeft={"javascript:void(0)"} onClick={() => void 0} />',
343+
errors: [preferButtonexpectedError],
344+
options: componentsAndSpecialLink,
345+
},
346+
].map(parserOptionsMapper),
347+
});

0 commit comments

Comments
 (0)