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.
@@ -79,15 +90,15 @@ const AX_CONCEPTS_BY_TAG = buildAxConceptsByTag();
7990function buildAxConceptsByTag ( ) {
8091 const index = new Map ( ) ;
8192 for ( const [ concept , axObjectNames ] of elementAXObjects ) {
82- const roles = new Set ( ) ;
93+ const conceptRoles = new Set ( ) ;
8394 for ( const axName of axObjectNames ) {
8495 const axRoles = AXObjectRoles . get ( axName ) ;
8596 if ( ! axRoles ) continue ;
8697 for ( const axRole of axRoles ) {
87- roles . add ( axRole . name ) ;
98+ conceptRoles . add ( axRole . name ) ;
8899 }
89100 }
90- const entry = { attributes : concept . attributes || [ ] , roles } ;
101+ const entry = { attributes : concept . attributes || [ ] , roles : conceptRoles } ;
91102 if ( ! index . has ( concept . name ) ) {
92103 index . set ( concept . name , [ ] ) ;
93104 }
@@ -106,8 +117,8 @@ function isSemanticRoleElement(node, role) {
106117 return false ;
107118 }
108119 const targetRole = role . toLowerCase ( ) ;
109- for ( const { attributes, roles } of entries ) {
110- if ( ! roles . has ( targetRole ) ) {
120+ for ( const { attributes, roles : conceptRoles } of entries ) {
121+ if ( ! conceptRoles . has ( targetRole ) ) {
111122 continue ;
112123 }
113124 const allMatch = attributes . every ( ( cAttr ) => {
@@ -127,26 +138,39 @@ function isSemanticRoleElement(node, role) {
127138 return false ;
128139}
129140
130- function getMissingRequiredAttributes ( role , foundAriaAttributes , node ) {
131- const roleDefinition = roles . get ( role ) ;
132-
133- if ( ! roleDefinition ) {
134- return null ;
135- }
136-
137- // If axobject-query classifies this {element, role} pair as a semantic role
138- // element, the native element provides all required ARIA state — skip the
139- // missing-attribute check entirely (matches jsx-a11y's approach).
140- if ( isSemanticRoleElement ( node , role ) ) {
141- return null ;
141+ // For an ARIA role-fallback list like "combobox listbox", check required
142+ // attributes against the FIRST recognised role (the primary) per ARIA 1.2
143+ // role-fallback semantics — a user agent picks the first role it recognises.
144+ // Abstract roles (widget, input, command, section, … — ARIA §5.3) are
145+ // ontology categories, not valid authoring roles, so UAs skip them too.
146+ //
147+ // When the primary role is a semantic-role element (axobject-query says the
148+ // native element provides the required ARIA state natively — e.g. <input
149+ // type=checkbox role=switch>), the element is exempt: return { role, missing: null }.
150+ //
151+ // Diverges from jsx-a11y, which validates every recognised token.
152+ function getMissingRequiredAttributes ( roleTokens , foundAriaAttributes , node ) {
153+ for ( const role of roleTokens ) {
154+ const roleDefinition = roles . get ( role ) ;
155+ if ( ! roleDefinition || roleDefinition . abstract ) {
156+ continue ;
157+ }
158+ // Semantic-role elements expose required ARIA state natively — skip.
159+ if ( isSemanticRoleElement ( node , role ) ) {
160+ return { role, missing : null } ;
161+ }
162+ const requiredAttributes = Object . keys ( roleDefinition . requiredProps ) ;
163+ const missingRequiredAttributes = requiredAttributes
164+ . filter ( ( requiredAttribute ) => ! foundAriaAttributes . includes ( requiredAttribute ) )
165+ // Sort for deterministic report order (aria-query's requiredProps
166+ // iteration order is not guaranteed stable across versions).
167+ . sort ( ) ;
168+ return {
169+ role,
170+ missing : missingRequiredAttributes . length > 0 ? missingRequiredAttributes : null ,
171+ } ;
142172 }
143-
144- const requiredAttributes = Object . keys ( roleDefinition . requiredProps ) ;
145- const missingRequiredAttributes = requiredAttributes . filter (
146- ( requiredAttribute ) => ! foundAriaAttributes . includes ( requiredAttribute )
147- ) ;
148-
149- return missingRequiredAttributes . length > 0 ? missingRequiredAttributes : null ;
173+ return null ;
150174}
151175
152176/** @type {import('eslint').Rule.RuleModule } */
@@ -189,46 +213,38 @@ module.exports = {
189213
190214 return {
191215 GlimmerElementNode ( node ) {
192- const role = getStaticRoleFromElement ( node ) ;
216+ const roleTokens = getStaticRolesFromElement ( node ) ;
193217
194- if ( ! role ) {
218+ if ( ! roleTokens ) {
195219 return ;
196220 }
197221
198222 const foundAriaAttributes = ( node . attributes ?? [ ] )
199223 . filter ( ( attribute ) => attribute . name ?. startsWith ( 'aria-' ) )
200224 . map ( ( attribute ) => attribute . name ) ;
201225
202- const missingRequiredAttributes = getMissingRequiredAttributes (
203- role ,
204- foundAriaAttributes ,
205- node
206- ) ;
226+ const result = getMissingRequiredAttributes ( roleTokens , foundAriaAttributes , node ) ;
207227
208- if ( missingRequiredAttributes ) {
209- reportMissingAttributes ( node , role , missingRequiredAttributes ) ;
228+ if ( result ?. missing ) {
229+ reportMissingAttributes ( node , result . role , result . missing ) ;
210230 }
211231 } ,
212232
213233 GlimmerMustacheStatement ( node ) {
214- const role = getStaticRoleFromMustache ( node ) ;
234+ const roleTokens = getStaticRolesFromMustache ( node ) ;
215235
216- if ( ! role ) {
236+ if ( ! roleTokens ) {
217237 return ;
218238 }
219239
220240 const foundAriaAttributes = ( node . hash ?. pairs ?? [ ] )
221241 . filter ( ( pair ) => pair . key . startsWith ( 'aria-' ) )
222242 . map ( ( pair ) => pair . key ) ;
223243
224- const missingRequiredAttributes = getMissingRequiredAttributes (
225- role ,
226- foundAriaAttributes ,
227- node
228- ) ;
244+ const result = getMissingRequiredAttributes ( roleTokens , foundAriaAttributes , node ) ;
229245
230- if ( missingRequiredAttributes ) {
231- reportMissingAttributes ( node , role , missingRequiredAttributes ) ;
246+ if ( result ?. missing ) {
247+ reportMissingAttributes ( node , result . role , result . missing ) ;
232248 }
233249 } ,
234250 } ;
0 commit comments