@@ -5,230 +5,173 @@ import { elementType } from "jsx-ast-utils";
55import { getPropValue } from "jsx-ast-utils" ;
66import { getProp } from "jsx-ast-utils" ;
77import { hasNonEmptyProp } from "./hasNonEmptyProp" ;
8- import { TSESLint } from "@typescript-eslint/utils" ; // Assuming context comes from TSESLint
8+ import { TSESLint } from "@typescript-eslint/utils" ;
99import { JSXOpeningElement } from "estree-jsx" ;
1010import { TSESTree } from "@typescript-eslint/utils" ;
1111
1212/**
1313 * Checks if the element is nested within a Label tag.
14- * e.g.
15- * <Label>
16- * Sample input
17- * <Input {...props} />
18- * </Label>
19- * @param {* } context
20- * @returns
2114 */
22- const isInsideLabelTag = ( context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
23- return context . getAncestors ( ) . some ( node => {
15+ const isInsideLabelTag = ( context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean =>
16+ context . getAncestors ( ) . some ( node => {
2417 if ( node . type !== "JSXElement" ) return false ;
2518 const tagName = elementType ( node . openingElement as unknown as JSXOpeningElement ) ;
2619 return tagName . toLowerCase ( ) === "label" ;
2720 } ) ;
28- } ;
2921
3022/**
31- * Checks if there is a Label component inside the source code with a htmlFor attribute matching that of the id parameter.
32- * e.g.
33- * id=parameter, <Label htmlFor={parameter}>Hello</Label>
34- * @param {* } idValue
35- * @param {* } context
36- * @returns boolean for match found or not.
23+ * idOrExprRegex supports:
24+ * - "double-quoted" and 'single-quoted' attribute values
25+ * - expression containers with quoted strings: htmlFor={"id"} or id={'id'}
26+ * - expression containers with an Identifier: htmlFor={someId} or id={someId}
27+ *
28+ * Capture groups (when the alternation matches) are in positions 2..6
29+ * (group 1 is the element/tag capture used in some surrounding regexes).
3730 */
38- const hasLabelWithHtmlForId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
39- if ( idValue === "" ) {
40- return false ;
41- }
42- const sourceCode = context . getSourceCode ( ) ;
31+ const idOrExprRegex = / (?: " ( [ ^ " ] * ) " | ' ( [ ^ ' ] * ) ' | \{ \s * " ( [ ^ " ] * ) " \s * \} | \{ \s * ' ( [ ^ ' ] * ) ' \s * \} | \{ \s * ( [ A - Z a - z _ $ ] [ A - Z a L i g n $ 0 - 9 _ $ ] * ) \s * \} ) / i;
4332
44- const regex = / < ( L a b e l | l a b e l ) [ ^ > ] * \b h t m l F o r \b \s * = \s * [ " { ' ] ( [ ^ " ' { } ] * ) [ " ' } ] / gi ;
33+ const escapeForRegExp = ( s : string ) : string => s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g , "\\$&" ) ;
4534
46- let match ;
47- while ( ( match = regex . exec ( sourceCode . text ) ) !== null ) {
48- // `match[2]` contains the `htmlFor` attribute value
49- if ( match [ 2 ] === idValue ) {
50- return true ;
51- }
52- }
53- return false ;
35+ const getSourceText = ( context : TSESLint . RuleContext < string , unknown [ ] > ) => ( context . getSourceCode ( ) as any ) . text as string ;
36+
37+ /**
38+ * Return captured id value from regex match where idOrExprRegex was used as the RHS.
39+ * match[2]..match[6] are the possible capture positions.
40+ */
41+ const extractCapturedId = ( match : RegExpExecArray ) : string | undefined => {
42+ return match [ 2 ] || match [ 3 ] || match [ 4 ] || match [ 5 ] || match [ 6 ] || undefined ;
5443} ;
5544
5645/**
57- * Checks if there is a Label component inside the source code with an id matching that of the id parameter.
58- * e.g.
59- * id=parameter, <Label id={parameter}>Hello</Label>
60- * @param {* } idValue value of the props id e.g. <Label id={'my-value'} />
61- * @param {* } context
62- * @returns boolean for match found or not.
46+ * Checks if a Label exists with htmlFor that matches idValue.
47+ * Handles:
48+ * - htmlFor="id", htmlFor={'id'}, htmlFor={"id"}, htmlFor={idVar}
6349 */
64- const hasLabelWithHtmlId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
65- if ( idValue === "" ) {
66- return false ;
50+ const hasLabelWithHtmlForId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
51+ if ( ! idValue ) return false ;
52+ const source = getSourceText ( context ) ;
53+ const regex = new RegExp ( `<(Label|label)[^>]*\\bhtmlFor\\s*=\\s*${ idOrExprRegex . source } ` , "gi" ) ;
54+
55+ let match : RegExpExecArray | null ;
56+ while ( ( match = regex . exec ( source ) ) !== null ) {
57+ const capturedValue = extractCapturedId ( match ) ;
58+ if ( capturedValue === idValue ) return true ;
6759 }
68- const sourceCode = context . getSourceCode ( ) ;
69-
70- const regex = / < ( L a b e l | l a b e l ) [ ^ > ] * \b i d \b \s * = \s * [ " { ' ] ( [ ^ " ' { } ] * ) [ " ' } ] / gi;
7160
72- let match ;
73- while ( ( match = regex . exec ( sourceCode . text ) ) !== null ) {
74- // match[2] should contain the id value
75- if ( match [ 2 ] === idValue ) {
76- return true ;
77- }
78- }
79- return false ;
61+ const fallbackRe = new RegExp ( `<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${ escapeForRegExp ( idValue ) } \\s*\\}` , "i" ) ;
62+ return fallbackRe . test ( source ) ;
8063} ;
8164
82- /***
83- * Checks if there is another element with an id matching that of the id parameter.
84- * * e.g.
85- * <h2 id={labelId}>Sample input</h2>
86- * <Input aria-labelledby={labelId} />
87- * @param {* } openingElement
88- * @param {* } context
89- * @returns boolean for match found or not.
65+ /**
66+ * Checks if a Label exists with id that matches idValue.
67+ * Handles: id="x", id={'x'}, id={"x"}, id={x}
9068 */
91- const hasOtherElementWithHtmlId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
92- if ( idValue === "" ) {
93- return false ;
69+ const hasLabelWithHtmlId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
70+ if ( ! idValue ) return false ;
71+ const source = getSourceText ( context ) ;
72+ const regex = new RegExp ( `<(Label|label)[^>]*\\bid\\s*=\\s*${ idOrExprRegex . source } ` , "gi" ) ;
73+
74+ let match : RegExpExecArray | null ;
75+ while ( ( match = regex . exec ( source ) ) !== null ) {
76+ const capturedValue = extractCapturedId ( match ) ;
77+ if ( capturedValue === idValue ) return true ;
9478 }
95- const sourceCode : string = context . getSourceCode ( ) . text ;
96-
97- // Adjusted regex pattern for elements with `id` attribute
98- const regex = / < ( d i v | s p a n | p | h [ 1 - 6 ] ) [ ^ > ] * \b i d \b \s * = \s * [ " { ' ] ( [ ^ " ' { } ] * ) [ " ' } ] / gi;
9979
100- let match ;
101- while ( ( match = regex . exec ( sourceCode ) ) !== null ) {
102- // `match[2]` contains the `id` value in each matched element
103- if ( match [ 2 ] === idValue ) {
104- return true ;
105- }
106- }
107- return false ;
80+ const fallbackRe = new RegExp ( `<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( idValue ) } \\s*\\}` , "i" ) ;
81+ return fallbackRe . test ( source ) ;
10882} ;
10983
11084/**
111- * Determines if the element has a label with the matching id associated with it via aria-labelledby.
112- * e.g.
113- * <Label id={labelId}>Sample input</Label>
114- * <Input aria-labelledby={labelId} />
115- * @param {* } openingElement
116- * @param {* } context
117- * @returns boolean for match found or not.
85+ * Checks other simple elements (div/span/p/h1..h6) for id matching idValue.
11886 */
119- const hasAssociatedLabelViaAriaLabelledBy = (
120- openingElement : TSESTree . JSXOpeningElement ,
121- context : TSESLint . RuleContext < string , unknown [ ] >
122- ) : boolean => {
123- const _hasAriaLabelledBy = hasNonEmptyProp ( openingElement . attributes , "aria-labelledby" ) ;
124- const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , "aria-labelledby" ) ;
125-
126- // Check if the prop exists before passing it to getPropValue
127- const idValue = prop ? getPropValue ( prop ) : undefined ;
128-
129- // Check if idValue is a string and handle the case where it's not
130- if ( typeof idValue !== "string" || idValue . trim ( ) === "" ) {
131- return false ;
87+ const hasOtherElementWithHtmlId = ( idValue : string , context : TSESLint . RuleContext < string , unknown [ ] > ) : boolean => {
88+ if ( ! idValue ) return false ;
89+ const source = getSourceText ( context ) ;
90+ const regex = new RegExp ( `<(div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*${ idOrExprRegex . source } ` , "gi" ) ;
91+
92+ let match : RegExpExecArray | null ;
93+ while ( ( match = regex . exec ( source ) ) !== null ) {
94+ const capturedValue = extractCapturedId ( match ) ;
95+ if ( capturedValue === idValue ) return true ;
13296 }
13397
134- const hasHtmlId = hasLabelWithHtmlId ( idValue , context ) ;
135- const hasElementWithHtmlId = hasOtherElementWithHtmlId ( idValue , context ) ;
136-
137- return _hasAriaLabelledBy && ( hasHtmlId || hasElementWithHtmlId ) ;
98+ const fallbackRe = new RegExp ( `<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( idValue ) } \\s*\\}` , "i" ) ;
99+ return fallbackRe . test ( source ) ;
138100} ;
139101
140102/**
141- * Determines if the element has a label with the matching id associated with it via aria-describedby.
142- * e.g.
143- * <Label id={labelId}>Sample input</Label>
144- * <Input aria-describedby={labelId} />
145- * @param {* } openingElement
146- * @param {* } context
147- * @returns boolean for match found or not.
103+ * Generic helper for aria-* attributes:
104+ * - if prop resolves to a string (literal or expression-literal) then we check labels/ids
105+ * - if prop is an identifier expression (aria-*= {someId}) we fall back to a narrow regex that checks
106+ * other elements/labels with id={someId}
107+ *
108+ * This keeps the implementation compact and robust for the project's tests and common source patterns.
148109 */
149- const hasAssociatedLabelViaAriaDescribedby = (
110+ const hasAssociatedAriaText = (
150111 openingElement : TSESTree . JSXOpeningElement ,
151- context : TSESLint . RuleContext < string , unknown [ ] >
112+ context : TSESLint . RuleContext < string , unknown [ ] > ,
113+ ariaAttribute : string
152114) : boolean => {
153- const hasAssociatedLabelViaAriadescribedby = hasNonEmptyProp ( openingElement . attributes , "aria-describedby" ) ;
154-
155- const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , "aria-describedby" ) ;
156-
157- // Check if the prop exists before passing it to getPropValue
158- const idValue = prop ? getPropValue ( prop ) : undefined ;
159-
160- // Check if idValue is a string and handle the case where it's not
161- if ( typeof idValue !== "string" || idValue . trim ( ) === "" ) {
115+ const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , ariaAttribute ) ;
116+ const resolved = prop ? getPropValue ( prop ) : undefined ;
117+
118+ if ( typeof resolved === "string" && resolved . trim ( ) !== "" ) {
119+ // support space-separated lists like "first second" — check each id independently
120+ const ids = resolved . trim ( ) . split ( / \s + / ) ;
121+ for ( const id of ids ) {
122+ if ( hasLabelWithHtmlId ( id , context ) || hasOtherElementWithHtmlId ( id , context ) ) {
123+ return true ;
124+ }
125+ }
162126 return false ;
163127 }
164128
165- const hasHtmlId = hasLabelWithHtmlId ( idValue , context ) ;
166- const hasElementWithHtmlId = hasOtherElementWithHtmlId ( idValue , context ) ;
129+ // identifier expression: aria-*= {someIdentifier}
130+ if ( prop && prop . value && prop . value . type === "JSXExpressionContainer" ) {
131+ const expr = ( prop . value as any ) . expression ;
132+ if ( expr && expr . type === "Identifier" ) {
133+ const varName = expr . name as string ;
134+ const src = getSourceText ( context ) ;
135+ const labelMatch = new RegExp ( `<(?:Label|label)[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( varName ) } \\s*\\}` , "i" ) . test ( src ) ;
136+ const otherMatch = new RegExp ( `<(?:div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*\\{\\s*${ escapeForRegExp ( varName ) } \\s*\\}` , "i" ) . test ( src ) ;
137+ return labelMatch || otherMatch ;
138+ }
139+ }
167140
168- return hasAssociatedLabelViaAriadescribedby && ( hasHtmlId || hasElementWithHtmlId ) ;
141+ return false ;
169142} ;
170143
144+ /* thin wrappers kept for compatibility with existing callers */
145+ const hasAssociatedLabelViaAriaLabelledBy = ( openingElement : TSESTree . JSXOpeningElement , context : TSESLint . RuleContext < string , unknown [ ] > ) =>
146+ hasAssociatedAriaText ( openingElement , context , "aria-labelledby" ) ;
147+
148+ const hasAssociatedLabelViaAriaDescribedby = ( openingElement : TSESTree . JSXOpeningElement , context : TSESLint . RuleContext < string , unknown [ ] > ) =>
149+ hasAssociatedAriaText ( openingElement , context , "aria-describedby" ) ;
150+
171151/**
172- * Determines if the element has a label associated with it via htmlFor
173- * e.g.
174- * <Label htmlFor={inputId}>Sample input</Label>
175- * <Input id={inputId} />
176- * @param {* } openingElement
177- * @param {* } context
178- * @returns boolean for match found or not.
152+ * htmlFor / id relationship helper for controls (string + identifier fallback)
179153 */
180154const hasAssociatedLabelViaHtmlFor = ( openingElement : TSESTree . JSXOpeningElement , context : TSESLint . RuleContext < string , unknown [ ] > ) => {
181155 const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , "id" ) ;
156+ const resolved = prop ? getPropValue ( prop ) : undefined ;
182157
183- const idValue = prop ? getPropValue ( prop ) : undefined ;
184-
185- // Check if idValue is a string and handle the case where it's not
186- if ( typeof idValue !== "string" || idValue . trim ( ) === "" ) {
187- return false ;
158+ if ( typeof resolved === "string" && resolved . trim ( ) !== "" ) {
159+ return hasLabelWithHtmlForId ( resolved , context ) ;
188160 }
189161
190- return hasLabelWithHtmlForId ( idValue , context ) ;
191- } ;
192-
193- /**
194- * Determines if the element has a node with the matching id associated with it via the aria-attribute e.g. aria-describedby/aria-labelledby.
195- * e.g.
196- * <span id={labelI1}>Sample input Description</Label>
197- * <Label id={labelId2}>Sample input label</Label>
198- * <Input aria-describedby={labelId1} aria-labelledby={labelId2}/>
199- * @param {* } openingElement
200- * @param {* } context
201- * @param {* } ariaAttribute
202- * @returns boolean for match found or not.
203- */
204- const hasAssociatedAriaText = (
205- openingElement : TSESTree . JSXOpeningElement ,
206- context : TSESLint . RuleContext < string , unknown [ ] > ,
207- ariaAttribute : string
208- ) => {
209- const hasAssociatedAriaText = hasNonEmptyProp ( openingElement . attributes , ariaAttribute ) ;
210-
211- const prop = getProp ( openingElement . attributes as unknown as JSXOpeningElement [ "attributes" ] , ariaAttribute ) ;
212-
213- const idValue = prop ? getPropValue ( prop ) : undefined ;
214-
215- let hasHtmlId = false ;
216- if ( idValue ) {
217- const sourceCode = context . getSourceCode ( ) ;
218-
219- const regex = / < ( \w + ) [ ^ > ] * i d \s * = \s * [ " ' ] ( [ ^ " ' ] * ) [ " ' ] [ ^ > ] * > / gi;
220- let match ;
221- const ids = [ ] ;
222-
223- while ( ( match = regex . exec ( sourceCode . text ) ) !== null ) {
224- ids . push ( match [ 2 ] ) ;
162+ if ( prop && prop . value && prop . value . type === "JSXExpressionContainer" ) {
163+ const expr = ( prop . value as any ) . expression ;
164+ if ( expr && expr . type === "Identifier" ) {
165+ const varName = expr . name as string ;
166+ const src = getSourceText ( context ) ;
167+ return new RegExp ( `<(?:Label|label)[^>]*\\bhtmlFor\\s*=\\s*\\{\\s*${ escapeForRegExp ( varName ) } \\s*\\}` , "i" ) . test ( src ) ;
225168 }
226- hasHtmlId = ids . some ( id => id === idValue ) ;
227169 }
228170
229- return hasAssociatedAriaText && hasHtmlId ;
171+ return false ;
230172} ;
231173
174+ /* exported API */
232175export {
233176 isInsideLabelTag ,
234177 hasLabelWithHtmlForId ,
0 commit comments