diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index ba1220b87..93f5541fd 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -9,6 +9,7 @@ This page contains documentation for all Herb Linter rules. - [`a11y-avoid-generic-link-text`](./a11y-avoid-generic-link-text.md) - Avoid generic link text like "Click here" or "Read more" - [`a11y-disabled-attribute`](./a11y-disabled-attribute.md) - Prevent usage of the `disabled` attribute on unsupported elements - [`a11y-no-accesskey-attribute`](./a11y-no-accesskey-attribute.md) - Prevent usage of the `accesskey` attribute +- [`a11y-no-aria-label-misuse`](./a11y-no-aria-label-misuse.md) - Prevent `aria-label` and `aria-labelledby` on non-interactive elements - [`a11y-no-aria-unsupported-elements`](./a11y-no-aria-unsupported-elements.md) - Prevent usage of ARIA on unsupported elements - [`a11y-no-autofocus-attribute`](./a11y-no-autofocus-attribute.md) - Prevent usage of the `autofocus` attribute - [`a11y-no-redundant-image-alt`](./a11y-no-redundant-image-alt.md) - Prevent redundant words in `` `alt` attributes diff --git a/javascript/packages/linter/docs/rules/a11y-no-aria-label-misuse.md b/javascript/packages/linter/docs/rules/a11y-no-aria-label-misuse.md new file mode 100644 index 000000000..dfb8f178c --- /dev/null +++ b/javascript/packages/linter/docs/rules/a11y-no-aria-label-misuse.md @@ -0,0 +1,62 @@ +# Linter Rule: No `aria-label` misuse + +**Rule:** `a11y-no-aria-label-misuse` + +## Description + +Enforce that `aria-label` and `aria-labelledby` are only used on interactive elements or elements with a permitted ARIA role. + +## Rationale + +`aria-label` and `aria-labelledby` support is only guaranteed on interactive elements like `button` or `a`, or on static elements like `div` and `span` with a permitted `role`. Using these attributes on non-interactive elements without a role, or on elements like headings, can lead to unexpected behavior with assistive technologies. + +This rule will allow `aria-label` and `aria-labelledby` usage on `div` and `span` elements if set to a role other than the ones listed in [W3C: roles which cannot be named](https://w3c.github.io/aria/#namefromprohibited). This rule will never permit usage on `h1`–`h6`, `strong`, `em`, `i`, `p`, `b`, or `code`. + +## Examples + +### ✅ Good + +```erb + +``` + +```erb +Hello +``` + +```erb +
+

Heading

+
+``` + +```erb +About +``` + +### 🚫 Bad + +```erb +I am some text. +``` + +```erb +
Goodbye
+``` + +```erb +

Page title

+``` + +```erb +
Content
+``` + +## References + +- [erblint-github: NoAriaLabelMisuse](https://github.com/github/erblint-github/blob/main/lib/erblint-github/linters/github/accessibility/no_aria_label_misuse.rb) +- [erblint-github docs](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/no-aria-label-misuse.md) +- [W3C: roles which cannot be named](https://w3c.github.io/aria/#namefromprohibited) +- [Not so short note on aria-label usage](https://html5accessibility.com/stuff/2020/11/07/not-so-short-note-on-aria-label-usage-big-table-edition/) diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 672a06563..89c2318f8 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -3,6 +3,7 @@ import type { RuleClass } from "./types.js" import { A11yAvoidGenericLinkTextRule } from "./rules/a11y-avoid-generic-link-text.js" import { A11yDisabledAttributeRule } from "./rules/a11y-disabled-attribute.js" import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribute.js" +import { A11yNoAriaLabelMisuseRule } from "./rules/a11y-no-aria-label-misuse.js" import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js" import { A11yNoAutofocusAttributeRule } from "./rules/a11y-no-autofocus-attribute.js" import { A11yNoRedundantImageAltRule } from "./rules/a11y-no-redundant-image-alt.js" @@ -110,6 +111,7 @@ export const rules: RuleClass[] = [ A11yAvoidGenericLinkTextRule, A11yDisabledAttributeRule, A11yNoAccesskeyAttributeRule, + A11yNoAriaLabelMisuseRule, A11yNoAriaUnsupportedElementsRule, A11yNoAutofocusAttributeRule, A11yNoRedundantImageAltRule, diff --git a/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts new file mode 100644 index 000000000..0130a3a13 --- /dev/null +++ b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts @@ -0,0 +1,106 @@ +import { ParserRule } from "../types.js" +import { BaseRuleVisitor } from "./rule-utils.js" + +import { getTagLocalName, getStaticAttributeValue, hasAttribute, getAttribute } from "@herb-tools/core" + +import type { ParseResult, ParserOptions, HTMLElementNode } from "@herb-tools/core" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + +const GENERIC_ELEMENTS = ["span", "div"] +const NAME_RESTRICTED_ELEMENTS = ["h1", "h2", "h3", "h4", "h5", "h6", "strong", "em", "i", "p", "b", "code"] +const LABEL_ATTRIBUTES = ["aria-label", "aria-labelledby"] as const + +// https://w3c.github.io/aria/#namefromprohibited +const ROLES_WHICH_CANNOT_BE_NAMED = [ + "caption", + "code", + "definition", + "deletion", + "emphasis", + "generic", + "insertion", + "mark", + "none", + "paragraph", + "presentation", + "strong", + "subscript", + "suggestion", + "superscript", + "term", + "time", + "tooltip", +] + +class NoAriaLabelMisuseVisitor extends BaseRuleVisitor { + visitHTMLElementNode(node: HTMLElementNode): void { + this.checkElement(node) + super.visitHTMLElementNode(node) + } + + private checkElement(node: HTMLElementNode): void { + const tagName = getTagLocalName(node) + if (!tagName) return + + for (const attributeName of LABEL_ATTRIBUTES) { + const attribute = getAttribute(node, attributeName) + if (!attribute) continue + + if (NAME_RESTRICTED_ELEMENTS.includes(tagName)) { + this.addOffense( + `The \`${attributeName}\` attribute must not be used on the \`<${tagName}>\` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.`, + attribute.location, + ) + + continue + } + + if (!GENERIC_ELEMENTS.includes(tagName)) continue + + if (!hasAttribute(node, "role")) { + this.addOffense( + `The \`${attributeName}\` attribute on \`<${tagName}>\` requires a permitted ARIA \`role\`. Add a valid \`role\` attribute (e.g. \`role="region"\`, \`role="group"\`, or \`role="img"\`), or use an interactive element like \`') + }) + + test("passes for a with aria-label", () => { + expectNoOffenses('About') + }) + + test("passes for input with aria-label", () => { + expectNoOffenses('') + }) + + test("passes for select with aria-labelledby", () => { + expectNoOffenses('') + }) + + test("passes for textarea with aria-label", () => { + expectNoOffenses('') + }) + + test("passes for span without aria-label", () => { + expectNoOffenses("Hello") + }) + + test("passes for div without aria-label", () => { + expectNoOffenses("
Goodbye
") + }) + + test("passes for h1 without aria-label", () => { + expectNoOffenses("

Page title

") + }) + + test("passes for div with role=dialog and aria-labelledby", () => { + expectNoOffenses('

Heading

') + }) + + test("passes for span with role=button and aria-label", () => { + expectNoOffenses('X') + }) + + test("passes for div with role=alert and aria-label", () => { + expectNoOffenses('
Warning message
') + }) + + test("fails for h1 with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `

` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('

Page title

') + }) + + test("fails for h2 with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `

` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('

Section

') + }) + + test("fails for h3 with aria-labelledby", () => { + expectWarning('The `aria-labelledby` attribute must not be used on the `

` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('

Heading

') + }) + + test("fails for h4 with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `

` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('

Heading

') + }) + + test("fails for h5 with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `
` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('
Heading
') + }) + + test("fails for h6 with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `
` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('
Heading
') + }) + + test("fails for strong with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('Bold text') + }) + + test("fails for em with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('Emphasis text') + }) + + test("fails for i with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('Italic text') + }) + + test("fails for p with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `

` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('

Paragraph

') + }) + + test("fails for b with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('Bold') + }) + + test("fails for code with aria-label", () => { + expectWarning('The `aria-label` attribute must not be used on the `` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('Code') + }) + + test("fails for p with aria-labelledby", () => { + expectWarning('The `aria-labelledby` attribute must not be used on the `

` element. Assistive technologies do not reliably support naming on this element. Use visible text content instead, or wrap the content in an element that supports naming.') + assertOffenses('

Paragraph

') + }) + + test("fails for span with aria-label and no role", () => { + expectWarning('The `aria-label` attribute on `` requires a permitted ARIA `role`. Add a valid `role` attribute (e.g. `role="region"`, `role="group"`, or `role="img"`), or use an interactive element like `