Skip to content

Commit a9e770d

Browse files
Merge pull request #1698 from equalizedigital/steve/pro-808-improper-use-of-link-rule-and-anchors-without-href
Add: support for named anchors as jump targets in link validation
2 parents 2501f1c + caa8b4b commit a9e770d

2 files changed

Lines changed: 66 additions & 0 deletions

File tree

src/pageScanner/checks/link-has-valid-href-or-role.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@ export default {
3030
return true;
3131
}
3232

33+
// Allow named anchors used as jump targets: no href, has id/name, no visible content,
34+
// and not keyboard-focusable.
35+
// Per the HTML spec, <a> without href has role="generic" and is not keyboard focusable,
36+
// so it does not create a false link for AT or keyboard users.
37+
// Use hasAttribute instead of !href to exclude href="" which is keyboard focusable.
38+
// Check children.length to exclude anchors wrapping images or other elements.
39+
const id = node.getAttribute( 'id' ) || '';
40+
const name = node.getAttribute( 'name' ) || '';
41+
const hasAnchorTargetName = id.trim() !== '' || name.trim() !== '';
42+
43+
const tabindex = node.getAttribute( 'tabindex' );
44+
const parsedTabindex = null === tabindex ? null : Number.parseInt( tabindex, 10 );
45+
const isKeyboardFocusable =
46+
null !== tabindex && ( Number.isNaN( parsedTabindex ) || parsedTabindex >= 0 );
47+
48+
if ( ! node.hasAttribute( 'href' ) && hasAnchorTargetName && ! isKeyboardFocusable && node.children.length === 0 && node.textContent.trim() === '' ) {
49+
return true;
50+
}
51+
3352
const trimmedHref = href ? href.trim() : '';
3453

3554
// Fail if href is missing, just '#', or contains invalid protocols

tests/jest/rules/linkImproper.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,53 @@ describe( 'Link Improper Rule', () => {
140140
html: '<a href="#" role="menuitem">Menu without aria-expanded</a>',
141141
shouldPass: false,
142142
},
143+
144+
// Named anchor (jump target) cases
145+
{
146+
name: 'Passes when empty anchor is used as a named jump target',
147+
html: '<a id="meet-the-team"></a>',
148+
shouldPass: true,
149+
},
150+
{
151+
name: 'Passes when whitespace-only anchor is used as a named jump target',
152+
html: '<a id="section-top"> </a>',
153+
shouldPass: true,
154+
},
155+
{
156+
name: 'Passes when legacy name-only anchor is used as a jump target',
157+
html: '<a name="section-top"></a>',
158+
shouldPass: true,
159+
},
160+
{
161+
name: 'Fails when legacy name-only anchor has text',
162+
html: '<a name="section-top">Text</a>',
163+
shouldPass: false,
164+
},
165+
{
166+
name: 'Fails when anchor has id and text content but no href',
167+
html: '<a id="meet-the-team">Meet the Team</a>',
168+
shouldPass: false,
169+
},
170+
{
171+
name: 'Fails when anchor has id and empty href',
172+
html: '<a id="top" href=""></a>',
173+
shouldPass: false,
174+
},
175+
{
176+
name: 'Fails when anchor has id and child image but no href',
177+
html: '<a id="logo"><img src="logo.png" alt="Home"/></a>',
178+
shouldPass: false,
179+
},
180+
{
181+
name: 'Fails when anchor is keyboard focusable via tabindex and has no href',
182+
html: '<a id="meet-the-team" tabindex="0"></a>',
183+
shouldPass: false,
184+
},
185+
{
186+
name: 'Passes when anchor has tabindex of -1 and no href',
187+
html: '<a id="meet-the-team" tabindex="-1"></a>',
188+
shouldPass: true,
189+
},
143190
] )( '$name', async ( { html, shouldPass, setup } ) => {
144191
document.body.innerHTML = html;
145192

0 commit comments

Comments
 (0)