11'use strict' ;
22
3- const { isComponentInvocation } = require ( '../utils/is-component-invocation' ) ;
4- const { isHtmlInteractiveContent } = require ( '../utils/html-interactive-content' ) ;
3+ const { isNativeElement } = require ( '../utils/is-native-element' ) ;
54
65function findAttr ( node , name ) {
76 return node . attributes ?. find ( ( a ) => a . name === name ) ;
@@ -26,51 +25,112 @@ function isAriaHiddenTrue(node) {
2625 return false ;
2726 }
2827 if ( value . type === 'GlimmerTextNode' ) {
29- return value . chars . toLowerCase ( ) === 'true' ;
28+ return value . chars . trim ( ) . toLowerCase ( ) === 'true' ;
3029 }
3130 if ( value . type === 'GlimmerMustacheStatement' && value . path ) {
3231 if ( value . path . type === 'GlimmerBooleanLiteral' ) {
3332 return value . path . value === true ;
3433 }
3534 if ( value . path . type === 'GlimmerStringLiteral' ) {
36- return value . path . value . toLowerCase ( ) === 'true' ;
35+ return value . path . value . trim ( ) . toLowerCase ( ) === 'true' ;
3736 }
3837 }
3938 return false ;
4039}
4140
42- function isFocusable ( node ) {
43- const tag = node . tag ?. toLowerCase ( ) ;
44- if ( ! tag ) {
41+ // Tags with an unconditional default focusable UI (sequentially focusable per
42+ // HTML §6.6.3 "focusable area" + widget roles per HTML-AAM).
43+ // NOTE: <label> is HTML-interactive-content (§3.2.5.2.7) but NOT keyboard-
44+ // focusable by default — clicks on a label forward to its associated control,
45+ // but the label itself isn't in the tab order. So it's excluded here even
46+ // though `isHtmlInteractiveContent` would return true for it.
47+ const UNCONDITIONAL_FOCUSABLE_TAGS = new Set ( [
48+ 'button' ,
49+ 'select' ,
50+ 'textarea' ,
51+ 'iframe' ,
52+ 'embed' ,
53+ 'summary' ,
54+ 'details' ,
55+ 'option' ,
56+ 'datalist' ,
57+ ] ) ;
58+
59+ // Form-control tags whose `disabled` attribute removes them from the tab order
60+ // (HTML §4.10.18.5 "disabled" + HTML §6.6.3 "focusable area").
61+ const DISABLEABLE_TAGS = new Set ( [ 'button' , 'input' , 'select' , 'textarea' , 'fieldset' ] ) ;
62+
63+ function isDisabledFormControl ( node , tag ) {
64+ if ( ! DISABLEABLE_TAGS . has ( tag ) ) {
65+ return false ;
66+ }
67+ return Boolean ( findAttr ( node , 'disabled' ) ) ;
68+ }
69+
70+ // Narrow rule-local "keyboard-focusable" check. Intentionally distinct from
71+ // `isHtmlInteractiveContent` (HTML content-model) — we want the sequential-
72+ // focus + programmatic-focus axis only. See WAI-ARIA "focusable" definition
73+ // and HTML §6.6.3.
74+ function isKeyboardFocusable ( node , getTextAttrValueFn ) {
75+ const rawTag = node ?. tag ;
76+ if ( typeof rawTag !== 'string' || rawTag . length === 0 ) {
77+ return false ;
78+ }
79+ const tag = rawTag . toLowerCase ( ) ;
80+
81+ // Disabled form controls are not focusable.
82+ if ( isDisabledFormControl ( node , tag ) ) {
4583 return false ;
4684 }
4785
48- // Opt-out via tabindex="-1" makes the element programmatically focusable
49- // (still reachable via .focus()) but removes it from the tab order.
50- // `aria-hidden` on such an element is still problematic — if it can receive
51- // focus, assistive tech should be able to see it. Match jsx-a11y: flag any
52- // tabindex that's not "undefined" (i.e. any tabindex attribute at all).
53- const tabindex = findAttr ( node , 'tabindex' ) ;
54- if ( tabindex ) {
86+ // Any tabindex (including "-1") makes the element at least programmatically
87+ // focusable — still a keyboard-trap risk under aria-hidden.
88+ if ( findAttr ( node , 'tabindex' ) ) {
5589 return true ;
5690 }
5791
58- // Delegate interactive-content classification to the shared util (HTML
59- // §3.2.5.2.7 + summary): button/details/embed/iframe/label/select/summary/
60- // textarea, input (non-hidden), a[href], img[usemap], and
61- // audio[controls]/video[controls].
62- return isHtmlInteractiveContent ( node , getTextAttrValue ) ;
92+ // contenteditable (truthy) makes the element focusable.
93+ const contentEditable = getTextAttrValueFn ( node , 'contenteditable' ) ;
94+ if ( contentEditable !== undefined && contentEditable !== null ) {
95+ const normalized = contentEditable . trim ( ) . toLowerCase ( ) ;
96+ // per HTML spec, "", "true", and "plaintext-only" all enable editing.
97+ if ( normalized === '' || normalized === 'true' || normalized === 'plaintext-only' ) {
98+ return true ;
99+ }
100+ }
101+
102+ if ( UNCONDITIONAL_FOCUSABLE_TAGS . has ( tag ) ) {
103+ return true ;
104+ }
105+
106+ if ( tag === 'input' ) {
107+ const type = getTextAttrValueFn ( node , 'type' ) ;
108+ return type === undefined || type === null || type . trim ( ) . toLowerCase ( ) !== 'hidden' ;
109+ }
110+
111+ if ( tag === 'a' || tag === 'area' ) {
112+ return Boolean ( findAttr ( node , 'href' ) ) ;
113+ }
114+
115+ if ( tag === 'img' ) {
116+ return Boolean ( findAttr ( node , 'usemap' ) ) ;
117+ }
118+
119+ if ( tag === 'audio' || tag === 'video' ) {
120+ return Boolean ( findAttr ( node , 'controls' ) ) ;
121+ }
122+
123+ return false ;
63124}
64125
65126// A focusable descendant of an aria-hidden="true" ancestor can still receive
66127// focus (aria-hidden does not remove elements from the tab order), so the
67128// ancestor hides AT-visible content that remains keyboard-reachable — a
68129// keyboard trap. This rule targets the anti-pattern flagged by axe's
69- // `aria-hidden-focus` check and by jsx-a11y's `no-aria-hidden-on-focusable`;
70- // the WAI-ARIA 1.2 spec itself only says authors MAY "with caution" use
71- // aria-hidden, so the rule rests on community a11y guidance, not a
72- // normative WAI-ARIA MUST-NOT.
73- function hasFocusableDescendant ( node ) {
130+ // `aria-hidden-focus` check and by jsx-a11y's `no-aria-hidden-on-focusable`.
131+ // WAI-ARIA 1.2 says authors SHOULD NOT put aria-hidden on focusable content
132+ // (the spec normatively warns against this in the aria-hidden authoring note).
133+ function hasFocusableDescendant ( node , sourceCode ) {
74134 const children = node . children ;
75135 if ( ! children || children . length === 0 ) {
76136 return false ;
@@ -81,14 +141,14 @@ function hasFocusableDescendant(node) {
81141 // expressions, and anything else whose rendered element we can't inspect.
82142 continue ;
83143 }
84- if ( isComponentInvocation ( child ) ) {
85- // Component / dynamic tag — opaque. Don't recurse.
144+ if ( ! isNativeElement ( child , sourceCode ) ) {
145+ // Component / dynamic / shadowed tag — opaque. Don't recurse.
86146 continue ;
87147 }
88- if ( isFocusable ( child ) ) {
148+ if ( isKeyboardFocusable ( child , getTextAttrValue ) ) {
89149 return true ;
90150 }
91- if ( hasFocusableDescendant ( child ) ) {
151+ if ( hasFocusableDescendant ( child , sourceCode ) ) {
92152 return true ;
93153 }
94154 }
@@ -116,16 +176,20 @@ module.exports = {
116176 } ,
117177
118178 create ( context ) {
179+ const sourceCode = context . sourceCode ?? context . getSourceCode ( ) ;
119180 return {
120181 GlimmerElementNode ( node ) {
121182 if ( ! isAriaHiddenTrue ( node ) ) {
122183 return ;
123184 }
124- if ( isFocusable ( node ) ) {
185+ if ( ! isNativeElement ( node , sourceCode ) ) {
186+ return ;
187+ }
188+ if ( isKeyboardFocusable ( node , getTextAttrValue ) ) {
125189 context . report ( { node, messageId : 'noAriaHiddenOnFocusable' } ) ;
126190 return ;
127191 }
128- if ( hasFocusableDescendant ( node ) ) {
192+ if ( hasFocusableDescendant ( node , sourceCode ) ) {
129193 context . report ( { node, messageId : 'noAriaHiddenOnAncestorOfFocusable' } ) ;
130194 }
131195 } ,
0 commit comments