Skip to content

Commit 7343380

Browse files
Merge pull request #2625 from NullVoxPopuli/nvp/template-lint-extract-rule-template-require-valid-form-groups
Extract rule: template-require-valid-form-groups
2 parents d63932f + 747a626 commit 7343380

4 files changed

Lines changed: 334 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
197197
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
198198
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
199199
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
200+
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
200201
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |
201202

202203
### Best Practices
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# ember/template-require-valid-form-groups
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Require grouped form controls to have appropriate semantics.
6+
7+
This rule requires appropriate semantics for grouped form controls. Correctly grouped
8+
form controls will take one of two approaches:
9+
10+
- use `<fieldset>` + `<legend>` (preferred)
11+
- associate controls using WAI-ARIA (also acceptable)
12+
13+
## Examples
14+
15+
This rule **forbids** the following:
16+
17+
```gjs
18+
<template>
19+
<div>
20+
<label for="radio-001">Chicago Zoey</label>
21+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
22+
<label for="radio-002">Office Hours Tomster</label>
23+
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster" />
24+
<label for="radio-003">A11y Zoey</label>
25+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
26+
</div>
27+
</template>
28+
```
29+
30+
This rule **allows** the following:
31+
32+
```gjs
33+
<template>
34+
<div role="group" aria-labelledby="preferred-mascot-heading">
35+
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
36+
<label for="radio-001">Chicago Zoey</label>
37+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
38+
<label for="radio-002">Office Hours Tomster</label>
39+
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster" />
40+
<label for="radio-003">A11y Zoey</label>
41+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
42+
</div>
43+
</template>
44+
```
45+
46+
```gjs
47+
<template>
48+
<fieldset>
49+
<legend>Preferred Mascot Version</legend>
50+
<div>
51+
<label for="radio-001">Chicago Zoey</label>
52+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
53+
</div>
54+
<div>
55+
<label for="radio-002">Office Hours Tomster</label>
56+
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster" />
57+
</div>
58+
<div>
59+
<label for="radio-003">A11y Zoey</label>
60+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
61+
</div>
62+
</fieldset>
63+
</template>
64+
```
65+
66+
## References
67+
68+
- [Grouping Controls](https://www.w3.org/WAI/tutorials/forms/grouping/)
69+
- [The Field Set element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
const FORM_ELEMENTS = new Set(['input']);
3+
4+
function hasRoleGroup(node) {
5+
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
6+
return roleAttr && roleAttr.value?.type === 'GlimmerTextNode' && roleAttr.value.chars === 'group';
7+
}
8+
9+
function hasAriaLabel(node) {
10+
return node.attributes?.some((attr) => attr.name === 'aria-labelledby');
11+
}
12+
13+
function isValidFormGroup(node) {
14+
if (node.tag === 'fieldset' || node.tag === 'legend') {
15+
return true;
16+
}
17+
18+
return hasRoleGroup(node) && hasAriaLabel(node);
19+
}
20+
21+
function hasMultipleFormElementsInParentScope(node) {
22+
const parent = node.parent;
23+
24+
if (!parent || parent.type !== 'GlimmerElementNode') {
25+
return false;
26+
}
27+
28+
const elementChildren =
29+
parent.children?.filter((child) => child.type === 'GlimmerElementNode') || [];
30+
const formElements = elementChildren.filter((child) => FORM_ELEMENTS.has(child.tag));
31+
32+
return formElements.length > 1;
33+
}
34+
35+
function hasValidGroupingAncestor(node) {
36+
let parent = node.parent;
37+
38+
while (parent) {
39+
if (parent.type === 'GlimmerElementNode' && isValidFormGroup(parent)) {
40+
return true;
41+
}
42+
43+
parent = parent.parent;
44+
}
45+
46+
return false;
47+
}
48+
49+
module.exports = {
50+
meta: {
51+
type: 'problem',
52+
docs: {
53+
description:
54+
'require grouped form controls to have fieldset/legend or WAI-ARIA group labeling',
55+
category: 'Accessibility',
56+
recommendedGjs: false,
57+
recommendedGts: false,
58+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-valid-form-groups.md',
59+
templateMode: 'both',
60+
},
61+
schema: [],
62+
messages: {
63+
requireValidFormGroups:
64+
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
65+
},
66+
originallyFrom: {
67+
name: 'ember-template-lint',
68+
rule: 'lib/rules/require-valid-form-groups.js',
69+
docs: 'docs/rule/require-valid-form-groups.md',
70+
tests: 'test/unit/rules/require-valid-form-groups-test.js',
71+
},
72+
},
73+
74+
create(context) {
75+
return {
76+
GlimmerElementNode(node) {
77+
if (!FORM_ELEMENTS.has(node.tag)) {
78+
return;
79+
}
80+
81+
if (!hasMultipleFormElementsInParentScope(node)) {
82+
return;
83+
}
84+
85+
if (hasValidGroupingAncestor(node)) {
86+
return;
87+
}
88+
89+
context.report({
90+
node,
91+
messageId: 'requireValidFormGroups',
92+
});
93+
},
94+
};
95+
},
96+
};
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
const rule = require('../../../lib/rules/template-require-valid-form-groups');
2+
const RuleTester = require('eslint').RuleTester;
3+
4+
const ruleTester = new RuleTester({
5+
parser: require.resolve('ember-eslint-parser'),
6+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
7+
});
8+
9+
ruleTester.run('template-require-valid-form-groups', rule, {
10+
valid: [
11+
`<template>
12+
<fieldset>
13+
<legend>Preferred Mascot Version</legend>
14+
<div>
15+
<label for="radio-001">Chicago Zoey</label>
16+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
17+
</div>
18+
<div>
19+
<label for="radio-002">Office Hours Tomster</label>
20+
<input
21+
id="radio-002"
22+
type="radio"
23+
name="prefMascot-OfficeHoursTomster"
24+
value="office hours tomster"
25+
/>
26+
</div>
27+
<div>
28+
<label for="radio-003">A11y Zoey</label>
29+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
30+
</div>
31+
</fieldset>
32+
</template>`,
33+
`<template>
34+
<div role="group" aria-labelledby="preferred-mascot-heading">
35+
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
36+
<label for="radio-001">Chicago Zoey</label>
37+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
38+
<label for="radio-002">Office Hours Tomster</label>
39+
<input
40+
id="radio-002"
41+
type="radio"
42+
name="prefMascot-OfficeHoursTomster"
43+
value="office hours tomster"
44+
/>
45+
<label for="radio-003">A11y Zoey</label>
46+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
47+
</div>
48+
</template>`,
49+
`<template>
50+
<div>
51+
<label for="radio-001">Chicago Zoey</label>
52+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
53+
</div>
54+
</template>`,
55+
56+
`<template><fieldset>
57+
<legend>Preferred Mascot Version</legend>
58+
<div>
59+
<label for="radio-001">Chicago Zoey</label>
60+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
61+
</div>
62+
<div>
63+
<label for="radio-002">Office Hours Tomster</label>
64+
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
65+
</div>
66+
<div>
67+
<label for="radio-003">A11y Zoey</label>
68+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
69+
</div>
70+
</fieldset></template>`,
71+
`<template><div role="group" aria-labelledby="preferred-mascot-heading">
72+
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
73+
<label for="radio-001">Chicago Zoey</label>
74+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
75+
<label for="radio-002">Office Hours Tomster</label>
76+
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
77+
<label for="radio-003">A11y Zoey</label>
78+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
79+
</div></template>`,
80+
`<template><div>
81+
<label for="radio-001">Chicago Zoey</label>
82+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
83+
</div></template>`,
84+
],
85+
invalid: [
86+
{
87+
code: '<template><div><input name="a1">Chicago Zoey<input name="a2">Chicago Tom</div></template>',
88+
output: null,
89+
errors: [{ messageId: 'requireValidFormGroups' }, { messageId: 'requireValidFormGroups' }],
90+
},
91+
{
92+
code: '<template><div><input id="prefMascot-Zoey"><label for="prefMascot-Zoey" /><input id="prefMascot-tom"><label for="prefMascot-tom" /></div></template>',
93+
output: null,
94+
errors: [{ messageId: 'requireValidFormGroups' }, { messageId: 'requireValidFormGroups' }],
95+
},
96+
],
97+
});
98+
99+
const hbsRuleTester = new RuleTester({
100+
parser: require.resolve('ember-eslint-parser/hbs'),
101+
parserOptions: {
102+
ecmaVersion: 2022,
103+
sourceType: 'module',
104+
},
105+
});
106+
107+
hbsRuleTester.run('template-require-valid-form-groups', rule, {
108+
valid: [
109+
`<fieldset>
110+
<legend>Preferred Mascot Version</legend>
111+
<div>
112+
<label for="radio-001">Chicago Zoey</label>
113+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
114+
</div>
115+
<div>
116+
<label for="radio-002">Office Hours Tomster</label>
117+
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
118+
</div>
119+
<div>
120+
<label for="radio-003">A11y Zoey</label>
121+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
122+
</div>
123+
</fieldset>`,
124+
`<div role="group" aria-labelledby="preferred-mascot-heading">
125+
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
126+
<label for="radio-001">Chicago Zoey</label>
127+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
128+
<label for="radio-002">Office Hours Tomster</label>
129+
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
130+
<label for="radio-003">A11y Zoey</label>
131+
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
132+
</div>`,
133+
`<div>
134+
<label for="radio-001">Chicago Zoey</label>
135+
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
136+
</div>`,
137+
],
138+
invalid: [
139+
{
140+
code: '<div><input name="a1">Chicago Zoey<input name="a2">Chicago Tom</div>',
141+
output: null,
142+
errors: [
143+
{
144+
message:
145+
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
146+
},
147+
{
148+
message:
149+
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
150+
},
151+
],
152+
},
153+
{
154+
code: '<div><input id="prefMascot-Zoey"><label for="prefMascot-Zoey" /><input id="prefMascot-tom"><label for="prefMascot-tom" /></div>',
155+
output: null,
156+
errors: [
157+
{
158+
message:
159+
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
160+
},
161+
{
162+
message:
163+
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
164+
},
165+
],
166+
},
167+
],
168+
});

0 commit comments

Comments
 (0)