Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` `alt` attributes

- [`a11y-svg-has-accessible-text`](./a11y-svg-has-accessible-text.md) - Require accessible text on `<svg>` elements

#### Action View

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Linter Rule: SVG must have accessible text

**Rule:** `a11y-svg-has-accessible-text`

## Description

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"`.

## 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
<svg aria-label="Company logo">
<path d="..." />
</svg>
```

```erb
<svg aria-labelledby="chart-title">
<title id="chart-title">Monthly sales chart</title>
<path d="..." />
</svg>
```

```erb
<svg>
<title>Search icon</title>
<path d="..." />
</svg>
```

```erb
<svg aria-hidden="true">
<path d="..." />
</svg>
```

### 🚫 Bad

```erb
<svg>
<path d="..." />
</svg>
```

```erb
<svg class="icon">
<use href="#icon-search" />
</svg>
```

## 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)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -112,6 +113,7 @@ export const rules: RuleClass[] = [
A11yNoAriaUnsupportedElementsRule,
A11yNoAutofocusAttributeRule,
A11yNoRedundantImageAltRule,
A11ySVGHasAccessibleTextRule,

ActionViewNoSilentHelperRule,
ActionViewNoSilentRenderRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
"`<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\"`.",
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<ParserOptions> {
return {
action_view_helpers: true
}
}

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
const visitor = new SvgHasAccessibleTextVisitor(this.ruleName, context)

visitor.visit(result.value)

return visitor.offenses
}
}
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 22 additions & 22 deletions javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading