Skip to content

Commit 0ecb3da

Browse files
committed
fix: use classifyAttribute for type=hidden + controls (rows i2, i3, m6/d3)
1 parent 17b0cb4 commit 0ecb3da

2 files changed

Lines changed: 29 additions & 6 deletions

File tree

lib/rules/template-no-interactive-element-to-noninteractive-role.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { roles, elementRoles } = require('aria-query');
44
const { AXObjects, elementAXObjects } = require('axobject-query');
55
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
66
const { isNativeElement } = require('../utils/is-native-element');
7+
const { classifyAttribute } = require('../utils/glimmer-attr-presence');
78

89
// Interactive-element derivation. Mirrors jsx-a11y's layered approach:
910
// 1. Primary signal — aria-query's `elementRoles`: an element is inherently
@@ -178,11 +179,14 @@ function isInteractiveElement(node) {
178179
// Special case: <input type="hidden"> is never user-facing. aria-query's
179180
// textbox entry would not match (it requires type=text/email/url/…), so
180181
// normally we'd be fine — but keep the explicit guard for clarity.
182+
//
183+
// Use classifyAttribute so the guard catches every form that renders
184+
// `type="hidden"` at runtime: GlimmerTextNode (i1), bare-mustache string
185+
// literal `type={{"hidden"}}` (i2 analog), and concat-with-literal
186+
// `type="{{'hidden'}}"` (i3 analog). Previous TextNode-only check was a
187+
// false-positive source on the latter two forms.
181188
if (tag === 'input') {
182-
const type = getTextAttrValue(findAttr(node, 'type'));
183-
// HTML type values are ASCII case-insensitive and may carry incidental
184-
// whitespace; normalize before comparison (matches the same guard in
185-
// sibling rules like template-interactive-supports-focus).
189+
const { value: type } = classifyAttribute(findAttr(node, 'type'));
186190
if (typeof type === 'string' && type.trim().toLowerCase() === 'hidden') {
187191
return false;
188192
}
@@ -196,8 +200,15 @@ function isInteractiveElement(node) {
196200
}
197201

198202
// Controls-gated fallback for <audio>/<video>: only interactive when the
199-
// `controls` attribute is present (matches user-facing-widget reality).
200-
if (CONTROLS_GATED_TAGS.has(tag) && hasAttr(node, 'controls')) {
203+
// `controls` attribute is rendered at runtime. Bare `controls={{false}}` /
204+
// `{{null}}` / `{{undefined}}` cause Glimmer to omit the attribute (per
205+
// cross-attribute observation in docs/glimmer-attribute-behavior.md), so
206+
// AST-presence is wrong. classifyAttribute returns 'present' only when
207+
// the attribute will actually render.
208+
if (
209+
CONTROLS_GATED_TAGS.has(tag) &&
210+
classifyAttribute(findAttr(node, 'controls')).presence === 'present'
211+
) {
201212
return true;
202213
}
203214

tests/lib/rules/template-no-interactive-element-to-noninteractive-role.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ ruleTester.run('template-no-interactive-element-to-noninteractive-role', rule, {
4444
'<template><input type="hidden" role="presentation" /></template>',
4545
'<template><input type="HIDDEN" role="presentation" /></template>',
4646
'<template><input type=" hidden " role="presentation" /></template>',
47+
// Mustache forms that resolve to `type="hidden"` at runtime (i2 / i3
48+
// analogs). Were previously false-positive flagged because the guard
49+
// only matched GlimmerTextNode.
50+
'<template><input type={{"hidden"}} role="presentation" /></template>',
51+
'<template><input type="{{\'hidden\'}}" role="presentation" /></template>',
4752

4853
// <a> without href is not interactive.
4954
'<template><a role="heading">Not a link</a></template>',
@@ -60,6 +65,13 @@ ruleTester.run('template-no-interactive-element-to-noninteractive-role', rule, {
6065
'<template><audio role="presentation" src="/x.mp3" /></template>',
6166
'<template><video role="img" src="/x.mp4" /></template>',
6267
'<template><audio role="img" src="/x.mp3" /></template>',
68+
// Bare-mustache falsy on `controls` (cross-attribute observation: HTML
69+
// boolean attrs follow rows m6/m9/m10) — Glimmer omits the attribute
70+
// at runtime, so the media has no user-operable UI and the
71+
// role="presentation" is allowed. Was a false positive before.
72+
'<template><video controls={{false}} role="presentation" src="/x.mp4" /></template>',
73+
'<template><video controls={{null}} role="presentation" src="/x.mp4" /></template>',
74+
'<template><audio controls={{undefined}} role="presentation" src="/x.mp3" /></template>',
6375
],
6476
invalid: [
6577
{

0 commit comments

Comments
 (0)