Skip to content

Commit 32d7d3f

Browse files
Detect ids in JSX expression containers and identifier refs
1 parent 2306b4d commit 32d7d3f

File tree

3 files changed

+231
-272
lines changed

3 files changed

+231
-272
lines changed

lib/util/labelUtils.ts

Lines changed: 111 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -5,230 +5,173 @@ import { elementType } from "jsx-ast-utils";
55
import { getPropValue } from "jsx-ast-utils";
66
import { getProp } from "jsx-ast-utils";
77
import { hasNonEmptyProp } from "./hasNonEmptyProp";
8-
import { TSESLint } from "@typescript-eslint/utils"; // Assuming context comes from TSESLint
8+
import { TSESLint } from "@typescript-eslint/utils";
99
import { JSXOpeningElement } from "estree-jsx";
1010
import { 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-Za-z_$][A-ZaLign$0-9_$]*)\s*\})/i;
4332

44-
const regex = /<(Label|label)[^>]*\bhtmlFor\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 = /<(Label|label)[^>]*\bid\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 = /<(div|span|p|h[1-6])[^>]*\bid\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
*/
180154
const 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+)[^>]*id\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 */
232175
export {
233176
isInsideLabelTag,
234177
hasLabelWithHtmlForId,

tests/lib/rules/combobox-needs-labelling.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ ruleTester.run("combobox-needs-labelling", rule as unknown as Rule.RuleModule, {
2121
'<Label>Best pet<Combobox placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></Label>',
2222
'<div><label id="my-label">Best pet</label><Combobox aria-labelledby="my-label" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
2323
'<div><Label id="my-label">Best pet</Label><Combobox aria-labelledby="my-label" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
24-
// '<div><Label id={"my-label"}>Best pet</Label><Combobox aria-labelledby={"my-label"} placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>', // TODO: modify regular expression
24+
'<div><Label id={"my-label"}>Best pet</Label><Combobox aria-labelledby={"my-label"} placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
2525
'<div><label htmlFor="my-input">Best pet</label><Combobox id="my-input" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
2626
'<div><Label htmlFor="my-input">Best pet</Label><Combobox id="my-input" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
2727
'<div><Label htmlFor={myInputId}>Best pet</Label><Combobox id={myInputId} placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',

0 commit comments

Comments
 (0)