diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 51261ae61..ba1220b87 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -12,7 +12,7 @@ This page contains documentation for all Herb Linter rules. - [`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 - +- [`a11y-svg-has-accessible-text`](./a11y-svg-has-accessible-text.md) - Require accessible text on `` elements #### Action View diff --git a/javascript/packages/linter/docs/rules/a11y-svg-has-accessible-text.md b/javascript/packages/linter/docs/rules/a11y-svg-has-accessible-text.md new file mode 100644 index 000000000..8c0155d45 --- /dev/null +++ b/javascript/packages/linter/docs/rules/a11y-svg-has-accessible-text.md @@ -0,0 +1,60 @@ +# Linter Rule: SVG must have accessible text + +**Rule:** `a11y-svg-has-accessible-text` + +## Description + +Enforce that `` elements have accessible text via `aria-label`, `aria-labelledby`, or a nested `` element. Decorative SVGs should be hidden with `aria-hidden="true"`. + +## Rationale + +SVG images without accessible text are invisible to screen readers and other assistive technologies. Every meaningful SVG should have a text alternative so that non-sighted users understand its purpose. Decorative SVGs that convey no meaning should be explicitly hidden with `aria-hidden="true"` to avoid confusing assistive technology users. + +## Examples + +### ✅ Good + +```erb + + + +``` + +```erb + + Monthly sales chart + + +``` + +```erb + + Search icon + + +``` + +```erb + +``` + +### 🚫 Bad + +```erb + + + +``` + +```erb + + + +``` + +## References + +- [WAI: SVG Accessibility](https://www.w3.org/wiki/SVG_Accessibility) +- [Deque: svg-img-alt](https://dequeuniversity.com/rules/axe/4.10/svg-img-alt) diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index bfffe7d4e..672a06563 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -6,6 +6,7 @@ import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribut 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" +import { A11ySVGHasAccessibleTextRule } from "./rules/a11y-svg-has-accessible-text.js" import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js" import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js" @@ -112,6 +113,7 @@ export const rules: RuleClass[] = [ A11yNoAriaUnsupportedElementsRule, A11yNoAutofocusAttributeRule, A11yNoRedundantImageAltRule, + A11ySVGHasAccessibleTextRule, ActionViewNoSilentHelperRule, ActionViewNoSilentRenderRule, diff --git a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts new file mode 100644 index 000000000..03305d261 --- /dev/null +++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts @@ -0,0 +1,71 @@ +import { ParserRule } from "../types.js" +import { BaseRuleVisitor } from "./rule-utils.js" + +import { hasAttribute, getStaticAttributeValue, getTagLocalName, isHTMLElementNode } from "@herb-tools/core" + +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" +import type { HTMLElementNode, ParseResult, ParserOptions } from "@herb-tools/core" + +class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { + visitHTMLElementNode(node: HTMLElementNode): void { + this.checkSVGElement(node) + super.visitHTMLElementNode(node) + } + + private checkSVGElement(node: HTMLElementNode): void { + const tagName = getTagLocalName(node) + + if (tagName !== "svg") return + if (this.hasAriaHidden(node)) return + + if (hasAttribute(node, "aria-label")) return + if (hasAttribute(node, "aria-labelledby")) return + if (this.hasDirectTitleChild(node)) return + + this.addOffense( + "`` must have accessible text. Set `aria-label`, or `aria-labelledby`, or nest a `` element. If the `` is decorative, hide it with `aria-hidden=\"true\"`.", + node.tag_name!.location, + ) + } + + private hasAriaHidden(node: HTMLElementNode): boolean { + if (!hasAttribute(node, "aria-hidden")) return false + + const value = getStaticAttributeValue(node, "aria-hidden") + if (value === null) return true + + return value === "true" + } + + private hasDirectTitleChild(node: HTMLElementNode): boolean { + const children = node.body ?? [] + + return children.some(child => isHTMLElementNode(child) && getTagLocalName(child) === "title") + } +} + +export class A11ySVGHasAccessibleTextRule extends ParserRule { + static ruleName = "a11y-svg-has-accessible-text" + static introducedIn = this.version("unreleased") + + 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 SvgHasAccessibleTextVisitor(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 c9dba8b6d..0e641638c 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -4,6 +4,7 @@ export * from "./a11y-no-accesskey-attribute.js" export * from "./a11y-no-aria-unsupported-elements.js" export * from "./a11y-no-autofocus-attribute.js" export * from "./a11y-no-redundant-image-alt.js" +export * from "./a11y-svg-has-accessible-text.js" export * from "./rule-utils.js" export * from "./prism-rule-utils.js" diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index 363f8c04b..119067d92 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 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 | 13 not enabled" + Rules 83 enabled | 14 not enabled" `; diff --git a/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts b/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts new file mode 100644 index 000000000..0ffc549a9 --- /dev/null +++ b/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts @@ -0,0 +1,110 @@ +import { describe, test } from "vitest" +import { A11ySVGHasAccessibleTextRule } from "../../src/rules/a11y-svg-has-accessible-text.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(A11ySVGHasAccessibleTextRule) + +const offenseMessage = '`` must have accessible text. Set `aria-label`, or `aria-labelledby`, or nest a `` element. If the `` is decorative, hide it with `aria-hidden="true"`.' + +describe("a11y-svg-has-accessible-text", () => { + test("passes for svg with aria-label", () => { + expectNoOffenses('') + }) + + test("passes for svg with aria-labelledby", () => { + expectNoOffenses('') + }) + + test("passes for svg with nested title element", () => { + expectNoOffenses('A circle') + }) + + test("passes for svg with aria-hidden=true", () => { + expectNoOffenses('') + }) + + test("fails for svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("fails for empty svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("fails for svg with aria-hidden=false", () => { + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("ignores non-svg elements", () => { + expectNoOffenses('
') + }) + + test("fails for multiple svg elements without accessible text", () => { + expectWarning(offenseMessage) + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("passes for svg with both aria-label and title", () => { + expectNoOffenses('A circle') + }) + + test("passes for svg with dynamic aria-label", () => { + expectNoOffenses('') + }) + + test("passes for svg with dynamic aria-labelledby", () => { + expectNoOffenses('') + }) + + test("passes for svg with dynamic aria-hidden=true", () => { + expectNoOffenses('') + }) + + test("passes for svg with ERB title child", () => { + expectNoOffenses('<%= title_text %>') + }) + + test("fails for title nested inside a group", () => { + expectWarning(offenseMessage) + assertOffenses('Nested title') + }) + + test("passes for svg with aria-hidden boolean attribute", () => { + expectNoOffenses('') + }) + + test("fails for tag.svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('<%= tag.svg %>') + }) + + test("passes for tag.svg with aria-label", () => { + expectNoOffenses('<%= tag.svg aria_label: "Icon" %>') + }) + + test("passes for tag.svg with aria-hidden", () => { + expectNoOffenses('<%= tag.svg aria_hidden: true %>') + }) + + test("fails for content_tag :svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('<%= content_tag :svg %>') + }) + + test("passes for content_tag :svg with aria-label", () => { + expectNoOffenses('<%= content_tag :svg, nil, "aria-label": "Icon" %>') + }) + + test("passes for tag.svg with tag.title child", () => { + expectNoOffenses('<%= tag.svg do %><%= tag.title "Icon" %><% end %>') + }) + + test("fails for tag.svg with tag.title nested in tag.g", () => { + expectWarning(offenseMessage) + assertOffenses('<%= tag.svg do %><%= tag.g do %><%= tag.title "Icon" %><% end %><% end %>') + }) +})