diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index e3a3b76d9d2..dcfa0d04439 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -147,6 +147,10 @@ Ensure `EuiIconTip` is used rather than ``, a
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.
+### `@elastic/eui/no-unnamed-interactive-element`
+Ensure that appropriate aria-attributes are set for `EuiBetaBadge`, `EuiButtonIcon`, `EuiComboBox`, `EuiSelect`, `EuiSelectWithWidth`,`EuiSuperSelect`,`EuiPagination`, `EuiTreeView`, `EuiBreadcrumbs`. Without this rule, screen reader users lose context, keyboard navigation can be confusing.
+
+
## Testing
### Running unit tests
diff --git a/packages/eslint-plugin/changelogs/upcoming/8973.md b/packages/eslint-plugin/changelogs/upcoming/8973.md
new file mode 100644
index 00000000000..04bd2703604
--- /dev/null
+++ b/packages/eslint-plugin/changelogs/upcoming/8973.md
@@ -0,0 +1,3 @@
+**Accessibility**
+
+- Added new `no-unnamed-interactive-element` rule.
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index ca7d25bf06c..0362ee5e0fb 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -26,6 +26,8 @@ import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_pro
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';
+import { NoUnnamedInteractiveElement } from './rules/a11y/no_unnamed_interactive_element';
+
const config = {
rules: {
@@ -37,6 +39,7 @@ const config = {
'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip,
'prefer-eui-icon-tip': PreferEuiIconTip,
'no-unnamed-radio-group' : NoUnnamedRadioGroup,
+ 'no-unnamed-interactive-element': NoUnnamedInteractiveElement,
},
configs: {
recommended: {
@@ -50,6 +53,7 @@ const config = {
'@elastic/eui/sr-output-disabled-tooltip': 'warn',
'@elastic/eui/prefer-eui-icon-tip': 'warn',
'@elastic/eui/no-unnamed-radio-group': 'warn',
+ '@elastic/eui/no-unnamed-interactive-element': 'warn',
},
},
},
diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts
new file mode 100644
index 00000000000..527decf183c
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 { NoUnnamedInteractiveElement } from './no_unnamed_interactive_element';
+
+const ruleTester = new RuleTester({});
+// Set the parser for RuleTester
+// @ts-ignore
+ruleTester.parser = require.resolve('@typescript-eslint/parser');
+
+ruleTester.run('NoUnnamedInteractiveElement', NoUnnamedInteractiveElement, {
+ valid: [
+ // Components with allowed a11y props
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ { code: '' },
+ // Wrapped in EuiFormRow with label
+ { code: '' },
+ { code: '' },
+ ],
+ invalid: [
+ // Missing a11y prop for interactive components
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ // Wrapped but missing label
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'missingA11y' }],
+ },
+ ],
+});
\ No newline at end of file
diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts
new file mode 100644
index 00000000000..7e686fa2837
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts
@@ -0,0 +1,125 @@
+/*
+ * 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, type TSESTree } from '@typescript-eslint/utils';
+import { hasSpread } from '../../utils/has_spread';
+import {
+ getAllowedA11yPropNamesForComponent,
+ type A11yConfig,
+} from '../../utils/get_allowed_a11y_prop_names_for_component';
+import { hasA11yPropForComponent } from '../../utils/has_a11y_prop_for_component';
+
+const interactiveComponents = [
+ 'EuiBetaBadge',
+ 'EuiButtonEmpty',
+ 'EuiButtonIcon',
+ 'EuiComboBox',
+ 'EuiSelect',
+ 'EuiSelectWithWidth',
+ 'EuiSuperSelect',
+ 'EuiPagination',
+ 'EuiTreeView',
+ 'EuiBreadcrumbs',
+] as const;
+
+const wrappingComponents = ['EuiFormRow'] as const;
+const interactiveComponentsWithLabel = ['EuiBetaBadge'] as const;
+const baseA11yProps = ['aria-label', 'aria-labelledby'] as const;
+
+// Single source of truth for the utils (keeps them reusable)
+const a11yConfig: A11yConfig = {
+ interactiveComponentsWithLabel: [...interactiveComponentsWithLabel],
+ wrappingComponents: [...wrappingComponents],
+ baseA11yProps: [...baseA11yProps],
+};
+
+export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({
+ meta: {
+ type: 'problem',
+ hasSuggestions: false,
+ schema: [],
+ messages: {
+ missingA11y:
+ '{{component}} must include an accessible label. Use one of: {{a11yProps}}',
+ },
+ },
+ defaultOptions: [],
+ create(context) {
+ const sourceCode = context.sourceCode;
+
+ function report(opening: TSESTree.JSXOpeningElement) {
+ if (opening.name.type !== 'JSXIdentifier') return;
+ const component = opening.name.name;
+ const allowed = getAllowedA11yPropNamesForComponent(component, a11yConfig).join(', ');
+ context.report({
+ node: opening,
+ messageId: 'missingA11y',
+ data: {
+ component,
+ a11yProps: allowed,
+ },
+ });
+ }
+
+ return {
+ JSXOpeningElement(node) {
+ if (node.name.type !== 'JSXIdentifier') return;
+
+ const componentName = node.name.name;
+ const isInteractive = (
+ interactiveComponents as readonly string[]
+ ).includes(componentName);
+ if (!isInteractive) return;
+
+ if (
+ hasSpread(node.attributes) ||
+ hasA11yPropForComponent(componentName, node.attributes, a11yConfig)
+ ) {
+ return;
+ }
+
+ const ancestors = sourceCode.getAncestors(node);
+ const wrapper = [...ancestors]
+ .reverse()
+ .find(
+ (a): a is TSESTree.JSXElement =>
+ a.type === 'JSXElement' &&
+ a.openingElement.name.type === 'JSXIdentifier' &&
+ (wrappingComponents as readonly string[]).includes(
+ a.openingElement.name.name
+ )
+ );
+
+ if (wrapper) {
+ const open = wrapper.openingElement;
+ const wrapperName =
+ open.name.type === 'JSXIdentifier' ? open.name.name : '';
+ if (
+ !hasSpread(open.attributes) &&
+ !hasA11yPropForComponent(wrapperName, open.attributes, a11yConfig)
+ ) {
+ report(open);
+ }
+ } else {
+ report(node);
+ }
+ },
+ };
+ },
+});
\ No newline at end of file
diff --git a/packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts b/packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts
new file mode 100644
index 00000000000..5df706a485d
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/get_allowed_a11y_prop_names_for_component.ts
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+/**
+ * Configuration describing which components accept a `label` prop
+ * and the baseline set of accessibility prop names allowed across components.
+ */
+export type A11yConfig = {
+ interactiveComponentsWithLabel: ReadonlyArray;
+ wrappingComponents: ReadonlyArray;
+ baseA11yProps: ReadonlyArray;
+};
+
+/**
+ * Compute the set of allowed accessibility prop names for a given component.
+ *
+ * - Always includes the provided `baseA11yProps`.
+ * - Conditionally includes `label` if the component is listed in either
+ * `interactiveComponentsWithLabel` or `wrappingComponents`.
+ * - Does **not** mutate the provided configuration; a new array is returned.
+ *
+ * @param componentName - The EUI component name (e.g., `'EuiButtonIcon'`).
+ * @param cfg - The accessibility configuration to use when resolving allowed props.
+ * @returns A new array of allowed prop names for `componentName`.
+ *
+ */
+export function getAllowedA11yPropNamesForComponent(
+ componentName: string,
+ cfg: A11yConfig
+): string[] {
+ const componentsWithLabel = new Set([
+ ...cfg.interactiveComponentsWithLabel,
+ ...cfg.wrappingComponents,
+ ]);
+
+ if (componentsWithLabel.has(componentName)) {
+ return [...cfg.baseA11yProps, 'label'];
+ }
+ return [...cfg.baseA11yProps];
+}
diff --git a/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts b/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts
new file mode 100644
index 00000000000..e3461b2094e
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 } from '@typescript-eslint/utils';
+import {
+ getAllowedA11yPropNamesForComponent,
+ type A11yConfig,
+} from './get_allowed_a11y_prop_names_for_component';
+
+/**
+ * Determines whether a JSX element declares at least one **allowed**
+ * accessibility-related prop for a given component.
+ *
+ * Allowed prop names are resolved via {@link getAllowedA11yPropNamesForComponent},
+ * which combines baseline a11y props (e.g. `aria-*`) and conditionally adds
+ * `label` for components that support it per the provided configuration.
+ *
+ * Only plain `JSXAttribute` nodes are considered—spread attributes are ignored here.
+ *
+ * @param componentName - The component name being checked (e.g., `"EuiButtonIcon"`).
+ * @param attrs - The attributes array from a `JSXOpeningElement` (ESTree).
+ * @param cfg - Accessibility configuration that defines base props and which
+ * components may accept a `label` prop.
+ * @returns `true` if any attribute name on the element is in the allowed set; otherwise `false`.
+ */
+
+export function hasA11yPropForComponent(
+ componentName: string,
+ attrs: TSESTree.JSXOpeningElement['attributes'],
+ cfg: A11yConfig
+): boolean {
+ const allowed = new Set(
+ getAllowedA11yPropNamesForComponent(componentName, cfg)
+ );
+ return attrs.some(
+ (attr): attr is TSESTree.JSXAttribute =>
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ allowed.has(attr.name.name)
+ );
+}
diff --git a/packages/eslint-plugin/src/utils/has_spread.ts b/packages/eslint-plugin/src/utils/has_spread.ts
new file mode 100644
index 00000000000..973274f956e
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/has_spread.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 } from '@typescript-eslint/utils';
+
+/**
+ * Checks whether a JSX opening element contains a spread attribute
+ * (e.g., `...props`). Spreads make it impossible to statically know
+ * all props present on an element, so ESLint rules often use this as
+ * a quick bail-out to avoid false positives.
+ *
+ * @param attrs - The attributes array from a `JSXOpeningElement` node (ESTree).
+ * @returns `true` if any attribute is a `JSXSpreadAttribute`; otherwise `false`.
+ */
+
+export function hasSpread(
+ attrs: TSESTree.JSXOpeningElement['attributes']
+): boolean {
+ return attrs.some((a) => a.type === 'JSXSpreadAttribute');
+}