@@ -41,6 +41,56 @@ const extractCapturedId = (match: RegExpExecArray): string | undefined => {
4141 return match [ 2 ] || match [ 3 ] || match [ 4 ] || match [ 5 ] || match [ 6 ] || undefined ;
4242} ;
4343
44+ /**
45+ * New small helper: normalize attribute value (string list vs identifier vs empty/none)
46+ * Keeps getProp/getPropValue usage isolated and provides a single place to trim/split.
47+ * Return shape (for consumers):
48+ * { kind: "string", raw: string, tokens: string[] }
49+ * { kind: "identifier", name: string }
50+ * { kind: "empty" }
51+ * { kind: "none" }
52+ */
53+ const getAttributeValueInfo = (
54+ openingElement : TSESTree . JSXOpeningElement ,
55+ context : TSESLint . RuleContext < string , unknown [ ] > ,
56+ attrName : string
57+ ) : any => {
58+ const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , attrName ) ;
59+
60+ if ( prop && prop . value && ( prop . value as any ) . type === "JSXExpressionContainer" ) {
61+ const expr = ( prop . value as any ) . expression ;
62+ if ( expr && expr . type === "Identifier" ) {
63+ return { kind : "identifier" , name : expr . name as string } ;
64+ }
65+ if ( expr && expr . type === "Literal" && typeof ( expr as any ) . value === "string" ) {
66+ const trimmed = ( ( expr as any ) . value as string ) . trim ( ) ;
67+ if ( trimmed === "" ) return { kind : "empty" } ;
68+ return { kind : "string" , raw : trimmed , tokens : trimmed . split ( / \s + / ) } ;
69+ }
70+ }
71+
72+ const resolved = prop ? getPropValue ( prop ) : undefined ;
73+ if ( typeof resolved === "string" ) {
74+ const trimmed = resolved . trim ( ) ;
75+ if ( trimmed === "" ) return { kind : "empty" } ;
76+ return { kind : "string" , raw : trimmed , tokens : trimmed . split ( / \s + / ) } ;
77+ }
78+
79+ return { kind : "none" } ;
80+ } ;
81+
82+ const hasBracedAttrId = (
83+ tagPattern : string ,
84+ attrName : string ,
85+ idValue : string ,
86+ context : TSESLint . RuleContext < string , unknown [ ] >
87+ ) : boolean => {
88+ if ( ! idValue ) return false ;
89+ const src = getSourceText ( context ) ;
90+ const re = new RegExp ( `<(?:${ tagPattern } )[^>]*\\b${ attrName } \\s*=\\s*\\{\\s*${ escapeForRegExp ( idValue ) } \\s*\\}` , "i" ) ;
91+ return re . test ( src ) ;
92+ } ;
93+
4494/**
4595 * Checks if a Label exists with htmlFor that matches idValue.
4696 * Handles:
@@ -57,8 +107,7 @@ const hasLabelWithHtmlForId = (idValue: string, context: TSESLint.RuleContext<st
57107 if ( capturedValue === idValue ) return true ;
58108 }
59109
60- const fallbackRe = new RegExp ( `<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${ escapeForRegExp ( idValue ) } \\s*\\}` , "i" ) ;
61- return fallbackRe . test ( source ) ;
110+ return hasBracedAttrId ( "Label|label" , "htmlFor" , idValue , context ) ;
62111} ;
63112
64113/**
@@ -76,8 +125,7 @@ const hasLabelWithHtmlId = (idValue: string, context: TSESLint.RuleContext<strin
76125 if ( capturedValue === idValue ) return true ;
77126 }
78127
79- const fallbackRe = new RegExp ( `<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( idValue ) } \\s*\\}` , "i" ) ;
80- return fallbackRe . test ( source ) ;
128+ return hasBracedAttrId ( "Label|label" , "id" , idValue , context ) ;
81129} ;
82130
83131/**
@@ -94,8 +142,7 @@ const hasOtherElementWithHtmlId = (idValue: string, context: TSESLint.RuleContex
94142 if ( capturedValue === idValue ) return true ;
95143 }
96144
97- const fallbackRe = new RegExp ( `<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( idValue ) } \\s*\\}` , "i" ) ;
98- return fallbackRe . test ( source ) ;
145+ return hasBracedAttrId ( "div|span|p|h[1-6]" , "id" , idValue , context ) ;
99146} ;
100147
101148/**
@@ -111,38 +158,25 @@ const hasAssociatedAriaText = (
111158 context : TSESLint . RuleContext < string , unknown [ ] > ,
112159 ariaAttribute : string
113160) : boolean => {
114- const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , ariaAttribute ) ;
115- const resolved = prop ? getPropValue ( prop ) : undefined ;
161+ const info = getAttributeValueInfo ( openingElement , context , ariaAttribute ) ;
116162
117- if ( typeof resolved === "string" && resolved . trim ( ) !== "" ) {
118- // support space-separated lists like "first second" — check each id independently
119- const ids = resolved . trim ( ) . split ( / \s + / ) ;
120- for ( const id of ids ) {
163+ if ( info . kind === "string" ) {
164+ for ( const id of info . tokens ) {
121165 if ( hasLabelWithHtmlId ( id , context ) || hasOtherElementWithHtmlId ( id , context ) ) {
122166 return true ;
123167 }
124168 }
125169 return false ;
126170 }
127171
128- // identifier expression: aria-*= {someIdentifier}
129- if ( prop && prop . value && prop . value . type === "JSXExpressionContainer" ) {
130- const expr = ( prop . value as any ) . expression ;
131- if ( expr && expr . type === "Identifier" ) {
132- const varName = expr . name as string ;
133- const src = getSourceText ( context ) ;
134- const labelMatch = new RegExp ( `<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( varName ) } \\s*\\}` , "i" ) . test ( src ) ;
135- const otherMatch = new RegExp ( `<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( varName ) } \\s*\\}` , "i" ) . test (
136- src
137- ) ;
138- return labelMatch || otherMatch ;
139- }
172+ if ( info . kind === "identifier" ) {
173+ const varName = info . name ;
174+ return hasBracedAttrId ( "Label|label" , "id" , varName , context ) || hasBracedAttrId ( "div|span|p|h[1-6]" , "id" , varName , context ) ;
140175 }
141176
142177 return false ;
143178} ;
144179
145- /* thin wrappers kept for compatibility with existing callers */
146180const hasAssociatedLabelViaAriaLabelledBy = (
147181 openingElement : TSESTree . JSXOpeningElement ,
148182 context : TSESLint . RuleContext < string , unknown [ ] >
@@ -153,30 +187,21 @@ const hasAssociatedLabelViaAriaDescribedby = (
153187 context : TSESLint . RuleContext < string , unknown [ ] >
154188) => hasAssociatedAriaText ( openingElement , context , "aria-describedby" ) ;
155189
156- /**
157- * htmlFor / id relationship helper for controls (string + identifier fallback)
158- */
159190const hasAssociatedLabelViaHtmlFor = ( openingElement : TSESTree . JSXOpeningElement , context : TSESLint . RuleContext < string , unknown [ ] > ) => {
160- const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , "id" ) ;
161- const resolved = prop ? getPropValue ( prop ) : undefined ;
191+ const info = getAttributeValueInfo ( openingElement , context , "id" ) ;
162192
163- if ( typeof resolved === "string" && resolved . trim ( ) !== " ") {
164- return hasLabelWithHtmlForId ( resolved , context ) ;
193+ if ( info . kind === "string" ) {
194+ return hasLabelWithHtmlForId ( info . raw , context ) ;
165195 }
166196
167- if ( prop && prop . value && prop . value . type === "JSXExpressionContainer" ) {
168- const expr = ( prop . value as any ) . expression ;
169- if ( expr && expr . type === "Identifier" ) {
170- const varName = expr . name as string ;
171- const src = getSourceText ( context ) ;
172- return new RegExp ( `<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${ escapeForRegExp ( varName ) } \\s*\\}` , "i" ) . test ( src ) ;
173- }
197+ if ( info . kind === "identifier" ) {
198+ const varName = info . name ;
199+ return hasBracedAttrId ( "Label|label" , "htmlFor" , varName , context ) ;
174200 }
175201
176202 return false ;
177203} ;
178204
179- /* exported API */
180205export {
181206 isInsideLabelTag ,
182207 hasLabelWithHtmlForId ,
@@ -185,5 +210,7 @@ export {
185210 hasAssociatedLabelViaHtmlFor ,
186211 hasAssociatedLabelViaAriaDescribedby ,
187212 hasAssociatedAriaText ,
188- hasOtherElementWithHtmlId
213+ hasOtherElementWithHtmlId ,
214+ hasBracedAttrId ,
215+ getAttributeValueInfo
189216} ;
0 commit comments