diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index 7d25d3a4aa4..d6401af226f 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -131,6 +131,9 @@ It's worth pointing out that although the examples provided are specific to EUI
Ensures that EUI modal components (`EuiModal`, `EuiFlyout`, `EuiConfirmModal`) have either an `aria-label` or `aria-labelledby` prop for accessibility. This helps screen reader users understand the purpose and content of modal dialogs.
+### `@elastic/eui/consistent-is-invalid-props`
+
+Ensures that form control components within `EuiFormRow` components have matching `isInvalid` prop values. This maintains consistent validation state between parent form rows and their child form controls, leading to a more predictable and accessible user experience.
## Testing
diff --git a/packages/eslint-plugin/changelogs/upcoming/8843.md b/packages/eslint-plugin/changelogs/upcoming/8843.md
new file mode 100644
index 00000000000..6876314a0bb
--- /dev/null
+++ b/packages/eslint-plugin/changelogs/upcoming/8843.md
@@ -0,0 +1 @@
+- Added new `consistent-is-invalid-props` rule.
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index c0168e6a485..cf8844f012f 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -22,6 +22,7 @@ import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports';
import { NoCssColor } from './rules/no_css_color';
import { RequireAriaLabelForModals } from './rules/a11y/require_aria_label_for_modals';
+import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_props';
const config = {
rules: {
@@ -29,6 +30,7 @@ const config = {
'no-restricted-eui-imports': NoRestrictedEuiImports,
'no-css-color': NoCssColor,
'require-aria-label-for-modals': RequireAriaLabelForModals,
+ 'consistent-is-invalid-props': ConsistentIsInvalidProps,
},
configs: {
recommended: {
@@ -38,6 +40,7 @@ const config = {
'@elastic/eui/no-restricted-eui-imports': 'warn',
'@elastic/eui/no-css-color': 'warn',
'@elastic/eui/require-aria-label-for-modals': 'warn',
+ '@elastic/eui/consistent-is-invalid-props': 'warn',
},
},
},
diff --git a/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.test.ts b/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.test.ts
new file mode 100644
index 00000000000..55aa3fc07e7
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.test.ts
@@ -0,0 +1,167 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import dedent from 'dedent';
+import { RuleTester } from '@typescript-eslint/rule-tester';
+import { ConsistentIsInvalidProps } from './consistent_is_invalid_props';
+
+const languageOptions = {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+};
+
+const ruleTester = new RuleTester();
+
+ruleTester.run('consistent-is-invalid-props', ConsistentIsInvalidProps, {
+ valid: [
+ {
+ code: dedent`
+ const MyComponent = () => {
+ const expression = true && Boolean(Date.now());
+ return (
+
+
+ );
+ }
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+ Not a form control
+
+ )
+ `,
+ languageOptions,
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ errors: [{ messageId: 'inconsistentIsInvalid' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ errors: [{ messageId: 'inconsistentIsInvalid' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ output: dedent`
+ const MyComponent = () => (
+
+
+
+ )
+ `,
+ languageOptions,
+ errors: [{ messageId: 'inconsistentIsInvalid' }],
+ },
+ {
+ code: dedent`
+ const MyComponent = () => {
+ const expression = true && Boolean(Date.now());
+ return (
+
+
+ );
+ };
+ `,
+ output: dedent`
+ const MyComponent = () => {
+ const expression = true && Boolean(Date.now());
+ return (
+
+
+ );
+ };
+ `,
+ languageOptions,
+ errors: [{ messageId: 'inconsistentIsInvalid' }],
+ },
+ ],
+});
diff --git a/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.ts b/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.ts
new file mode 100644
index 00000000000..6b8fa267f81
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/consistent_is_invalid_props.ts
@@ -0,0 +1,128 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { type TSESTree, ESLintUtils } from '@typescript-eslint/utils';
+import { getAttrValue } from '../../utils/get_attr_value';
+const formControlComponent = 'EuiFormRow';
+
+const formControlChildComponents = [
+ 'EuiFieldNumber',
+ 'EuiFilePicker',
+ 'EuiFieldText',
+ 'EuiComboBox',
+ 'EuiTextArea',
+ 'EuiFormControlLayoutDelimited',
+ 'SingleFieldSelect',
+ 'EuiSelect',
+];
+
+export const ConsistentIsInvalidProps = ESLintUtils.RuleCreator.withoutDocs({
+ create(context) {
+ return {
+ JSXElement(node) {
+ const openingElement = node.openingElement;
+
+ if (
+ openingElement?.name.type !== 'JSXIdentifier' ||
+ openingElement.name.name !== formControlComponent ||
+ openingElement.attributes.length === 0
+ ) {
+ return;
+ }
+
+ const formRowIsInvalid = getAttrValue(
+ context,
+ openingElement.attributes,
+ 'isInvalid'
+ );
+
+ if (formRowIsInvalid === undefined) {
+ return;
+ }
+
+ const childElement = node.children.find(
+ (child): child is TSESTree.JSXElement =>
+ child.type === 'JSXElement' &&
+ child.openingElement?.name.type === 'JSXIdentifier' &&
+ formControlChildComponents.includes(child.openingElement.name.name)
+ );
+
+ if (!childElement) {
+ return;
+ }
+
+ const childIsInvalid = getAttrValue(
+ context,
+ childElement.openingElement.attributes,
+ 'isInvalid'
+ );
+
+ if (childIsInvalid !== formRowIsInvalid) {
+ const componentName = (
+ childElement.openingElement.name as TSESTree.JSXIdentifier
+ ).name;
+
+ context.report({
+ node: childElement,
+ messageId: 'inconsistentIsInvalid',
+ fix: (fixer) => {
+ const childIsInvalidAttr =
+ childElement.openingElement.attributes.find(
+ (attr): attr is TSESTree.JSXAttribute =>
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ attr.name.name === 'isInvalid'
+ );
+
+ if (childIsInvalidAttr) {
+ return fixer.replaceText(
+ childIsInvalidAttr,
+ `isInvalid={${formRowIsInvalid}}`
+ );
+ }
+
+ const insertPosition = childElement.openingElement.name.range[1];
+
+ return fixer.insertTextAfterRange(
+ [insertPosition, insertPosition],
+ ` isInvalid={${formRowIsInvalid}}`
+ );
+ },
+ data: {
+ formControlComponent: formControlComponent,
+ component: componentName,
+ },
+ });
+ }
+ },
+ };
+ },
+ meta: {
+ type: 'problem',
+ docs: {
+ description: `Ensure {{component}} inherit "isInvalid" prop from parent {{formControlComponent}}`,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ inconsistentIsInvalid: `{{component}} should have the same "isInvalid" prop value as its parent {{formControlComponent}}.`,
+ },
+ },
+ defaultOptions: [],
+});
diff --git a/packages/eslint-plugin/src/utils/get_attr_value.ts b/packages/eslint-plugin/src/utils/get_attr_value.ts
new file mode 100644
index 00000000000..c4d55977f8f
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/get_attr_value.ts
@@ -0,0 +1,55 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { type TSESTree, type TSESLint} from '@typescript-eslint/utils';
+
+export function getAttrValue<
+ TContext extends TSESLint.RuleContext
+>(
+ context: TContext,
+ attributes: TSESTree.JSXOpeningElement['attributes'],
+ attrName: string
+): string | undefined {
+ const attr = attributes.find(
+ (attr): attr is TSESTree.JSXAttribute =>
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ attr.name.name === attrName
+ );
+
+ if (!attr?.value) {
+ return undefined;
+ }
+
+ if (attr.value.type === 'Literal') {
+ return String(attr.value.value);
+ }
+
+ if (attr.value.type === 'JSXExpressionContainer') {
+ const expression = attr.value.expression;
+
+ if (expression.type === 'Literal') {
+ return String(expression.value);
+ }
+
+ return context.sourceCode.getText(expression);
+ }
+
+ return undefined;
+}