Skip to content

Commit 65d9a59

Browse files
committed
Extract rule: template-no-invalid-aria-attributes
1 parent 0149ef1 commit 65d9a59

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ rules in templates can be disabled with eslint directives with mustache or html
186186
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187187
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
188188
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
189+
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
189190

190191
### Best Practices
191192

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# ember/template-no-invalid-aria-attributes
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow invalid ARIA attributes. Only use valid ARIA attributes as defined in the ARIA specification.
6+
7+
## Rule Details
8+
9+
This rule validates that only standard ARIA attributes are used on elements.
10+
11+
## Examples
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```gjs
16+
<template>
17+
<div aria-fake="value">Content</div>
18+
</template>
19+
20+
<template>
21+
<div aria-invalid-attr="value">Content</div>
22+
</template>
23+
```
24+
25+
Examples of **correct** code for this rule:
26+
27+
```gjs
28+
<template>
29+
<div aria-label="Label">Content</div>
30+
</template>
31+
32+
<template>
33+
<div aria-hidden="true">Content</div>
34+
</template>
35+
36+
<template>
37+
<div aria-describedby="description-id">Content</div>
38+
</template>
39+
```
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// ARIA attribute type definitions per WAI-ARIA spec
2+
const ARIA_ATTRIBUTE_TYPES = {
3+
'aria-activedescendant': { type: 'id' },
4+
'aria-atomic': { type: 'boolean' },
5+
'aria-autocomplete': { type: 'token', values: ['inline', 'list', 'both', 'none'] },
6+
'aria-busy': { type: 'boolean' },
7+
'aria-checked': { type: 'tristate', allowundefined: true },
8+
'aria-colcount': { type: 'integer' },
9+
'aria-colindex': { type: 'integer' },
10+
'aria-colspan': { type: 'integer' },
11+
'aria-controls': { type: 'idlist' },
12+
'aria-current': {
13+
type: 'token',
14+
values: ['page', 'step', 'location', 'date', 'time', 'true', 'false'],
15+
},
16+
'aria-describedby': { type: 'idlist' },
17+
'aria-details': { type: 'id' },
18+
'aria-disabled': { type: 'boolean' },
19+
'aria-dropeffect': {
20+
type: 'tokenlist',
21+
values: ['copy', 'execute', 'link', 'move', 'none', 'popup'],
22+
},
23+
'aria-errormessage': { type: 'id' },
24+
'aria-expanded': { type: 'boolean', allowundefined: true },
25+
'aria-flowto': { type: 'idlist' },
26+
'aria-grabbed': { type: 'boolean', allowundefined: true },
27+
'aria-haspopup': {
28+
type: 'token',
29+
values: ['false', 'true', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
30+
},
31+
'aria-hidden': { type: 'boolean', allowundefined: true },
32+
'aria-invalid': { type: 'token', values: ['grammar', 'false', 'spelling', 'true'] },
33+
'aria-keyshortcuts': { type: 'string' },
34+
'aria-label': { type: 'string' },
35+
'aria-labelledby': { type: 'idlist' },
36+
'aria-level': { type: 'integer' },
37+
'aria-live': { type: 'token', values: ['assertive', 'off', 'polite'] },
38+
'aria-modal': { type: 'boolean' },
39+
'aria-multiline': { type: 'boolean' },
40+
'aria-multiselectable': { type: 'boolean' },
41+
'aria-orientation': { type: 'token', values: ['horizontal', 'vertical', 'undefined'] },
42+
'aria-owns': { type: 'idlist' },
43+
'aria-placeholder': { type: 'string' },
44+
'aria-posinset': { type: 'integer' },
45+
'aria-pressed': { type: 'tristate', allowundefined: true },
46+
'aria-readonly': { type: 'boolean' },
47+
'aria-relevant': { type: 'tokenlist', values: ['additions', 'all', 'removals', 'text'] },
48+
'aria-required': { type: 'boolean' },
49+
'aria-roledescription': { type: 'string' },
50+
'aria-rowcount': { type: 'integer' },
51+
'aria-rowindex': { type: 'integer' },
52+
'aria-rowspan': { type: 'integer' },
53+
'aria-selected': { type: 'boolean', allowundefined: true },
54+
'aria-setsize': { type: 'integer' },
55+
'aria-sort': { type: 'token', values: ['ascending', 'descending', 'none', 'other'] },
56+
'aria-valuemax': { type: 'number' },
57+
'aria-valuemin': { type: 'number' },
58+
'aria-valuenow': { type: 'number' },
59+
'aria-valuetext': { type: 'string' },
60+
};
61+
62+
const validAriaAttributes = new Set(Object.keys(ARIA_ATTRIBUTE_TYPES));
63+
64+
function isBoolean(value) {
65+
return value === 'true' || value === 'false';
66+
}
67+
68+
function isNumeric(value) {
69+
if (typeof value !== 'string' || value === '') {
70+
return false;
71+
}
72+
return !Number.isNaN(Number(value));
73+
}
74+
75+
function isValidAriaValue(attrName, value) {
76+
const attrDef = ARIA_ATTRIBUTE_TYPES[attrName];
77+
if (!attrDef) {
78+
return true;
79+
}
80+
81+
if (value === 'undefined') {
82+
return Boolean(attrDef.allowundefined);
83+
}
84+
85+
switch (attrDef.type) {
86+
case 'boolean': {
87+
return isBoolean(value);
88+
}
89+
case 'tristate': {
90+
return isBoolean(value) || value === 'mixed';
91+
}
92+
case 'string': {
93+
return typeof value === 'string';
94+
}
95+
case 'id': {
96+
return typeof value === 'string' && !isBoolean(value);
97+
}
98+
case 'idlist': {
99+
return (
100+
typeof value === 'string' &&
101+
value.split(' ').every((token) => token.length > 0 && !isBoolean(token))
102+
);
103+
}
104+
case 'integer': {
105+
return /^-?\d+$/.test(value);
106+
}
107+
case 'number': {
108+
return isNumeric(value) && !isBoolean(value);
109+
}
110+
case 'token': {
111+
return attrDef.values.includes(value);
112+
}
113+
case 'tokenlist': {
114+
return value.split(' ').every((token) => attrDef.values.includes(token.toLowerCase()));
115+
}
116+
default: {
117+
return true;
118+
}
119+
}
120+
}
121+
122+
/** @type {import('eslint').Rule.RuleModule} */
123+
module.exports = {
124+
meta: {
125+
type: 'problem',
126+
docs: {
127+
description: 'disallow invalid aria-* attributes',
128+
category: 'Accessibility',
129+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-aria-attributes.md',
130+
},
131+
schema: [],
132+
messages: {
133+
noInvalidAriaAttribute: 'Invalid ARIA attribute: {{attribute}}',
134+
invalidAriaAttributeValue: 'Invalid value for ARIA attribute {{attribute}}.',
135+
},
136+
strictGjs: true,
137+
strictGts: true,
138+
},
139+
140+
create(context) {
141+
return {
142+
GlimmerAttrNode(node) {
143+
if (!node.name.startsWith('aria-')) {
144+
return;
145+
}
146+
147+
// Check for unknown ARIA attribute
148+
if (!validAriaAttributes.has(node.name)) {
149+
context.report({
150+
node,
151+
messageId: 'noInvalidAriaAttribute',
152+
data: { attribute: node.name },
153+
});
154+
return;
155+
}
156+
157+
// Skip value validation for dynamic values (MustacheStatement, ConcatStatement)
158+
if (
159+
!node.value ||
160+
node.value.type === 'GlimmerMustacheStatement' ||
161+
node.value.type === 'GlimmerConcatStatement'
162+
) {
163+
return;
164+
}
165+
166+
// Validate value for text node values
167+
if (node.value.type === 'GlimmerTextNode') {
168+
const value = node.value.chars;
169+
if (!isValidAriaValue(node.name, value)) {
170+
context.report({
171+
node,
172+
messageId: 'invalidAriaAttributeValue',
173+
data: { attribute: node.name },
174+
});
175+
}
176+
}
177+
},
178+
};
179+
},
180+
};
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//------------------------------------------------------------------------------
2+
// Requirements
3+
//------------------------------------------------------------------------------
4+
5+
const rule = require('../../../lib/rules/template-no-invalid-aria-attributes');
6+
const RuleTester = require('eslint').RuleTester;
7+
8+
const ruleTester = new RuleTester({
9+
parser: require.resolve('ember-eslint-parser'),
10+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
11+
});
12+
13+
ruleTester.run('template-no-invalid-aria-attributes', rule, {
14+
valid: [
15+
'<template><div aria-label="Label">Content</div></template>',
16+
'<template><div aria-hidden="true">Content</div></template>',
17+
'<template><div aria-describedby="id">Content</div></template>',
18+
19+
// Test cases ported from ember-template-lint
20+
'<template><h1 aria-hidden="true">Valid Heading</h1></template>',
21+
'<template><h1 aria-hidden={{true}}>Second valid Heading</h1></template>',
22+
'<template><input type="email" aria-required="true" /></template>',
23+
'<template><input type="text" aria-labelledby="label1 label2" /></template>',
24+
'<template><div role="checkbox" aria-checked="true" onclick="handleCheckbox()" tabindex="0"></div></template>',
25+
'<template><button aria-haspopup="true"></button></template>',
26+
'<template><button aria-haspopup="dialog"></button></template>',
27+
'<template><div role="slider" aria-valuenow="50" aria-valuemax="100" aria-valuemin="0" /></template>',
28+
'<template><div role="heading" aria-level={{2}}></div></template>',
29+
'<template><input type="text" id="name" aria-invalid="grammar" /></template>',
30+
'<template><div role="region" aria-live="polite" aria-relevant="additions text">Valid live region</div></template>',
31+
'<template><div aria-label="{{@foo.bar}} baz"></div></template>',
32+
'<template><CustomComponent @ariaRequired={{this.ariaRequired}} aria-errormessage="errorId" /></template>',
33+
'<template><button type="submit" aria-disabled={{this.isDisabled}}>Submit</button></template>',
34+
'<template><div role="textbox" aria-sort={{if this.hasCustomSort "other" "ascending"}}></div></template>',
35+
'<template><div role="combobox" aria-expanded="undefined"></div></template>',
36+
'<template><button aria-label={{if @isNew (t "actions.add") (t "actions.edit")}}></button></template>',
37+
],
38+
invalid: [
39+
{
40+
code: '<template><div aria-fake="value">Content</div></template>',
41+
output: null,
42+
errors: [{ messageId: 'noInvalidAriaAttribute', data: { attribute: 'aria-fake' } }],
43+
},
44+
{
45+
code: '<template><div aria-invalid-attr="value">Content</div></template>',
46+
output: null,
47+
errors: [{ messageId: 'noInvalidAriaAttribute', data: { attribute: 'aria-invalid-attr' } }],
48+
},
49+
50+
// Test cases ported from ember-template-lint
51+
{
52+
code: '<template><input aria-text="inaccessible text" /></template>',
53+
output: null,
54+
errors: [{ messageId: 'noInvalidAriaAttribute' }],
55+
},
56+
{
57+
code: '<template><div role="slider" aria-valuenow={{this.foo}} aria-valuemax={{this.bar}} aria-value-min={{this.baz}} /></template>',
58+
output: null,
59+
errors: [{ messageId: 'noInvalidAriaAttribute' }],
60+
},
61+
{
62+
code: '<template><h1 aria--hidden="true">Broken heading</h1></template>',
63+
output: null,
64+
errors: [{ messageId: 'noInvalidAriaAttribute' }],
65+
},
66+
{
67+
code: '<template><CustomComponent role="region" aria-alert="polite" /></template>',
68+
output: null,
69+
errors: [{ messageId: 'noInvalidAriaAttribute' }],
70+
},
71+
{
72+
code: '<template><span role="checkbox" aria-checked="bad-value" tabindex="0" aria-label="Forget me"></span></template>',
73+
output: null,
74+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
75+
},
76+
{
77+
code: '<template><button type="submit" disabled="true" aria-disabled="123">Submit</button></template>',
78+
output: null,
79+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
80+
},
81+
{
82+
code: '<template><input type="text" disabled="true" aria-errormessage="false" /></template>',
83+
output: null,
84+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
85+
},
86+
{
87+
code: '<template><button type="submit" aria-describedby="blah false">Continue at your own risk</button></template>',
88+
output: null,
89+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
90+
},
91+
{
92+
code: '<template><div role="heading" aria-level="bogus">Inaccessible heading</div></template>',
93+
output: null,
94+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
95+
},
96+
{
97+
code: '<template><div role="heading" aria-level="true">Another inaccessible heading</div></template>',
98+
output: null,
99+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
100+
},
101+
{
102+
code: '<template><div role="slider" aria-valuenow=(2*2) aria-valuemax="100" aria-valuemin="30">Broken slider</div></template>',
103+
output: null,
104+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
105+
},
106+
{
107+
code: '<template><div role="region" aria-live="no-such-value">Inaccessible live region</div></template>',
108+
output: null,
109+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
110+
},
111+
{
112+
code: '<template><div role="region" aria-live="polite" aria-relevant="additions errors">Inaccessible live region</div></template>',
113+
output: null,
114+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
115+
},
116+
{
117+
code: '<template><input type="text" aria-required="undefined" /></template>',
118+
output: null,
119+
errors: [{ messageId: 'invalidAriaAttributeValue' }],
120+
},
121+
],
122+
});

0 commit comments

Comments
 (0)