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
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` `alt` attributes
Expand Down
62 changes: 62 additions & 0 deletions javascript/packages/linter/docs/rules/a11y-no-aria-label-misuse.md
Original file line number Diff line number Diff line change
@@ -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
<button aria-label="Close">
<svg src="closeIcon"></svg>
</button>
```

```erb
<span>Hello</span>
```

```erb
<div role="dialog" aria-labelledby="dialogHeading">
<h1 id="dialogHeading">Heading</h1>
</div>
```

```erb
<a href="/about" aria-label="About us">About</a>
```

### 🚫 Bad

```erb
<span aria-label="This is a tooltip">I am some text.</span>
```

```erb
<div aria-labelledby="heading1">Goodbye</div>
```

```erb
<h1 aria-label="This will override the page title completely">Page title</h1>
```

```erb
<div role="none" aria-label="Hidden">Content</div>
```

## 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/)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -110,6 +111,7 @@ export const rules: RuleClass[] = [
A11yAvoidGenericLinkTextRule,
A11yDisabledAttributeRule,
A11yNoAccesskeyAttributeRule,
A11yNoAriaLabelMisuseRule,
A11yNoAriaUnsupportedElementsRule,
A11yNoAutofocusAttributeRule,
A11yNoRedundantImageAltRule,
Expand Down
106 changes: 106 additions & 0 deletions javascript/packages/linter/src/rules/a11y-no-aria-label-misuse.ts
Original file line number Diff line number Diff line change
@@ -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 \`<button>\` or \`<a>\` instead.`,
attribute.location,
)

continue
}

const role = getStaticAttributeValue(node, "role")
if (role === null) continue

if (ROLES_WHICH_CANNOT_BE_NAMED.includes(role)) {
this.addOffense(
`The \`${attributeName}\` attribute on \`<${tagName}>\` is not allowed with ARIA role \`${role}\` because that role cannot be named. Change the \`role\` to one that supports naming, or remove the \`${attributeName}\` attribute.`,
attribute.location,
)
}
}
}
}

export class A11yNoAriaLabelMisuseRule extends ParserRule {
static ruleName = "a11y-no-aria-label-misuse"
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 NoAriaLabelMisuseVisitor(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
@@ -1,6 +1,7 @@
export * from "./a11y-avoid-generic-link-text.js"
export * from "./a11y-disabled-attribute.js"
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"
export * from "./a11y-no-redundant-image-alt.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