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-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | | | |
| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | | | |
| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | | | |
| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | | | |
Expand Down
126 changes: 126 additions & 0 deletions docs/rules/template-require-input-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# ember/template-require-input-label

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

Users with assistive technology need user-input form elements to have
associated labels.

The rule applies to the following HTML tags:

- `<input>`
- `<textarea>`
- `<select>`

The rule also applies to the following ember components:

- `<Textarea />`
- `<Input />`
- `{{textarea}}`
- `{{input}}`

The label is **essential** for users. Leaving it out will cause **three**
different WCAG criteria to fail:

- [1.3.1, Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
- [3.3.2, Labels or Instructions](https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html)
- [4.1.2, Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)

It is also associated with this common failure:

- [#68: Failure of Success Criterion 4.1.2 due to a user interface control not having a programmatically determined name](https://www.w3.org/WAI/WCAG21/Techniques/failures/F68)

This rule checks to see if the input is contained by a label element. If it is
not, it checks to see if the input has any of these three attributes: `id`,
`aria-label`, or `aria-labelledby`. While the `id` element on the input is not
a concrete indicator of the presence of an associated `<label>` element with a
`for` attribute, it is a good indicator that one likely exists.

This rule does not allow an input to use a `title` attribute for a valid label.
This is because implementation by browsers is unreliable and incomplete.

This rule is unable to determine if a valid label is present if `...attributes`
is used, and must allow it to pass. However, developers are encouraged to write
tests to ensure that a valid label is present for each input element present.

## Examples

This rule **forbids** the following:

```gjs
<template>
<div><input /></div>
</template>
```

```gjs
<template>
<input title="some label text" />
</template>
```

```gjs
<template>
<textarea />
</template>
```

This rule **allows** the following:

```gjs
<template>
<label>Some Label Text<input /></label>
</template>
```

```gjs
<template>
<input id="someId" />
</template>
```

```gjs
<template>
<input aria-label="Label Text Here" />
</template>
```

```gjs
<template>
<input aria-labelledby="someButtonId" />
</template>
```

```gjs
<template>
<input ...attributes />
</template>
```

```gjs
<template>
<input type="hidden" />
</template>
```

## Migration

- the recommended fix is to add an associated label element.
- another option is to add an aria-label to the input element.
- wrapping the input element in a label element is also allowed; however this is less flexible for styling purposes, so use with awareness.

## Configuration

- boolean - `true` to enable / `false` to disable
- object -- An object with the following keys:
- `labelTags` -- An array of component names for that may be used as label replacements (in addition to the HTML `label` tag)

## References

- [Understanding Success Criterion 1.3.1: Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships)
- [Understanding Success Criterion 3.3.2: Labels or Instructions](https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html)
- [Understanding Success Criterion 4.1.2: Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
- [Using label elements to associate text labels and form controls](https://www.w3.org/WAI/WCAG21/Techniques/html/H44.html)
- [Using aria-labelledby to provide a name for user interface controls](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA16)
- [Using aria-label to provide an invisible label where a visible label cannot be used](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA14.html)
- [Failure due to a user interface control not having a programmatically determined name](https://www.w3.org/WAI/WCAG21/Techniques/failures/F68)
- [Failure due to visually formatting a set of phone number fields but not including a text label](https://www.w3.org/WAI/WCAG21/Techniques/failures/F82)
201 changes: 201 additions & 0 deletions lib/rules/template-require-input-label.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
}

function isString(value) {
return typeof value === 'string';
}

function isRegExp(value) {
return value instanceof RegExp;
}

function allowedFormat(value) {
return isString(value) || isRegExp(value);
}

function parseConfig(config) {
if (config === false) {
return false;
}

if (config === true || config === undefined) {
return { labelTags: ['label'] };
}

if (config && typeof config === 'object' && Array.isArray(config.labelTags)) {
return {
labelTags: ['label', ...config.labelTags.filter(allowedFormat)],
};
}

return { labelTags: ['label'] };
}

function matchesLabelTag(tag, configuredTag) {
return isRegExp(configuredTag) ? configuredTag.test(tag) : configuredTag === tag;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'require label for form input elements',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-input-label.md',
templateMode: 'both',
},
schema: [
{
anyOf: [
{ type: 'boolean' },
{
type: 'object',
properties: {
labelTags: {
type: 'array',
},
},
additionalProperties: false,
},
],
},
],
messages: {
requireLabel: 'form elements require a valid associated label.',
multipleLabels: 'form elements should not have multiple labels.',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/require-input-label.js',
docs: 'docs/rule/require-input-label.md',
tests: 'test/unit/rules/require-input-label-test.js',
},
},

create(context) {
const config = parseConfig(context.options[0]);
if (config === false) {
return {};
}

const filename = context.getFilename();
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');
const elementStack = [];

function hasValidLabelParent() {
for (let i = elementStack.length - 1; i >= 0; i--) {
const entry = elementStack[i];
const hasMatchingLabelTag = config.labelTags.some((configuredTag) =>
matchesLabelTag(entry.tag, configuredTag)
);

if (hasMatchingLabelTag) {
if (entry.tag !== 'label') {
return true;
}

const children = entry.node.children || [];
return children.length > 1;
}
}
return false;
}

return {
GlimmerElementNode(node) {
elementStack.push({ tag: node.tag, node });

if (isStrictMode && (node.tag === 'Input' || node.tag === 'Textarea')) {
return;
}

const tagName = node.tag?.toLowerCase();
if (tagName !== 'input' && tagName !== 'textarea' && tagName !== 'select') {
return;
}

// Skip if input has type="hidden"
const typeAttr = node.attributes?.find((a) => a.name === 'type');
if (typeAttr?.value?.type === 'GlimmerTextNode' && typeAttr.value.chars === 'hidden') {
return;
}

// Skip if has ...attributes (can't determine labelling)
if (hasAttr(node, '...attributes')) {
return;
}

let labelCount = 0;
const validLabel = hasValidLabelParent();
if (validLabel) {
labelCount++;
}

const hasId = hasAttr(node, 'id');
const hasAriaLabel = hasAttr(node, 'aria-label');
const hasAriaLabelledBy = hasAttr(node, 'aria-labelledby');
if (hasId) {
labelCount++;
}
if (hasAriaLabel) {
labelCount++;
}
if (hasAriaLabelledBy) {
labelCount++;
}

if (labelCount === 1) {
return;
}

if (validLabel && hasId) {
return;
}

context.report({
node,
messageId: labelCount === 0 ? 'requireLabel' : 'multipleLabels',
});
},
'GlimmerElementNode:exit'() {
elementStack.pop();
},

GlimmerMustacheStatement(node) {
const name = node.path?.original;
if (name !== 'input' && name !== 'textarea') {
return;
}

const pairs = node.hash?.pairs || [];

function hasPair(key) {
return pairs.some((p) => p.key === key);
}

// Skip if type="hidden" (literal string only)
const typePair = pairs.find((p) => p.key === 'type');
if (typePair?.value?.type === 'GlimmerStringLiteral' && typePair.value.value === 'hidden') {
return;
}

// If in a valid label, it's valid
if (hasValidLabelParent()) {
return;
}

// If has id, it's valid
if (hasPair('id')) {
return;
}

context.report({
node,
messageId: 'requireLabel',
});
},
};
},
};
Loading
Loading