Skip to content

Commit

Permalink
feat: add require-input-label rule (#252)
Browse files Browse the repository at this point in the history
* feat: add require-input-label

* implement

* impl

* add word

* add docs

* add docs

* add tests
  • Loading branch information
yeonjuan authored Dec 18, 2024
1 parent 097360f commit 53af02b
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 13 deletions.
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"sourcecode",
"espree",
"nohtml",
"tmpl"
"tmpl",
"labelledby"
]
}
23 changes: 12 additions & 11 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,18 @@

## Accessibility

| Rule | Description | |
| ---------------------------------------------------------- | --------------------------------------------------------------- | --- |
| [no-abstract-roles](rules/no-abstract-roles) | Disallow to use of abstract roles | |
| [no-accesskey-attrs](rules/no-accesskey-attrs) | Disallow to use of accesskey attribute | |
| [no-aria-hidden-body](rules/no-aria-hidden-body) | Disallow to use aria-hidden attributes on the `body` element. | |
| [no-non-scalable-viewport](rules/no-non-scalable-viewport) | Disallow use of `user-scalable=no` in `<meta name="viewport">`. | |
| [no-positive-tabindex](rules/no-positive-tabindex) | Disallow use of positive `tabindex`. | |
| [no-skip-heading-levels](rules/no-skip-heading-levels) | Disallow skipping heading levels | |
| [require-frame-title](rules/require-frame-title) | Require `title` in `<frame>`, `<iframe>` | |
| [require-img-alt](rules/require-img-alt) | Require `alt` attribute at `<img>` tag ||
| [require-meta-viewport](rules/require-meta-viewport) | Enforce to use `<meta name="viewport">` in `<head>` | |
| Rule | Description | |
| ---------------------------------------------------------- | ---------------------------------------------------------------------- | --- |
| [no-abstract-roles](rules/no-abstract-roles) | Disallow to use of abstract roles | |
| [no-accesskey-attrs](rules/no-accesskey-attrs) | Disallow to use of accesskey attribute | |
| [no-aria-hidden-body](rules/no-aria-hidden-body) | Disallow to use aria-hidden attributes on the `body` element. | |
| [no-non-scalable-viewport](rules/no-non-scalable-viewport) | Disallow use of `user-scalable=no` in `<meta name="viewport">`. | |
| [no-positive-tabindex](rules/no-positive-tabindex) | Disallow use of positive `tabindex`. | |
| [no-skip-heading-levels](rules/no-skip-heading-levels) | Disallow skipping heading levels | |
| [require-frame-title](rules/require-frame-title) | Require `title` in `<frame>`, `<iframe>` | |
| [require-img-alt](rules/require-img-alt) | Require `alt` attribute at `<img>` tag ||
| [require-input-label](rules/require-input-label) | Enforces use of label for form elements(`input`, `textarea`, `select`) | |
| [require-meta-viewport](rules/require-meta-viewport) | Enforce to use `<meta name="viewport">` in `<head>` | |

## Style

Expand Down
36 changes: 36 additions & 0 deletions docs/rules/require-input-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# require-input-label

This rule enforces the presence of accessible labels for input elements such as `<input type="text">`, `<select>` and `<textarea>`.

## Why?

- Labels improve accessibility for users of assistive technologies by ensuring that form controls are properly described.

## How to use

```js,.eslintrc.js
module.exports = {
rules: {
"@html-eslint/require-input-label": "error",
},
};
```

## Rule Details

Examples of **incorrect** code for this rule:

```html,incorrect
<input type="text">
<input type="hidden">
<textarea></textarea>
<select></select>
```

Examples of **correct** code for this rule:

```html,correct
<input id="fo">
<label>name: <input></label>
<textarea aria-labelledby="foo"></textarea>
```
2 changes: 2 additions & 0 deletions packages/eslint-plugin/lib/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const lowercase = require("./lowercase");
const requireOpenGraphProtocol = require("./require-open-graph-protocol");
const sortAttrs = require("./sort-attrs");
const preferHttps = require("./prefer-https");
const requireInputLabel = require("./require-input-label");

module.exports = {
"require-lang": requireLang,
Expand Down Expand Up @@ -80,4 +81,5 @@ module.exports = {
"require-open-graph-protocol": requireOpenGraphProtocol,
"sort-attrs": sortAttrs,
"prefer-https": preferHttps,
"require-input-label": requireInputLabel,
};
77 changes: 77 additions & 0 deletions packages/eslint-plugin/lib/rules/require-input-label.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @typedef { import("../types").RuleModule } RuleModule
* @typedef { import("../types").Tag } Tag
*/

const { RULE_CATEGORY } = require("../constants");
const { createVisitors } = require("./utils/visitors");
const { findParent, isTag } = require("./utils/node");

const MESSAGE_IDS = {
MISSING: "missingLabel",
};

const INPUT_TAGS = new Set(["input", "textarea", "select"]);

const LABEL_ATTRIBUTES = new Set(["id", "aria-labelledby", "aria-label"]);

/**
* @type {RuleModule}
*/
module.exports = {
meta: {
type: "code",

docs: {
description:
"Enforces use of label for form elements(`input`, `textarea`, `select`)",
category: RULE_CATEGORY.ACCESSIBILITY,
recommended: false,
},
fixable: null,
schema: [],
messages: {
[MESSAGE_IDS.MISSING]: "Missing an associated label",
},
},
create(context) {
return createVisitors(context, {
Tag(node) {
if (!INPUT_TAGS.has(node.name.toLowerCase())) {
return;
}

for (const attr of node.attributes) {
if (
LABEL_ATTRIBUTES.has(attr.key.value.toLowerCase()) &&
attr.value &&
attr.value.value
) {
return;
}

if (
attr.key.value.toLowerCase() === "type" &&
attr.value &&
attr.value.value === "hidden"
) {
return;
}
}

const label = findParent(node, (parent) => {
return isTag(parent) && parent.name.toLowerCase() === "label";
});

if (label) {
return;
}

context.report({
node,
messageId: "missingLabel",
});
},
});
},
};
22 changes: 22 additions & 0 deletions packages/eslint-plugin/lib/rules/utils/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,27 @@ function codeToLines(source) {
return source.split(lineEndingPattern);
}

/**
* @param {Exclude<AnyNode, Line>} node
* @param {(node: AnyNode) => boolean} predicate
* @returns {null | AnyNode}
*/
function findParent(node, predicate) {
if (!node.parent) {
return null;
}
if (
node.type === "TaggedTemplateExpression" ||
node.type === "TemplateLiteral"
) {
return null;
}
if (predicate(node.parent)) {
return node.parent;
}
return findParent(node.parent, predicate);
}

/**
*
* @param {AnyToken[]} tokens
Expand All @@ -226,6 +247,7 @@ module.exports = {
isNodeTokensOnSameLine,
splitToLineNodes,
getLocBetween,
findParent,
isExpressionInTemplate,
isTag,
isComment,
Expand Down
3 changes: 3 additions & 0 deletions packages/eslint-plugin/lib/types/ast.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,14 @@ export interface Line {

export interface TaggedTemplateExpression
extends estree.TaggedTemplateExpression {
parent: estree.Node | null;
loc: eslint.AST.SourceLocation;
range: eslint.AST.Range;
quasi: TemplateLiteral;
}

export interface TemplateLiteral extends estree.TemplateLiteral {
parent: estree.Node | null;
loc: eslint.AST.SourceLocation;
range: eslint.AST.Range;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-plugin/lib/types/rule.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import eslint from "eslint";
import { AST } from "./ast";
import * as AST from "./ast";

type PostFix<T, S extends string> = {
[K in keyof T as `${K & string}${S}`]: T[K];
Expand Down
72 changes: 72 additions & 0 deletions packages/eslint-plugin/tests/rules/require-input-label.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const createRuleTester = require("../rule-tester");
const rule = require("../../lib/rules/require-input-label");

const ruleTester = createRuleTester();
const templateRuleTester = createRuleTester("espree");

ruleTester.run("require-input-label", rule, {
valid: [
{
code: `<input id="foo">`,
},
{
code: `<textarea id="foo"></textarea>`,
},
{
code: `<input type="hidden">`,
},
{
code: `<label>name: <input></label>`,
},
{
code: `<textarea aria-labelledby="foo" />`,
},
{
code: `<textarea aria-label="foo" />`,
},
],
invalid: [
{
code: `<input type="text">`,
errors: [
{
messageId: "missingLabel",
},
],
},
{
code: `<textarea></textarea>`,
errors: [
{
messageId: "missingLabel",
},
],
},
{
code: `<label>name: </label><input type="text">`,
errors: [
{
messageId: "missingLabel",
},
],
},
],
});

templateRuleTester.run("[template] require-input-label", rule, {
valid: [
{
code: `html\`<input id="foo">\``,
},
],
invalid: [
{
code: `html\`<input type="text">\``,
errors: [
{
messageId: "missingLabel",
},
],
},
],
});

0 comments on commit 53af02b

Please sign in to comment.