Skip to content

Commit 3f7ea79

Browse files
committed
feat: add template-no-role-presentation-on-focusable
Flags role="presentation" / role="none" on focusable elements (button/a[href]/input/select/textarea/summary/details/iframe/embed, or any element with a tabindex attribute). Stripping semantics from a focusable element is an accessibility anti-pattern: screen-reader users hear nothing where keyboard users can still focus and potentially activate the element. Per WAI-ARIA 1.2 (presentation role): authors should not apply role="presentation" to a focusable element. Companion to template-no-aria-hidden-on-focusable (M6); same focusable detection, different anti-pattern. Only eslint-plugin-vuejs- accessibility has a direct equivalent rule among our peer plugins.
1 parent f400aca commit 3f7ea79

3 files changed

Lines changed: 225 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# ember/template-no-role-presentation-on-focusable
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow `role="presentation"` / `role="none"` on focusable elements.
6+
7+
`role="presentation"` and `role="none"` strip an element's semantics from the accessibility tree. When applied to a focusable element, the result is a widget that keyboard users can still tab to, but one that screen readers announce as plain text with no indication of its role or function.
8+
9+
Per [WAI-ARIA 1.2 — presentation role](https://www.w3.org/TR/wai-aria-1.2/#presentation): authors should not apply `role="presentation"` to a focusable element, since doing so has the same effect as the [element being inaccessible to assistive technology](https://www.w3.org/WAI/ARIA/apg/).
10+
11+
## Examples
12+
13+
This rule **forbids** the following:
14+
15+
```gjs
16+
<template>
17+
<button role="presentation">Click</button>
18+
<a href="/x" role="none">Link</a>
19+
<input type="text" role="presentation" />
20+
<div tabindex="0" role="presentation">Focusable</div>
21+
</template>
22+
```
23+
24+
This rule **allows** the following:
25+
26+
```gjs
27+
<template>
28+
{{! Presentation on non-focusable elements }}
29+
<div role="presentation"></div>
30+
<span role="none" class="spacer"></span>
31+
32+
{{! Presentation + aria-hidden — fully removed from AT }}
33+
<div role="presentation" aria-hidden="true"></div>
34+
35+
{{! input type="hidden" isn't focusable }}
36+
<input type="hidden" role="presentation" />
37+
</template>
38+
```
39+
40+
## References
41+
42+
- [WAI-ARIA 1.2 — presentation role](https://www.w3.org/TR/wai-aria-1.2/#presentation)
43+
- [`no-role-presentation-on-focusable` — eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/no-role-presentation-on-focusable.md)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const INHERENTLY_FOCUSABLE_TAGS = new Set([
2+
'button',
3+
'details',
4+
'embed',
5+
'iframe',
6+
'input',
7+
'select',
8+
'summary',
9+
'textarea',
10+
]);
11+
12+
function findAttr(node, name) {
13+
return node.attributes?.find((a) => a.name === name);
14+
}
15+
16+
function getTextAttrValue(attr) {
17+
if (attr?.value?.type === 'GlimmerTextNode') {
18+
return attr.value.chars;
19+
}
20+
return undefined;
21+
}
22+
23+
function hasPresentationRole(node) {
24+
const attr = findAttr(node, 'role');
25+
if (!attr || attr.value?.type !== 'GlimmerTextNode') {
26+
return false;
27+
}
28+
return attr.value.chars
29+
.trim()
30+
.toLowerCase()
31+
.split(/\s+/u)
32+
.some((t) => t === 'presentation' || t === 'none');
33+
}
34+
35+
function isFocusable(node) {
36+
const tag = node.tag?.toLowerCase();
37+
if (!tag) {
38+
return false;
39+
}
40+
41+
if (findAttr(node, 'tabindex')) {
42+
return true;
43+
}
44+
if (INHERENTLY_FOCUSABLE_TAGS.has(tag)) {
45+
if (tag === 'input') {
46+
const type = getTextAttrValue(findAttr(node, 'type'));
47+
if (type === 'hidden') {
48+
return false;
49+
}
50+
}
51+
return true;
52+
}
53+
if (tag === 'a' && findAttr(node, 'href')) {
54+
return true;
55+
}
56+
return false;
57+
}
58+
59+
/** @type {import('eslint').Rule.RuleModule} */
60+
module.exports = {
61+
meta: {
62+
type: 'problem',
63+
docs: {
64+
description: 'disallow role="presentation" / role="none" on focusable elements',
65+
category: 'Accessibility',
66+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-role-presentation-on-focusable.md',
67+
templateMode: 'both',
68+
},
69+
fixable: null,
70+
schema: [],
71+
messages: {
72+
invalidPresentation:
73+
'role="presentation"/"none" must not be used on focusable elements — stripping semantics from a focusable element leaves it announced as text while keyboard users can still focus it.',
74+
},
75+
},
76+
77+
create(context) {
78+
return {
79+
GlimmerElementNode(node) {
80+
if (!hasPresentationRole(node)) {
81+
return;
82+
}
83+
if (isFocusable(node)) {
84+
context.report({ node, messageId: 'invalidPresentation' });
85+
}
86+
},
87+
};
88+
},
89+
};
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use strict';
2+
3+
const rule = require('../../../lib/rules/template-no-role-presentation-on-focusable');
4+
const RuleTester = require('eslint').RuleTester;
5+
6+
const ruleTester = new RuleTester({
7+
parser: require.resolve('ember-eslint-parser'),
8+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
9+
});
10+
11+
ruleTester.run('template-no-role-presentation-on-focusable', rule, {
12+
valid: [
13+
// Presentation role on non-focusable elements — fine.
14+
'<template><div role="presentation"></div></template>',
15+
'<template><span role="none" class="spacer"></span></template>',
16+
'<template><div role="presentation" aria-hidden="true"></div></template>',
17+
18+
// Focusable elements without presentation role — fine.
19+
'<template><button>Click me</button></template>',
20+
'<template><a href="/x">Link</a></template>',
21+
'<template><input type="text" /></template>',
22+
23+
// <input type="hidden"> isn't focusable.
24+
'<template><input type="hidden" role="presentation" /></template>',
25+
26+
// <a> without href isn't focusable.
27+
'<template><a role="presentation">Not a link</a></template>',
28+
29+
// Components — rule skips.
30+
'<template><CustomBtn role="presentation" /></template>',
31+
32+
// No role at all.
33+
'<template><button></button></template>',
34+
],
35+
invalid: [
36+
{
37+
code: '<template><button role="presentation">Click</button></template>',
38+
output: null,
39+
errors: [{ messageId: 'invalidPresentation' }],
40+
},
41+
{
42+
code: '<template><button role="none">Click</button></template>',
43+
output: null,
44+
errors: [{ messageId: 'invalidPresentation' }],
45+
},
46+
{
47+
code: '<template><a href="/x" role="presentation">Link</a></template>',
48+
output: null,
49+
errors: [{ messageId: 'invalidPresentation' }],
50+
},
51+
{
52+
code: '<template><input type="text" role="presentation" /></template>',
53+
output: null,
54+
errors: [{ messageId: 'invalidPresentation' }],
55+
},
56+
// Non-interactive element made focusable via tabindex.
57+
{
58+
code: '<template><div tabindex="0" role="presentation"></div></template>',
59+
output: null,
60+
errors: [{ messageId: 'invalidPresentation' }],
61+
},
62+
{
63+
code: '<template><div tabindex="-1" role="none"></div></template>',
64+
output: null,
65+
errors: [{ messageId: 'invalidPresentation' }],
66+
},
67+
],
68+
});
69+
70+
const hbsRuleTester = new RuleTester({
71+
parser: require.resolve('ember-eslint-parser/hbs'),
72+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
73+
});
74+
75+
hbsRuleTester.run('template-no-role-presentation-on-focusable', rule, {
76+
valid: [
77+
'<div role="presentation"></div>',
78+
'<input type="hidden" role="presentation" />',
79+
'<CustomBtn role="presentation" />',
80+
],
81+
invalid: [
82+
{
83+
code: '<button role="presentation">Click</button>',
84+
output: null,
85+
errors: [{ messageId: 'invalidPresentation' }],
86+
},
87+
{
88+
code: '<div tabindex="0" role="none"></div>',
89+
output: null,
90+
errors: [{ messageId: 'invalidPresentation' }],
91+
},
92+
],
93+
});

0 commit comments

Comments
 (0)