Skip to content

Commit edb6645

Browse files
committed
Fix: allow anchors with role none, presentation or slider
An <a> with role="none" or role="presentation" is removed from the accessibility tree, and an <a> with role="slider" is exposed as a slider. Neither is a link, so neither should be reported as improper link usage. role="slider" joins the existing button and tab allowlist, which stays unconditional: none of those roles is exposed as a link, so the anchor is not being used improperly however it is focused. The presentational roles get a narrower exemption, per the presentational roles conflict resolution: the role is ignored, and the element still exposed, when the anchor is focusable or carries a global ARIA state or property. So <a href="#" role="none"> continues to fail. The global ARIA half is approximated as any aria-* attribute, which errs toward reporting. aria-hidden is excluded because it removes the element from the tree outright, making the role moot, matching how linked-image-alt-present handles it. Update how_to_fix and the rule help text to name the presentational roles the scanner now accepts, following ac471ce. Fixes #1748
1 parent 3c7163d commit edb6645

4 files changed

Lines changed: 94 additions & 6 deletions

File tree

includes/classes/Rules/Rule/LinkImproperRule.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ public static function get_rule(): array {
4141
),
4242
'why_it_matters' => esc_html__( 'Anchor tags (a.k.a. links) are intended for navigation to a new page or a different place on the same page. When they are used to trigger actions (such as expanding accordions or opening modals) without the correct roles or behavior, they confuse users, particularly those using screen readers or keyboards, who expect links to navigate rather than perform actions. They also are likely not to function with the space bar, which is an expectation of a button.', 'accessibility-checker' ),
4343
'how_to_fix' => sprintf(
44-
// translators: %1$s is <code>&lt;button&gt;</code>, %2$s is <code>role="button"</code>.
45-
esc_html__( 'If the element is used to trigger an action, replace the anchor tag with a %1$s. If you cannot replace it, ensure that %2$s is added to the link, along with JavaScript that adds support for triggering it with the space bar key, and that appropriate ARIA attributes are used for toggle states or other functionality.', 'accessibility-checker' ),
44+
// translators: %1$s is <code>&lt;button&gt;</code>, %2$s is <code>role="button"</code>, %3$s is <code>role="none"</code>, %4$s is <code>role="presentation"</code>.
45+
esc_html__( 'If the element is used to trigger an action, replace the anchor tag with a %1$s. If you cannot replace it, ensure that %2$s is added to the link, along with JavaScript that adds support for triggering it with the space bar key, and that appropriate ARIA attributes are used for toggle states or other functionality. If the anchor is purely decorative and cannot be focused, %3$s or %4$s marks it as presentational so it is not reported.', 'accessibility-checker' ),
4646
'<code>&lt;button&gt;</code>',
47-
'<code>role="button"</code>'
47+
'<code>role="button"</code>',
48+
'<code>role="none"</code>',
49+
'<code>role="presentation"</code>'
4850
),
4951
'references' => [
5052
[

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export default {
1818
// Parse roles once for efficiency (DRY principle)
1919
const roles = role.toLowerCase().split( /\s+/ );
2020

21-
// Allow roles of button or tab
22-
if ( roles.some( ( r ) => [ 'button', 'tab' ].includes( r ) ) ) {
21+
// Allow roles of button, tab or slider
22+
if ( roles.some( ( r ) => [ 'button', 'tab', 'slider' ].includes( r ) ) ) {
2323
return true;
2424
}
2525

@@ -30,6 +30,24 @@ export default {
3030
return true;
3131
}
3232

33+
// Allow role="none"/"presentation", which removes the element from the accessibility
34+
// tree so it is never exposed as a link. Unlike the named-anchor exemption below,
35+
// content is permitted here: the role applies regardless of what the anchor wraps.
36+
// Per the presentational roles conflict resolution the role is ignored on an element
37+
// that is focusable or carries global ARIA states or properties, so neither may apply.
38+
const isFocusable = node.hasAttribute( 'href' ) || node.hasAttribute( 'tabindex' );
39+
if ( ! isFocusable && ( roles.includes( 'none' ) || roles.includes( 'presentation' ) ) ) {
40+
// Approximated as any aria-* attribute, which errs toward reporting. aria-hidden is
41+
// excluded because it removes the element from the tree outright, making the role moot.
42+
const hasGlobalAria = Array.from( node.attributes ).some(
43+
( attr ) => attr.name.startsWith( 'aria-' ) && attr.name !== 'aria-hidden'
44+
);
45+
46+
if ( ! hasGlobalAria ) {
47+
return true;
48+
}
49+
}
50+
3351
// Allow named anchors used as jump targets: no href, has id/name, no visible content,
3452
// and not keyboard-focusable.
3553
// Per the HTML spec, <a> without href has role="generic" and is not keyboard focusable,

src/pageScanner/rules/link-improper.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default {
1313
],
1414
metadata: {
1515
description: 'Links must have a meaningful href or an appropriate role if used as buttons.',
16-
help: 'Avoid using <a> tags without href or with href="#" unless role="button" is used.',
16+
help: 'Avoid using <a> tags without href or with href="#" unless a widget role such as role="button" is used, or the anchor is non-focusable and marked role="none".',
1717
impact: 'serious',
1818
},
1919
all: [],

tests/jest/rules/linkImproper.test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,74 @@ describe( 'Link Improper Rule', () => {
202202
html: '<a id="meet-the-team" tabindex="-1"></a>',
203203
shouldPass: true,
204204
},
205+
206+
// Presentational role cases. See https://github.com/equalizedigital/accessibility-checker/issues/1748
207+
{
208+
name: 'Passes with role="none" on a non-focusable dropdown wrapper',
209+
html: '<a role="none"><span class="nav-drop-title-wrap">eBranch<span class="dropdown-nav-toggle"><span class="kadence-svg-iconset svg-baseline"><svg aria-hidden="true" class="kadence-svg-icon" fill="currentColor" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>Expand</title><path d="M5.293 9.707l6 6c0.391 0.391 1.024 0.391 1.414 0l6-6z"></path> </svg></span></span></span></a>',
210+
shouldPass: true,
211+
},
212+
{
213+
name: 'Passes with role="presentation" and no href',
214+
html: '<a role="presentation"><span>Menu</span></a>',
215+
shouldPass: true,
216+
},
217+
{
218+
name: 'Passes with multiple roles including none and no href',
219+
html: '<a role="foo none bar"><span>Menu</span></a>',
220+
shouldPass: true,
221+
},
222+
{
223+
name: 'Fails with role="none" when focusable via href="#"',
224+
html: '<a href="#" role="none">Click</a>',
225+
shouldPass: false,
226+
},
227+
{
228+
name: 'Fails with role="none" when focusable via tabindex',
229+
html: '<a role="none" tabindex="0">Click</a>',
230+
shouldPass: false,
231+
},
232+
{
233+
// tabindex="-1" is focusable programmatically and by click, so the presentational
234+
// role is ignored per the conflict resolution.
235+
name: 'Fails with role="none" and tabindex of -1',
236+
html: '<a role="none" tabindex="-1">Click</a>',
237+
shouldPass: false,
238+
},
239+
{
240+
name: 'Fails with role="none" and a global aria attribute',
241+
html: '<a role="none" aria-label="Open menu"><span>Menu</span></a>',
242+
shouldPass: false,
243+
},
244+
{
245+
name: 'Passes with role="none" and aria-hidden="true"',
246+
html: '<a role="none" aria-hidden="true"><span>Menu</span></a>',
247+
shouldPass: true,
248+
},
249+
250+
// Slider role cases. See https://github.com/equalizedigital/accessibility-checker/issues/1748
251+
{
252+
name: 'Passes with role="slider" on a media player volume control',
253+
html: '<a class="mejs-horizontal-volume-slider" href="javascript:void(0);" aria-label="Volume Slider" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" role="slider"><span class="mejs-offscreen">Use Up/Down Arrow keys to increase or decrease volume.</span><div class="mejs-horizontal-volume-total"><div class="mejs-horizontal-volume-current"></div><div class="mejs-horizontal-volume-handle"></div></div></a>',
254+
shouldPass: true,
255+
},
256+
{
257+
name: 'Passes with role="slider" and href="#"',
258+
html: '<a href="#" role="slider" aria-valuenow="50">Volume</a>',
259+
shouldPass: true,
260+
},
261+
{
262+
name: 'Passes with role="slider" and no href',
263+
html: '<a role="slider" tabindex="0" aria-valuenow="50">Volume</a>',
264+
shouldPass: true,
265+
},
266+
{
267+
// An inactive tab in a roving tabindex tablist is correct markup, so the widget
268+
// role exemption must not depend on the anchor being in the tab order.
269+
name: 'Passes with role="tab" and tabindex of -1',
270+
html: '<a role="tab" tabindex="-1" id="tab-2" aria-controls="panel-2">Tab 2</a>',
271+
shouldPass: true,
272+
},
205273
] )( '$name', async ( { html, shouldPass, setup } ) => {
206274
document.body.innerHTML = html;
207275

0 commit comments

Comments
 (0)