diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 172393b5f2a..6dd013d556c 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -143,6 +143,10 @@ Ensures `disableScreenReaderOutput` is set when `EuiToolTip` content matches `Eu Ensure `EuiIconTip` is used rather than ``, as it provides better accessibility and improved support for assistive technologies. +### `@elastic/eui/no-unnamed-radio-group` + +Ensure that all radio input components (`EuiRadio`, `EuiRadioGroup`) have a `name` attribute. The `name` attribute is required for radio inputs to be grouped correctly, allowing users to select only one option from a set. Without a `name`, radios may not behave as expected and can cause accessibility issues for assistive technologies. + ## Testing ### Running unit tests diff --git a/packages/eslint-plugin/changelogs/upcoming/8929.md b/packages/eslint-plugin/changelogs/upcoming/8929.md new file mode 100644 index 00000000000..ec96548d180 --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/8929.md @@ -0,0 +1 @@ +- Added new `no-unnamed-radio-group` rule. diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 32054ddccb2..ca7d25bf06c 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -25,6 +25,7 @@ import { RequireAriaLabelForModals } from './rules/a11y/require_aria_label_for_m import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_props'; import { ScreenReaderOutputDisabledTooltip } from './rules/a11y/sr_output_disabled_tooltip'; import { PreferEuiIconTip } from './rules/a11y/prefer_eui_icon_tip'; +import { NoUnnamedRadioGroup } from './rules/a11y/no_unnamed_radio_group'; const config = { rules: { @@ -35,6 +36,7 @@ const config = { 'consistent-is-invalid-props': ConsistentIsInvalidProps, 'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip, 'prefer-eui-icon-tip': PreferEuiIconTip, + 'no-unnamed-radio-group' : NoUnnamedRadioGroup, }, configs: { recommended: { @@ -47,6 +49,7 @@ const config = { '@elastic/eui/consistent-is-invalid-props': 'warn', '@elastic/eui/sr-output-disabled-tooltip': 'warn', '@elastic/eui/prefer-eui-icon-tip': 'warn', + '@elastic/eui/no-unnamed-radio-group': 'warn', }, }, }, diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_radio_group.test.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_radio_group.test.ts new file mode 100644 index 00000000000..f2737bd6ccf --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_radio_group.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { NoUnnamedRadioGroup } from './no_unnamed_radio_group'; + +const languageOptions = { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-unnamed-radio-group', NoUnnamedRadioGroup, { + valid: [ + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( +
Not a radio
+ ) + `, + languageOptions, + }, + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + }, + ], + invalid: [ + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + errors: [ + { + messageId: 'missingRadioName', + data: { component: 'EuiRadio' }, + }, + ], + }, + { + code: dedent` + const MyComponent = () => ( + + ) + `, + languageOptions, + errors: [ + { + messageId: 'missingRadioName', + data: { component: 'EuiRadioGroup' }, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_radio_group.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_radio_group.ts new file mode 100644 index 00000000000..af3bd77cf5f --- /dev/null +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_radio_group.ts @@ -0,0 +1,63 @@ +/* + * 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 { ESLintUtils } from '@typescript-eslint/utils'; + +const radioComponents = ['EuiRadio', 'EuiRadioGroup']; + +export const NoUnnamedRadioGroup = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + return { + JSXOpeningElement(node) { + if ( + node.name.type === 'JSXIdentifier' && + radioComponents.includes(node.name.name) + ) { + const hasNameAttr = node.attributes.some( + (attr) => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'name' + ); + + if (!hasNameAttr) { + context.report({ + node, + messageId: 'missingRadioName', + data: { component: node.name.name }, + }); + } + } + }, + }; + }, + meta: { + type: 'problem', + docs: { + description: + 'Ensure that all radio input components have a `name` attribute. The `name` attribute is required for radio inputs to be grouped correctly, allowing users to select only one option from a set. Without a `name`, radios may not behave as expected and can cause accessibility issues for assistive technologies.', + }, + schema: [], + messages: { + missingRadioName: + '{{ component }} must have a `name` attribute. The `name` attribute is required for radio inputs to be grouped correctly, ensuring only one option can be selected and improving accessibility for all users.', + }, + }, + defaultOptions: [], +});