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.
Comment thread
bhavyarm marked this conversation as resolved.


## 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**
Comment thread
bhavyarm marked this conversation as resolved.

- Added new `no-unnamed-interactive-element` rule.
Comment thread
bhavyarm marked this conversation as resolved.
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,111 @@
/*
* 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 languageOptions = {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
};

const ruleTester = new RuleTester();

ruleTester.run('no-unnamed-interactive-element', NoUnnamedInteractiveElement, {
Comment thread
bhavyarm marked this conversation as resolved.
Outdated
valid: [
{
code: dedent`
const MyComponent = () => (
<EuiButtonEmpty aria-label="Click me" />
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiFormRow label="Form row">
<EuiSelect />
</EuiFormRow>
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<div>Not an EUI element</div>
)
`,
languageOptions,
},
{
code: dedent`
const MyComponent = () => (
<EuiButtonIcon aria-labelledby={dynamicLabelId} />
)
`,
languageOptions,
},
],
invalid: [
// Unwrapped interactive element with no a11y name
{
code: dedent`
const MyComponent = () => (
<EuiButtonEmpty />
)
`,
languageOptions,
errors: [
{
messageId: 'missingA11y',
data: {
component: 'EuiButtonEmpty',
how: '`aria-label` or `aria-labelledby`',
},
},
],
},
// Wrapped interactive element; suggest wrapper's label in addition
{
code: dedent`
const MyComponent = () => (
<EuiFormRow>
<EuiSelect />
</EuiFormRow>
)
`,
languageOptions,
errors: [
{
messageId: 'missingA11y',
data: {
component: 'EuiFormRow',
how: '`aria-label` or `aria-labelledby` or the wrapper\'s \`label\` (e.g., \`EuiFormRow\`)',
},
},
],
},
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* 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';

const interactiveComponents = [
'EuiBetaBadge',
'EuiButtonEmpty',
'EuiButtonIcon',
'EuiComboBox',
'EuiSelect',
'EuiSelectWithWidth',
'EuiSuperSelect',
'EuiPagination',
'EuiTreeView',
'EuiBreadcrumbs',
] as const;


const wrappingComponents = ['EuiFormRow'] as const;
const a11yProps = ['aria-label', 'aria-labelledby', 'label'] as const;

function hasSpread(attrs: TSESTree.JSXOpeningElement['attributes']): boolean {
return attrs.some((a) => a.type === 'JSXSpreadAttribute');
}

const interactiveComponentsWithoutLabel = [
'EuiBetaBadge',
Comment thread
bhavyarm marked this conversation as resolved.
Outdated
'EuiButtonIcon',
'EuiButtonEmpty',
'EuiBreadcrumbs',
];


function getAllowedA11yPropNamesForComponent(
componentName: string,
): string[] {
if (interactiveComponentsWithoutLabel.includes(componentName)) {
return a11yProps.filter((p) => p !== 'label');
}
return [...a11yProps];
}

function hasA11yProp(attrs: TSESTree.JSXOpeningElement['attributes']): boolean {
return attrs.some(
(attr): attr is TSESTree.JSXAttribute =>
attr.type === 'JSXAttribute' &&
attr.name.type === 'JSXIdentifier' &&
a11yProps.includes(attr.name.name as (typeof a11yProps)[number]),
);
}


export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
hasSuggestions: false,
schema: [],
messages: {
missingA11y:
'{{component}} must include an accessible label. Use one of: {{a11yProps}}'
},
Comment thread
bhavyarm marked this conversation as resolved.
},
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).join(', ');
context.report({
node: opening,
messageId: 'missingA11y',
data: {
component,
a11yProps: allowed,
},
});
}

return {
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier') return;

const isInteractive = interactiveComponents.includes(
node.name.name as (typeof interactiveComponents)[number],
);
if (!isInteractive) return;

if (hasSpread(node.attributes) || hasA11yProp(node.attributes)) 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.includes(
a.openingElement.name.name as (typeof wrappingComponents)[number],
),
);

if (wrapper) {
const open = wrapper.openingElement;
if (!hasSpread(open.attributes) && !hasA11yProp(open.attributes)) {
report(open);
}
} else {
report(node);
}
},
};
},
});