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

feat: new rule `aria-label-misuse`

fixes #110

Based on Nu Html Checker rule with same name.
parent 244d37d3
Pipeline #268573338 passed with stages
in 11 minutes and 9 seconds
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/aria-label-misuse.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/aria-label-misuse.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 22,
"context": undefined,
"line": 1,
"message": "\\"aria-label\\" cannot be used on this element",
"offset": 21,
"ruleId": "aria-label-misuse",
"selector": "input",
"severity": 2,
"size": 10,
},
],
"source": "<input type=\\"hidden\\" aria-label=\\"foobar\\">",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<input type="hidden" aria-label="foobar">`;
markup["correct"] = `<input type="text" aria-label="foobar">`;
describe("docs/rules/aria-label-misuse.md", () => {
it("inline validation: incorrect", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"aria-label-misuse":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"aria-label-misuse":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
---
docType: rule
name: aria-label-misuse
category: a17y
summary: Disallow `aria-label` misuse
---
# Disallow `aria-label` misuse (`aria-label-misuse`)
`aria-label` is used to set the label of an element when no native text is present or non-descriptive.
The attribute can only be used on the following elements:
- Interactive elements
- Labelable elements
- Landmark elements
- Elements with roles inheriting from widget
- `<area>`
- `<form>` and `<fieldset>`
- `<iframe>`
- `<img>` and `<figure>`
- `<summary>`
- `<table>`, `<td>` and `<th>`
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="aria-label-misuse">
<input type="hidden" aria-label="foobar">
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="aria-label-misuse">
<input type="text" aria-label="foobar">
</validate>
......@@ -2,6 +2,7 @@ import { ConfigData } from "../config-data";
const config: ConfigData = {
rules: {
"aria-label-misuse": "error",
"deprecated-rule": "warn",
"empty-heading": "error",
"empty-title": "error",
......
......@@ -2,9 +2,10 @@ import { ConfigData } from "../config-data";
const config: ConfigData = {
rules: {
"aria-label-misuse": "error",
"attr-case": "error",
"attr-spacing": "error",
"attr-quotes": "error",
"attr-spacing": "error",
"attribute-allowed-values": "error",
"attribute-boolean-style": "error",
"attribute-empty-style": "error",
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule aria-label-misuse should contain documentation 1`] = `
Object {
"description": "\`aria-label\` can only be used on:
- Interactive elements
- Labelable elements
- Landmark elements
- Elements with roles inheriting from widget
- \`<area>\`
- \`<form>\` and \`<fieldset>\`
- \`<iframe>\`
- \`<img>\` and \`<figure>\`
- \`<summary>\`
- \`<table>\`, \`<td>\` and \`<th>\`
",
"url": "https://html-validate.org/rules/aria-label-misuse.html",
}
`;
import HtmlValidate from "../htmlvalidate";
import "../matchers";
import { processAttribute } from "../transform/mocks/attribute";
describe("rule aria-label-misuse", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "aria-label-misuse": ["error"] },
});
});
it("should not report for element without aria-label", () => {
expect.assertions(1);
const markup = "<p></p>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element with empty aria-label", () => {
expect.assertions(1);
const markup = '<p aria-label=""></p>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element with boolean aria-label", () => {
expect.assertions(1);
const markup = "<p aria-label></p>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element without meta", () => {
expect.assertions(1);
const markup = '<custom-element aria-label="foobar"></custom-element>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element with role", () => {
expect.assertions(1);
const markup = '<p aria-label="foobar" role="widget"></p>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
describe("should not report error for", () => {
it.each`
markup | description
${'<button aria-label="foobar"></button>'} | ${"Interactive elements"}
${'<input type="text" aria-label="foobar">'} | ${"Labelable elements"}
${'<main aria-label="foobar"></main>'} | ${"Landmark elements"}
${'<p role="widget" aria-label="foobar">'} | ${'[role=".."]'}
${'<p tabindex="0" aria-label="foobar"></p>'} | ${"[tabindex]"}
${'<area aria-label="foobar"></area>'} | ${"<area>"}
${'<form aria-label="foobar"></form>'} | ${"<form>"}
${'<fieldset aria-label="foobar"></fieldset>'} | ${"<fieldset>"}
${'<iframe aria-label="foobar"></iframe>'} | ${"<iframe>"}
${'<img aria-label="foobar">'} | ${"<img>"}
${'<figure aria-label="foobar"></figure>'} | ${"<figure>"}
${'<summary aria-label="foobar"></summary>'} | ${"<summary>"}
${'<table aria-label="foobar"></table>'} | ${"<table>"}
${'<td aria-label="foobar"></td>'} | ${"<td>"}
${'<th aria-label="foobar"></th>'} | ${"<th>"}
`("$description", ({ markup }) => {
expect.assertions(1);
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
it("should report error when aria-label is used on invalid element", () => {
expect.assertions(2);
const markup = '<p aria-label="foobar"></p>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should report error when aria-label is used on input hidden", () => {
expect.assertions(2);
const markup = '<input type="hidden" aria-label="foobar">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should handle dynamic attribute", () => {
expect.assertions(2);
const markup = '<p dynamic-aria-label="foobar"></p>';
const report = htmlvalidate.validateString(markup, { processAttribute });
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should handle interpolated attribute", () => {
expect.assertions(2);
const markup = '<p aria-label="{{ interpolated }}"></p>';
const report = htmlvalidate.validateString(markup, { processAttribute });
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should contain documentation", () => {
expect.assertions(1);
expect(htmlvalidate.getRuleDocumentation("aria-label-misuse")).toMatchSnapshot();
});
});
import { HtmlElement } from "../dom";
import { DOMReadyEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const whitelisted = [
"main",
"nav",
"table",
"td",
"th",
"aside",
"header",
"footer",
"section",
"article",
"form",
"img",
"area",
"fieldset",
"summary",
"figure",
];
export default class AriaLabelMisuse extends Rule {
public documentation(): RuleDocumentation {
const valid = [
"Interactive elements",
"Labelable elements",
"Landmark elements",
"Elements with roles inheriting from widget",
"`<area>`",
"`<form>` and `<fieldset>`",
"`<iframe>`",
"`<img>` and `<figure>`",
"`<summary>`",
"`<table>`, `<td>` and `<th>`",
];
const lines = valid.map((it) => `- ${it}\n`).join("");
return {
description: `\`aria-label\` can only be used on:\n\n${lines}`,
url: ruleDocumentationUrl(__filename),
};
}
public setup(): void {
this.on("dom:ready", (event: DOMReadyEvent) => {
const { document } = event;
for (const target of document.querySelectorAll("[aria-label]")) {
this.validateElement(target);
}
});
}
private validateElement(target: HtmlElement): void {
const attr = target.getAttribute("aria-label");
if (!attr || !attr.value || attr.valueMatches("", false)) {
return;
}
/* ignore elements without meta */
const meta = target.meta;
if (!meta) {
return;
}
/* ignore landmark and other whitelisted elements */
if (whitelisted.includes(target.tagName)) {
return;
}
/* ignore elements with role, @todo check if the role is widget or landmark */
if (target.hasAttribute("role")) {
return;
}
/* ignore elements with tabindex (implicit interactive) */
if (target.hasAttribute("tabindex")) {
return;
}
/* ignore interactive and labelable elements */
if (meta.interactive || meta.labelable) {
return;
}
this.report(target, `"aria-label" cannot be used on this element`, attr.keyLocation);
}
}
import { RuleConstructor } from "../rule";
import AllowedLinks from "./allowed-links";
import AriaLabelMisuse from "./aria-label-misuse";
import AttrCase from "./attr-case";
import AttrSpacing from "./attr-spacing";
import AttrQuotes from "./attr-quotes";
import AttrSpacing from "./attr-spacing";
import AttributeAllowedValues from "./attribute-allowed-values";
import AttributeBooleanStyle from "./attribute-boolean-style";
import AttributeEmptyStyle from "./attribute-empty-style";
......@@ -63,9 +64,10 @@ import WCAG from "./wcag";
const bundledRules: Record<string, RuleConstructor<any, any>> = {
"allowed-links": AllowedLinks,
"aria-label-misuse": AriaLabelMisuse,
"attr-case": AttrCase,
"attr-spacing": AttrSpacing,
"attr-quotes": AttrQuotes,
"attr-spacing": AttrSpacing,
"attribute-allowed-values": AttributeAllowedValues,
"attribute-boolean-style": AttributeBooleanStyle,
"attribute-empty-style": AttributeEmptyStyle,
......
......@@ -16,6 +16,6 @@
<button type="reset">foo</button>
<!-- should allow aria-label -->
<button type="button">
<i class="icon icon-foo" aria-label="foobar"></i>
<button type="button" aria-label="foobar">
<i class="icon icon-foo"></i>
</button>
Supports Markdown
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