Skip to content

Commit 0372cab

Browse files
Linter: Implement a11y-nested-interactive-elements rule (#1674)
Implements the `a11y-nested-interactive-elements` rule from erblint-github's `NestedInteractiveElements`. This rule flags interactive elements (`<a>`, `<button>`, `<input>`, `<select>`, `<summary>`, `<textarea>`) that are nested inside other interactive elements. Nesting interactive elements produces invalid HTML, and assistive technologies, such as screen readers, might ignore or respond unexpectedly to such nested controls. Exceptions: - `<a>` inside `<summary>` is allowed - `<input type="hidden">` is not considered interactive The rule is disabled by default with a severity of `error`. Closes #281 Closes #1222 --------- Co-authored-by: Marco Roth <marco.roth@intergga.ch>
1 parent 17ffac1 commit 0372cab

8 files changed

Lines changed: 306 additions & 22 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This page contains documentation for all Herb Linter rules.
88

99
- [`a11y-avoid-generic-link-text`](./a11y-avoid-generic-link-text.md) - Avoid generic link text like "Click here" or "Read more"
1010
- [`a11y-disabled-attribute`](./a11y-disabled-attribute.md) - Prevent usage of the `disabled` attribute on unsupported elements
11+
- [`a11y-nested-interactive-elements`](./a11y-nested-interactive-elements.md) - Disallow nesting interactive elements
1112
- [`a11y-no-accesskey-attribute`](./a11y-no-accesskey-attribute.md) - Prevent usage of the `accesskey` attribute
1213
- [`a11y-no-aria-label-misuse`](./a11y-no-aria-label-misuse.md) - Prevent `aria-label` and `aria-labelledby` on non-interactive elements
1314
- [`a11y-no-aria-unsupported-elements`](./a11y-no-aria-unsupported-elements.md) - Prevent usage of ARIA on unsupported elements
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Linter Rule: No nested interactive elements
2+
3+
**Rule:** `a11y-nested-interactive-elements`
4+
5+
## Description
6+
7+
Disallow nesting interactive elements inside other interactive elements. Interactive controls such as `<button>`, `<summary>`, `<input>`, `<select>`, `<textarea>`, or `<a>` must not contain other interactive elements.
8+
9+
## Rationale
10+
11+
Nesting interactive elements produces invalid HTML, and assistive technologies, such as screen readers, might ignore or respond unexpectedly to such nested controls.
12+
13+
## Exceptions
14+
15+
- `<a>` inside `<summary>` is allowed.
16+
- `<input type="hidden">` is not considered an interactive element.
17+
18+
## Examples
19+
20+
### ✅ Good
21+
22+
```erb
23+
<button>Confirm</button>
24+
```
25+
26+
```erb
27+
<a href="/about">About</a>
28+
```
29+
30+
```erb
31+
<div><a href="/about">About</a></div>
32+
```
33+
34+
```erb
35+
<summary><a href="/about">About</a></summary>
36+
```
37+
38+
```erb
39+
<button><input type="hidden" name="token" /></button>
40+
```
41+
42+
### 🚫 Bad
43+
44+
```erb
45+
<button><a href="https://github.com/">Go to GitHub</a></button>
46+
```
47+
48+
```erb
49+
<a href="/about"><button>Click</button></a>
50+
```
51+
52+
```erb
53+
<button><select><option>A</option></select></button>
54+
```
55+
56+
```erb
57+
<button><input type="text" /></button>
58+
```
59+
60+
## References
61+
62+
- [erblint-github: NestedInteractiveElements](https://github.com/github/erblint-github/blob/main/lib/erblint-github/linters/github/accessibility/nested_interactive_elements.rb)
63+
- [erblint-github docs](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/nested-interactive-elements.md)
64+
- [Deque University: nested-interactive](https://dequeuniversity.com/rules/axe/4.8/nested-interactive)
65+
- [Accessibility Insights](https://accessibilityinsights.io/info-examples/web/nested-interactive/)

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { RuleClass } from "./types.js"
22

33
import { A11yAvoidGenericLinkTextRule } from "./rules/a11y-avoid-generic-link-text.js"
44
import { A11yDisabledAttributeRule } from "./rules/a11y-disabled-attribute.js"
5+
import { A11yNestedInteractiveElementsRule } from "./rules/a11y-nested-interactive-elements.js"
56
import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribute.js"
67
import { A11yNoAriaLabelMisuseRule } from "./rules/a11y-no-aria-label-misuse.js"
78
import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js"
@@ -110,6 +111,7 @@ import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.
110111
export const rules: RuleClass[] = [
111112
A11yAvoidGenericLinkTextRule,
112113
A11yDisabledAttributeRule,
114+
A11yNestedInteractiveElementsRule,
113115
A11yNoAccesskeyAttributeRule,
114116
A11yNoAriaLabelMisuseRule,
115117
A11yNoAriaUnsupportedElementsRule,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { ElementStackVisitor } from "./rule-utils.js"
2+
import { getTagLocalName, getStaticAttributeValue } from "@herb-tools/core"
3+
4+
import { ParserRule } from "../types.js"
5+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
6+
import type { HTMLElementNode, ParseResult, ParserOptions } from "@herb-tools/core"
7+
8+
const INTERACTIVE_ELEMENTS = new Set([
9+
"a",
10+
"button",
11+
"input",
12+
"select",
13+
"summary",
14+
"textarea",
15+
])
16+
17+
class NestedInteractiveElementsVisitor extends ElementStackVisitor {
18+
visitHTMLElementNode(node: HTMLElementNode): void {
19+
const tagName = getTagLocalName(node)
20+
21+
if (tagName && INTERACTIVE_ELEMENTS.has(tagName)) {
22+
if (tagName === "input" && getStaticAttributeValue(node, "type") === "hidden") {
23+
super.visitHTMLElementNode(node)
24+
return
25+
}
26+
27+
const ancestor = this.findInteractiveAncestor(tagName)
28+
29+
if (ancestor) {
30+
this.addOffense(
31+
`Found \`<${tagName}>\` nested inside of \`<${ancestor}>\`. Nesting interactive elements produces invalid HTML, and assistive technologies, such as screen readers, might ignore or respond unexpectedly to such nested controls.`,
32+
node.location,
33+
)
34+
}
35+
}
36+
37+
super.visitHTMLElementNode(node)
38+
}
39+
40+
private findInteractiveAncestor(childTagName: string): string | null {
41+
for (const ancestor of this.ancestors) {
42+
const ancestorTag = getTagLocalName(ancestor)
43+
44+
if (!ancestorTag || !INTERACTIVE_ELEMENTS.has(ancestorTag)) continue
45+
if (ancestorTag === "summary" && childTagName === "a") continue
46+
47+
return ancestorTag
48+
}
49+
50+
return null
51+
}
52+
}
53+
54+
export class A11yNestedInteractiveElementsRule extends ParserRule {
55+
static ruleName = "a11y-nested-interactive-elements"
56+
static introducedIn = this.version("unreleased")
57+
58+
get defaultConfig(): FullRuleConfig {
59+
return {
60+
enabled: false,
61+
severity: "error"
62+
}
63+
}
64+
65+
get parserOptions(): Partial<ParserOptions> {
66+
return {
67+
action_view_helpers: true,
68+
}
69+
}
70+
71+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
72+
const visitor = new NestedInteractiveElementsVisitor(this.ruleName, context)
73+
visitor.visit(result.value)
74+
return visitor.offenses
75+
}
76+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./a11y-avoid-generic-link-text.js"
22
export * from "./a11y-disabled-attribute.js"
3+
export * from "./a11y-nested-interactive-elements.js"
34
export * from "./a11y-no-accesskey-attribute.js"
45
export * from "./a11y-no-aria-label-misuse.js"
56
export * from "./a11y-no-aria-unsupported-elements.js"

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ export abstract class ElementStackVisitor<TAutofixContext extends BaseAutofixCon
226226
})
227227
}
228228

229+
/**
230+
* All ancestor HTML elements, from outermost to innermost.
231+
*/
232+
protected get ancestors(): readonly HTMLElementNode[] {
233+
return this.elementStack
234+
}
235+
229236
/**
230237
* The current nesting depth (number of ancestor HTML elements).
231238
*/

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)