11const { roles } = require ( 'aria-query' ) ;
22const { AXObjectRoles, elementAXObjects } = require ( 'axobject-query' ) ;
33
4- function getStaticRoleFromElement ( node ) {
4+ // ARIA role values are whitespace-separated tokens compared ASCII-case-insensitively.
5+ // Returns the list of normalised tokens, or undefined when the attribute is
6+ // missing or dynamic.
7+ function getStaticRolesFromElement ( node ) {
58 const roleAttr = node . attributes ?. find ( ( attr ) => attr . name === 'role' ) ;
69
710 if ( roleAttr ?. value ?. type === 'GlimmerTextNode' ) {
8- return roleAttr . value . chars || undefined ;
11+ return splitRoleTokens ( roleAttr . value . chars ) ;
912 }
1013
1114 return undefined ;
1215}
1316
14- function getStaticRoleFromMustache ( node ) {
17+ function getStaticRolesFromMustache ( node ) {
1518 const rolePair = node . hash ?. pairs ?. find ( ( pair ) => pair . key === 'role' ) ;
1619
1720 if ( rolePair ?. value ?. type === 'GlimmerStringLiteral' ) {
18- return rolePair . value . value ;
21+ return splitRoleTokens ( rolePair . value . value ) ;
1922 }
2023
2124 return undefined ;
2225}
2326
27+ function splitRoleTokens ( value ) {
28+ if ( ! value ) {
29+ return undefined ;
30+ }
31+ const tokens = value . trim ( ) . toLowerCase ( ) . split ( / \s + / u) . filter ( Boolean ) ;
32+ return tokens . length > 0 ? tokens : undefined ;
33+ }
34+
2435// Reads the static lowercase value of `name` from either a GlimmerElementNode
2536// (angle-bracket attributes) or a GlimmerMustacheStatement (hash pairs).
2637// Returns undefined for dynamic values or missing attributes.
@@ -42,20 +53,30 @@ function getStaticAttrValue(node, name) {
4253 return undefined ;
4354}
4455
45- function getTagName ( node ) {
56+ // In classic Handlebars (.hbs) `{{input}}` globally resolves to Ember's
57+ // built-in input helper, which renders a native <input>. In strict-mode
58+ // GJS/GTS there is no corresponding lowercase `input` export from
59+ // `@ember/component` (only the PascalCase `<Input>` component), so
60+ // `{{input}}` in strict mode is always a user-bound identifier and cannot
61+ // be assumed to render a native <input>. Treating it as native there
62+ // would silently skip required-ARIA checks on arbitrary components.
63+ function isClassicHbsFilename ( context ) {
64+ const filename = context . filename || context . getFilename ?. ( ) || '' ;
65+ return ! filename . endsWith ( '.gjs' ) && ! filename . endsWith ( '.gts' ) ;
66+ }
67+
68+ function getTagName ( node , context ) {
4669 if ( node ?. type === 'GlimmerElementNode' ) {
47- return node . tag ;
70+ // HTML tag names are case-insensitive; normalize so <INPUT>/<Input> match
71+ // the lowercase keys in AX_CONCEPTS_BY_TAG and the semantic-role maps.
72+ return node . tag ?. toLowerCase ( ) ;
4873 }
4974 if ( node ?. type === 'GlimmerMustacheStatement' && node . path ?. original === 'input' ) {
50- // The classic `{{input}}` helper renders a native <input>.
51- //
52- // Caveat: in strict GJS/GTS mode, `{{input}}` is whatever was imported
53- // under the name `input` — it could be the classic helper (still renders
54- // native <input>) or some user-defined component. We assume the classic
55- // helper; the false-positive rate in practice is low because strict-mode
56- // authors rarely use `{{input}}` at all (idiomatic is <input> or
57- // <Input>), and when they do, it's almost always the imported built-in.
58- return 'input' ;
75+ if ( ! context || isClassicHbsFilename ( context ) ) {
76+ return 'input' ;
77+ }
78+ // Strict-mode {{input}} — not the classic helper, can't claim native.
79+ return null ;
5980 }
6081 return null ;
6182}
@@ -79,17 +100,17 @@ const AX_CONCEPTS_BY_TAG = buildAxConceptsByTag();
79100function buildAxConceptsByTag ( ) {
80101 const index = new Map ( ) ;
81102 for ( const [ concept , axObjectNames ] of elementAXObjects ) {
82- const roles = new Set ( ) ;
103+ const conceptRoles = new Set ( ) ;
83104 for ( const axName of axObjectNames ) {
84105 const axRoles = AXObjectRoles . get ( axName ) ;
85106 if ( ! axRoles ) {
86107 continue ;
87108 }
88109 for ( const axRole of axRoles ) {
89- roles . add ( axRole . name ) ;
110+ conceptRoles . add ( axRole . name ) ;
90111 }
91112 }
92- const entry = { attributes : concept . attributes || [ ] , roles } ;
113+ const entry = { attributes : concept . attributes || [ ] , roles : conceptRoles } ;
93114 if ( ! index . has ( concept . name ) ) {
94115 index . set ( concept . name , [ ] ) ;
95116 }
@@ -98,8 +119,8 @@ function buildAxConceptsByTag() {
98119 return index ;
99120}
100121
101- function isSemanticRoleElement ( node , role ) {
102- const tag = getTagName ( node ) ;
122+ function isSemanticRoleElement ( node , role , context ) {
123+ const tag = getTagName ( node , context ) ;
103124 if ( ! tag || typeof role !== 'string' ) {
104125 return false ;
105126 }
@@ -108,8 +129,8 @@ function isSemanticRoleElement(node, role) {
108129 return false ;
109130 }
110131 const targetRole = role . toLowerCase ( ) ;
111- for ( const { attributes, roles } of entries ) {
112- if ( ! roles . has ( targetRole ) ) {
132+ for ( const { attributes, roles : conceptRoles } of entries ) {
133+ if ( ! conceptRoles . has ( targetRole ) ) {
113134 continue ;
114135 }
115136 const allMatch = attributes . every ( ( cAttr ) => {
@@ -129,26 +150,39 @@ function isSemanticRoleElement(node, role) {
129150 return false ;
130151}
131152
132- function getMissingRequiredAttributes ( role , foundAriaAttributes , node ) {
133- const roleDefinition = roles . get ( role ) ;
134-
135- if ( ! roleDefinition ) {
136- return null ;
137- }
138-
139- // If axobject-query classifies this {element, role} pair as a semantic role
140- // element, the native element provides all required ARIA state — skip the
141- // missing-attribute check entirely (matches jsx-a11y's approach).
142- if ( isSemanticRoleElement ( node , role ) ) {
143- return null ;
153+ // For an ARIA role-fallback list like "combobox listbox", check required
154+ // attributes against the FIRST recognised role (the primary) per ARIA 1.2
155+ // role-fallback semantics — a user agent picks the first role it recognises.
156+ // Abstract roles (widget, input, command, section, … — ARIA §5.3) are
157+ // ontology categories, not valid authoring roles, so UAs skip them too.
158+ //
159+ // When the primary role is a semantic-role element (axobject-query says the
160+ // native element provides the required ARIA state natively — e.g. <input
161+ // type=checkbox role=switch>), the element is exempt: return { role, missing: null }.
162+ //
163+ // Diverges from jsx-a11y, which validates every recognised token.
164+ function getMissingRequiredAttributes ( roleTokens , foundAriaAttributes , node , context ) {
165+ for ( const role of roleTokens ) {
166+ const roleDefinition = roles . get ( role ) ;
167+ if ( ! roleDefinition || roleDefinition . abstract ) {
168+ continue ;
169+ }
170+ // Semantic-role elements expose required ARIA state natively — skip.
171+ if ( isSemanticRoleElement ( node , role , context ) ) {
172+ return { role, missing : null } ;
173+ }
174+ const requiredAttributes = Object . keys ( roleDefinition . requiredProps ) ;
175+ const missingRequiredAttributes = requiredAttributes
176+ . filter ( ( requiredAttribute ) => ! foundAriaAttributes . includes ( requiredAttribute ) )
177+ // Sort for deterministic report order (aria-query's requiredProps
178+ // iteration order is not guaranteed stable across versions).
179+ . sort ( ) ;
180+ return {
181+ role,
182+ missing : missingRequiredAttributes . length > 0 ? missingRequiredAttributes : null ,
183+ } ;
144184 }
145-
146- const requiredAttributes = Object . keys ( roleDefinition . requiredProps ) ;
147- const missingRequiredAttributes = requiredAttributes . filter (
148- ( requiredAttribute ) => ! foundAriaAttributes . includes ( requiredAttribute )
149- ) ;
150-
151- return missingRequiredAttributes . length > 0 ? missingRequiredAttributes : null ;
185+ return null ;
152186}
153187
154188/** @type {import('eslint').Rule.RuleModule } */
@@ -191,46 +225,38 @@ module.exports = {
191225
192226 return {
193227 GlimmerElementNode ( node ) {
194- const role = getStaticRoleFromElement ( node ) ;
228+ const roleTokens = getStaticRolesFromElement ( node ) ;
195229
196- if ( ! role ) {
230+ if ( ! roleTokens ) {
197231 return ;
198232 }
199233
200234 const foundAriaAttributes = ( node . attributes ?? [ ] )
201235 . filter ( ( attribute ) => attribute . name ?. startsWith ( 'aria-' ) )
202236 . map ( ( attribute ) => attribute . name ) ;
203237
204- const missingRequiredAttributes = getMissingRequiredAttributes (
205- role ,
206- foundAriaAttributes ,
207- node
208- ) ;
238+ const result = getMissingRequiredAttributes ( roleTokens , foundAriaAttributes , node , context ) ;
209239
210- if ( missingRequiredAttributes ) {
211- reportMissingAttributes ( node , role , missingRequiredAttributes ) ;
240+ if ( result ?. missing ) {
241+ reportMissingAttributes ( node , result . role , result . missing ) ;
212242 }
213243 } ,
214244
215245 GlimmerMustacheStatement ( node ) {
216- const role = getStaticRoleFromMustache ( node ) ;
246+ const roleTokens = getStaticRolesFromMustache ( node ) ;
217247
218- if ( ! role ) {
248+ if ( ! roleTokens ) {
219249 return ;
220250 }
221251
222252 const foundAriaAttributes = ( node . hash ?. pairs ?? [ ] )
223253 . filter ( ( pair ) => pair . key . startsWith ( 'aria-' ) )
224254 . map ( ( pair ) => pair . key ) ;
225255
226- const missingRequiredAttributes = getMissingRequiredAttributes (
227- role ,
228- foundAriaAttributes ,
229- node
230- ) ;
256+ const result = getMissingRequiredAttributes ( roleTokens , foundAriaAttributes , node , context ) ;
231257
232- if ( missingRequiredAttributes ) {
233- reportMissingAttributes ( node , role , missingRequiredAttributes ) ;
258+ if ( result ?. missing ) {
259+ reportMissingAttributes ( node , result . role , result . missing ) ;
234260 }
235261 } ,
236262 } ;
0 commit comments