Skip to content
Draft
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 @@ -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) - Disallow misuse of `aria-label` and `aria-labelledby`
- [`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

Expand Down
71 changes: 71 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,71 @@
# Linter Rule: No ARIA label misuse

**Rule:** `a11y-no-aria-label-misuse`

## Description

Disallow misuse of `aria-label` and `aria-labelledby` on elements where accessible names are not reliably supported.

## Rationale

`aria-label` and `aria-labelledby` should not be used as a generic replacement for visible text. These attributes are only dependable on elements that support author-provided accessible names.

This rule:

- disallows these attributes on `h1`-`h6`, `strong`, `i`, `p`, `b`, and `code`
- requires `div` and `span` to have a permitted ARIA `role`
- disallows `div` and `span` roles that cannot be named

When the `role` value is dynamic and cannot be determined statically, the rule does not report an offense.

## Examples

### ✅ Good

```erb
<button aria-label="Close">
<svg></svg>
</button>
```

```erb
<a aria-labelledby="details-heading" href="/details">
Open
</a>
```

```erb
<div role="dialog" aria-labelledby="dialog-heading">
<h1 id="dialog-heading">Dialog title</h1>
</div>
```

```erb
<span role="img" aria-label="Warning"></span>
```

### 🚫 Bad

```erb
<span aria-label="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
<span role="presentation" aria-label="Decorative icon"></span>
```

## References

- [erblint-github: `GitHub::Accessibility::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)
- [WAI-ARIA: 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/)
- [Primer: Tooltip alternatives](https://primer.style/design/accessibility/tooltip-alternatives)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -104,6 +105,7 @@ import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.

export const rules: RuleClass[] = [
A11yNoAccesskeyAttributeRule,
A11yNoAriaLabelMisuseRule,
A11yNoAriaUnsupportedElementsRule,
A11yNoAutofocusAttributeRule,

Expand Down
160 changes: 160 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,160 @@
import { ParserRule } from "../types.js";
import {
AttributeVisitorMixin,
StaticAttributeDynamicValueParams,
StaticAttributeStaticValueParams,
} from "./rule-utils.js";
import {
findAttributeByName,
getAttributes,
getStaticAttributeValue,
getTagLocalName,
} from "@herb-tools/core";

import type {
FullRuleConfig,
LintContext,
UnboundLintOffense,
} from "../types.js";
import type {
HTMLAttributeNode,
HTMLOpenTagNode,
ParserOptions,
ParseResult,
} from "@herb-tools/core";

const TARGET_ATTRIBUTES = new Set(["aria-label", "aria-labelledby"]);
const NAMEABLE_CONTAINER_ELEMENTS = new Set(["div", "span"]);
const NEVER_ALLOWED_ELEMENTS = new Set([
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"strong",
"i",
"p",
"b",
"code",
]);

const NAME_FROM_PROHIBITED_ROLES = new Set([
"caption",
"code",
"definition",
"deletion",
"emphasis",
"generic",
"insertion",
"mark",
"none",
"paragraph",
"presentation",
"strong",
"subscript",
"suggestion",
"superscript",
"term",
"time",
]);

class NoAriaLabelMisuseVisitor extends AttributeVisitorMixin {
protected checkStaticAttributeStaticValue({
attributeName,
attributeNode,
parentNode,
}: StaticAttributeStaticValueParams): void {
this.checkAttributeUsage(attributeName, attributeNode, parentNode);
}

protected checkStaticAttributeDynamicValue({
attributeName,
attributeNode,
parentNode,
}: StaticAttributeDynamicValueParams): void {
this.checkAttributeUsage(attributeName, attributeNode, parentNode);
}

private checkAttributeUsage(
attributeName: string,
attributeNode: HTMLAttributeNode,
node: HTMLOpenTagNode,
): void {
if (!TARGET_ATTRIBUTES.has(attributeName)) return;

const tagName = getTagLocalName(node);
if (!tagName) return;

if (NEVER_ALLOWED_ELEMENTS.has(tagName)) {
this.addOffense(
`The \`${attributeName}\` attribute must not be used on the \`<${tagName}>\` element.`,
attributeNode.location,
);
return;
}

if (!NAMEABLE_CONTAINER_ELEMENTS.has(tagName)) return;

const roleAttribute = findAttributeByName(getAttributes(node), "role");
if (!roleAttribute) {
this.addOffense(
`The \`${attributeName}\` attribute on \`<${tagName}>\` requires a permitted ARIA \`role\`.`,
attributeNode.location,
);
return;
}

if (this.hasDynamicRole(roleAttribute)) return;

const roleValue = getStaticAttributeValue(roleAttribute)?.trim()
.toLowerCase().split(/\s+/)[0];
if (!roleValue) {
this.addOffense(
`The \`${attributeName}\` attribute on \`<${tagName}>\` requires a permitted ARIA \`role\`.`,
attributeNode.location,
);
return;
}

if (NAME_FROM_PROHIBITED_ROLES.has(roleValue)) {
this.addOffense(
`The \`${attributeName}\` attribute on \`<${tagName}>\` is not allowed with ARIA role \`${roleValue}\` because that role cannot be named.`,
attributeNode.location,
);
}
}

private hasDynamicRole(roleAttribute: HTMLAttributeNode): boolean {
return getStaticAttributeValue(roleAttribute) === null;
}
}

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,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"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import dedent from "dedent"
import { describe, test } from "vitest"

import { createLinterTest } from "../helpers/linter-test-helper.js"
import { A11yNoAriaLabelMisuseRule } from "../../src/rules/a11y-no-aria-label-misuse.js"

const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(A11yNoAriaLabelMisuseRule)

describe("a11y-no-aria-label-misuse", () => {
test("passes for button with aria-label", () => {
expectNoOffenses(`<button aria-label="Close">X</button>`)
})

test("passes for anchor with aria-labelledby", () => {
expectNoOffenses(`<a href="/details" aria-labelledby="details-heading">Open</a>`)
})

test("passes for div with permitted role", () => {
expectNoOffenses(`<div role="dialog" aria-labelledby="dialog-heading"></div>`)
})

test("passes for span with permitted role", () => {
expectNoOffenses(`<span role="img" aria-label="Warning"></span>`)
})

test("passes for div with dynamic role", () => {
expectNoOffenses(`<div role="<%= dialog_role %>" aria-label="Dialog"></div>`)
})

test("passes when target attributes are absent", () => {
expectNoOffenses(`<span>Hello</span>`)
})

test("fails for span without role", () => {
expectWarning("The `aria-label` attribute on `<span>` requires a permitted ARIA `role`.")
assertOffenses(`<span aria-label="Tooltip">I am some text.</span>`)
})

test("fails for div without role", () => {
expectWarning("The `aria-labelledby` attribute on `<div>` requires a permitted ARIA `role`.")
assertOffenses(`<div aria-labelledby="heading1">Goodbye</div>`)
})

test("fails for hard-banned heading elements", () => {
expectWarning("The `aria-label` attribute must not be used on the `<h1>` element.")
assertOffenses(`<h1 aria-label="This will override the page title completely">Page title</h1>`)
})

test("fails for paragraph elements", () => {
expectWarning("The `aria-labelledby` attribute must not be used on the `<p>` element.")
assertOffenses(`<p aria-labelledby="description">Paragraph</p>`)
})

test("fails for code-style inline elements", () => {
expectWarning("The `aria-label` attribute must not be used on the `<i>` element.")
assertOffenses(`<i aria-label="Close"></i>`)
})

test("fails for prohibited generic role", () => {
expectWarning("The `aria-label` attribute on `<div>` is not allowed with ARIA role `generic` because that role cannot be named.")
assertOffenses(`<div role="generic" aria-label="Dialog"></div>`)
})

test("fails for prohibited presentation role", () => {
expectWarning("The `aria-labelledby` attribute on `<span>` is not allowed with ARIA role `presentation` because that role cannot be named.")
assertOffenses(`<span role="presentation" aria-labelledby="label-id"></span>`)
})

test("reports one offense per offending attribute", () => {
expectWarning("The `aria-label` attribute on `<span>` requires a permitted ARIA `role`.")
expectWarning("The `aria-labelledby` attribute on `<span>` requires a permitted ARIA `role`.")

assertOffenses(`<span aria-label="Tooltip" aria-labelledby="tooltip-label"></span>`)
})

test("still fails on hard-banned tags when role is dynamic", () => {
expectWarning("The `aria-label` attribute must not be used on the `<p>` element.")

assertOffenses(dedent`
<p role="<%= role_name %>" aria-label="Description">
Text
</p>
`)
})
})
Loading