@@ -8,6 +8,8 @@ import { TSESLint } from "@typescript-eslint/utils";
88import { JSXOpeningElement } from "estree-jsx" ;
99import { TSESTree } from "@typescript-eslint/utils" ;
1010
11+ const validIdentifierRe = / ^ [ A - Z a - z _ $ ] [ A - Z a - z 0 - 9 _ $ ] * $ / ;
12+
1113/**
1214 * Checks if the element is nested within a Label tag.
1315 */
@@ -27,7 +29,8 @@ const isInsideLabelTag = (context: TSESLint.RuleContext<string, unknown[]>): boo
2729 * Capture groups (when the alternation matches) are in positions 2..6
2830 * (group 1 is the element/tag capture used in some surrounding regexes).
2931 */
30- const idOrExprRegex = / (?: " ( [ ^ " ] * ) " | ' ( [ ^ ' ] * ) ' | \{ \s * " ( [ ^ " ] * ) " \s * \} | \{ \s * ' ( [ ^ ' ] * ) ' \s * \} | \{ \s * ( [ A - Z a - z _ $ ] [ A - Z a L i g n $ 0 - 9 _ $ ] * ) \s * \} ) / i;
32+ // FIXED: typo in identifier character class (A-ZaLign -> A-Za-z)
33+ const idOrExprRegex = / (?: " ( [ ^ " ] * ) " | ' ( [ ^ ' ] * ) ' | \{ \s * " ( [ ^ " ] * ) " \s * \} | \{ \s * ' ( [ ^ ' ] * ) ' \s * \} | \{ \s * ( [ A - Z a - z _ $ ] [ A - Z a - z 0 - 9 _ $ ] * ) \s * \} ) / i;
3134
3235const escapeForRegExp = ( s : string ) : string => s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
3336
@@ -42,11 +45,54 @@ const extractCapturedId = (match: RegExpExecArray): string | undefined => {
4245} ;
4346
4447/**
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+ * Evaluate simple constant BinaryExpression concatenations (left/right are Literals or nested BinaryExpressions).
49+ * Returns string when evaluation succeeds, otherwise undefined.
50+ */
51+ const evalConstantString = ( node : any ) : string | undefined => {
52+ if ( ! node || typeof node !== "object" ) return undefined ;
53+ if ( node . type === "Literal" ) {
54+ return String ( node . value ) ;
55+ }
56+ if ( node . type === "BinaryExpression" && node . operator === "+" ) {
57+ const left = evalConstantString ( node . left ) ;
58+ if ( left === undefined ) return undefined ;
59+ const right = evalConstantString ( node . right ) ;
60+ if ( right === undefined ) return undefined ;
61+ return left + right ;
62+ }
63+ return undefined ;
64+ } ;
65+
66+ /**
67+ * Small renderer to reconstruct simple expression source text for BinaryExpressions and Literals.
68+ * This provides a normalized textual form we can use to search the raw source for an exact expression match.
69+ * For strings, we preserve quotes by using JSON.stringify; numbers use String(value).
70+ */
71+ const renderSimpleExprSource = ( node : any ) : string | undefined => {
72+ if ( ! node || typeof node !== "object" ) return undefined ;
73+ if ( node . type === "Literal" ) {
74+ const val = ( node as any ) . value ;
75+ if ( typeof val === "string" ) return JSON . stringify ( val ) ; // keep the quotes "..."
76+ return String ( val ) ;
77+ }
78+ if ( node . type === "BinaryExpression" && node . operator === "+" ) {
79+ const left = renderSimpleExprSource ( node . left ) ;
80+ if ( left === undefined ) return undefined ;
81+ const right = renderSimpleExprSource ( node . right ) ;
82+ if ( right === undefined ) return undefined ;
83+ return `${ left } + ${ right } ` ;
84+ }
85+ // Not attempting to render arbitrary expressions (Identifiers, MemberExpression, etc.)
86+ return undefined ;
87+ } ;
88+
89+ /**
90+ * New small helper: normalize attribute value (string list vs identifier vs empty/none vs template)
91+ *
92+ * Return shapes:
4893 * { kind: "string", raw: string, tokens: string[] }
4994 * { kind: "identifier", name: string }
95+ * { kind: "template", template: string } // template uses backticks and ${exprName} placeholders
5096 * { kind: "empty" }
5197 * { kind: "none" }
5298 */
@@ -57,18 +103,71 @@ const getAttributeValueInfo = (
57103) : any => {
58104 const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , attrName ) ;
59105
106+ // Prefer inspecting the AST expression container directly when present
60107 if ( prop && prop . value && ( prop . value as any ) . type === "JSXExpressionContainer" ) {
61108 const expr = ( prop . value as any ) . expression ;
109+
110+ // Identifier: only accept valid JS identifiers
62111 if ( expr && expr . type === "Identifier" ) {
63- return { kind : "identifier" , name : expr . name as string } ;
112+ if ( typeof expr . name === "string" && validIdentifierRe . test ( expr . name ) ) {
113+ return { kind : "identifier" , name : expr . name as string } ;
114+ }
115+ return { kind : "none" } ;
64116 }
117+
118+ // Literal inside expression container: {"x"} or {'x'}
65119 if ( expr && expr . type === "Literal" && typeof ( expr as any ) . value === "string" ) {
66120 const trimmed = ( ( expr as any ) . value as string ) . trim ( ) ;
67121 if ( trimmed === "" ) return { kind : "empty" } ;
68- return { kind : "string" , raw : trimmed , tokens : trimmed . split ( / \s + / ) } ;
122+ return { kind : "string" , raw : trimmed , tokens : trimmed . split ( / \s + / ) , exprText : JSON . stringify ( ( expr as any ) . value ) } ;
123+ }
124+
125+ // BinaryExpression evaluation for constant concatenations: {"my-" + "label"} or {"my-" + 1}
126+ if ( expr && expr . type === "BinaryExpression" ) {
127+ const v = evalConstantString ( expr ) ;
128+ if ( typeof v === "string" ) {
129+ const trimmed = v . trim ( ) ;
130+ if ( trimmed === "" ) return { kind : "empty" } ;
131+ // Reconstruct simple source for the binary expression so we can search for an exact occurrence in raw source
132+ const exprText = renderSimpleExprSource ( expr ) ;
133+ if ( exprText ) {
134+ return { kind : "string" , raw : trimmed , tokens : trimmed . split ( / \s + / ) , exprText } ;
135+ }
136+ return { kind : "string" , raw : trimmed , tokens : trimmed . split ( / \s + / ) } ;
137+ }
138+ }
139+
140+ // TemplateLiteral: reconstruct a canonical template string (preserve placeholders as ${name})
141+ if ( expr && expr . type === "TemplateLiteral" ) {
142+ try {
143+ const quasis = ( expr as any ) . quasis || [ ] ;
144+ const expressions = ( expr as any ) . expressions || [ ] ;
145+ let templateRaw = "`" ;
146+ for ( let i = 0 ; i < quasis . length ; i ++ ) {
147+ const q = quasis [ i ] ;
148+ const rawPart = ( q && q . value && ( q . value . raw ?? q . value . cooked ) ) || "" ;
149+ templateRaw += rawPart ;
150+ if ( i < expressions . length ) {
151+ const e = expressions [ i ] ;
152+ if ( e && e . type === "Identifier" && typeof e . name === "string" ) {
153+ templateRaw += "${" + e . name + "}" ;
154+ } else if ( e && e . type === "Literal" ) {
155+ templateRaw += "${" + String ( ( e as any ) . value ) + "}" ;
156+ } else {
157+ // unknown expression placeholder — include empty placeholder
158+ templateRaw += "${}" ;
159+ }
160+ }
161+ }
162+ templateRaw += "`" ;
163+ return { kind : "template" , template : templateRaw } ;
164+ } catch {
165+ // if anything goes wrong, fall through
166+ }
69167 }
70168 }
71169
170+ // Fallback: try to resolve via getPropValue (covers literal attrs and expression-literals and other resolvable forms)
72171 const resolved = prop ? getPropValue ( prop ) : undefined ;
73172 if ( typeof resolved === "string" ) {
74173 const trimmed = resolved . trim ( ) ;
@@ -93,8 +192,6 @@ const hasBracedAttrId = (
93192
94193/**
95194 * Checks if a Label exists with htmlFor that matches idValue.
96- * Handles:
97- * - htmlFor="id", htmlFor={'id'}, htmlFor={"id"}, htmlFor={idVar}
98195 */
99196const hasLabelWithHtmlForId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
100197 if ( ! idValue ) return false ;
@@ -112,7 +209,6 @@ const hasLabelWithHtmlForId = (idValue: string, context: TSESLint.RuleContext<st
112209
113210/**
114211 * Checks if a Label exists with id that matches idValue.
115- * Handles: id="x", id={'x'}, id={"x"}, id={x}
116212 */
117213const hasLabelWithHtmlId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
118214 if ( ! idValue ) return false ;
@@ -146,12 +242,7 @@ const hasOtherElementWithHtmlId = (idValue: string, context: TSESLint.RuleContex
146242} ;
147243
148244/**
149- * Generic helper for aria-* attributes:
150- * - if prop resolves to a string (literal or expression-literal) then we check labels/ids
151- * - if prop is an identifier expression (aria-*= {someId}) we fall back to a narrow regex that checks
152- * other elements/labels with id={someId}
153- *
154- * This keeps the implementation compact and robust for the project's tests and common source patterns.
245+ * Generic helper for aria-* attributes.
155246 */
156247const hasAssociatedAriaText = (
157248 openingElement : TSESTree . JSXOpeningElement ,
@@ -165,6 +256,14 @@ const hasAssociatedAriaText = (
165256 if ( hasLabelWithHtmlId ( id , context ) || hasOtherElementWithHtmlId ( id , context ) ) {
166257 return true ;
167258 }
259+ // Fallback: if this string was produced by evaluating a BinaryExpression in the source,
260+ // attempt to match the exact binary-expression source in other element id attributes.
261+ if ( info . exprText ) {
262+ const labelRe = new RegExp ( `<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( info . exprText ) } \\s*\\}` , "i" ) ;
263+ const otherRe = new RegExp ( `<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( info . exprText ) } \\s*\\}` , "i" ) ;
264+ const src = getSourceText ( context ) ;
265+ if ( labelRe . test ( src ) || otherRe . test ( src ) ) return true ;
266+ }
168267 }
169268 return false ;
170269 }
@@ -174,6 +273,27 @@ const hasAssociatedAriaText = (
174273 return hasBracedAttrId ( "Label|label" , "id" , varName , context ) || hasBracedAttrId ( "div|span|p|h[1-6]" , "id" , varName , context ) ;
175274 }
176275
276+ if ( info . kind === "template" ) {
277+ const templ = info . template as string ;
278+ const src = getSourceText ( context ) ;
279+ // Build a pattern which matches the template's literal parts but allows any expression
280+ // inside `${...}` placeholders. This lets templates with non-Identifier expressions
281+ // (e.g. `${a.b}`) match the canonicalized template produced from the AST.
282+ const placeholderRe = / \$ \{ [ ^ } ] * \} / g;
283+ let pattern = "" ;
284+ let idx = 0 ;
285+ let m : RegExpExecArray | null ;
286+ while ( ( m = placeholderRe . exec ( templ ) ) !== null ) {
287+ pattern += escapeForRegExp ( templ . slice ( idx , m . index ) ) ;
288+ pattern += "\\$\\{[^}]*\\}" ;
289+ idx = m . index + m [ 0 ] . length ;
290+ }
291+ pattern += escapeForRegExp ( templ . slice ( idx ) ) ;
292+ const labelRe = new RegExp ( `<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${ pattern } \\s*\\}` , "i" ) ;
293+ const otherRe = new RegExp ( `<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${ pattern } \\s*\\}` , "i" ) ;
294+ return labelRe . test ( src ) || otherRe . test ( src ) ;
295+ }
296+
177297 return false ;
178298} ;
179299
@@ -191,14 +311,28 @@ const hasAssociatedLabelViaHtmlFor = (openingElement: TSESTree.JSXOpeningElement
191311 const info = getAttributeValueInfo ( openingElement , context , "id" ) ;
192312
193313 if ( info . kind === "string" ) {
194- return hasLabelWithHtmlForId ( info . raw , context ) ;
314+ // primary: match literal/htmlFor forms
315+ if ( hasLabelWithHtmlForId ( info . raw , context ) ) return true ;
316+ // fallback: match htmlFor written as a BinaryExpression / other expression that matches the same source text
317+ if ( info . exprText ) {
318+ const src = getSourceText ( context ) ;
319+ const htmlForRe = new RegExp ( `<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${ escapeForRegExp ( info . exprText ) } \\s*\\}` , "i" ) ;
320+ if ( htmlForRe . test ( src ) ) return true ;
321+ }
322+ return false ;
195323 }
196324
197325 if ( info . kind === "identifier" ) {
198326 const varName = info . name ;
199327 return hasBracedAttrId ( "Label|label" , "htmlFor" , varName , context ) ;
200328 }
201329
330+ if ( info . kind === "template" ) {
331+ const templ = info . template as string ;
332+ const src = getSourceText ( context ) ;
333+ return new RegExp ( `<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${ escapeForRegExp ( templ ) } \\s*\\}` , "i" ) . test ( src ) ;
334+ }
335+
202336 return false ;
203337} ;
204338
0 commit comments