@@ -185,97 +185,108 @@ function checkLabelAssociation(el, document) {
185185 return { matched : false } ;
186186}
187187
188- function isIdReferencePattern ( attrName , expectedValue ) {
189- return ID_REFERENCE_ATTRS . includes ( attrName ) && expectedValue &&
190- ( expectedValue . includes ( ' id' ) || expectedValue . startsWith ( '.k-' ) ) ;
191- }
192-
193- function validateIdReference ( el , attrName , expectedValue , document ) {
194- const actualValue = el . getAttribute ( attrName ) ;
195- if ( ! actualValue ?. trim ( ) ) {
196- return { matched : false , reason : 'missing-id-reference' , expected : `ID reference (${ expectedValue } )` , actual : actualValue } ;
197- }
188+ // ── Strategy validators ─────────────────────────────────────────────
189+ // Each returns { matched, actual?, expected?, reason? }
198190
199- const targetSelector = expectedValue . replace ( / \s + i d $ / , '' ) . trim ( ) ;
200- const targetEl = document . getElementById ( actualValue ) ;
201- if ( ! targetEl ) {
202- return { matched : false , reason : 'id-not-found' , expected : `element with id="${ actualValue } "` , actual : 'no element found' } ;
203- }
204-
205- if ( targetSelector ?. startsWith ( '.k-' ) ) {
206- const isListboxSel = targetSelector === '.k-list-ul' ;
207- if ( ! targetEl . matches ( targetSelector ) && ! ( isListboxSel && targetEl . matches ( '.k-list-content[role="listbox"]' ) ) ) {
208- return { matched : false , reason : 'id-wrong-element' , expected : `${ targetSelector } with id="${ actualValue } "` , actual : targetEl . className } ;
209- }
210- }
211- return { matched : true , actual : actualValue , reason : 'id-reference-valid' } ;
212- }
213-
214- /**
215- * Check a single attribute spec against a DOM element.
216- * Handles: boolean attrs, ID references, "or" alternatives, label associations,
217- * nodeName checks, and simple presence/value checks.
218- */
219- function checkSingleAttr ( el , attrSpec , document ) {
220- if ( attrSpec === 'label for' ) {
221- return checkLabelAssociation ( el , document ) ;
222- }
223- if ( attrSpec . startsWith ( 'nodeName=' ) ) {
224- return { matched : el . nodeName . toUpperCase ( ) === attrSpec . split ( '=' ) [ 1 ] . toUpperCase ( ) } ;
225- }
191+ const attrValidators = {
192+ labelFor : ( el , _attr , _val , doc ) =>
193+ checkLabelAssociation ( el , doc ) ,
226194
227- const m = attrSpec . match ( / ^ ( \S + ? ) (?: = ( . + ) ) ? $ / ) ;
228- if ( ! m ) { return { matched : false } ; }
195+ nodeName : ( el , _attr , tag ) =>
196+ ( { matched : el . nodeName . toUpperCase ( ) === tag . toUpperCase ( ) } ) ,
229197
230- const [ , attr , rawVal ] = m ;
231- // Strip surrounding quotes from value (e.g. role="switch" → switch)
232- const val = rawVal ?. replace ( / ^ [ " ' ] | [ " ' ] $ / g, '' ) ;
233- if ( BOOLEAN_ATTRS . includes ( attr ) ) {
198+ boolean : ( el , attr ) => {
234199 const present = el . getAttribute ( attr ) !== null ;
235200 return { matched : present , actual : present ? 'present' : null , expected : 'present' } ;
236- }
237- if ( isIdReferencePattern ( attr , val ) ) {
238- return validateIdReference ( el , attr , val , document ) ;
239- }
201+ } ,
240202
241- // Role check — account for implicit roles from semantic HTML
242- if ( attr === 'role' && val ) {
203+ idReference : ( el , attr , val , doc ) => {
204+ const actual = el . getAttribute ( attr ) ;
205+ if ( ! actual ?. trim ( ) ) {
206+ return { matched : false , reason : 'missing-id-reference' , expected : `ID reference (${ val } )` , actual } ;
207+ }
208+ const targetSelector = val . replace ( / \s + i d $ / , '' ) . trim ( ) ;
209+ const targetEl = doc . getElementById ( actual ) ;
210+ if ( ! targetEl ) {
211+ return { matched : false , reason : 'id-not-found' , expected : `element with id="${ actual } "` , actual : 'no element found' } ;
212+ }
213+ if ( targetSelector ?. startsWith ( '.k-' ) ) {
214+ const isListboxSel = targetSelector === '.k-list-ul' ;
215+ if ( ! targetEl . matches ( targetSelector ) && ! ( isListboxSel && targetEl . matches ( '.k-list-content[role="listbox"]' ) ) ) {
216+ return { matched : false , reason : 'id-wrong-element' , expected : `${ targetSelector } with id="${ actual } "` , actual : targetEl . className } ;
217+ }
218+ }
219+ return { matched : true , actual, reason : 'id-reference-valid' } ;
220+ } ,
221+
222+ role : ( el , _attr , val ) => {
243223 const explicit = el . getAttribute ( 'role' ) ;
244224 if ( explicit === val ) { return { matched : true , actual : explicit } ; }
245225 const implicit = getImplicitRole ( el ) ;
246226 if ( implicit === val ) { return { matched : true , actual : `${ val } (implicit from <${ el . nodeName . toLowerCase ( ) } >)` } ; }
247227 return { matched : false , actual : explicit || implicit || null , expected : val } ;
248- }
249-
250- // tabindex — natively focusable elements don't need explicit tabindex="0"
251- if ( attr === 'tabindex' && val === '0' && NATIVE_FOCUSABLE . includes ( el . nodeName ) ) {
252- return { matched : true , actual : '0 (natively focusable)' , reason : 'native-focusable' } ;
253- }
228+ } ,
254229
255- const actual = el . getAttribute ( attr ) ;
230+ nativeFocusable : ( ) =>
231+ ( { matched : true , actual : '0 (natively focusable)' , reason : 'native-focusable' } ) ,
256232
257- // Value with alternatives: "true/false" or "list|both|inline"
258- // If the attribute is absent, the rule passes (value constraint only applies when present)
259- if ( val && ( val . includes ( '/' ) || val . includes ( '|' ) ) ) {
233+ multiValue : ( el , attr , val ) => {
260234 const alternatives = val . split ( / [ / | ] / ) . map ( v => v . trim ( ) ) ;
235+ const actual = el . getAttribute ( attr ) ;
261236 if ( actual === null ) { return { matched : true , actual : null , reason : 'optional-value-absent' } ; }
262- const matched = alternatives . includes ( actual ) ;
263- return { matched, actual, expected : alternatives . join ( ' or ' ) } ;
264- }
237+ return { matched : alternatives . includes ( actual ) , actual, expected : alternatives . join ( ' or ' ) } ;
238+ } ,
265239
266- // Template variable pattern: ${id}-something → match any non-empty value
267- if ( val && val . includes ( '${' ) ) {
268- const matched = actual !== null && actual . trim ( ) !== '' ;
269- return { matched, actual, expected : `pattern: ${ val } ` } ;
270- }
240+ templatePattern : ( el , attr , val ) => {
241+ const actual = el . getAttribute ( attr ) ;
242+ return { matched : actual !== null && actual . trim ( ) !== '' , actual, expected : `pattern: ${ val } ` } ;
243+ } ,
244+
245+ stateDependentAbsent : ( ) =>
246+ ( { matched : true , actual : null , reason : 'state-dependent-absent' } ) ,
271247
272- // State-dependent attributes: presence-only check (no =value) passes when absent
273- if ( ! val && STATE_DEPENDENT_ATTRS . includes ( attr ) && actual === null ) {
274- return { matched : true , actual : null , reason : 'state-dependent-absent' } ;
248+ exactValue : ( el , attr , val ) => {
249+ const actual = el . getAttribute ( attr ) ;
250+ return { matched : actual === val , actual, expected : val } ;
251+ } ,
252+
253+ presence : ( el , attr ) => {
254+ const actual = el . getAttribute ( attr ) ;
255+ return { matched : actual !== null , actual, expected : 'present' } ;
275256 }
257+ } ;
258+
259+ // ── Classifier ──────────────────────────────────────────────────────
260+ // Maps an attribute spec string + element context to a strategy key + parsed parts.
261+
262+ function classifyAttr ( attrSpec , el ) {
263+ if ( attrSpec === 'label for' ) { return { type : 'labelFor' } ; }
264+ if ( attrSpec . startsWith ( 'nodeName=' ) ) { return { type : 'nodeName' , val : attrSpec . split ( '=' ) [ 1 ] } ; }
276265
277- const matched = val ? actual === val : actual !== null ;
278- return { matched, actual, expected : val || 'present' } ;
266+ const m = attrSpec . match ( / ^ ( \S + ?) (?: = ( .+ ) ) ? $ / ) ;
267+ if ( ! m ) { return null ; }
268+
269+ const attr = m [ 1 ] ;
270+ const val = m [ 2 ] ?. replace ( / ^ [ " ' ] | [ " ' ] $ / g, '' ) ;
271+
272+ if ( BOOLEAN_ATTRS . includes ( attr ) ) { return { type : 'boolean' , attr } ; }
273+ if ( ID_REFERENCE_ATTRS . includes ( attr ) && val && ( val . includes ( ' id' ) || val . startsWith ( '.k-' ) ) ) { return { type : 'idReference' , attr, val } ; }
274+ if ( attr === 'role' && val ) { return { type : 'role' , attr, val } ; }
275+ if ( attr === 'tabindex' && val === '0' && NATIVE_FOCUSABLE . includes ( el . nodeName ) ) { return { type : 'nativeFocusable' , attr, val } ; }
276+ if ( val && ( val . includes ( '/' ) || val . includes ( '|' ) ) ) { return { type : 'multiValue' , attr, val } ; }
277+ if ( val && val . includes ( '${' ) ) { return { type : 'templatePattern' , attr, val } ; }
278+ if ( ! val && STATE_DEPENDENT_ATTRS . includes ( attr ) && el . getAttribute ( attr ) === null ) { return { type : 'stateDependentAbsent' , attr } ; }
279+ if ( val ) { return { type : 'exactValue' , attr, val } ; }
280+ return { type : 'presence' , attr } ;
281+ }
282+
283+ // ── Single attribute check (delegates to strategy) ──────────────────
284+
285+ function checkSingleAttr ( el , attrSpec , document ) {
286+ const classified = classifyAttr ( attrSpec , el ) ;
287+ if ( ! classified ) { return { matched : false } ; }
288+ const { type, attr, val } = classified ;
289+ return attrValidators [ type ] ( el , attr , val , document ) ;
279290}
280291
281292function checkAttributeRule ( el , attribute , document ) {
0 commit comments