Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
| [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 | | | |
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |

### Best Practices
Expand Down
69 changes: 69 additions & 0 deletions docs/rules/template-require-valid-form-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ember/template-require-valid-form-groups

<!-- end auto-generated rule header -->

Require grouped form controls to have appropriate semantics.

This rule requires appropriate semantics for grouped form controls. Correctly grouped
form controls will take one of two approaches:

- use `<fieldset>` + `<legend>` (preferred)
- associate controls using WAI-ARIA (also acceptable)

## Examples

This rule **forbids** the following:

```gjs
<template>
<div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
<label for="radio-002">Office Hours Tomster</label>
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster" />
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
</div>
</template>
```

This rule **allows** the following:

```gjs
<template>
<div role="group" aria-labelledby="preferred-mascot-heading">
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
<label for="radio-002">Office Hours Tomster</label>
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster" />
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
</div>
</template>
```

```gjs
<template>
<fieldset>
<legend>Preferred Mascot Version</legend>
<div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
</div>
<div>
<label for="radio-002">Office Hours Tomster</label>
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster" />
</div>
<div>
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
</div>
</fieldset>
</template>
```

## References

- [Grouping Controls](https://www.w3.org/WAI/tutorials/forms/grouping/)
- [The Field Set element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset)
96 changes: 96 additions & 0 deletions lib/rules/template-require-valid-form-groups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/** @type {import('eslint').Rule.RuleModule} */
const FORM_ELEMENTS = new Set(['input']);

function hasRoleGroup(node) {
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
return roleAttr && roleAttr.value?.type === 'GlimmerTextNode' && roleAttr.value.chars === 'group';
}

function hasAriaLabel(node) {
return node.attributes?.some((attr) => attr.name === 'aria-labelledby');
}

function isValidFormGroup(node) {
if (node.tag === 'fieldset' || node.tag === 'legend') {
return true;
}

return hasRoleGroup(node) && hasAriaLabel(node);
}

function hasMultipleFormElementsInParentScope(node) {
const parent = node.parent;

if (!parent || parent.type !== 'GlimmerElementNode') {
return false;
}

const elementChildren =
parent.children?.filter((child) => child.type === 'GlimmerElementNode') || [];
const formElements = elementChildren.filter((child) => FORM_ELEMENTS.has(child.tag));

return formElements.length > 1;
}

function hasValidGroupingAncestor(node) {
let parent = node.parent;

while (parent) {
if (parent.type === 'GlimmerElementNode' && isValidFormGroup(parent)) {
return true;
}

parent = parent.parent;
}

return false;
}

module.exports = {
meta: {
type: 'problem',
docs: {
description:
'require grouped form controls to have fieldset/legend or WAI-ARIA group labeling',
category: 'Accessibility',
recommendedGjs: false,
recommendedGts: false,
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-valid-form-groups.md',
templateMode: 'both',
},
schema: [],
messages: {
requireValidFormGroups:
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/require-valid-form-groups.js',
docs: 'docs/rule/require-valid-form-groups.md',
tests: 'test/unit/rules/require-valid-form-groups-test.js',
},
},

create(context) {
return {
GlimmerElementNode(node) {
if (!FORM_ELEMENTS.has(node.tag)) {
return;
}

if (!hasMultipleFormElementsInParentScope(node)) {
return;
}

if (hasValidGroupingAncestor(node)) {
return;
}

context.report({
node,
messageId: 'requireValidFormGroups',
});
},
};
},
};
168 changes: 168 additions & 0 deletions tests/lib/rules/template-require-valid-form-groups.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
const rule = require('../../../lib/rules/template-require-valid-form-groups');
const RuleTester = require('eslint').RuleTester;

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

ruleTester.run('template-require-valid-form-groups', rule, {
valid: [
`<template>
<fieldset>
<legend>Preferred Mascot Version</legend>
<div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
</div>
<div>
<label for="radio-002">Office Hours Tomster</label>
<input
id="radio-002"
type="radio"
name="prefMascot-OfficeHoursTomster"
value="office hours tomster"
/>
</div>
<div>
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
</div>
</fieldset>
</template>`,
`<template>
<div role="group" aria-labelledby="preferred-mascot-heading">
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
<label for="radio-002">Office Hours Tomster</label>
<input
id="radio-002"
type="radio"
name="prefMascot-OfficeHoursTomster"
value="office hours tomster"
/>
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey" />
</div>
</template>`,
`<template>
<div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey" />
</div>
</template>`,

`<template><fieldset>
<legend>Preferred Mascot Version</legend>
<div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
</div>
<div>
<label for="radio-002">Office Hours Tomster</label>
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
</div>
<div>
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
</div>
</fieldset></template>`,
`<template><div role="group" aria-labelledby="preferred-mascot-heading">
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
<label for="radio-002">Office Hours Tomster</label>
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
</div></template>`,
`<template><div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
</div></template>`,
],
invalid: [
{
code: '<template><div><input name="a1">Chicago Zoey<input name="a2">Chicago Tom</div></template>',
output: null,
errors: [{ messageId: 'requireValidFormGroups' }, { messageId: 'requireValidFormGroups' }],
},
{
code: '<template><div><input id="prefMascot-Zoey"><label for="prefMascot-Zoey" /><input id="prefMascot-tom"><label for="prefMascot-tom" /></div></template>',
output: null,
errors: [{ messageId: 'requireValidFormGroups' }, { messageId: 'requireValidFormGroups' }],
},
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

hbsRuleTester.run('template-require-valid-form-groups', rule, {
valid: [
`<fieldset>
<legend>Preferred Mascot Version</legend>
<div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
</div>
<div>
<label for="radio-002">Office Hours Tomster</label>
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
</div>
<div>
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
</div>
</fieldset>`,
`<div role="group" aria-labelledby="preferred-mascot-heading">
<div id="preferred-mascot-heading">Preferred Mascot Version</div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
<label for="radio-002">Office Hours Tomster</label>
<input id="radio-002" type="radio" name="prefMascot-OfficeHoursTomster" value="office hours tomster">
<label for="radio-003">A11y Zoey</label>
<input id="radio-003" type="radio" name="prefMascot-Zoey" value="a11y zoey">
</div>`,
`<div>
<label for="radio-001">Chicago Zoey</label>
<input id="radio-001" type="radio" name="prefMascot-Zoey" value="chicago zoey">
</div>`,
],
invalid: [
{
code: '<div><input name="a1">Chicago Zoey<input name="a2">Chicago Tom</div>',
output: null,
errors: [
{
message:
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
},
{
message:
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
},
],
},
{
code: '<div><input id="prefMascot-Zoey"><label for="prefMascot-Zoey" /><input id="prefMascot-tom"><label for="prefMascot-tom" /></div>',
output: null,
errors: [
{
message:
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
},
{
message:
'Grouped form controls should have appropriate semantics such as fieldset and legend or WAI-ARIA labels',
},
],
},
],
});
Loading