Skip to content

Commit 2a02ec4

Browse files
Linter: Implement a11y-svg-has-accessible-text rule (#1669)
Add a new linter rule that ensures `<svg>` elements have accessible text. The rule reports an offense when an `<svg>` is missing all of the following: - `aria-label` attribute - `aria-labelledby` attribute - A nested `<title>` child element SVGs marked as decorative with `aria-hidden="true"` are exempt. Based on the erblint-github `GitHub::Accessibility::SvgHasAccessibleText` rule. Closes #1226 --------- Signed-off-by: Marco Roth <marco.roth@intergga.ch> Co-authored-by: Marco Roth <marco.roth@intergga.ch>
1 parent f691c1f commit 2a02ec4

7 files changed

Lines changed: 267 additions & 23 deletions

File tree

javascript/packages/linter/docs/rules/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ This page contains documentation for all Herb Linter rules.
1212
- [`a11y-no-aria-unsupported-elements`](./a11y-no-aria-unsupported-elements.md) - Prevent usage of ARIA on unsupported elements
1313
- [`a11y-no-autofocus-attribute`](./a11y-no-autofocus-attribute.md) - Prevent usage of the `autofocus` attribute
1414
- [`a11y-no-redundant-image-alt`](./a11y-no-redundant-image-alt.md) - Prevent redundant words in `<img>` `alt` attributes
15-
15+
- [`a11y-svg-has-accessible-text`](./a11y-svg-has-accessible-text.md) - Require accessible text on `<svg>` elements
1616

1717
#### Action View
1818

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Linter Rule: SVG must have accessible text
2+
3+
**Rule:** `a11y-svg-has-accessible-text`
4+
5+
## Description
6+
7+
Enforce that `<svg>` elements have accessible text via `aria-label`, `aria-labelledby`, or a nested `<title>` element. Decorative SVGs should be hidden with `aria-hidden="true"`.
8+
9+
## Rationale
10+
11+
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.
12+
13+
## Examples
14+
15+
### ✅ Good
16+
17+
```erb
18+
<svg aria-label="Company logo">
19+
<path d="..." />
20+
</svg>
21+
```
22+
23+
```erb
24+
<svg aria-labelledby="chart-title">
25+
<title id="chart-title">Monthly sales chart</title>
26+
<path d="..." />
27+
</svg>
28+
```
29+
30+
```erb
31+
<svg>
32+
<title>Search icon</title>
33+
<path d="..." />
34+
</svg>
35+
```
36+
37+
```erb
38+
<svg aria-hidden="true">
39+
<path d="..." />
40+
</svg>
41+
```
42+
43+
### 🚫 Bad
44+
45+
```erb
46+
<svg>
47+
<path d="..." />
48+
</svg>
49+
```
50+
51+
```erb
52+
<svg class="icon">
53+
<use href="#icon-search" />
54+
</svg>
55+
```
56+
57+
## References
58+
59+
- [WAI: SVG Accessibility](https://www.w3.org/wiki/SVG_Accessibility)
60+
- [Deque: svg-img-alt](https://dequeuniversity.com/rules/axe/4.10/svg-img-alt)

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribut
66
import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js"
77
import { A11yNoAutofocusAttributeRule } from "./rules/a11y-no-autofocus-attribute.js"
88
import { A11yNoRedundantImageAltRule } from "./rules/a11y-no-redundant-image-alt.js"
9+
import { A11ySVGHasAccessibleTextRule } from "./rules/a11y-svg-has-accessible-text.js"
910

1011
import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js"
1112
import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js"
@@ -112,6 +113,7 @@ export const rules: RuleClass[] = [
112113
A11yNoAriaUnsupportedElementsRule,
113114
A11yNoAutofocusAttributeRule,
114115
A11yNoRedundantImageAltRule,
116+
A11ySVGHasAccessibleTextRule,
115117

116118
ActionViewNoSilentHelperRule,
117119
ActionViewNoSilentRenderRule,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { ParserRule } from "../types.js"
2+
import { BaseRuleVisitor } from "./rule-utils.js"
3+
4+
import { hasAttribute, getStaticAttributeValue, getTagLocalName, isHTMLElementNode } from "@herb-tools/core"
5+
6+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
7+
import type { HTMLElementNode, ParseResult, ParserOptions } from "@herb-tools/core"
8+
9+
class SvgHasAccessibleTextVisitor extends BaseRuleVisitor {
10+
visitHTMLElementNode(node: HTMLElementNode): void {
11+
this.checkSVGElement(node)
12+
super.visitHTMLElementNode(node)
13+
}
14+
15+
private checkSVGElement(node: HTMLElementNode): void {
16+
const tagName = getTagLocalName(node)
17+
18+
if (tagName !== "svg") return
19+
if (this.hasAriaHidden(node)) return
20+
21+
if (hasAttribute(node, "aria-label")) return
22+
if (hasAttribute(node, "aria-labelledby")) return
23+
if (this.hasDirectTitleChild(node)) return
24+
25+
this.addOffense(
26+
"`<svg>` must have accessible text. Set `aria-label`, or `aria-labelledby`, or nest a `<title>` element. If the `<svg>` is decorative, hide it with `aria-hidden=\"true\"`.",
27+
node.tag_name!.location,
28+
)
29+
}
30+
31+
private hasAriaHidden(node: HTMLElementNode): boolean {
32+
if (!hasAttribute(node, "aria-hidden")) return false
33+
34+
const value = getStaticAttributeValue(node, "aria-hidden")
35+
if (value === null) return true
36+
37+
return value === "true"
38+
}
39+
40+
private hasDirectTitleChild(node: HTMLElementNode): boolean {
41+
const children = node.body ?? []
42+
43+
return children.some(child => isHTMLElementNode(child) && getTagLocalName(child) === "title")
44+
}
45+
}
46+
47+
export class A11ySVGHasAccessibleTextRule extends ParserRule {
48+
static ruleName = "a11y-svg-has-accessible-text"
49+
static introducedIn = this.version("unreleased")
50+
51+
get defaultConfig(): FullRuleConfig {
52+
return {
53+
enabled: false,
54+
severity: "warning"
55+
}
56+
}
57+
58+
get parserOptions(): Partial<ParserOptions> {
59+
return {
60+
action_view_helpers: true
61+
}
62+
}
63+
64+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
65+
const visitor = new SvgHasAccessibleTextVisitor(this.ruleName, context)
66+
67+
visitor.visit(result.value)
68+
69+
return visitor.offenses
70+
}
71+
}

javascript/packages/linter/src/rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from "./a11y-no-accesskey-attribute.js"
44
export * from "./a11y-no-aria-unsupported-elements.js"
55
export * from "./a11y-no-autofocus-attribute.js"
66
export * from "./a11y-no-redundant-image-alt.js"
7+
export * from "./a11y-svg-has-accessible-text.js"
78

89
export * from "./rule-utils.js"
910
export * from "./prism-rule-utils.js"

javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

Lines changed: 22 additions & 22 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)