Skip to content

Commit 138c400

Browse files
Refactor and add test cases to increase coverage as needed.
1 parent b41b704 commit 138c400

File tree

2 files changed

+236
-161
lines changed

2 files changed

+236
-161
lines changed

lib/util/labelUtils.ts

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -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 */
146180
const 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-
*/
159190
const 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 */
180205
export {
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

Comments
 (0)