Skip to content

Commit 6ed505f

Browse files
authored
Linter: Implement html-no-abstract-roles rule (#1164)
Closes #173
1 parent 61b3235 commit 6ed505f

8 files changed

Lines changed: 248 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ This page contains documentation for all Herb Linter rules.
4040
- [`html-input-require-autocomplete`](./html-input-require-autocomplete.md) - Require `autocomplete` attributes on `<input>` tags.
4141
- [`html-img-require-alt`](./html-img-require-alt.md) - Requires `alt` attributes on `<img>` tags
4242
- [`html-navigation-has-label`](./html-navigation-has-label.md) - Navigation landmarks must have accessible labels
43+
- [`html-no-abstract-roles`](./html-no-abstract-roles.md) - No abstract ARIA roles
4344
- [`html-no-aria-hidden-on-body`](./html-no-aria-hidden-on-body.md) - No `aria-hidden` on `<body>`
4445
- [`html-no-aria-hidden-on-focusable`](./html-no-aria-hidden-on-focusable.md) - Focusable elements should not have `aria-hidden="true"`
4546
- [`html-no-block-inside-inline`](./html-no-block-inside-inline.md) - Prevents block-level elements inside inline elements
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Linter Rule: No abstract ARIA roles
2+
3+
**Rule:** `html-no-abstract-roles`
4+
5+
## Description
6+
7+
Prevent usage of WAI-ARIA abstract roles in the `role` attribute.
8+
9+
## Rationale
10+
11+
The WAI-ARIA specification defines a set of abstract roles that are used to support the ARIA Roles Model for the purpose of defining general role concepts.
12+
13+
Abstract roles are used for the ontology only. They exist to help organize the hierarchy of roles and define shared characteristics, but they are not meant to be used by authors directly. Using abstract roles in content provides no semantic meaning to assistive technologies and can lead to accessibility issues.
14+
15+
Authors **MUST NOT** use abstract roles in content. Instead, use one of the concrete roles that inherit from these abstract roles. For example, use `button` instead of `command`, or `navigation` instead of `landmark`.
16+
17+
The following abstract roles must not be used:
18+
19+
- `command`
20+
- `composite`
21+
- `input`
22+
- `landmark`
23+
- `range`
24+
- `roletype`
25+
- `section`
26+
- `sectionhead`
27+
- `select`
28+
- `structure`
29+
- `widget`
30+
- `window`
31+
32+
## Examples
33+
34+
### ✅ Good
35+
36+
```erb
37+
<div role="button">Push it</div>
38+
```
39+
40+
```erb
41+
<nav role="navigation">Menu</nav>
42+
```
43+
44+
```erb
45+
<div role="alert">Warning!</div>
46+
```
47+
48+
```erb
49+
<div role="slider" aria-valuenow="50">Volume</div>
50+
```
51+
52+
### 🚫 Bad
53+
54+
```erb
55+
<div role="window">Hello, world!</div>
56+
```
57+
58+
```erb
59+
<div role="widget">Content</div>
60+
```
61+
62+
```erb
63+
<div role="command">Action</div>
64+
```
65+
66+
```erb
67+
<div role="landmark">Navigation</div>
68+
```
69+
70+
## References
71+
72+
- [WAI-ARIA 1.0: Abstract Roles](https://www.w3.org/TR/wai-aria-1.0/roles#abstract_roles)
73+
- [WAI-ARIA 1.2: Abstract Roles](https://www.w3.org/TR/wai-aria-1.2/#abstract_roles)
74+
- [ember-template-lint: no-abstract-roles](https://github.com/ember-template-lint/ember-template-lint/blob/main/docs/rule/no-abstract-roles.md)

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { HTMLIframeHasTitleRule } from "./rules/html-iframe-has-title.js"
3939
import { HTMLImgRequireAltRule } from "./rules/html-img-require-alt.js"
4040
import { HTMLInputRequireAutocompleteRule } from "./rules/html-input-require-autocomplete.js"
4141
import { HTMLNavigationHasLabelRule } from "./rules/html-navigation-has-label.js"
42+
import { HTMLNoAbstractRolesRule } from "./rules/html-no-abstract-roles.js"
4243
import { HTMLNoAriaHiddenOnBodyRule } from "./rules/html-no-aria-hidden-on-body.js"
4344
import { HTMLNoAriaHiddenOnFocusableRule } from "./rules/html-no-aria-hidden-on-focusable.js"
4445
import { HTMLNoBlockInsideInlineRule } from "./rules/html-no-block-inside-inline.js"
@@ -99,6 +100,7 @@ export const rules: RuleClass[] = [
99100
HTMLImgRequireAltRule,
100101
HTMLInputRequireAutocompleteRule,
101102
HTMLNavigationHasLabelRule,
103+
HTMLNoAbstractRolesRule,
102104
HTMLNoAriaHiddenOnBodyRule,
103105
HTMLNoAriaHiddenOnFocusableRule,
104106
HTMLNoBlockInsideInlineRule,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { ParserRule } from "../types.js"
2+
import { AttributeVisitorMixin, ABSTRACT_ARIA_ROLES, StaticAttributeStaticValueParams } from "./rule-utils.js"
3+
4+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
5+
import type { ParseResult } from "@herb-tools/core"
6+
7+
class NoAbstractRolesVisitor extends AttributeVisitorMixin {
8+
protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
9+
if (attributeName !== "role") return
10+
if (!attributeValue) return
11+
12+
const normalizedValue = attributeValue.toLowerCase()
13+
14+
if (!ABSTRACT_ARIA_ROLES.has(normalizedValue)) return
15+
16+
this.addOffense(
17+
`The \`role\` attribute must not use abstract ARIA role \`${attributeValue}\`. Abstract roles are not meant to be used directly.`,
18+
attributeNode.location,
19+
)
20+
}
21+
}
22+
23+
export class HTMLNoAbstractRolesRule extends ParserRule {
24+
name = "html-no-abstract-roles"
25+
26+
get defaultConfig(): FullRuleConfig {
27+
return {
28+
enabled: true,
29+
severity: "error"
30+
}
31+
}
32+
33+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
34+
const visitor = new NoAbstractRolesVisitor(this.name, context)
35+
36+
visitor.visit(result.value)
37+
38+
return visitor.offenses
39+
}
40+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export * from "./html-iframe-has-title.js"
4040
export * from "./html-img-require-alt.js"
4141
export * from "./html-input-require-autocomplete.js"
4242
export * from "./html-navigation-has-label.js"
43+
export * from "./html-no-abstract-roles.js"
4344
export * from "./html-no-aria-hidden-on-body.js"
4445
export * from "./html-no-aria-hidden-on-focusable.js"
4546
export * from "./html-no-block-inside-inline.js"

javascript/packages/linter/src/rules/rule-utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,26 @@ export const VALID_ARIA_ROLES = new Set([
471471
"log", "marquee"
472472
]);
473473

474+
/**
475+
* Abstract ARIA roles used to support the WAI-ARIA Roles Model.
476+
* Authors MUST NOT use abstract roles in content.
477+
* @see https://www.w3.org/TR/wai-aria-1.0/roles#abstract_roles
478+
*/
479+
export const ABSTRACT_ARIA_ROLES = new Set([
480+
"command",
481+
"composite",
482+
"input",
483+
"landmark",
484+
"range",
485+
"roletype",
486+
"section",
487+
"sectionhead",
488+
"select",
489+
"structure",
490+
"widget",
491+
"window"
492+
]);
493+
474494
/**
475495
* Parameter types for AttributeVisitorMixin methods
476496
*/

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

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, test } from "vitest"
2+
import { HTMLNoAbstractRolesRule } from "../../src/rules/html-no-abstract-roles.js"
3+
import { createLinterTest } from "../helpers/linter-test-helper.js"
4+
5+
const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLNoAbstractRolesRule)
6+
7+
describe("html-no-abstract-roles", () => {
8+
test("passes for valid role attribute", () => {
9+
expectNoOffenses('<div role="button">Push it</div>')
10+
})
11+
12+
test("passes for valid landmark role", () => {
13+
expectNoOffenses('<nav role="navigation">Menu</nav>')
14+
})
15+
16+
test("passes for multiple valid roles", () => {
17+
expectNoOffenses('<div role="button"></div><div role="alert"></div>')
18+
})
19+
20+
test("passes for element without role", () => {
21+
expectNoOffenses('<div>Hello</div>')
22+
})
23+
24+
test("fails for abstract role: command", () => {
25+
expectError('The `role` attribute must not use abstract ARIA role `command`. Abstract roles are not meant to be used directly.')
26+
27+
assertOffenses('<div role="command">Content</div>')
28+
})
29+
30+
test("fails for abstract role: composite", () => {
31+
expectError('The `role` attribute must not use abstract ARIA role `composite`. Abstract roles are not meant to be used directly.')
32+
33+
assertOffenses('<div role="composite">Content</div>')
34+
})
35+
36+
test("fails for abstract role: input", () => {
37+
expectError('The `role` attribute must not use abstract ARIA role `input`. Abstract roles are not meant to be used directly.')
38+
39+
assertOffenses('<div role="input">Content</div>')
40+
})
41+
42+
test("fails for abstract role: landmark", () => {
43+
expectError('The `role` attribute must not use abstract ARIA role `landmark`. Abstract roles are not meant to be used directly.')
44+
45+
assertOffenses('<div role="landmark">Content</div>')
46+
})
47+
48+
test("fails for abstract role: range", () => {
49+
expectError('The `role` attribute must not use abstract ARIA role `range`. Abstract roles are not meant to be used directly.')
50+
51+
assertOffenses('<div role="range">Content</div>')
52+
})
53+
54+
test("fails for abstract role: roletype", () => {
55+
expectError('The `role` attribute must not use abstract ARIA role `roletype`. Abstract roles are not meant to be used directly.')
56+
57+
assertOffenses('<div role="roletype">Content</div>')
58+
})
59+
60+
test("fails for abstract role: section", () => {
61+
expectError('The `role` attribute must not use abstract ARIA role `section`. Abstract roles are not meant to be used directly.')
62+
63+
assertOffenses('<div role="section">Content</div>')
64+
})
65+
66+
test("fails for abstract role: sectionhead", () => {
67+
expectError('The `role` attribute must not use abstract ARIA role `sectionhead`. Abstract roles are not meant to be used directly.')
68+
69+
assertOffenses('<div role="sectionhead">Content</div>')
70+
})
71+
72+
test("fails for abstract role: select", () => {
73+
expectError('The `role` attribute must not use abstract ARIA role `select`. Abstract roles are not meant to be used directly.')
74+
75+
assertOffenses('<div role="select">Content</div>')
76+
})
77+
78+
test("fails for abstract role: structure", () => {
79+
expectError('The `role` attribute must not use abstract ARIA role `structure`. Abstract roles are not meant to be used directly.')
80+
81+
assertOffenses('<div role="structure">Content</div>')
82+
})
83+
84+
test("fails for abstract role: widget", () => {
85+
expectError('The `role` attribute must not use abstract ARIA role `widget`. Abstract roles are not meant to be used directly.')
86+
87+
assertOffenses('<div role="widget">Content</div>')
88+
})
89+
90+
test("fails for abstract role: window", () => {
91+
expectError('The `role` attribute must not use abstract ARIA role `window`. Abstract roles are not meant to be used directly.')
92+
93+
assertOffenses('<div role="window">Hello, world!</div>')
94+
})
95+
96+
test("handles uppercase abstract role", () => {
97+
expectError('The `role` attribute must not use abstract ARIA role `WINDOW`. Abstract roles are not meant to be used directly.')
98+
99+
assertOffenses('<div role="WINDOW">Content</div>')
100+
})
101+
102+
test("handles mixed case abstract role", () => {
103+
expectError('The `role` attribute must not use abstract ARIA role `Widget`. Abstract roles are not meant to be used directly.')
104+
105+
assertOffenses('<div role="Widget">Content</div>')
106+
})
107+
})

0 commit comments

Comments
 (0)