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 @@ -8,6 +8,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-nested-interactive-elements`](./a11y-nested-interactive-elements.md) - Disallow nesting interactive 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Linter Rule: No nested interactive elements

**Rule:** `a11y-nested-interactive-elements`

## Description

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.

## Rationale

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 an interactive element.

## Examples

### ✅ Good

```erb
<button>Confirm</button>
```

```erb
<a href="/about">About</a>
```

```erb
<div><a href="/about">About</a></div>
```

```erb
<summary><a href="/about">About</a></summary>
```

```erb
<button><input type="hidden" name="token" /></button>
```

### 🚫 Bad

```erb
<button><a href="https://github.com/">Go to GitHub</a></button>
```

```erb
<a href="/about"><button>Click</button></a>
```

```erb
<button><select><option>A</option></select></button>
```

```erb
<button><input type="text" /></button>
```

## References

- [erblint-github: NestedInteractiveElements](https://github.com/github/erblint-github/blob/main/lib/erblint-github/linters/github/accessibility/nested_interactive_elements.rb)
- [erblint-github docs](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/nested-interactive-elements.md)
- [Deque University: nested-interactive](https://dequeuniversity.com/rules/axe/4.8/nested-interactive)
- [Accessibility Insights](https://accessibilityinsights.io/info-examples/web/nested-interactive/)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,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 { A11yNestedInteractiveElementsRule } from "./rules/a11y-nested-interactive-elements.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"
Expand Down Expand Up @@ -110,6 +111,7 @@ import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.
export const rules: RuleClass[] = [
A11yAvoidGenericLinkTextRule,
A11yDisabledAttributeRule,
A11yNestedInteractiveElementsRule,
A11yNoAccesskeyAttributeRule,
A11yNoAriaLabelMisuseRule,
A11yNoAriaUnsupportedElementsRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ElementStackVisitor } from "./rule-utils.js"
import { getTagLocalName, getStaticAttributeValue } from "@herb-tools/core"

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

const INTERACTIVE_ELEMENTS = new Set([
"a",
"button",
"input",
"select",
"summary",
"textarea",
])

class NestedInteractiveElementsVisitor extends ElementStackVisitor {
visitHTMLElementNode(node: HTMLElementNode): void {
const tagName = getTagLocalName(node)

if (tagName && INTERACTIVE_ELEMENTS.has(tagName)) {
if (tagName === "input" && getStaticAttributeValue(node, "type") === "hidden") {
super.visitHTMLElementNode(node)
return
}

const ancestor = this.findInteractiveAncestor(tagName)

if (ancestor) {
this.addOffense(
`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.`,
node.location,
)
}
}

super.visitHTMLElementNode(node)
}

private findInteractiveAncestor(childTagName: string): string | null {
for (const ancestor of this.ancestors) {
const ancestorTag = getTagLocalName(ancestor)

if (!ancestorTag || !INTERACTIVE_ELEMENTS.has(ancestorTag)) continue
if (ancestorTag === "summary" && childTagName === "a") continue

return ancestorTag
}

return null
}
}

export class A11yNestedInteractiveElementsRule extends ParserRule {
static ruleName = "a11y-nested-interactive-elements"
static introducedIn = this.version("unreleased")

get defaultConfig(): FullRuleConfig {
return {
enabled: false,
severity: "error"
}
}

get parserOptions(): Partial<ParserOptions> {
return {
action_view_helpers: true,
}
}

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
const visitor = new NestedInteractiveElementsVisitor(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,5 +1,6 @@
export * from "./a11y-avoid-generic-link-text.js"
export * from "./a11y-disabled-attribute.js"
export * from "./a11y-nested-interactive-elements.js"
export * from "./a11y-no-accesskey-attribute.js"
export * from "./a11y-no-aria-label-misuse.js"
export * from "./a11y-no-aria-unsupported-elements.js"
Expand Down
7 changes: 7 additions & 0 deletions javascript/packages/linter/src/rules/rule-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ export abstract class ElementStackVisitor<TAutofixContext extends BaseAutofixCon
})
}

/**
* All ancestor HTML elements, from outermost to innermost.
*/
protected get ancestors(): readonly HTMLElementNode[] {
return this.elementStack
}

/**
* The current nesting depth (number of ancestor HTML elements).
*/
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