@@ -4,6 +4,7 @@ const { roles, elementRoles } = require('aria-query');
44const { AXObjects, elementAXObjects } = require ( 'axobject-query' ) ;
55const { INTERACTIVE_ROLES } = require ( '../utils/interactive-roles' ) ;
66const { 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
0 commit comments