Skip to content

feat(label-has-associated-control): add option for enforcing label's htmlFor matches control's id #1042

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all 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
17 changes: 17 additions & 0 deletions __mocks__/JSXFragmentMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @flow
*/

export type JSXFragmentMockType = {
type: 'JSXFragment',
children: Array<Node>,
};

export default function JSXFragmentMock(
children?: Array<Node> = [],
): JSXFragmentMockType {
return {
type: 'JSXFragment',
children,
};
}
33 changes: 33 additions & 0 deletions __tests__/src/rules/label-has-associated-control-test.js
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ const errorMessages = {
nesting: 'A form label must have an associated control as a descendant.',
either: 'A form label must either have a valid htmlFor attribute or a control as a descendant.',
both: 'A form label must have a valid htmlFor attribute and a control as a descendant.',
htmlForShouldMatchId: 'A form label must have a htmlFor attribute that matches the id of the associated control.',
};
const expectedErrors = {};
Object.keys(errorMessages).forEach((key) => {
@@ -58,6 +59,7 @@ const htmlForValid = [
{ code: '<label htmlFor="js_id" aria-label="A label" />' },
{ code: '<label htmlFor="js_id" aria-labelledby="A label" />' },
{ code: '<div><label htmlFor="js_id">A label</label><input id="js_id" /></div>' },
{ code: '<div><label htmlFor={inputId}>A label</label><input id={inputId} /></div>' },
{ code: '<label for="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }], settings: attributesSettings },
{ code: '<label for="js_id" aria-label="A label" />', settings: attributesSettings },
{ code: '<label for="js_id" aria-labelledby="A label" />', settings: attributesSettings },
@@ -128,6 +130,12 @@ const alwaysValid = [
{ code: '<input type="hidden" />' },
];

const shouldHtmlForMatchIdValid = [
{ code: '<label htmlFor="js_id" aria-label="A label"><span><span><input id="js_id" /></span></span></label>', options: [{ depth: 4, shouldHtmlForMatchId: true }] },
{ code: '<div><label htmlFor="js_id">A label</label><input id="js_id" /></div>', options: [{ shouldHtmlForMatchId: true }] },
{ code: '<div><label htmlFor={inputId} aria-label="A label" /><input id={inputId} /></div>', options: [{ shouldHtmlForMatchId: true }] },
];

const htmlForInvalid = (assertType) => {
const expectedError = expectedErrors[assertType];
return [
@@ -164,6 +172,13 @@ const nestingInvalid = (assertType) => {
{ code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', settings: componentsSettings, errors: [expectedError] },
];
};
const shouldHtmlForMatchIdInvalid = [
{ code: '<label htmlFor="js_id" aria-label="A label"><span><span><input /></span></span></label>', options: [{ depth: 4, shouldHtmlForMatchId: true }], errors: [expectedErrors.htmlForShouldMatchId] },
{ code: '<label htmlFor="js_id" aria-label="A label"><span><span><input name="js_id" /></span></span></label>', options: [{ depth: 4, shouldHtmlForMatchId: true }], errors: [expectedErrors.htmlForShouldMatchId] },
{ code: '<div><label htmlFor="js_id">A label</label><input /></div>', options: [{ shouldHtmlForMatchId: true }], errors: [expectedErrors.htmlForShouldMatchId] },
{ code: '<div><label htmlFor="js_id">A label</label><input name="js_id" /></div>', options: [{ shouldHtmlForMatchId: true }], errors: [expectedErrors.htmlForShouldMatchId] },
{ code: '<div><label htmlFor={inputId} aria-label="A label" /><input name={inputId} /></div>', options: [{ shouldHtmlForMatchId: true }], errors: [expectedErrors.htmlForShouldMatchId] },
];

const neverValid = (assertType) => {
const expectedError = expectedErrors[assertType];
@@ -266,3 +281,21 @@ ruleTester.run(ruleName, rule, {
assert: 'both',
})).map(parserOptionsMapper),
});

// shouldHtmlForMatchId
ruleTester.run(ruleName, rule, {
valid: parsers.all([].concat(
...shouldHtmlForMatchIdValid,
))
.map(ruleOptionsMapperFactory({
assert: 'htmlFor',
}))
.map(parserOptionsMapper),
invalid: parsers.all([].concat(
...shouldHtmlForMatchIdInvalid,
))
.map(ruleOptionsMapperFactory({
assert: 'htmlFor',
}))
.map(parserOptionsMapper),
});
224 changes: 224 additions & 0 deletions __tests__/src/util/getChildComponent-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import test from 'tape';

import getChildComponent from '../../../src/util/getChildComponent';
import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock';
import JSXElementMock from '../../../__mocks__/JSXElementMock';
import JSXExpressionContainerMock from '../../../__mocks__/JSXExpressionContainerMock';

test('mayContainChildComponent', (t) => {
t.equal(
getChildComponent(
JSXElementMock('div', [], [
JSXElementMock('div', [], [
JSXElementMock('span', [], []),
JSXElementMock('span', [], [
JSXElementMock('span', [], []),
JSXElementMock('span', [], [
JSXElementMock('span', [], []),
]),
]),
]),
JSXElementMock('span', [], []),
JSXElementMock('img', [
JSXAttributeMock('src', 'some/path'),
]),
]),
'FancyComponent',
5,
),
undefined,
'no FancyComponent returns undefined',
);

t.test('contains an indicated component', (st) => {
const inputMock = JSXElementMock('input');
st.equal(
getChildComponent(
JSXElementMock('div', [], [
inputMock,
]),
'input',
),
inputMock,
'returns input',
);

const FancyComponentMock = JSXElementMock('FancyComponent');
st.equal(
getChildComponent(
JSXElementMock('div', [], [
FancyComponentMock,
]),
'FancyComponent',
),
FancyComponentMock,
'returns FancyComponent',
);

st.equal(
getChildComponent(
JSXElementMock('div', [], [
JSXElementMock('div', [], [
JSXElementMock('FancyComponent'),
]),
]),
'FancyComponent',
),
undefined,
'FancyComponent is outside of default depth, should return undefined',
);

st.equal(
getChildComponent(
JSXElementMock('div', [], [
JSXElementMock('div', [], [
FancyComponentMock,
]),
]),
'FancyComponent',
2,
),
FancyComponentMock,
'FancyComponent is inside of custom depth, should return FancyComponent',
);

st.equal(
getChildComponent(
JSXElementMock('div', [], [
JSXElementMock('div', [], [
JSXElementMock('span', [], []),
JSXElementMock('span', [], [
JSXElementMock('span', [], []),
JSXElementMock('span', [], [
JSXElementMock('span', [], [
JSXElementMock('span', [], [
FancyComponentMock,
]),
]),
]),
]),
]),
JSXElementMock('span', [], []),
JSXElementMock('img', [
JSXAttributeMock('src', 'some/path'),
]),
]),
'FancyComponent',
6,
),
FancyComponentMock,
'deep nesting, returns FancyComponent',
);

st.end();
});

const MysteryBox = JSXExpressionContainerMock('mysteryBox');
t.equal(
getChildComponent(
JSXElementMock('div', [], [
MysteryBox,
]),
'FancyComponent',
),
MysteryBox,
'Indeterminate situations + expression container children - returns component',
);

t.test('Glob name matching - component name contains question mark ? - match any single character', (st) => {
const FancyComponentMock = JSXElementMock('FancyComponent');
st.equal(
getChildComponent(
JSXElementMock('div', [], [
FancyComponentMock,
]),
'Fanc?Co??onent',
),
FancyComponentMock,
'returns component',
);

st.equal(
getChildComponent(
JSXElementMock('div', [], [
JSXElementMock('FancyComponent'),
]),
'FancyComponent?',
),
undefined,
'returns undefined',
);

st.test('component name contains asterisk * - match zero or more characters', (s2t) => {
s2t.equal(
getChildComponent(
JSXElementMock('div', [], [
FancyComponentMock,
]),
'Fancy*',
),
FancyComponentMock,
'returns component',
);

s2t.equal(
getChildComponent(
JSXElementMock('div', [], [
FancyComponentMock,
]),
'*Component',
),
FancyComponentMock,
'returns component',
);

s2t.equal(
getChildComponent(
JSXElementMock('div', [], [
FancyComponentMock,
]),
'Fancy*C*t',
),
FancyComponentMock,
'returns component',
);

s2t.end();
});

st.end();
});

t.test('using a custom elementType function', (st) => {
const CustomInputMock = JSXElementMock('CustomInput');
st.equal(
getChildComponent(
JSXElementMock('div', [], [
CustomInputMock,
]),
'input',
2,
() => 'input',
),
CustomInputMock,
'returns the component when the custom elementType returns the proper name',
);

st.equal(
getChildComponent(
JSXElementMock('div', [], [
JSXElementMock('CustomInput'),
]),
'input',
2,
() => 'button',
),
undefined,
'returns undefined when the custom elementType returns a wrong name',
);

st.end();
});

t.end();
});
177 changes: 177 additions & 0 deletions __tests__/src/util/getParentElement-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import test from 'tape';

import getParentElement from '../../../src/util/getParentElement';
import JSXElementMock from '../../../__mocks__/JSXElementMock';
import JSXFragmentMock from '../../../__mocks__/JSXFragmentMock';

test('getParentElement', (t) => {
const MockElement = JSXElementMock('input', [], []);

t.test('new context api', (st) => {
st.test('no parent', (st2) => {
const mockContext = {
sourceCode: {
getAncestors: () => [],
},
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
undefined,
'no parent -> returns undefined',
);

st2.end();
});

st.test('one parent element', (st2) => {
const MockParentElement = JSXElementMock('div', [], [MockElement]);

const mockContext = {
sourceCode: {
getAncestors: () => [MockParentElement],
},
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
MockParentElement,
'one parent JSXElement -> returns parent',
);

st2.end();
});

st.test('one parent fragment', (st2) => {
const MockParentFragment = JSXFragmentMock([MockElement]);

const mockContext = {
sourceCode: {
getAncestors: () => [MockParentFragment],
},
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
MockParentFragment,
'one parent JSXFragment -> returns parent',
);

st2.end();
});

st.test('multiple ancestors', (st2) => {
const MockParentElement = JSXElementMock('div', [], [MockElement]);
const MockUncleElement = JSXElementMock('div', [], []);
const MockGrandparentFragment = JSXFragmentMock([MockParentElement, MockUncleElement]);

const mockContext = {
sourceCode: {
getAncestors: () => [MockGrandparentFragment, MockParentElement],
},
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
MockParentElement,
'multiple ancestors -> returns direct parent',
);

st2.end();
});

st.end();
});

t.test('legacy context', (st) => {
st.test('no parent', (st2) => {
const mockContext = {
getAncestors: () => [],
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
undefined,
'no parent -> returns undefined',
);

st2.end();
});

st.test('one parent element', (st2) => {
const MockParentElement = JSXElementMock('div', [], [MockElement]);

const mockContext = {
getAncestors: () => [MockParentElement],
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
MockParentElement,
'one parent JSXElement -> returns parent',
);

st2.end();
});

st.test('one parent fragment', (st2) => {
const MockParentFragment = JSXFragmentMock([MockElement]);

const mockContext = {
getAncestors: () => [MockParentFragment],
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
MockParentFragment,
'one parent JSXFragment -> returns parent',
);

st2.end();
});

st.test('multiple ancestors', (st2) => {
const MockParentElement = JSXElementMock('div', [], [MockElement]);
const MockUncleElement = JSXElementMock('div', [], []);
const MockGrandparentFragment = JSXFragmentMock([MockParentElement, MockUncleElement]);

const mockContext = {
getAncestors: () => [MockGrandparentFragment, MockParentElement],
};

st2.equal(
getParentElement(
MockElement,
mockContext,
),
MockParentElement,
'multiple ancestors -> returns direct parent',
);

st2.end();
});

st.end();
});
});
42 changes: 25 additions & 17 deletions docs/rules/label-has-associated-control.md
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ The simplest way to achieve an association between a label and an input is to wr

All modern browsers and assistive technology will associate the label with the control.

### Case: The label is a sibling of the control.
### Case: The label is a sibling of the control

In this case, use `htmlFor` and an ID to associate the controls.

@@ -37,7 +37,7 @@ In this case, use `htmlFor` and an ID to associate the controls.
<input type="text" id={domId} />
```

### Case: My label and input components are custom components.
### Case: My label and input components are custom components

You can configure the rule to be aware of your custom components.

@@ -49,14 +49,14 @@ You can configure the rule to be aware of your custom components.

And the configuration:

```json
```js
{
"rules": {
"jsx-a11y/label-has-associated-control": [ 2, {
"labelComponents": ["CustomInputLabel"],
"labelAttributes": ["label"],
"controlComponents": ["CustomInput"],
"depth": 3,
rules: {
'jsx-a11y/label-has-associated-control': [ 2, {
labelComponents: ['CustomInputLabel'],
labelAttributes: ['label'],
controlComponents: ['CustomInput'],
depth: 3,
}],
}
}
@@ -89,15 +89,16 @@ To restate, **every ID needs to be deterministic**, on the server and the client

This rule takes one optional object argument of type object:

```json
```js
{
"rules": {
"jsx-a11y/label-has-associated-control": [ 2, {
"labelComponents": ["CustomLabel"],
"labelAttributes": ["inputLabel"],
"controlComponents": ["CustomInput"],
"assert": "both",
"depth": 3,
rules: {
'jsx-a11y/label-has-associated-control': [ 2, {
labelComponents: ['CustomLabel'],
labelAttributes: ['inputLabel'],
controlComponents: ['CustomInput'],
assert: 'both',
depth: 3,
shouldHtmlForMatchId: true,
}],
}
}
@@ -113,14 +114,18 @@ This rule takes one optional object argument of type object:

`depth` (default 2, max 25) is an integer that determines how deep within a `JSXElement` label the rule should look for text content or an element with a label to determine if the `label` element will have an accessible label.

`shouldHtmlForMatchId` (default `false`) is a boolean dictating whether the `htmlFor` attribute on a label element should be validated as matching the `id` attributed on an associated control (nested or sibling as described in the cases above).

### Fail

```jsx
function Foo(props) {
return <label {...props} />
}
```

### Succeed

```jsx
function Foo(props) {
const {
@@ -133,12 +138,14 @@ function Foo(props) {
```

### Fail

```jsx
<input type="text" />
<label>Surname</label>
```

### Succeed

```jsx
<label>
<input type="text" />
@@ -147,6 +154,7 @@ function Foo(props) {
```

## Accessibility guidelines

- [WCAG 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships)
- [WCAG 3.3.2](https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions)
- [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value)
15 changes: 15 additions & 0 deletions flow/eslint-jsx.js
Original file line number Diff line number Diff line change
@@ -3,9 +3,24 @@
*/
import type {
JSXAttribute,
JSXElement,
JSXOpeningElement,
} from 'ast-types-flow';

export type ESLintJSXAttribute = {
parent: JSXOpeningElement
} & JSXAttribute;

type JSXOpeningFragment = {
type: 'JSXOpeningFragment',
};

type JSXClosingFragment = {
type: 'JSXClosingFragment',
};

export type JSXFragment = JSXElement & {
Copy link
Contributor Author

@michaelfaith michaelfaith Dec 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would have been better to use Omit here to remove openingElement and closingElement from JSXElement, but this version of flow doesn't have Omit (that was introduced in 0.211.0). I'd be up for upgrading the version of flow in a separate change if you'd like?

Copy link
Contributor Author

@michaelfaith michaelfaith Dec 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ast-types-flow package doesn't include a type for JSXFragment

type: 'JSXFragment',
openingFragment: JSXOpeningFragment,
closingFragment: JSXClosingFragment,
};
6 changes: 6 additions & 0 deletions flow/eslint.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/*
* @flow
*/
import type { Node } from 'ast-types-flow';

export type ESLintReport = {
node: any,
message: string,
@@ -20,6 +22,10 @@ export type ESLintContext = {
options: Array<Object>,
report: (ESLintReport) => void,
settings: ESLintSettings,
getAncestors?: () => Array<any>,
sourceCode?: {
getAncestors?: (node: Node) => Array<any>,
}
};

export type ESLintConfig = {
118 changes: 111 additions & 7 deletions src/rules/label-has-associated-control.js
Original file line number Diff line number Diff line change
@@ -10,11 +10,13 @@
// ----------------------------------------------------------------------------

import { hasProp, getProp, getPropValue } from 'jsx-ast-utils';
import type { JSXElement } from 'ast-types-flow';
import type { JSXElement, JSXOpeningElement } from 'ast-types-flow';
import minimatch from 'minimatch';
import { generateObjSchema, arraySchema } from '../util/schemas';
import type { ESLintConfig, ESLintContext, ESLintVisitorSelectorConfig } from '../../flow/eslint';
import getChildComponent from '../util/getChildComponent';
import getElementType from '../util/getElementType';
import getParentElement from '../util/getParentElement';
import mayContainChildComponent from '../util/mayContainChildComponent';
import mayHaveAccessibleLabel from '../util/mayHaveAccessibleLabel';

@@ -24,6 +26,7 @@ const errorMessages = {
nesting: 'A form label must have an associated control as a descendant.',
either: 'A form label must either have a valid htmlFor attribute or a control as a descendant.',
both: 'A form label must have a valid htmlFor attribute and a control as a descendant.',
htmlForShouldMatchId: 'A form label must have a htmlFor attribute that matches the id of the associated control.',
};

const schema = generateObjSchema({
@@ -40,8 +43,100 @@ const schema = generateObjSchema({
type: 'integer',
minimum: 0,
},
shouldHtmlForMatchId: {
description: 'If true, the htmlFor prop of the label must match the id of the associated control',
type: 'boolean',
},
});

/**
* Given a label node, validate that the htmlFor prop matches the id of a child
* component in our list of possible control components.
* @param node - Label node
* @param controlComponents - List of control components
*/
const validateChildHasMatchingId = (
node: JSXElement,
controlComponents: string[],
recursionDepth: number,
elementTypeFn: (node: JSXOpeningElement) => string,
) => {
const htmlForAttr = getProp(node.openingElement.attributes, 'htmlFor');
const htmlForValue = getPropValue(htmlForAttr);

const eligibleChildren = controlComponents.map((name) => getChildComponent(node, name, recursionDepth, elementTypeFn));

const matchingChild = eligibleChildren?.find((child) => {
if (!child) {
return false;
}

// If this is an expression container, we can't validate the id.
// So we'll assume it's valid.
if (child.type === 'JSXExpressionContainer') {
return true;
}

const childIdAttr = getProp(child.openingElement.attributes, 'id');
const childIdValue = getPropValue(childIdAttr);

return htmlForValue === childIdValue;
});
return !!matchingChild;
};

/**
* Given a label node, validate that the htmlFor prop matches the id of a sibling
* component in our list of possible control components.
* @param node - Label node
* @param controlComponents - List of control components
*/
const validateSiblingHasMatchingId = (
node: JSXElement,
controlComponents: string[],
elementTypeFn: (node: JSXOpeningElement) => string,
context: ESLintContext,
) => {
const htmlForAttr = getProp(node.openingElement.attributes, 'htmlFor');
const htmlForValue = getPropValue(htmlForAttr);

const parent = getParentElement(node, context);

// Filter siblings to only include JSXExpressionContainers and JSXElements that match our list of
// control components.
const siblingElements = parent?.children.filter(
(child) => child !== node
&& (child.type === 'JSXExpressionContainer'
|| (child.type === 'JSXElement'
&& child.openingElement
&& controlComponents.some((name) => minimatch(elementTypeFn(child.openingElement), name)))),
);

if (!siblingElements || siblingElements.length === 0) {
return false;
}

// Of those siblings, find the one that has an id that matches the htmlFor prop.
const matchingSibling = siblingElements.find((sibling) => {
// We can't really do better than just assuming an expression container is valid.
if (sibling.type === 'JSXExpressionContainer') {
return true;
}
// This is only necessary because the filter operation above doesn't properly type narrow based
// on the filter condition.
if (sibling.type !== 'JSXElement') {
return false;
}

const siblingIdAttr = getProp(sibling.openingElement.attributes, 'id');
const siblingIdValue = getPropValue(siblingIdAttr);

return htmlForValue === siblingIdValue;
});

return !!matchingSibling;
};

const validateHtmlFor = (node, context) => {
const { settings } = context;
const htmlForAttributes = settings['jsx-a11y']?.attributes?.for ?? ['htmlFor'];
@@ -74,6 +169,7 @@ export default ({
const assertType = options.assert || 'either';
const labelComponentNames = ['label'].concat(labelComponents);
const elementType = getElementType(context);
const shouldHtmlForMatchId = !!options.shouldHtmlForMatchId;

const rule = (node: JSXElement) => {
const isLabelComponent = labelComponentNames.some((name) => minimatch(elementType(node.openingElement), name));
@@ -97,12 +193,7 @@ export default ({
);
const hasHtmlFor = validateHtmlFor(node.openingElement, context);
// Check for multiple control components.
const hasNestedControl = controlComponents.some((name) => mayContainChildComponent(
node,
name,
recursionDepth,
elementType,
));
const hasNestedControl = controlComponents.some((name) => mayContainChildComponent(node, name, recursionDepth, elementType));
const hasAccessibleLabel = mayHaveAccessibleLabel(
node,
recursionDepth,
@@ -126,6 +217,7 @@ export default ({
node: node.openingElement,
message: errorMessages.htmlFor,
});
return;
}
break;
case 'nesting':
@@ -134,6 +226,7 @@ export default ({
node: node.openingElement,
message: errorMessages.nesting,
});
return;
}
break;
case 'both':
@@ -142,6 +235,7 @@ export default ({
node: node.openingElement,
message: errorMessages.both,
});
return;
}
break;
case 'either':
@@ -150,11 +244,21 @@ export default ({
node: node.openingElement,
message: errorMessages.either,
});
return;
}
break;
default:
break;
}
// Lastly, let's check to see if the htmlFor prop matches the id of a valid sibling or descendent component.
if (shouldHtmlForMatchId && hasHtmlFor) {
if (!validateSiblingHasMatchingId(node, controlComponents, elementType, context) && !validateChildHasMatchingId(node, controlComponents, recursionDepth, elementType)) {
context.report({
node: node.openingElement,
message: errorMessages.htmlForShouldMatchId,
});
}
}
};

// Create visitor selectors.
52 changes: 52 additions & 0 deletions src/util/getChildComponent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @flow
*/

import type {
JSXElement, JSXExpressionContainer, JSXOpeningElement, Node,
} from 'ast-types-flow';
import { elementType as rawElementType } from 'jsx-ast-utils';
import minimatch from 'minimatch';

export default function getChildComponent(
root: Node,
componentName: string,
maxDepth: number = 1,
elementType: ((node: JSXOpeningElement) => string) = rawElementType,
): JSXElement | JSXExpressionContainer | void {
function traverseChildren(
node: Node,
depth: number,
): JSXElement | JSXExpressionContainer | void {
// Bail when maxDepth is exceeded.
if (depth > maxDepth) {
return undefined;
}
if (node.children) {
/* $FlowFixMe */
for (let i = 0; i < node.children.length; i += 1) {
/* $FlowFixMe */
const childNode = node.children[i];
// Assume an expression container satisfies our conditions. It is the best we can
// do in this case.
if (childNode.type === 'JSXExpressionContainer') {
return childNode;
}
// Check for components with the provided name.
if (
childNode.type === 'JSXElement'
&& childNode.openingElement
&& minimatch(elementType(childNode.openingElement), componentName)
) {
return childNode;
}
const grandChild = traverseChildren(childNode, depth + 1);
if (grandChild) {
return grandChild;
}
}
}
return undefined;
}
return traverseChildren(root, 1);
}
25 changes: 25 additions & 0 deletions src/util/getParentElement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Given a node and the eslint context, return the node's parent element,
* but only if it's a JSXElement or JSXFragment.
*
* @flow
*/

import type { JSXElement, Node } from 'ast-types-flow';

import type { ESLintContext } from '../../flow/eslint';
import type { JSXFragment } from '../../flow/eslint-jsx';

const getParentElement = (node: Node, context: ESLintContext): JSXElement | JSXFragment | void => {
const ancestors = context.sourceCode?.getAncestors?.(node) || context.getAncestors?.() || [];
if (ancestors.length === 0) {
return undefined;
}
const parent = ancestors[ancestors.length - 1];
if (parent.type === 'JSXElement' || parent.type === 'JSXFragment') {
return parent;
}
return undefined;
};

export default getParentElement;
43 changes: 4 additions & 39 deletions src/util/mayContainChildComponent.js
Original file line number Diff line number Diff line change
@@ -1,54 +1,19 @@
/**
* Returns true if it can positively determine that the element lacks an
* accessible label. If no determination is possible, it returns false. Treat
* false as an unknown value. The element might still have an accessible label,
* but this module cannot determine it positively.
* Returns true if it can positively determine that the element has a
* child element matching the elementType matching function.
*
* @flow
*/

import type { JSXOpeningElement, Node } from 'ast-types-flow';
import { elementType as rawElementType } from 'jsx-ast-utils';
import minimatch from 'minimatch';
import getChildComponent from './getChildComponent';

export default function mayContainChildComponent(
root: Node,
componentName: string,
maxDepth: number = 1,
elementType: ((node: JSXOpeningElement) => string) = rawElementType,
): boolean {
function traverseChildren(
node: Node,
depth: number,
): boolean {
// Bail when maxDepth is exceeded.
if (depth > maxDepth) {
return false;
}
if (node.children) {
/* $FlowFixMe */
for (let i = 0; i < node.children.length; i += 1) {
/* $FlowFixMe */
const childNode = node.children[i];
// Assume an expression container renders a label. It is the best we can
// do in this case.
if (childNode.type === 'JSXExpressionContainer') {
return true;
}
// Check for components with the provided name.
if (
childNode.type === 'JSXElement'
&& childNode.openingElement
&& minimatch(elementType(childNode.openingElement), componentName)
) {
return true;
}
if (traverseChildren(childNode, depth + 1)) {
return true;
}
}
}
return false;
}
return traverseChildren(root, 1);
return !!getChildComponent(root, componentName, maxDepth, elementType);
}