diff --git a/__tests__/src/rules/label-has-associated-control-test.js b/__tests__/src/rules/label-has-associated-control-test.js
index 633275c3..01d02e06 100644
--- a/__tests__/src/rules/label-has-associated-control-test.js
+++ b/__tests__/src/rules/label-has-associated-control-test.js
@@ -21,15 +21,20 @@ const ruleTester = new RuleTester();
const ruleName = 'label-has-associated-control';
-const expectedError = {
- message: 'A form label must be associated with a control.',
- type: 'JSXOpeningElement',
-};
-
-const expectedErrorNoLabel = {
- message: 'A form label must have accessible text.',
- type: 'JSXOpeningElement',
+const errorMessages = {
+ accessibleLabel: 'A form label must have accessible text.',
+ htmlFor: 'A form label must have a valid htmlFor attribute.',
+ 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.',
};
+const expectedErrors = {};
+Object.keys(errorMessages).forEach((key) => {
+ expectedErrors[key] = {
+ message: errorMessages[key],
+ type: 'JSXOpeningElement',
+ };
+});
const componentsSettings = {
'jsx-a11y': {
@@ -123,59 +128,68 @@ const alwaysValid = [
{ code: '' },
];
-const htmlForInvalid = [
- { code: '', options: [{ depth: 4 }], errors: [expectedError] },
- { code: '', errors: [expectedError] },
- { code: '', errors: [expectedError] },
- // Custom label component.
- { code: '', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
- { code: '', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
- { code: '', settings: componentsSettings, errors: [expectedError] },
- // Custom label attributes.
- { code: '', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
-];
-const nestingInvalid = [
- { code: '', errors: [expectedError] },
- { code: '', errors: [expectedError] },
- { code: '', errors: [expectedError] },
- { code: '', errors: [expectedError] },
- { code: '', errors: [expectedError] },
- { code: '', options: [{ depth: 3 }], errors: [expectedError] },
- { code: '', options: [{ depth: 4 }], errors: [expectedError] },
- { code: '', options: [{ depth: 5 }], errors: [expectedError] },
- { code: '', options: [{ depth: 5 }], errors: [expectedError] },
- { code: '', options: [{ depth: 5 }], errors: [expectedError] },
- // Custom controlComponents.
- { code: '', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
- { code: '', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
- { code: 'A label', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
- { code: '', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedError] },
- { code: '', settings: componentsSettings, errors: [expectedError] },
- { code: 'A label', settings: componentsSettings, errors: [expectedError] },
-];
+const htmlForInvalid = (assertType) => {
+ const expectedError = expectedErrors[assertType];
+ return [
+ { code: '', options: [{ depth: 4 }], errors: [expectedError] },
+ { code: '', errors: [expectedError] },
+ { code: '', errors: [expectedError] },
+ // Custom label component.
+ { code: '', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
+ { code: '', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
+ { code: '', settings: componentsSettings, errors: [expectedError] },
+ // Custom label attributes.
+ { code: '', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
+ ];
+};
+const nestingInvalid = (assertType) => {
+ const expectedError = expectedErrors[assertType];
+ return [
+ { code: '', errors: [expectedError] },
+ { code: '', errors: [expectedError] },
+ { code: '', errors: [expectedError] },
+ { code: '', errors: [expectedError] },
+ { code: '', errors: [expectedError] },
+ { code: '', options: [{ depth: 3 }], errors: [expectedError] },
+ { code: '', options: [{ depth: 4 }], errors: [expectedError] },
+ { code: '', options: [{ depth: 5 }], errors: [expectedError] },
+ { code: '', options: [{ depth: 5 }], errors: [expectedError] },
+ { code: '', options: [{ depth: 5 }], errors: [expectedError] },
+ // Custom controlComponents.
+ { code: '', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
+ { code: '', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
+ { code: 'A label', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
+ { code: '', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedError] },
+ { code: '', settings: componentsSettings, errors: [expectedError] },
+ { code: 'A label', settings: componentsSettings, errors: [expectedError] },
+ ];
+};
-const neverValid = [
- { code: '', errors: [expectedErrorNoLabel] },
- { code: '', errors: [expectedErrorNoLabel] },
- { code: '', errors: [expectedErrorNoLabel] },
- { code: '', errors: [expectedErrorNoLabel] },
- { code: '', errors: [expectedError] },
- { code: '
', errors: [expectedErrorNoLabel] },
- { code: '', errors: [expectedError] },
- // Custom label component.
- { code: '', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
- { code: '', options: [{ labelComponents: ['???Label'] }], errors: [expectedError] },
- { code: '', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
- { code: '', settings: componentsSettings, errors: [expectedError] },
- // Custom label attributes.
- { code: '', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
- // Custom controlComponents.
- { code: '', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedErrorNoLabel] },
- { code: '', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedErrorNoLabel] },
- { code: '', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedErrorNoLabel] },
- { code: '', settings: componentsSettings, errors: [expectedErrorNoLabel] },
- { code: '', settings: componentsSettings, errors: [expectedErrorNoLabel] },
-];
+const neverValid = (assertType) => {
+ const expectedError = expectedErrors[assertType];
+ return [
+ { code: '', errors: [expectedErrors.accessibleLabel] },
+ { code: '', errors: [expectedErrors.accessibleLabel] },
+ { code: '', errors: [expectedErrors.accessibleLabel] },
+ { code: '', errors: [expectedErrors.accessibleLabel] },
+ { code: '', errors: [expectedError] },
+ { code: '', errors: [expectedErrors.accessibleLabel] },
+ { code: '', errors: [expectedError] },
+ // Custom label component.
+ { code: '', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
+ { code: '', options: [{ labelComponents: ['???Label'] }], errors: [expectedError] },
+ { code: '', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
+ { code: '', settings: componentsSettings, errors: [expectedError] },
+ // Custom label attributes.
+ { code: '', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
+ // Custom controlComponents.
+ { code: '', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedErrors.accessibleLabel] },
+ { code: '', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedErrors.accessibleLabel] },
+ { code: '', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedErrors.accessibleLabel] },
+ { code: '', settings: componentsSettings, errors: [expectedErrors.accessibleLabel] },
+ { code: '', settings: componentsSettings, errors: [expectedErrors.accessibleLabel] },
+ ];
+};
// htmlFor valid
ruleTester.run(ruleName, rule, {
valid: parsers.all([].concat(
@@ -187,8 +201,8 @@ ruleTester.run(ruleName, rule, {
}))
.map(parserOptionsMapper),
invalid: parsers.all([].concat(
- ...neverValid,
- ...nestingInvalid,
+ ...neverValid('htmlFor'),
+ ...nestingInvalid('htmlFor'),
))
.map(ruleOptionsMapperFactory({
assert: 'htmlFor',
@@ -207,8 +221,8 @@ ruleTester.run(ruleName, rule, {
}))
.map(parserOptionsMapper),
invalid: parsers.all([].concat(
- ...neverValid,
- ...htmlForInvalid,
+ ...neverValid('nesting'),
+ ...htmlForInvalid('nesting'),
))
.map(ruleOptionsMapperFactory({
assert: 'nesting',
@@ -228,8 +242,10 @@ ruleTester.run(ruleName, rule, {
}))
.map(parserOptionsMapper),
invalid: parsers.all([].concat(
- ...neverValid,
- )).map(parserOptionsMapper),
+ ...neverValid('either'),
+ )).map(ruleOptionsMapperFactory({
+ assert: 'either',
+ })).map(parserOptionsMapper),
});
// both valid
@@ -243,6 +259,10 @@ ruleTester.run(ruleName, rule, {
}))
.map(parserOptionsMapper),
invalid: parsers.all([].concat(
- ...neverValid,
- )).map(parserOptionsMapper),
+ ...neverValid('both'),
+ ...htmlForInvalid('both'),
+ ...nestingInvalid('both'),
+ )).map(ruleOptionsMapperFactory({
+ assert: 'both',
+ })).map(parserOptionsMapper),
});
diff --git a/src/rules/label-has-associated-control.js b/src/rules/label-has-associated-control.js
index dd6b199b..d65abe98 100644
--- a/src/rules/label-has-associated-control.js
+++ b/src/rules/label-has-associated-control.js
@@ -18,8 +18,13 @@ import getElementType from '../util/getElementType';
import mayContainChildComponent from '../util/mayContainChildComponent';
import mayHaveAccessibleLabel from '../util/mayHaveAccessibleLabel';
-const errorMessage = 'A form label must be associated with a control.';
-const errorMessageNoLabel = 'A form label must have accessible text.';
+const errorMessages = {
+ accessibleLabel: 'A form label must have accessible text.',
+ htmlFor: 'A form label must have a valid htmlFor attribute.',
+ 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.',
+};
const schema = generateObjSchema({
labelComponents: arraySchema,
@@ -37,7 +42,7 @@ const schema = generateObjSchema({
},
});
-function validateID(node, context) {
+const validateHtmlFor = (node, context) => {
const { settings } = context;
const htmlForAttributes = settings['jsx-a11y']?.attributes?.for ?? ['htmlFor'];
@@ -52,7 +57,7 @@ function validateID(node, context) {
}
return false;
-}
+};
export default ({
meta: {
@@ -76,20 +81,21 @@ export default ({
return;
}
- const controlComponents = [
+ const controlComponents = [].concat(
'input',
'meter',
'output',
'progress',
'select',
'textarea',
- ].concat((options.controlComponents || []));
+ options.controlComponents || [],
+ );
// Prevent crazy recursion.
const recursionDepth = Math.min(
options.depth === undefined ? 2 : options.depth,
25,
);
- const hasLabelId = validateID(node.openingElement, context);
+ const hasHtmlFor = validateHtmlFor(node.openingElement, context);
// Check for multiple control components.
const hasNestedControl = controlComponents.some((name) => mayContainChildComponent(
node,
@@ -105,44 +111,50 @@ export default ({
controlComponents,
);
+ // Bail out immediately if we don't have an accessible label.
if (!hasAccessibleLabel) {
context.report({
node: node.openingElement,
- message: errorMessageNoLabel,
+ message: errorMessages.accessibleLabel,
});
return;
}
-
switch (assertType) {
case 'htmlFor':
- if (hasLabelId) {
- return;
+ if (!hasHtmlFor) {
+ context.report({
+ node: node.openingElement,
+ message: errorMessages.htmlFor,
+ });
}
break;
case 'nesting':
- if (hasNestedControl) {
- return;
+ if (!hasNestedControl) {
+ context.report({
+ node: node.openingElement,
+ message: errorMessages.nesting,
+ });
}
break;
case 'both':
- if (hasLabelId && hasNestedControl) {
- return;
+ if (!hasHtmlFor || !hasNestedControl) {
+ context.report({
+ node: node.openingElement,
+ message: errorMessages.both,
+ });
}
break;
case 'either':
- if (hasLabelId || hasNestedControl) {
- return;
+ if (!hasHtmlFor && !hasNestedControl) {
+ context.report({
+ node: node.openingElement,
+ message: errorMessages.either,
+ });
}
break;
default:
break;
}
-
- // htmlFor case
- context.report({
- node: node.openingElement,
- message: errorMessage,
- });
};
// Create visitor selectors.