Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ Ensure `EuiIconTip` is used rather than `<EuiToolTip><EuiIcon/></EuiToolTip>`, 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
Expand Down
3 changes: 3 additions & 0 deletions packages/eslint-plugin/changelogs/upcoming/8973.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Accessibility**

- Added new `no-unnamed-interactive-element` rule.
4 changes: 4 additions & 0 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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',
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '<EuiBetaBadge aria-label="Beta badge" />' },
{ code: '<EuiButtonEmpty aria-labelledby="btnLabel" />' },
{ code: '<EuiButtonIcon aria-label="Icon" />' },
{ code: '<EuiComboBox label="Combo label" />' },
{ code: '<EuiSelect aria-label="Select label" />' },
{ code: '<EuiSelectWithWidth label="SelectWithWidth label" />' },
{ code: '<EuiSuperSelect aria-labelledby="superLabel" />' },
{ code: '<EuiPagination label="Pagination label" />' },
{ code: '<EuiTreeView label="TreeView label" />' },
{ code: '<EuiBreadcrumbs aria-label="Breadcrumbs label" />' },
// Wrapped in EuiFormRow with label
{ code: '<EuiFormRow label="Row label"><EuiComboBox /></EuiFormRow>' },
{ code: '<EuiFormRow label="Row label"><EuiSelect /></EuiFormRow>' },
],
invalid: [
// Missing a11y prop for interactive components
{
code: '<EuiBetaBadge />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiButtonEmpty />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiButtonIcon />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiComboBox />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiSelect />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiSelectWithWidth />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiSuperSelect />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiPagination />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiTreeView />',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiBreadcrumbs />',
errors: [{ messageId: 'missingA11y' }],
},
// Wrapped but missing label
{
code: '<EuiFormRow><EuiComboBox /></EuiFormRow>',
errors: [{ messageId: 'missingA11y' }],
},
{
code: '<EuiFormRow><EuiSelect /></EuiFormRow>',
errors: [{ messageId: 'missingA11y' }],
},
],
});
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
},
});
Original file line number Diff line number Diff line change
@@ -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<string>;
wrappingComponents: ReadonlyArray<string>;
baseA11yProps: ReadonlyArray<string>;
};

/**
* 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<string>([
...cfg.interactiveComponentsWithLabel,
...cfg.wrappingComponents,
]);

if (componentsWithLabel.has(componentName)) {
return [...cfg.baseA11yProps, 'label'];
}
return [...cfg.baseA11yProps];
}
57 changes: 57 additions & 0 deletions packages/eslint-plugin/src/utils/has_a11y_prop_for_component.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
Loading