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; +}