Skip to content

Commit 4d1fec1

Browse files
committed
docs: cite ARIA §4.6 for deliberate non-recursion vs. vue-a11y
1 parent 2e20fcd commit 4d1fec1

3 files changed

Lines changed: 60 additions & 8 deletions

File tree

docs/rules/template-no-role-presentation-on-focusable.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,27 @@ This rule **allows** the following:
3737
</template>
3838
```
3939

40+
## Scope / Rationale
41+
42+
This rule inspects **only the element that carries the `role="presentation"` / `role="none"`** — it does not recurse into descendants. Per [WAI-ARIA 1.2 §4.6 Conflict Resolution](https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none) and [§5.3.3 Document Structure](https://www.w3.org/TR/wai-aria-1.2/#document_structure_roles), `role="presentation"` / `role="none"` does **not** cascade to descendants — each descendant retains its own role and semantics.
43+
44+
As a result, wrapper patterns are **not flagged**:
45+
46+
```gjs
47+
<template>
48+
{{! Not flagged: the div's role is a no-op (div had no meaningful role to
49+
suppress), and the button keeps its role + keyboard behavior. }}
50+
<div role="presentation">
51+
<button type="button">Click</button>
52+
</div>
53+
</template>
54+
```
55+
56+
This is a deliberate divergence from [eslint-plugin-vuejs-accessibility's `no-role-presentation-on-focusable`](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/no-role-presentation-on-focusable.md), which recurses into descendants and flags the wrapper case above. Vue's recursion is uncommented in their source and appears to have been copy-pasted from their `aria-hidden` rule, where descendant recursion **is** spec-correct because `aria-hidden` **does** cascade (see [`template-no-aria-hidden-on-focusable`](./template-no-aria-hidden-on-focusable.md)).
57+
4058
## References
4159

4260
- [WAI-ARIA 1.2 — presentation role](https://www.w3.org/TR/wai-aria-1.2/#presentation)
61+
- [WAI-ARIA 1.2 §4.6 — Conflict Resolution for the `presentation` / `none` roles](https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none)
62+
- [WAI-ARIA 1.2 §5.3.3 — Document Structure Roles](https://www.w3.org/TR/wai-aria-1.2/#document_structure_roles)
4363
- [`no-role-presentation-on-focusable` — eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/no-role-presentation-on-focusable.md)

lib/rules/template-no-role-presentation-on-focusable.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
// Per WAI-ARIA 1.2 §4.6 Conflict Resolution, role="presentation" / role="none"
2+
// does NOT cascade to descendants — each descendant retains its own role and
3+
// semantics. So `<div role="presentation"><button>X</button></div>` is NOT a
4+
// semantic problem: the div's role is a no-op (div had no meaningful role to
5+
// suppress), and the button remains fully interactive with its role intact.
6+
//
7+
// Therefore, unlike our template-no-aria-hidden-on-focusable rule (which recurses
8+
// into descendants because aria-hidden DOES cascade and creates a keyboard trap
9+
// landing on AT-hidden content), this rule only checks the element carrying the
10+
// presentation role.
11+
//
12+
// Deliberately diverges from vue-a11y's no-role-presentation-on-focusable, which
13+
// recurses into descendants. Vue's recursion is uncommented in their source and
14+
// appears to be a copy-paste from their aria-hidden rule.
15+
116
const INHERENTLY_FOCUSABLE_TAGS = new Set([
217
'button',
318
'details',

tests/audit/no-role-presentation-on-focusable/peer-parity.js

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,32 @@ ruleTester.run('audit:no-role-presentation-on-focusable (gts)', rule, {
8989
// DESCENDANT is focusable:
9090
// <div role='presentation'><button>Submit</button></div> → vue: INVALID
9191
// Our rule only inspects the element bearing the role. A non-focusable wrapper
92-
// is left alone; the authoring error vue targets (stripping semantics from
93-
// a container whose children remain focusable) is not detected by us.
92+
// is left alone.
93+
//
94+
// Spec grounding (WAI-ARIA 1.2):
95+
// - §4.6 "Presentational Roles Conflict Resolution"
96+
// (https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none)
97+
// describes how role="presentation" / role="none" behaves on the element
98+
// that carries it — NOT a cascade. It explicitly states that descendants
99+
// retain their own semantics, and only the host element's implicit role is
100+
// suppressed (and even that is overridden when the host is focusable or has
101+
// global ARIA state/property attributes).
102+
// - §5.3.3 "Document Structure Roles"
103+
// (https://www.w3.org/TR/wai-aria-1.2/#document_structure_roles)
104+
// reaffirms: role="presentation" / "none" does not propagate into the
105+
// subtree; each descendant keeps its role and interactivity.
106+
//
107+
// So `<div role="presentation"><button>X</button></div>` is not a semantic
108+
// problem: the div's role is a no-op (div had no meaningful role to suppress),
109+
// and the button remains fully interactive with its role intact. Vue's flagging
110+
// of the wrapper is not spec-mandated; their descendant recursion is uncommented
111+
// in source and appears to be a copy-paste from their aria-hidden rule.
112+
//
113+
// Contrast with `template-no-aria-hidden-on-focusable` (see G5.1), where
114+
// recursion into descendants IS spec-correct because aria-hidden DOES cascade
115+
// to the entire subtree per WAI-ARIA 1.2 §6.6 and creates a real keyboard trap
116+
// (focus lands on AT-hidden content).
94117
//
95-
// This is a known gap, not a bug fix waiting to happen:
96-
// - The WAI-ARIA §4.6 conflict-resolution text is phrased for elements with
97-
// role=presentation that are themselves focusable. Applying it to ancestors
98-
// is vue's interpretation, not a spec mandate.
99-
// - Our rule is narrower and has fewer false positives, at the cost of this
100-
// true positive.
101118
// Captured here as a "valid" bucket for OUR rule to make the divergence
102119
// explicit in the audit output.
103120
ruleTester.run('audit:no-role-presentation-on-focusable — wrapper scope (gts)', rule, {

0 commit comments

Comments
 (0)