From e3fec4a7ee9eb02994f1feb388b89eeec0119ae7 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 23 Apr 2026 11:53:23 -0600 Subject: [PATCH 1/9] Implement a11y-no-aria-label-misuse linter rule Implements the `a11y-no-aria-label-misuse` rule from erblint-github's `NoAriaLabelMisuse`. This rule flags misuse of `aria-label` and `aria-labelledby` attributes on elements where they are not reliably supported: - Always flags on name-restricted elements: h1-h6, strong, i, p, b, code - Flags on generic elements (div, span) without a role attribute - Flags on generic elements with a prohibited role (none, presentation, paragraph, etc. per W3C namefromprohibited) - Allows usage on interactive elements (button, a, input, etc.) - Allows usage on generic elements with a permitted role (dialog, alert, etc.) Closes #1223 --- .../docs/rules/a11y-no-aria-label-misuse.md | 62 ++++++++ javascript/packages/linter/src/rules.ts | 2 + .../src/rules/a11y-no-aria-label-misuse.ts | 86 +++++++++++ javascript/packages/linter/src/rules/index.ts | 1 + .../rules/a11y-no-aria-label-misuse.test.ts | 140 ++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 javascript/packages/linter/docs/rules/a11y-no-aria-label-misuse.md create mode 100644 javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts create mode 100644 javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts 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..c0421908f --- /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`, `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 7a4f935b3..2e0dcdbcd 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -1,6 +1,7 @@ import type { RuleClass } from "./types.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" @@ -104,6 +105,7 @@ import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id. export const rules: RuleClass[] = [ A11yNoAccesskeyAttributeRule, + A11yNoAriaLabelMisuseRule, A11yNoAriaUnsupportedElementsRule, A11yNoAutofocusAttributeRule, 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..b0eff7c12 --- /dev/null +++ b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts @@ -0,0 +1,86 @@ +import { type ParseResult, type ParserOptions, type HTMLElementNode, getTagLocalName, getStaticAttributeValue, hasAttribute } from "@herb-tools/core" + +import { BaseRuleVisitor } from "./rule-utils.js" +import { ParserRule } from "../types.js" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + +const GENERIC_ELEMENTS = ["span", "div"] + +const NAME_RESTRICTED_ELEMENTS = ["h1", "h2", "h3", "h4", "h5", "h6", "strong", "i", "p", "b", "code"] + +// https://w3c.github.io/aria/#namefromprohibited +const ROLES_WHICH_CANNOT_BE_NAMED = [ + "caption", + "code", + "definition", + "deletion", + "emphasis", + "insertion", + "mark", + "none", + "paragraph", + "presentation", + "strong", + "subscript", + "suggestion", + "superscript", + "term", + "time", +] + +const MESSAGE = + "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." + +class NoAriaLabelMisuseVisitor extends BaseRuleVisitor { + visitHTMLElementNode(node: HTMLElementNode): void { + const tagName = getTagLocalName(node) + + if (tagName && this.hasAriaLabelAttribute(node)) { + if (NAME_RESTRICTED_ELEMENTS.includes(tagName)) { + this.addOffense(MESSAGE, node.tag_name!.location) + } else if (GENERIC_ELEMENTS.includes(tagName)) { + const role = getStaticAttributeValue(node, "role") + + if (role) { + if (ROLES_WHICH_CANNOT_BE_NAMED.includes(role)) { + this.addOffense(MESSAGE, node.tag_name!.location) + } + } else { + this.addOffense(MESSAGE, node.tag_name!.location) + } + } + } + + super.visitHTMLElementNode(node) + } + + private hasAriaLabelAttribute(node: HTMLElementNode): boolean { + return hasAttribute(node, "aria-label") || hasAttribute(node, "aria-labelledby") + } +} + +export class A11yNoAriaLabelMisuseRule extends ParserRule { + static ruleName = "a11y-no-aria-label-misuse" + static introducedIn = this.version("0.9.4") + + get defaultConfig(): FullRuleConfig { + return { + enabled: false, + severity: "warning", + } + } + + get parserOptions(): Partial { + return { + action_view_helpers: true, + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new NoAriaLabelMisuseVisitor(this.ruleName, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index 815093506..ce737a33d 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -1,4 +1,5 @@ export * from "./a11y-no-accesskey-attribute.js" +export * from "./a11y-no-aria-label-misuse.js" export * from "./a11y-no-aria-unsupported-elements.js" export * from "./a11y-no-autofocus-attribute.js" diff --git a/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts new file mode 100644 index 000000000..135eef244 --- /dev/null +++ b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts @@ -0,0 +1,140 @@ +import { describe, test } from "vitest" +import { A11yNoAriaLabelMisuseRule } from "../../src/rules/a11y-no-aria-label-misuse.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(A11yNoAriaLabelMisuseRule) + +const MESSAGE = + "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." + +describe("a11y-no-aria-label-misuse", () => { + // Correct usage: interactive elements + test("passes for button with aria-label", () => { + expectNoOffenses('') + }) + + 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('') + }) + + // Correct usage: elements without aria-label + 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

") + }) + + // Correct usage: generic elements with permitted role + 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
') + }) + + // Incorrect usage: name-restricted elements + test("fails for h1 with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('

Page title

') + }) + + test("fails for h2 with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('

Section

') + }) + + test("fails for h3 with aria-labelledby", () => { + expectWarning(MESSAGE) + assertOffenses('

Heading

') + }) + + test("fails for h4 with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('

Heading

') + }) + + test("fails for h5 with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('
Heading
') + }) + + test("fails for h6 with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('
Heading
') + }) + + test("fails for strong with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('Bold text') + }) + + test("fails for i with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('Italic text') + }) + + test("fails for p with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('

Paragraph

') + }) + + test("fails for b with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('Bold') + }) + + test("fails for code with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('Code') + }) + + // Incorrect usage: generic elements without role + test("fails for span with aria-label and no role", () => { + expectWarning(MESSAGE) + assertOffenses('Text') + }) + + test("fails for div with aria-labelledby and no role", () => { + expectWarning(MESSAGE) + assertOffenses('
Content
') + }) + + // Incorrect usage: generic elements with prohibited role + test("fails for div with role=none and aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('
Content
') + }) + + test("fails for span with role=presentation and aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('Content') + }) + + test("fails for div with role=paragraph and aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('
Content
') + }) +}) From 6c3b843f704de5bff4f7a3f213a43a7cfb26714c Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 8 May 2026 14:44:58 -0600 Subject: [PATCH 2/9] Add a11y-no-aria-label-misuse to rules README --- javascript/packages/linter/docs/rules/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index a506bccaf..b9c590296 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -7,6 +7,7 @@ This page contains documentation for all Herb Linter rules. #### Accessibility - [`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 From 0abc3d49684db864ce29fe8ba72dc6fdc801c09d Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 8 May 2026 15:41:29 -0600 Subject: [PATCH 3/9] Update CLI test snapshots for new rule count --- .../test/__snapshots__/cli.test.ts.snap | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index 1b38e8234..04fb24b07 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -111,7 +111,7 @@ test/fixtures/ignored.html.erb:8:8 Offenses 5 errors | 2 warnings (7 offenses across 1 file) Note 3 additional offenses reported (would have been ignored) Fixable 7 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > Excluded Files > skips excluded file in subdirectory with README.md project indicator 1`] = ` @@ -177,7 +177,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = ` @@ -202,7 +202,7 @@ test/fixtures/no-trailing-newline.html.erb:1:29 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 1 offense | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = ` @@ -266,7 +266,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:4 Checked 1 file Offenses 4 errors | 0 warnings (4 offenses across 1 file) Fixable 4 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > Ignores disabled rules 1`] = ` @@ -307,7 +307,7 @@ test/fixtures/ignored.html.erb:6:14 Checked 1 file Offenses 2 errors | 0 warnings | 3 ignored (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > allows tag.attributes in attribute position 1`] = ` @@ -346,7 +346,7 @@ test/fixtures/tag-attributes.html.erb:5:0 Checked 1 file Offenses 2 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > diplays only parsers errors if one is present 1`] = ` @@ -372,7 +372,7 @@ test/fixtures/parser-errors.html.erb:2:16 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > displays most violated rules with multiple offenses 1`] = ` @@ -587,7 +587,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:2 Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > displays rule offenses when showing all rules 1`] = ` @@ -684,7 +684,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0 Checked 1 file Offenses 4 errors | 2 warnings (6 offenses across 1 file) Fixable 6 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for bad file 1`] = ` @@ -723,7 +723,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = ` @@ -735,7 +735,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = ` @@ -795,7 +795,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = ` @@ -834,7 +834,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] = ` @@ -1038,7 +1038,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats simple output correctly 1`] = ` @@ -1057,7 +1057,7 @@ test/fixtures/test-file-simple.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats simple output for bad-file correctly 1`] = ` @@ -1076,7 +1076,7 @@ test/fixtures/bad-file.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats success output correctly 1`] = ` @@ -1088,7 +1088,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > handles boolean attributes 1`] = ` @@ -1100,7 +1100,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > handles multiple errors correctly 1`] = ` @@ -1135,7 +1135,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 1`] = ` @@ -1267,7 +1267,7 @@ test/fixtures/disabled-1.html.erb:14:19 Checked 1 file Offenses 2 errors | 6 warnings | 8 ignored (8 offenses across 1 file) Fixable 8 offenses | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 2`] = ` @@ -1470,7 +1470,7 @@ test/fixtures/disabled-2.html.erb:2:44 Checked 1 file Offenses 6 errors | 7 warnings | 5 ignored (13 offenses across 1 file) Fixable 13 offenses | 6 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > uses GitHub Actions format by default when GITHUB_ACTIONS is true 1`] = ` @@ -1530,5 +1530,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; From a9d8f6939a7856c6c86b9912650dae1d2e04cd08 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 5 Jun 2026 22:19:44 +0200 Subject: [PATCH 4/9] Apply suggestion Signed-off-by: Marco Roth --- .../packages/linter/src/rules/a11y-no-aria-label-misuse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b0eff7c12..3bb847b1c 100644 --- a/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts +++ b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts @@ -61,7 +61,7 @@ class NoAriaLabelMisuseVisitor extends BaseRuleVisitor { export class A11yNoAriaLabelMisuseRule extends ParserRule { static ruleName = "a11y-no-aria-label-misuse" - static introducedIn = this.version("0.9.4") + static introducedIn = this.version("unreleased") get defaultConfig(): FullRuleConfig { return { From c881c55527b003a80009f72ffe8228f5bf983c82 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 11:47:29 +0200 Subject: [PATCH 5/9] Update snapshots --- .../test/__snapshots__/cli.test.ts.snap | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index 119067d92..f726878e3 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -111,7 +111,7 @@ test/fixtures/ignored.html.erb:8:8 Offenses 5 errors | 2 warnings (7 offenses across 1 file) Note 3 additional offenses reported (would have been ignored) Fixable 7 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > Excluded Files > skips excluded file in subdirectory with README.md project indicator 1`] = ` @@ -177,7 +177,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = ` @@ -202,7 +202,7 @@ test/fixtures/no-trailing-newline.html.erb:1:29 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 1 offense | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = ` @@ -266,7 +266,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:4 Checked 1 file Offenses 4 errors | 0 warnings (4 offenses across 1 file) Fixable 4 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > Ignores disabled rules 1`] = ` @@ -307,7 +307,7 @@ test/fixtures/ignored.html.erb:6:14 Checked 1 file Offenses 2 errors | 0 warnings | 3 ignored (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > allows tag.attributes in attribute position 1`] = ` @@ -346,7 +346,7 @@ test/fixtures/tag-attributes.html.erb:5:0 Checked 1 file Offenses 2 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > diplays only parsers errors if one is present 1`] = ` @@ -372,7 +372,7 @@ test/fixtures/parser-errors.html.erb:2:16 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 0 offenses - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > displays most violated rules with multiple offenses 1`] = ` @@ -587,7 +587,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:7 Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > displays rule offenses when showing all rules 1`] = ` @@ -684,7 +684,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0 Checked 1 file Offenses 4 errors | 2 warnings (6 offenses across 1 file) Fixable 6 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for bad file 1`] = ` @@ -723,7 +723,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = ` @@ -735,7 +735,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = ` @@ -795,7 +795,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = ` @@ -834,7 +834,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] = ` @@ -1038,7 +1038,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats simple output correctly 1`] = ` @@ -1057,7 +1057,7 @@ test/fixtures/test-file-simple.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats simple output for bad-file correctly 1`] = ` @@ -1076,7 +1076,7 @@ test/fixtures/bad-file.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > formats success output correctly 1`] = ` @@ -1088,7 +1088,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > handles boolean attributes 1`] = ` @@ -1100,7 +1100,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > handles multiple errors correctly 1`] = ` @@ -1135,7 +1135,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 1`] = ` @@ -1267,7 +1267,7 @@ test/fixtures/disabled-1.html.erb:14:19 Checked 1 file Offenses 2 errors | 6 warnings | 8 ignored (8 offenses across 1 file) Fixable 8 offenses | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 2`] = ` @@ -1470,7 +1470,7 @@ test/fixtures/disabled-2.html.erb:2:44 Checked 1 file Offenses 6 errors | 7 warnings | 5 ignored (13 offenses across 1 file) Fixable 13 offenses | 6 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; exports[`CLI Output Formatting > uses GitHub Actions format by default when GITHUB_ACTIONS is true 1`] = ` @@ -1530,5 +1530,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 14 not enabled" + Rules 83 enabled | 15 not enabled" `; From 5ce78ea928295065f36f25df2c99e074cc8fddc0 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 11:54:48 +0200 Subject: [PATCH 6/9] Cleanup --- .../src/rules/a11y-no-aria-label-misuse.ts | 54 ++++++++++-------- .../rules/a11y-no-aria-label-misuse.test.ts | 57 ++++++++++++++++--- 2 files changed, 79 insertions(+), 32 deletions(-) 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 index 3bb847b1c..9dd5fe952 100644 --- a/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts +++ b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts @@ -1,11 +1,12 @@ -import { type ParseResult, type ParserOptions, type HTMLElementNode, getTagLocalName, getStaticAttributeValue, hasAttribute } from "@herb-tools/core" - -import { BaseRuleVisitor } from "./rule-utils.js" import { ParserRule } from "../types.js" +import { BaseRuleVisitor } from "./rule-utils.js" + +import { getTagLocalName, getStaticAttributeValue, hasAttribute } 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", "i", "p", "b", "code"] // https://w3c.github.io/aria/#namefromprohibited @@ -28,34 +29,39 @@ const ROLES_WHICH_CANNOT_BE_NAMED = [ "time", ] -const MESSAGE = - "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." +const MESSAGE = "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." 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 && this.hasAriaLabelAttribute(node)) { - if (NAME_RESTRICTED_ELEMENTS.includes(tagName)) { - this.addOffense(MESSAGE, node.tag_name!.location) - } else if (GENERIC_ELEMENTS.includes(tagName)) { - const role = getStaticAttributeValue(node, "role") - - if (role) { - if (ROLES_WHICH_CANNOT_BE_NAMED.includes(role)) { - this.addOffense(MESSAGE, node.tag_name!.location) - } - } else { - this.addOffense(MESSAGE, node.tag_name!.location) - } - } + if (!tagName) return + if (!hasAttribute(node, "aria-label") && !hasAttribute(node, "aria-labelledby")) return + + if (NAME_RESTRICTED_ELEMENTS.includes(tagName)) { + this.addOffense(MESSAGE, node.tag_name!.location) + return } - super.visitHTMLElementNode(node) - } + if (!GENERIC_ELEMENTS.includes(tagName)) return + + const role = getStaticAttributeValue(node, "role") - private hasAriaLabelAttribute(node: HTMLElementNode): boolean { - return hasAttribute(node, "aria-label") || hasAttribute(node, "aria-labelledby") + if (!hasAttribute(node, "role")) { + this.addOffense(MESSAGE, node.tag_name!.location) + return + } + + if (role === null) return + + if (ROLES_WHICH_CANNOT_BE_NAMED.includes(role)) { + this.addOffense(MESSAGE, node.tag_name!.location) + } } } diff --git a/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts index 135eef244..2cc9c35fb 100644 --- a/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts +++ b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts @@ -4,11 +4,9 @@ import { createLinterTest } from "../helpers/linter-test-helper.js" const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(A11yNoAriaLabelMisuseRule) -const MESSAGE = - "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." +const MESSAGE = "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." describe("a11y-no-aria-label-misuse", () => { - // Correct usage: interactive elements test("passes for button with aria-label", () => { expectNoOffenses('') }) @@ -29,7 +27,6 @@ describe("a11y-no-aria-label-misuse", () => { expectNoOffenses('') }) - // Correct usage: elements without aria-label test("passes for span without aria-label", () => { expectNoOffenses("Hello") }) @@ -42,7 +39,6 @@ describe("a11y-no-aria-label-misuse", () => { expectNoOffenses("

Page title

") }) - // Correct usage: generic elements with permitted role test("passes for div with role=dialog and aria-labelledby", () => { expectNoOffenses('

Heading

') }) @@ -55,7 +51,6 @@ describe("a11y-no-aria-label-misuse", () => { expectNoOffenses('
Warning message
') }) - // Incorrect usage: name-restricted elements test("fails for h1 with aria-label", () => { expectWarning(MESSAGE) assertOffenses('

Page title

') @@ -111,7 +106,6 @@ describe("a11y-no-aria-label-misuse", () => { assertOffenses('Code') }) - // Incorrect usage: generic elements without role test("fails for span with aria-label and no role", () => { expectWarning(MESSAGE) assertOffenses('Text') @@ -122,7 +116,6 @@ describe("a11y-no-aria-label-misuse", () => { assertOffenses('
Content
') }) - // Incorrect usage: generic elements with prohibited role test("fails for div with role=none and aria-label", () => { expectWarning(MESSAGE) assertOffenses('
Content
') @@ -137,4 +130,52 @@ describe("a11y-no-aria-label-misuse", () => { expectWarning(MESSAGE) assertOffenses('
Content
') }) + + test("fails for p with aria-labelledby", () => { + expectWarning(MESSAGE) + assertOffenses('

Paragraph

') + }) + + test("fails for span with aria-labelledby and no role", () => { + expectWarning(MESSAGE) + assertOffenses('Text') + }) + + test("passes for div with dynamic role and aria-label", () => { + expectNoOffenses('
Content
') + }) + + test("passes for nav with aria-label", () => { + expectNoOffenses('') + }) + + test("passes for section with aria-labelledby", () => { + expectNoOffenses('

Title

') + }) + + test("passes for form with aria-label", () => { + expectNoOffenses('
') + }) + + test("passes for img with aria-label", () => { + expectNoOffenses('') + }) + + test("fails for tag.h1 with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('<%= tag.h1 aria_label: "Override" %>') + }) + + test("fails for tag.span with aria-label and no role", () => { + expectWarning(MESSAGE) + assertOffenses('<%= tag.span aria_label: "Tooltip" %>') + }) + + test("passes for tag.div with permitted role and aria-label", () => { + expectNoOffenses('<%= tag.div role: "dialog", aria_label: "Modal" %>') + }) + + test("passes for tag.button with aria-label", () => { + expectNoOffenses('<%= tag.button aria_label: "Close" %>') + }) }) From cd122dad146dcf5c77247edad3c57b6469374c17 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 12:55:42 +0200 Subject: [PATCH 7/9] Add generic and tooltip --- .../src/rules/a11y-no-aria-label-misuse.ts | 2 ++ .../rules/a11y-no-aria-label-misuse.test.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) 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 index 9dd5fe952..72c1436ed 100644 --- a/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts +++ b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts @@ -16,6 +16,7 @@ const ROLES_WHICH_CANNOT_BE_NAMED = [ "definition", "deletion", "emphasis", + "generic", "insertion", "mark", "none", @@ -27,6 +28,7 @@ const ROLES_WHICH_CANNOT_BE_NAMED = [ "superscript", "term", "time", + "tooltip", ] const MESSAGE = "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." diff --git a/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts index 2cc9c35fb..b1faed2cc 100644 --- a/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts +++ b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts @@ -178,4 +178,28 @@ describe("a11y-no-aria-label-misuse", () => { test("passes for tag.button with aria-label", () => { expectNoOffenses('<%= tag.button aria_label: "Close" %>') }) + + test("fails for div with role=generic and aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('
Content
') + }) + + test("fails for span with role=tooltip and aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('Content') + }) + + test("passes for span with role=img and aria-label", () => { + expectNoOffenses('') + }) + + test("reports offense for both aria-label and aria-labelledby on same element", () => { + expectWarning(MESSAGE) + assertOffenses('') + }) + + test("fails for hard-banned element even with dynamic role", () => { + expectWarning(MESSAGE) + assertOffenses('

Text

') + }) }) From 5240db370e29dd1c6bcca1ca78e1a859c12bb600 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 19:03:08 +0200 Subject: [PATCH 8/9] Add `em` element --- .../packages/linter/docs/rules/a11y-no-aria-label-misuse.md | 2 +- .../packages/linter/src/rules/a11y-no-aria-label-misuse.ts | 2 +- .../linter/test/rules/a11y-no-aria-label-misuse.test.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) 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 index c0421908f..dfb8f178c 100644 --- a/javascript/packages/linter/docs/rules/a11y-no-aria-label-misuse.md +++ b/javascript/packages/linter/docs/rules/a11y-no-aria-label-misuse.md @@ -10,7 +10,7 @@ Enforce that `aria-label` and `aria-labelledby` are only used on interactive ele `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`, `i`, `p`, `b`, or `code`. +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 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 index 72c1436ed..4f7efd439 100644 --- a/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts +++ b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts @@ -7,7 +7,7 @@ import type { ParseResult, ParserOptions, HTMLElementNode } from "@herb-tools/co import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" const GENERIC_ELEMENTS = ["span", "div"] -const NAME_RESTRICTED_ELEMENTS = ["h1", "h2", "h3", "h4", "h5", "h6", "strong", "i", "p", "b", "code"] +const NAME_RESTRICTED_ELEMENTS = ["h1", "h2", "h3", "h4", "h5", "h6", "strong", "em", "i", "p", "b", "code"] // https://w3c.github.io/aria/#namefromprohibited const ROLES_WHICH_CANNOT_BE_NAMED = [ diff --git a/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts index b1faed2cc..76ddb2007 100644 --- a/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts +++ b/javascript/packages/linter/test/rules/a11y-no-aria-label-misuse.test.ts @@ -202,4 +202,9 @@ describe("a11y-no-aria-label-misuse", () => { expectWarning(MESSAGE) assertOffenses('

Text

') }) + + test("fails for em with aria-label", () => { + expectWarning(MESSAGE) + assertOffenses('Emphasis text') + }) }) From fba2c05f3ecb558e8597ce3fcceb05b51a5aa8f6 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 7 Jun 2026 11:32:34 +0200 Subject: [PATCH 9/9] Improve linter messages --- .../src/rules/a11y-no-aria-label-misuse.ts | 48 +++++--- .../rules/a11y-no-aria-label-misuse.test.ts | 109 +++++++++--------- 2 files changed, 84 insertions(+), 73 deletions(-) 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 index 4f7efd439..0130a3a13 100644 --- a/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts +++ b/javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts @@ -1,13 +1,14 @@ import { ParserRule } from "../types.js" import { BaseRuleVisitor } from "./rule-utils.js" -import { getTagLocalName, getStaticAttributeValue, hasAttribute } from "@herb-tools/core" +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 = [ @@ -31,8 +32,6 @@ const ROLES_WHICH_CANNOT_BE_NAMED = [ "tooltip", ] -const MESSAGE = "`aria-label` and `aria-labelledby` usage are only reliably supported on interactive elements and a subset of ARIA roles." - class NoAriaLabelMisuseVisitor extends BaseRuleVisitor { visitHTMLElementNode(node: HTMLElementNode): void { this.checkElement(node) @@ -41,28 +40,41 @@ class NoAriaLabelMisuseVisitor extends BaseRuleVisitor { private checkElement(node: HTMLElementNode): void { const tagName = getTagLocalName(node) - if (!tagName) return - if (!hasAttribute(node, "aria-label") && !hasAttribute(node, "aria-labelledby")) return - if (NAME_RESTRICTED_ELEMENTS.includes(tagName)) { - this.addOffense(MESSAGE, node.tag_name!.location) - return - } + for (const attributeName of LABEL_ATTRIBUTES) { + const attribute = getAttribute(node, attributeName) + if (!attribute) continue - if (!GENERIC_ELEMENTS.includes(tagName)) return + 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, + ) - const role = getStaticAttributeValue(node, "role") + continue + } - if (!hasAttribute(node, "role")) { - this.addOffense(MESSAGE, node.tag_name!.location) - return - } + 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 \`') @@ -52,99 +50,125 @@ describe("a11y-no-aria-label-misuse", () => { }) test("fails for h1 with aria-label", () => { - expectWarning(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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(MESSAGE) + 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 `