Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 141 additions & 166 deletions lib/util/labelUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,229 +4,202 @@
import { elementType } from "jsx-ast-utils";
import { getPropValue } from "jsx-ast-utils";
import { getProp } from "jsx-ast-utils";
import { hasNonEmptyProp } from "./hasNonEmptyProp";
import { TSESLint } from "@typescript-eslint/utils"; // Assuming context comes from TSESLint
import { TSESLint } from "@typescript-eslint/utils";
import { JSXOpeningElement } from "estree-jsx";
import { TSESTree } from "@typescript-eslint/utils";

/**
* Checks if the element is nested within a Label tag.
* e.g.
* <Label>
* Sample input
* <Input {...props} />
* </Label>
* @param {*} context
* @returns
*/
const isInsideLabelTag = (context: TSESLint.RuleContext<string, unknown[]>): boolean => {
return context.getAncestors().some(node => {
const isInsideLabelTag = (context: TSESLint.RuleContext<string, unknown[]>): boolean =>
context.getAncestors().some(node => {
if (node.type !== "JSXElement") return false;
const tagName = elementType(node.openingElement as unknown as JSXOpeningElement);
return tagName.toLowerCase() === "label";
});
};

/**
* Checks if there is a Label component inside the source code with a htmlFor attribute matching that of the id parameter.
* e.g.
* id=parameter, <Label htmlFor={parameter}>Hello</Label>
* @param {*} idValue
* @param {*} context
* @returns boolean for match found or not.
* idOrExprRegex supports:
* - "double-quoted" and 'single-quoted' attribute values
* - expression containers with quoted strings: htmlFor={"id"} or id={'id'}
* - expression containers with an Identifier: htmlFor={someId} or id={someId}
*
* Capture groups (when the alternation matches) are in positions 2..6
* (group 1 is the element/tag capture used in some surrounding regexes).
*/
const hasLabelWithHtmlForId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
if (idValue === "") {
return false;
}
const sourceCode = context.getSourceCode();
const idOrExprRegex = /(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\}|\{\s*([A-Za-z_$][A-ZaLign$0-9_$]*)\s*\})/i;

const regex = /<(Label|label)[^>]*\bhtmlFor\b\s*=\s*["{']([^"'{}]*)["'}]/gi;
const escapeForRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

let match;
while ((match = regex.exec(sourceCode.text)) !== null) {
// `match[2]` contains the `htmlFor` attribute value
if (match[2] === idValue) {
return true;
}
}
return false;
};
const getSourceText = (context: TSESLint.RuleContext<string, unknown[]>) => (context.getSourceCode() as any).text as string;

/**
* Checks if there is a Label component inside the source code with an id matching that of the id parameter.
* e.g.
* id=parameter, <Label id={parameter}>Hello</Label>
* @param {*} idValue value of the props id e.g. <Label id={'my-value'} />
* @param {*} context
* @returns boolean for match found or not.
* Return captured id value from regex match where idOrExprRegex was used as the RHS.
* match[2]..match[6] are the possible capture positions.
*/
const hasLabelWithHtmlId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
if (idValue === "") {
return false;
}
const sourceCode = context.getSourceCode();

const regex = /<(Label|label)[^>]*\bid\b\s*=\s*["{']([^"'{}]*)["'}]/gi;

let match;
while ((match = regex.exec(sourceCode.text)) !== null) {
// match[2] should contain the id value
if (match[2] === idValue) {
return true;
}
}
return false;
const extractCapturedId = (match: RegExpExecArray): string | undefined => {
return match[2] || match[3] || match[4] || match[5] || match[6] || undefined;
};

/***
* Checks if there is another element with an id matching that of the id parameter.
* * e.g.
* <h2 id={labelId}>Sample input</h2>
* <Input aria-labelledby={labelId} />
* @param {*} openingElement
* @param {*} context
* @returns boolean for match found or not.
/**
* New small helper: normalize attribute value (string list vs identifier vs empty/none)
* Keeps getProp/getPropValue usage isolated and provides a single place to trim/split.
* Return shape (for consumers):
* { kind: "string", raw: string, tokens: string[] }
* { kind: "identifier", name: string }
* { kind: "empty" }
* { kind: "none" }
*/
const hasOtherElementWithHtmlId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
if (idValue === "") {
return false;
const getAttributeValueInfo = (
openingElement: TSESTree.JSXOpeningElement,
context: TSESLint.RuleContext<string, unknown[]>,
attrName: string
): any => {
const prop = getProp(openingElement.attributes as unknown as JSXOpeningElement["attributes"], attrName);

if (prop && prop.value && (prop.value as any).type === "JSXExpressionContainer") {
const expr = (prop.value as any).expression;
if (expr && expr.type === "Identifier") {
return { kind: "identifier", name: expr.name as string };
}
if (expr && expr.type === "Literal" && typeof (expr as any).value === "string") {
const trimmed = ((expr as any).value as string).trim();
if (trimmed === "") return { kind: "empty" };
return { kind: "string", raw: trimmed, tokens: trimmed.split(/\s+/) };
}
}
const sourceCode: string = context.getSourceCode().text;

// Adjusted regex pattern for elements with `id` attribute
const regex = /<(div|span|p|h[1-6])[^>]*\bid\b\s*=\s*["{']([^"'{}]*)["'}]/gi;

let match;
while ((match = regex.exec(sourceCode)) !== null) {
// `match[2]` contains the `id` value in each matched element
if (match[2] === idValue) {
return true;
}
const resolved = prop ? getPropValue(prop) : undefined;
if (typeof resolved === "string") {
const trimmed = resolved.trim();
if (trimmed === "") return { kind: "empty" };
return { kind: "string", raw: trimmed, tokens: trimmed.split(/\s+/) };
}
return false;

return { kind: "none" };
};

/**
* Determines if the element has a label with the matching id associated with it via aria-labelledby.
* e.g.
* <Label id={labelId}>Sample input</Label>
* <Input aria-labelledby={labelId} />
* @param {*} openingElement
* @param {*} context
* @returns boolean for match found or not.
*/
const hasAssociatedLabelViaAriaLabelledBy = (
openingElement: TSESTree.JSXOpeningElement,
const hasBracedAttrId = (
tagPattern: string,
attrName: string,
idValue: string,
context: TSESLint.RuleContext<string, unknown[]>
): boolean => {
const _hasAriaLabelledBy = hasNonEmptyProp(openingElement.attributes, "aria-labelledby");
const prop = getProp(openingElement.attributes as unknown as JSXOpeningElement["attributes"], "aria-labelledby");

// Check if the prop exists before passing it to getPropValue
const idValue = prop ? getPropValue(prop) : undefined;
if (!idValue) return false;
const src = getSourceText(context);
const re = new RegExp(`<(?:${tagPattern})[^>]*\\b${attrName}\\s*=\\s*\\{\\s*${escapeForRegExp(idValue)}\\s*\\}`, "i");
return re.test(src);
};

// Check if idValue is a string and handle the case where it's not
if (typeof idValue !== "string" || idValue.trim() === "") {
return false;
/**
* Checks if a Label exists with htmlFor that matches idValue.
* Handles:
* - htmlFor="id", htmlFor={'id'}, htmlFor={"id"}, htmlFor={idVar}
*/
const hasLabelWithHtmlForId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
if (!idValue) return false;
const source = getSourceText(context);
const regex = new RegExp(`<(Label|label)[^>]*\\bhtmlFor\\s*=\\s*${idOrExprRegex.source}`, "gi");

let match: RegExpExecArray | null;
while ((match = regex.exec(source)) !== null) {
const capturedValue = extractCapturedId(match);
if (capturedValue === idValue) return true;
}

const hasHtmlId = hasLabelWithHtmlId(idValue, context);
const hasElementWithHtmlId = hasOtherElementWithHtmlId(idValue, context);

return _hasAriaLabelledBy && (hasHtmlId || hasElementWithHtmlId);
return hasBracedAttrId("Label|label", "htmlFor", idValue, context);
};

/**
* Determines if the element has a label with the matching id associated with it via aria-describedby.
* e.g.
* <Label id={labelId}>Sample input</Label>
* <Input aria-describedby={labelId} />
* @param {*} openingElement
* @param {*} context
* @returns boolean for match found or not.
* Checks if a Label exists with id that matches idValue.
* Handles: id="x", id={'x'}, id={"x"}, id={x}
*/
const hasAssociatedLabelViaAriaDescribedby = (
openingElement: TSESTree.JSXOpeningElement,
context: TSESLint.RuleContext<string, unknown[]>
): boolean => {
const hasAssociatedLabelViaAriadescribedby = hasNonEmptyProp(openingElement.attributes, "aria-describedby");

const prop = getProp(openingElement.attributes as unknown as JSXOpeningElement["attributes"], "aria-describedby");

// Check if the prop exists before passing it to getPropValue
const idValue = prop ? getPropValue(prop) : undefined;

// Check if idValue is a string and handle the case where it's not
if (typeof idValue !== "string" || idValue.trim() === "") {
return false;
const hasLabelWithHtmlId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
if (!idValue) return false;
const source = getSourceText(context);
const regex = new RegExp(`<(Label|label)[^>]*\\bid\\s*=\\s*${idOrExprRegex.source}`, "gi");

let match: RegExpExecArray | null;
while ((match = regex.exec(source)) !== null) {
const capturedValue = extractCapturedId(match);
if (capturedValue === idValue) return true;
}

const hasHtmlId = hasLabelWithHtmlId(idValue, context);
const hasElementWithHtmlId = hasOtherElementWithHtmlId(idValue, context);

return hasAssociatedLabelViaAriadescribedby && (hasHtmlId || hasElementWithHtmlId);
return hasBracedAttrId("Label|label", "id", idValue, context);
};

/**
* Determines if the element has a label associated with it via htmlFor
* e.g.
* <Label htmlFor={inputId}>Sample input</Label>
* <Input id={inputId} />
* @param {*} openingElement
* @param {*} context
* @returns boolean for match found or not.
* Checks other simple elements (div/span/p/h1..h6) for id matching idValue.
*/
const hasAssociatedLabelViaHtmlFor = (openingElement: TSESTree.JSXOpeningElement, context: TSESLint.RuleContext<string, unknown[]>) => {
const prop = getProp(openingElement.attributes as unknown as JSXOpeningElement["attributes"], "id");

const idValue = prop ? getPropValue(prop) : undefined;

// Check if idValue is a string and handle the case where it's not
if (typeof idValue !== "string" || idValue.trim() === "") {
return false;
const hasOtherElementWithHtmlId = (idValue: string, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
if (!idValue) return false;
const source = getSourceText(context);
const regex = new RegExp(`<(div|span|p|h[1-6])[^>]*\\bid\\s*=\\s*${idOrExprRegex.source}`, "gi");

let match: RegExpExecArray | null;
while ((match = regex.exec(source)) !== null) {
const capturedValue = extractCapturedId(match);
if (capturedValue === idValue) return true;
}

return hasLabelWithHtmlForId(idValue, context);
return hasBracedAttrId("div|span|p|h[1-6]", "id", idValue, context);
};

/**
* Determines if the element has a node with the matching id associated with it via the aria-attribute e.g. aria-describedby/aria-labelledby.
* e.g.
* <span id={labelI1}>Sample input Description</Label>
* <Label id={labelId2}>Sample input label</Label>
* <Input aria-describedby={labelId1} aria-labelledby={labelId2}/>
* @param {*} openingElement
* @param {*} context
* @param {*} ariaAttribute
* @returns boolean for match found or not.
* Generic helper for aria-* attributes:
* - if prop resolves to a string (literal or expression-literal) then we check labels/ids
* - if prop is an identifier expression (aria-*= {someId}) we fall back to a narrow regex that checks
* other elements/labels with id={someId}
*
* This keeps the implementation compact and robust for the project's tests and common source patterns.
*/
const hasAssociatedAriaText = (
openingElement: TSESTree.JSXOpeningElement,
context: TSESLint.RuleContext<string, unknown[]>,
ariaAttribute: string
) => {
const hasAssociatedAriaText = hasNonEmptyProp(openingElement.attributes, ariaAttribute);
): boolean => {
const info = getAttributeValueInfo(openingElement, context, ariaAttribute);

if (info.kind === "string") {
for (const id of info.tokens) {
if (hasLabelWithHtmlId(id, context) || hasOtherElementWithHtmlId(id, context)) {
return true;
}
}
return false;
}

if (info.kind === "identifier") {
const varName = info.name;
return hasBracedAttrId("Label|label", "id", varName, context) || hasBracedAttrId("div|span|p|h[1-6]", "id", varName, context);
}

const prop = getProp(openingElement.attributes as unknown as JSXOpeningElement["attributes"], ariaAttribute);
return false;
};

const hasAssociatedLabelViaAriaLabelledBy = (
openingElement: TSESTree.JSXOpeningElement,
context: TSESLint.RuleContext<string, unknown[]>
) => hasAssociatedAriaText(openingElement, context, "aria-labelledby");

const idValue = prop ? getPropValue(prop) : undefined;
const hasAssociatedLabelViaAriaDescribedby = (
openingElement: TSESTree.JSXOpeningElement,
context: TSESLint.RuleContext<string, unknown[]>
) => hasAssociatedAriaText(openingElement, context, "aria-describedby");

let hasHtmlId = false;
if (idValue) {
const sourceCode = context.getSourceCode();
const hasAssociatedLabelViaHtmlFor = (openingElement: TSESTree.JSXOpeningElement, context: TSESLint.RuleContext<string, unknown[]>) => {
const info = getAttributeValueInfo(openingElement, context, "id");

const regex = /<(\w+)[^>]*id\s*=\s*["']([^"']*)["'][^>]*>/gi;
let match;
const ids = [];
if (info.kind === "string") {
return hasLabelWithHtmlForId(info.raw, context);
}

while ((match = regex.exec(sourceCode.text)) !== null) {
ids.push(match[2]);
}
hasHtmlId = ids.some(id => id === idValue);
if (info.kind === "identifier") {
const varName = info.name;
return hasBracedAttrId("Label|label", "htmlFor", varName, context);
}

return hasAssociatedAriaText && hasHtmlId;
return false;
};

export {
Expand All @@ -237,5 +210,7 @@ export {
hasAssociatedLabelViaHtmlFor,
hasAssociatedLabelViaAriaDescribedby,
hasAssociatedAriaText,
hasOtherElementWithHtmlId
hasOtherElementWithHtmlId,
hasBracedAttrId,
getAttributeValueInfo
};
2 changes: 1 addition & 1 deletion tests/lib/rules/combobox-needs-labelling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ ruleTester.run("combobox-needs-labelling", rule as unknown as Rule.RuleModule, {
'<Label>Best pet<Combobox placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></Label>',
'<div><label id="my-label">Best pet</label><Combobox aria-labelledby="my-label" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
'<div><Label id="my-label">Best pet</Label><Combobox aria-labelledby="my-label" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
// '<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
'<div><Label id={"my-label"}>Best pet</Label><Combobox aria-labelledby={"my-label"} placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
'<div><label htmlFor="my-input">Best pet</label><Combobox id="my-input" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
'<div><Label htmlFor="my-input">Best pet</Label><Combobox id="my-input" placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
'<div><Label htmlFor={myInputId}>Best pet</Label><Combobox id={myInputId} placeholder="Select an animal" {...props}><Option>{"Cat"}</Option></Combobox></div>',
Expand Down
Loading
Loading