Commit ee287745 authored by David Sveningsson's avatar David Sveningsson
Browse files

feat(rules): new rule `multiple-labeled-controls`

fixes #86
parent a2395b6d
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/multiple-labeled-controls.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/multiple-labeled-controls.md inline validation: incorrect-both 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 2,
"context": undefined,
"line": 1,
"message": "<label> is associated with multiple controls",
"offset": 1,
"ruleId": "multiple-labeled-controls",
"selector": "label",
"severity": 2,
"size": 5,
},
],
"source": "<label for=\\"bar\\">
<input type=\\"text\\" id=\\"foo\\">
</label>
<input type=\\"text\\" id=\\"bar\\">",
"warningCount": 0,
},
]
`;
exports[`docs/rules/multiple-labeled-controls.md inline validation: incorrect-multiple 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 2,
"context": undefined,
"line": 1,
"message": "<label> is associated with multiple controls",
"offset": 1,
"ruleId": "multiple-labeled-controls",
"selector": "label",
"severity": 2,
"size": 5,
},
],
"source": "<label>
<input type=\\"text\\">
<input type=\\"text\\">
</label>",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect-multiple"] = `<label>
<input type="text">
<input type="text">
</label>`;
markup["incorrect-both"] = `<label for="bar">
<input type="text" id="foo">
</label>
<input type="text" id="bar">`;
markup["correct"] = `<label>
<input type="text">
</label>`;
describe("docs/rules/multiple-labeled-controls.md", () => {
it("inline validation: incorrect-multiple", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"multiple-labeled-controls":"error"}});
const report = htmlvalidate.validateString(markup["incorrect-multiple"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: incorrect-both", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"multiple-labeled-controls":"error"}});
const report = htmlvalidate.validateString(markup["incorrect-both"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"multiple-labeled-controls":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
---
docType: rule
name: multiple-labeled-controls
category: a17y
summary: Disallow labels associated with multiple controls
---
# Disallow labels associated with multiple controls (`multiple-labeled-controls`)
`<label>` can only be associated with a single control at once.
It should either wrap a single [labelable][] control or use the `for` attribute to reference the control.
[labelable]: https://html.spec.whatwg.org/multipage/forms.html#category-label
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect-multiple" rules="multiple-labeled-controls">
<label>
<input type="text">
<input type="text">
</label>
</validate>
<validate name="incorrect-both" rules="multiple-labeled-controls">
<label for="bar">
<input type="text" id="foo">
</label>
<input type="text" id="bar">
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="multiple-labeled-controls">
<label>
<input type="text">
</label>
</validate>
......@@ -6,6 +6,7 @@ const config: ConfigData = {
"empty-heading": "error",
"empty-title": "error",
"meta-refresh": "error",
"multiple-labeled-controls": "error",
"no-autoplay": ["error", { include: ["audio", "video"] }],
"no-dup-id": "error",
"no-redundant-for": "error",
......
......@@ -23,6 +23,7 @@ const config: ConfigData = {
"empty-title": "error",
"long-title": "error",
"meta-refresh": "error",
"multiple-labeled-controls": "error",
"no-autoplay": ["error", { include: ["audio", "video"] }],
"no-conditional-comment": "error",
"no-deprecated-attr": "error",
......
......@@ -14,6 +14,7 @@ const config: ConfigData = {
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"multiple-labeled-controls": "error",
"no-deprecated-attr": "error",
"no-dup-attr": "error",
"no-dup-id": "error",
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule multiple-labeled-controls should contain documentation 1`] = `
Object {
"description": "A \`<label>\` element can only be associated with one control at a time.",
"url": "https://html-validate.org/rules/multiple-labeled-controls.html",
}
`;
......@@ -25,6 +25,7 @@ import InputMissingLabel from "./input-missing-label";
import LongTitle from "./long-title";
import MetaRefresh from "./meta-refresh";
import MissingDoctype from "./missing-doctype";
import MultipleLabeledControls from "./multiple-labeled-controls";
import NoAutoplay from "./no-autoplay";
import NoConditionalComment from "./no-conditional-comment";
import NoDeprecatedAttr from "./no-deprecated-attr";
......@@ -81,6 +82,7 @@ const bundledRules: Record<string, RuleConstructor<any, any>> = {
"long-title": LongTitle,
"meta-refresh": MetaRefresh,
"missing-doctype": MissingDoctype,
"multiple-labeled-controls": MultipleLabeledControls,
"no-autoplay": NoAutoplay,
"no-conditional-comment": NoConditionalComment,
"no-deprecated-attr": NoDeprecatedAttr,
......
import HtmlValidate from "../htmlvalidate";
import "../matchers";
describe("rule multiple-labeled-controls", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "multiple-labeled-controls": "error" },
});
});
it("should not report when <label> has no controls", () => {
expect.assertions(1);
const report = htmlvalidate.validateString("<label></label>");
expect(report).toBeValid();
});
it("should not report when <label> has one wrapped control", () => {
expect.assertions(1);
const report = htmlvalidate.validateString("<labe><input></label>");
expect(report).toBeValid();
});
it("should not report when <label> has one referenced control", () => {
expect.assertions(1);
const report = htmlvalidate.validateString(
'<label for="foo"></label><input id="foo">'
);
expect(report).toBeValid();
});
it("should not report when <label> both references and wraps a single control", () => {
expect.assertions(1);
const report = htmlvalidate.validateString(
'<label for="foo"><input id="foo"></label>'
);
expect(report).toBeValid();
});
it("should not report error for other elements", () => {
expect.assertions(1);
const report = htmlvalidate.validateString(
'<custom-element for="bar"><input id="foo"></custom-element><input id="bar">'
);
expect(report).toBeValid();
});
it("should report error when <label> have multiple wrapped controls", () => {
expect.assertions(2);
const report = htmlvalidate.validateString("<label><input><input></label>");
expect(report).toBeInvalid();
expect(report).toHaveError(
"multiple-labeled-controls",
"<label> is associated with multiple controls"
);
});
it("should report error when <label> have both for attribute and another wrapped control", () => {
expect.assertions(2);
const report = htmlvalidate.validateString(
'<label for="bar"><input id="foo"></label><input id="bar">'
);
expect(report).toBeInvalid();
expect(report).toHaveError(
"multiple-labeled-controls",
"<label> is associated with multiple controls"
);
});
it("should contain documentation", () => {
expect.assertions(1);
expect(
htmlvalidate.getRuleDocumentation("multiple-labeled-controls")
).toMatchSnapshot();
});
});
import { ElementReadyEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { HtmlElement } from "../dom";
const labelable = [
"button",
"input",
"keygen",
"meter",
"output",
"progress",
"select",
"textarea",
].join(",");
export default class MultipleLabeledControls extends Rule {
public documentation(): RuleDocumentation {
return {
description: `A \`<label>\` element can only be associated with one control at a time.`,
url: ruleDocumentationUrl(__filename),
};
}
public setup(): void {
this.on("element:ready", (event: ElementReadyEvent) => {
const { target } = event;
/* only handle <label> */
if (target.tagName !== "label") {
return;
}
/* no error if it references 0 or 1 controls */
const numControls = this.getNumLabledControls(target);
if (numControls <= 1) {
return;
}
this.report(
target,
"<label> is associated with multiple controls",
target.location
);
});
}
private getNumLabledControls(src: HtmlElement): number {
/* get all controls wrapped by label element */
const controls = src.querySelectorAll(labelable).map((node) => node.id);
/* only count wrapped controls if the "for" attribute is missing or static,
* for dynamic "for" attributes it is better to run in document mode later */
const attr = src.getAttribute("for");
if (!attr || attr.isDynamic) {
return controls.length;
}
/* if "for" attribute references a wrapped element it should not be counted
* multiple times */
const redundant = controls.includes(attr.value.toString());
if (redundant) {
return controls.length;
}
/* has "for" attribute pointing to element outside wrapped controls */
return controls.length + 1;
}
}
......@@ -43,9 +43,6 @@ describe("rule no-redundant-for", () => {
it("should contain documentation", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
rules: { "no-redundant-for": ["error", { style: "auto" }] },
});
expect(
htmlvalidate.getRuleDocumentation("no-redundant-for")
).toMatchSnapshot();
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment