Skip to content

feat: add template-no-role-presentation-on-focusable#22

Closed
johanrd wants to merge 1 commit intomasterfrom
feat/template-no-role-presentation-on-focusable
Closed

feat: add template-no-role-presentation-on-focusable#22
johanrd wants to merge 1 commit intomasterfrom
feat/template-no-role-presentation-on-focusable

Conversation

@johanrd
Copy link
Copy Markdown
Owner

@johanrd johanrd commented Apr 21, 2026

Note

This is part of a series where Claude has audited eslint-plugin-ember against jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate, ember-template-lint, and the HTML and WCAG specs.

  • Premise: Per WAI-ARIA 1.2 "Presentational Roles Conflict Resolution": "User agents MUST NOT expose elements having explicit or inherited presentational role in the accessibility tree, with these exceptions: If an element is focusable, or otherwise interactive, user agents MUST ignore the presentation role and expose the element with its implicit role, in order to ensure that the element is operable." That is a UA-directed conflict-resolution procedure, not an author-directed prohibition — but the practical consequence is the same: role="presentation" on a focusable element is silently ignored by the UA, producing a mixed signal (the author said "decorative," the UA exposes the element anyway). This rule flags the author-side anti-pattern.
  • Problem: No rule catches this in our plugin today.

Fix: add template-no-role-presentation-on-focusable. Shares focusable-detection with template-no-aria-hidden-on-focusable (#19) — both target the same anti-pattern shape via different attributes.

role="none" is handled identically. WAI-ARIA 1.2 explicitly defines none as a synonym for presentation: "See synonym presentation."

Flags

<button role="presentation">Click</button>
<a href="/x" role="presentation">Link</a>
<a href="/x" role="none">Link</a>
<input type="text" role="presentation" />
<div tabindex="0" role="presentation"></div>

Allows

<div role="presentation"><button tabindex="-1">…</button></div>   {{! role on non-focusable; children handled recursively }}
<button tabindex="-1" role="presentation">…</button>              {{! jsx-a11y + vue-a11y both allow tabindex="-1" + presentation }}
<CustomBtn role="presentation" />                                 {{! component, opaque }}

Prior art

Verified each peer in source:

Plugin Rule Notes
vuejs-accessibility no-role-presentation-on-focusable Exists; checks role === "presentation" only (not "none"). Our rule extends to "none" per spec synonymy.
jsx-a11y No equivalent rule.
lit-a11y No equivalent rule.
@angular-eslint/template No equivalent rule.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 21, 2026

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
js small 13.65 ms 13.83 ms +1.3%
🟢 js medium 6.88 ms 6.56 ms -4.5%
js large 2.73 ms 2.71 ms -0.7%
gjs small 1.21 ms 1.20 ms -1.4%
gjs medium 609.31 µs 604.25 µs -0.8%
gjs large 241.87 µs 240.21 µs -0.7%
🟢 gts small 1.24 ms 1.21 ms -2.2%
🟢 gts medium 624.53 µs 605.14 µs -3.1%
gts large 240.95 µs 240.13 µs -0.3%

🟢 faster · 🔴 slower · 🟠 slightly slower · ⚪ within 2%

Full mitata output
clk: ~3.09 GHz
cpu: AMD EPYC 7763 64-Core Processor
runtime: node 24.15.0 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
------------------------------------------- -------------------------------
js small (control)            16.34 ms/iter  17.39 ms █▃                   
                      (12.00 ms … 30.76 ms)  30.60 ms ██ ▂  ▂              
                    (  5.22 mb …  10.59 mb)   7.21 mb ██▆█▁▃█▃▁▁▆▃▁▃▁▃▁▃▁▁▃

js small (experiment)         14.23 ms/iter  15.00 ms    █                 
                      (12.46 ms … 18.31 ms)  17.40 ms ▃ ▃█▃ ▃              
                    (  6.07 mb …   8.20 mb)   6.88 mb █▁███▁█▆▄▆▆▆▄▄▁▆▄▄▁▁▆

                             ┌                                            ┐
                             ╷┌────────┬──┐                               ╷
          js small (control) ├┤        │  ├───────────────────────────────┤
                             ╵└────────┴──┘                               ╵
                              ╷ ┌─┬─┐     ╷
       js small (experiment)  ├─┤ │ ├─────┤
                              ╵ └─┴─┘     ╵
                             └                                            ┘
                             12.00 ms           21.30 ms           30.60 ms

summary
  js small (experiment)
   1.15x faster than js small (control)

------------------------------------------- -------------------------------
js medium (control)            7.42 ms/iter   7.65 ms  █                   
                       (6.25 ms … 12.68 ms)  12.19 ms  ██                  
                    (  2.56 mb …   4.48 mb)   3.53 mb ███▆▅▇▅▂▁▂▁▄▁▃▂▁▂▁▁▂▃

js medium (experiment)         7.19 ms/iter   7.43 ms  █                   
                       (6.24 ms … 11.96 ms)  11.95 ms  █                   
                    (  2.23 mb …   4.77 mb)   3.51 mb ██▆▃▃▃▂▅▃▂▁▂▁▁▂▂▁▁▁▂▂

                             ┌                                            ┐
                             ╷ ┌──────┬─┐                                 ╷
         js medium (control) ├─┤      │ ├─────────────────────────────────┤
                             ╵ └──────┴─┘                                 ╵
                             ╷ ┌────┬─┐                                 ╷
      js medium (experiment) ├─┤    │ ├─────────────────────────────────┤
                             ╵ └────┴─┘                                 ╵
                             └                                            ┘
                             6.24 ms            9.21 ms            12.19 ms

summary
  js medium (experiment)
   1.03x faster than js medium (control)

------------------------------------------- -------------------------------
js large (control)             3.13 ms/iter   2.88 ms  █                   
                       (2.38 ms … 11.04 ms)   8.43 ms ▆█                   
                    (276.70 kb …   3.52 mb)   1.43 mb ██▄▃▃▂▂▁▁▁▂▁▂▁▁▁▁▁▁▁▁

js large (experiment)          2.90 ms/iter   2.80 ms  █▇                  
                        (2.49 ms … 5.86 ms)   5.33 ms ▇██                  
                    (191.63 kb …   2.89 mb)   1.43 mb ███▄▃▂▃▃▁▂▂▁▂▂▂▁▁▁▂▁▁

                             ┌                                            ┐
                             ╷┌────┬                                      ╷
          js large (control) ├┤    │──────────────────────────────────────┤
                             ╵└────┴                                      ╵
                              ╷┌─┬                 ╷
       js large (experiment)  ├┤ │─────────────────┤
                              ╵└─┴                 ╵
                             └                                            ┘
                             2.38 ms            5.41 ms             8.43 ms

summary
  js large (experiment)
   1.08x faster than js large (control)

------------------------------------------- -------------------------------
gjs small (control)            1.33 ms/iter   1.29 ms █                    
                        (1.18 ms … 6.64 ms)   4.71 ms █                    
                    (290.38 kb …   1.66 mb)   1.05 mb ██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs small (experiment)         1.31 ms/iter   1.22 ms █                    
                        (1.14 ms … 4.89 ms)   4.53 ms █                    
                    (403.52 kb …   1.72 mb)   1.06 mb █▆▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌┬                                          ╷
         gjs small (control) ├┤│──────────────────────────────────────────┤
                             ╵└┴                                          ╵
                             ┌─┬                                        ╷
      gjs small (experiment) │ │────────────────────────────────────────┤
                             └─┴                                        ╵
                             └                                            ┘
                             1.14 ms            2.92 ms             4.71 ms

summary
  gjs small (experiment)
   1.02x faster than gjs small (control)

------------------------------------------- -------------------------------
gjs medium (control)         657.14 µs/iter 623.47 µs █                    
                      (580.49 µs … 4.78 ms)   3.18 ms █                    
                    (155.59 kb …   1.07 mb) 541.40 kb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      647.90 µs/iter 618.58 µs  █                   
                      (575.34 µs … 5.13 ms)   1.60 ms ▇█                   
                    (308.86 kb …   1.26 mb) 541.39 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
        gjs medium (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌┬                ╷
     gjs medium (experiment) ││────────────────┤
                             └┴                ╵
                             └                                            ┘
                             575.34 µs           1.88 ms            3.18 ms

summary
  gjs medium (experiment)
   1.01x faster than gjs medium (control)

------------------------------------------- -------------------------------
gjs large (control)          263.15 µs/iter 257.64 µs  █                   
                      (232.31 µs … 4.41 ms) 321.99 µs ▄██▃  ▃              
                    (170.92 kb … 739.91 kb) 217.18 kb ████▃▃█▄▆▂▂▁▂▂▁▁▁▁▁▁▁

gjs large (experiment)       263.12 µs/iter 256.77 µs  █                   
                      (231.42 µs … 4.54 ms) 337.35 µs  █▇                  
                    ( 88.56 kb … 992.66 kb) 216.49 kb ▆██▃▂▇▆▄▂▁▂▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌──────────┬                        ╷
         gjs large (control) ├─┤          │────────────────────────┤
                             ╵ └──────────┴                        ╵
                             ╷ ┌──────────┬                               ╷
      gjs large (experiment) ├─┤          │───────────────────────────────┤
                             ╵ └──────────┴                               ╵
                             └                                            ┘
                             231.42 µs         284.38 µs          337.35 µs

summary
  gjs large (experiment)
   1x faster than gjs large (control)

------------------------------------------- -------------------------------
gts small (control)            1.32 ms/iter   1.26 ms █                    
                        (1.21 ms … 5.69 ms)   4.08 ms █                    
                    (504.14 kb …   1.64 mb)   1.06 mb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)         1.31 ms/iter   1.23 ms █                    
                        (1.18 ms … 5.76 ms)   5.12 ms █                    
                    (299.69 kb …   1.78 mb)   1.05 mb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌┬                              ╷
         gts small (control) ├┤│──────────────────────────────┤
                             ╵└┴                              ╵
                             ┌─┬                                          ╷
      gts small (experiment) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             └                                            ┘
                             1.18 ms            3.15 ms             5.12 ms

summary
  gts small (experiment)
   1.01x faster than gts small (control)

------------------------------------------- -------------------------------
gts medium (control)         664.63 µs/iter 640.48 µs  ▄█                  
                      (586.11 µs … 5.38 ms)   1.05 ms  ██                  
                    (295.93 kb …   0.99 mb) 541.62 kb ▄███▃▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts medium (experiment)      648.82 µs/iter 618.43 µs  █                   
                      (576.88 µs … 5.15 ms)   1.61 ms ██                   
                    (124.38 kb … 984.88 kb) 539.79 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌─┬                ╷
        gts medium (control) ├─┤ │────────────────┤
                             ╵ └─┴                ╵
                             ╷┌─┬                                         ╷
     gts medium (experiment) ├┤ │─────────────────────────────────────────┤
                             ╵└─┴                                         ╵
                             └                                            ┘
                             576.88 µs           1.09 ms            1.61 ms

summary
  gts medium (experiment)
   1.02x faster than gts medium (control)

------------------------------------------- -------------------------------
gts large (control)          263.37 µs/iter 257.28 µs  █                   
                      (231.68 µs … 4.64 ms) 313.77 µs  █▄                  
                    (216.09 kb … 688.85 kb) 216.97 kb ▆███▅▂▆▆▅▃▃▁▁▁▂▁▁▁▁▁▁

gts large (experiment)       263.15 µs/iter 257.54 µs  █                   
                      (231.77 µs … 4.52 ms) 323.79 µs  █▅                  
                    (177.13 kb … 739.04 kb) 216.50 kb ▆██▆▃▂█▅▂▂▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌────────────┬                        ╷
         gts large (control) ├─┤            │────────────────────────┤
                             ╵ └────────────┴                        ╵
                             ╷ ┌────────────┬                             ╷
      gts large (experiment) ├─┤            │─────────────────────────────┤
                             ╵ └────────────┴                             ╵
                             └                                            ┘
                             231.68 µs         277.74 µs          323.79 µs

summary
  gts large (experiment)
   1x faster than gts large (control)

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Ember template accessibility lint rule to flag role="presentation" / role="none" when applied to focusable/interactive native elements, plus supporting utilities and tests.

Changes:

  • Added ember/template-no-role-presentation-on-focusable rule with focusability/interactive detection and special-casing for <area href>.
  • Introduced new utils for component-invocation detection and HTML interactive-content classification.
  • Added rule docs, README entry, and test coverage (including a peer-parity audit fixture).

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/lib/utils/is-component-invocation-test.js Adds unit tests for the new component-invocation detection helper.
tests/lib/utils/html-interactive-content-test.js Adds unit tests for the new HTML interactive-content classification helper.
tests/lib/rules/template-no-role-presentation-on-focusable.js Adds RuleTester coverage for the new rule in both GJS/GTS and HBS modes.
tests/audit/no-role-presentation-on-focusable/peer-parity.js Adds an audit/peer-parity test fixture for behavioral comparison with vue-a11y.
lib/utils/is-component-invocation.js Implements isComponentInvocation helper to skip opaque component nodes.
lib/utils/html-interactive-content.js Implements isHtmlInteractiveContent helper based on HTML spec interactive content.
lib/rules/template-no-role-presentation-on-focusable.js Implements the new rule logic and reporting.
docs/rules/template-no-role-presentation-on-focusable.md Documents the new rule, examples, and intended scope.
README.md Adds the new rule to the rules list table.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-no-role-presentation-on-focusable.js Outdated
Comment thread lib/rules/template-no-role-presentation-on-focusable.js Outdated
Comment thread docs/rules/template-no-role-presentation-on-focusable.md Outdated
Comment thread docs/rules/template-no-role-presentation-on-focusable.md
Comment thread tests/audit/no-role-presentation-on-focusable/peer-parity.js Outdated
Comment thread lib/utils/is-component-invocation.js Outdated
Comment thread lib/rules/template-no-role-presentation-on-focusable.js Outdated
Comment thread lib/rules/template-no-role-presentation-on-focusable.js Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/audit/no-role-presentation-on-focusable/peer-parity.js Outdated
Comment thread lib/rules/template-no-role-presentation-on-focusable.js
Comment thread lib/utils/html-interactive-content.js
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/utils/is-native-element.js
Comment thread lib/rules/template-no-role-presentation-on-focusable.js
Comment thread lib/rules/template-no-role-presentation-on-focusable.js
johanrd added a commit that referenced this pull request Apr 24, 2026
johanrd added a commit that referenced this pull request Apr 24, 2026
…view)

Per HTML §6.6.3 'Sequential focus navigation', none of <details>,
<option>, or <datalist> are focusable by default:
- <details>: the focusable control is its <summary> child
- <option>: not in default tab order; <select> is focused and arrow keys
  navigate options within it
- <datalist>: no user-facing UI; the paired <input list> is focused

Including them in UNCONDITIONAL_FOCUSABLE_TAGS was over-aggressive and
caused false positives on this rule. Tests updated to pin the
now-allowed cases.
@johanrd johanrd requested a review from Copilot April 24, 2026 13:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/utils/is-native-element.js
Comment thread tests/lib/rules/template-no-role-presentation-on-focusable.js Outdated
Comment thread docs/rules/template-no-role-presentation-on-focusable.md
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@johanrd johanrd force-pushed the feat/template-no-role-presentation-on-focusable branch from 19cd641 to 23f6ef7 Compare April 26, 2026 08:10
@johanrd johanrd requested a review from Copilot April 26, 2026 08:42
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-no-role-presentation-on-focusable.js
Comment thread docs/rules/template-no-role-presentation-on-focusable.md Outdated
Comment thread lib/rules/template-no-role-presentation-on-focusable.js
@johanrd johanrd force-pushed the feat/template-no-role-presentation-on-focusable branch from a368d5c to 7857a1f Compare April 27, 2026 14:01
@johanrd johanrd force-pushed the feat/template-no-role-presentation-on-focusable branch from 7857a1f to 749af04 Compare April 27, 2026 19:26
@johanrd johanrd requested a review from Copilot April 27, 2026 19:34
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +140 to +142
if (tag === 'img') {
return Boolean(findAttr(node, 'usemap'));
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isKeyboardFocusable currently treats details, option, and datalist as unconditionally focusable, and treats img[usemap] as focusable. These elements are not generally keyboard-focusable themselves (e.g., <option>/<datalist> are not focusable form controls on their own; image maps transfer focus/interaction to <area href>, not the <img>). This can produce false positives for the rule. Recommendation: remove details/option/datalist from UNCONDITIONAL_FOCUSABLE_TAGS, and drop (or rework) the img[usemap] focusability branch (leave focusability to tabindex/other explicit focus vectors; handle <area href> separately as you already do). Add targeted tests proving the corrected behavior for these tags.

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +107
// tabindex="-1" on an inherently-focusable element still accepts programmatic
// focus (focus() works; the element stays in the a11y tree). Rule flags it.
// vuejs-accessibility treats tabindex="-1" as "removed from tab order = not focusable" — we disagree.
{
code: '<template><button tabindex="-1" role="presentation">Press</button></template>',
output: null,
errors: [{ messageId: 'invalidPresentation' }],
},
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description’s “Allows” section states that tabindex="-1" + role="presentation" is allowed (citing jsx-a11y/vue-a11y), but the implemented behavior + tests explicitly flag this case as invalid. Please align the PR description with the actual rule behavior (or, if the description is authoritative, adjust the rule/tests/docs to match).

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +61
function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) {
const rawTag = node && node.tag;
if (typeof rawTag !== 'string' || rawTag.length === 0) {
return false;
}
const tag = rawTag.toLowerCase();

if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
return true;
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new options.ignoreUsemap branch is user-visible behavior but isn’t covered by the new unit tests. Add a test asserting that isHtmlInteractiveContent(makeNode('img', { usemap: '#m' }), getTextAttrValue, { ignoreUsemap: true }) returns false (and ideally a companion test for the default behavior returning true).

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +80
// img — interactive only when usemap is present (image map)
if (tag === 'img') {
if (options.ignoreUsemap) {
return false;
}
return hasAttribute(node, 'usemap');
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new options.ignoreUsemap branch is user-visible behavior but isn’t covered by the new unit tests. Add a test asserting that isHtmlInteractiveContent(makeNode('img', { usemap: '#m' }), getTextAttrValue, { ignoreUsemap: true }) returns false (and ideally a companion test for the default behavior returning true).

Copilot uses AI. Check for mistakes.
@johanrd johanrd closed this Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants