1- const NATIVE_INTERACTIVE_ELEMENTS = new Set ( [
2- 'button' ,
3- 'details' ,
4- 'embed' ,
5- 'iframe' ,
6- 'input' ,
7- 'label' ,
8- 'select' ,
9- 'summary' ,
10- 'textarea' ,
11- ] ) ;
1+ 'use strict' ;
122
13- const INTERACTIVE_ROLES = new Set ( [
14- 'button' ,
15- 'checkbox' ,
16- 'link' ,
17- 'searchbox' ,
18- 'spinbutton' ,
19- 'switch' ,
20- 'textbox' ,
21- 'radio' ,
22- 'slider' ,
23- 'tab' ,
24- 'menuitem' ,
25- 'menuitemcheckbox' ,
26- 'menuitemradio' ,
27- 'option' ,
28- 'combobox' ,
29- 'gridcell' ,
30- ] ) ;
3+ const { isHtmlInteractiveContent } = require ( '../utils/html-interactive-content' ) ;
4+ const { INTERACTIVE_ROLES , COMPOSITE_WIDGET_CHILDREN } = require ( '../utils/interactive-roles' ) ;
315
326function hasAttr ( node , name ) {
337 return node . attributes ?. some ( ( a ) => a . name === name ) ;
@@ -41,8 +15,38 @@ function getTextAttr(node, name) {
4115 return undefined ;
4216}
4317
18+ function getRole ( node ) {
19+ return getTextAttr ( node , 'role' ) ;
20+ }
21+
22+ // Menu submenu pattern — per WAI-ARIA APG, a `menuitem` with `aria-haspopup`
23+ // may own a nested `menu`. aria-query's `requiredOwnedElements` does not
24+ // express this "menu-inside-menuitem" direction, so it is handled explicitly.
25+ const MENUITEM_ROLES = new Set ( [ 'menuitem' , 'menuitemcheckbox' , 'menuitemradio' ] ) ;
26+
27+ function isCompositeWidgetPattern ( parentRole , childRole ) {
28+ if ( ! parentRole || ! childRole ) {
29+ return false ;
30+ }
31+ const allowedChildren = COMPOSITE_WIDGET_CHILDREN . get ( parentRole ) ;
32+ if ( allowedChildren && allowedChildren . has ( childRole ) ) {
33+ return true ;
34+ }
35+ // Submenu: <… role="menuitem"><… role="menu"> …
36+ if ( MENUITEM_ROLES . has ( parentRole ) && childRole === 'menu' ) {
37+ return true ;
38+ }
39+ return false ;
40+ }
41+
4442function isMenuItemNode ( node ) {
45- return getTextAttr ( node , 'role' ) === 'menuitem' ;
43+ // Match all three menu-item role variants per ARIA taxonomy. `menuitem`,
44+ // `menuitemcheckbox`, and `menuitemradio` are all "menu items" — they can
45+ // carry submenus (via MENUITEM_ROLES in isCompositeWidgetPattern) and nest
46+ // each other (via the nested-menuitem compat exception). Keeping both
47+ // predicates symmetric avoids false positives on APG Menu Button /
48+ // Menubar patterns that mix the variants.
49+ return MENUITEM_ROLES . has ( getTextAttr ( node , 'role' ) ) ;
4650}
4751
4852function isAllowedDetailsChild ( childNode , parentEntry ) {
@@ -122,22 +126,23 @@ module.exports = {
122126 return true ;
123127 }
124128
125- if ( NATIVE_INTERACTIVE_ELEMENTS . has ( tag ) ) {
126- if ( tag === 'input' ) {
127- const type = getTextAttr ( node , 'type' ) ;
128- if ( type === 'hidden' ) {
129- return false ;
130- }
131- }
129+ // HTML §3.2.5.2.7 interactive content (authoritative for content-model
130+ // nesting — handles a[href], audio/video[controls], input[!hidden],
131+ // img[usemap], plus label/button/select/textarea/iframe/etc. unconditional).
132+ if ( isHtmlInteractiveContent ( node , getTextAttr , { ignoreUsemap } ) ) {
132133 return true ;
133134 }
134135
135- // <a> with href is interactive (without href, <a> is not interactive)
136- if ( tag === 'a' && hasAttr ( node , 'href' ) ) {
136+ // <canvas> — not in §3.2.5.2.7 but upstream ember-template-lint treats
137+ // it as interactive. Preserved for parity.
138+ if ( tag === 'canvas' ) {
137139 return true ;
138140 }
139141
140- // Check role
142+ // ARIA widget roles (author-declared interactivity — separate authority
143+ // from HTML content-model: `html-interactive-content.js` speaks to the
144+ // HTML §3.2.5.2.7 content model, while `interactive-roles.js` speaks to
145+ // the WAI-ARIA 1.2 widget taxonomy).
141146 const role = getTextAttr ( node , 'role' ) ;
142147 if ( role && INTERACTIVE_ROLES . has ( role ) ) {
143148 return true ;
@@ -148,14 +153,21 @@ module.exports = {
148153 return true ;
149154 }
150155
151- // Check contenteditable
152- const ce = getTextAttr ( node , 'contenteditable' ) ;
153- if ( ce && ce !== 'false' ) {
154- return true ;
156+ // Check contenteditable. Per HTML spec, valid keywords are "true",
157+ // "false", "plaintext-only", and the empty string (which is the default
158+ // state, equivalent to "true"). So the attribute enables editing unless
159+ // its value is "false".
160+ if ( hasAttr ( node , 'contenteditable' ) ) {
161+ const ce = getTextAttr ( node , 'contenteditable' ) ;
162+ if ( ce === undefined || ce === null || ce . trim ( ) . toLowerCase ( ) !== 'false' ) {
163+ return true ;
164+ }
155165 }
156166
157- // Check usemap (only on img and object elements)
158- if ( ! ignoreUsemap && ( tag === 'img' || tag === 'object' ) && hasAttr ( node , 'usemap' ) ) {
167+ // <object usemap> — not in HTML §3.2.5.2.7 but upstream ember-template-lint
168+ // treats object+usemap as interactive (image-map behavior). Rule-level
169+ // special case for upstream parity; revisit if/when HTML-AAM clarifies.
170+ if ( ! ignoreUsemap && tag === 'object' && hasAttr ( node , 'usemap' ) ) {
159171 return true ;
160172 }
161173
@@ -175,19 +187,21 @@ module.exports = {
175187 if ( additionalInteractiveTags . has ( tag ) ) {
176188 return false ;
177189 }
178- if ( NATIVE_INTERACTIVE_ELEMENTS . has ( tag ) ) {
190+ if ( tag === 'canvas' ) {
179191 return false ;
180192 }
181- if ( tag === 'a' && hasAttr ( node , 'href' ) ) {
193+ if ( isHtmlInteractiveContent ( node , getTextAttr , { ignoreUsemap } ) ) {
182194 return false ;
183195 }
184196 const role = getTextAttr ( node , 'role' ) ;
185197 if ( role && INTERACTIVE_ROLES . has ( role ) ) {
186198 return false ;
187199 }
188- const ce = getTextAttr ( node , 'contenteditable' ) ;
189- if ( ce && ce !== 'false' ) {
190- return false ;
200+ if ( hasAttr ( node , 'contenteditable' ) ) {
201+ const ce = getTextAttr ( node , 'contenteditable' ) ;
202+ if ( ce === undefined || ce === null || ce . trim ( ) . toLowerCase ( ) !== 'false' ) {
203+ return false ;
204+ }
191205 }
192206 if ( ( tag === 'img' || tag === 'object' ) && hasAttr ( node , 'usemap' ) ) {
193207 return false ;
@@ -214,8 +228,18 @@ module.exports = {
214228 parentEntry . interactiveChildCount ++ ;
215229 } else if ( isAllowedDetailsChild ( node , parentEntry ) ) {
216230 // flow content in the disclosed panel, or <summary> as first child
231+ } else if ( isCompositeWidgetPattern ( getRole ( parentEntry . node ) , getRole ( node ) ) ) {
232+ // Canonical ARIA composite-widget hierarchies — e.g. option inside
233+ // listbox, tab inside tablist, treeitem inside tree, row inside
234+ // grid/treegrid, gridcell/columnheader/rowheader inside row,
235+ // radio inside radiogroup, menu inside menuitem (submenu).
236+ // Derived from aria-query's requiredOwnedElements with superClass
237+ // inheritance — see lib/utils/interactive-roles.js.
217238 } else if ( isMenuItemNode ( parentEntry . node ) && isMenuItemNode ( node ) ) {
218- // Nested menuitem nodes are valid (menu/sub-menu pattern)
239+ // Nested menu-item nodes (any combination of menuitem /
240+ // menuitemcheckbox / menuitemradio) are valid — menu/sub-menu
241+ // pattern per WAI-ARIA APG. Kept for historical compat since
242+ // aria-query doesn't encode this via requiredOwnedElements.
219243 } else {
220244 context . report ( {
221245 node,
0 commit comments