Add support for wrapping elements in eslint-plugin-eui-a11y plugin (#216339)

## Summary

Adds support for wrapping elements.

| Code | Turns into |
|--------|--------|
| <img width="764" alt="Screenshot 2025-04-01 at 09 25 09"
src="https://github.com/user-attachments/assets/9b5d2743-3b61-4d21-b726-0a0be9539d99"
/> | <img width="827" alt="Screenshot 2025-04-01 at 09 25 20"
src="https://github.com/user-attachments/assets/9879d1cb-e22f-4c80-a666-001b273d6d7d"
/>
| <img width="744" alt="Screenshot 2025-04-01 at 09 25 54"
src="https://github.com/user-attachments/assets/c4320ff8-baa2-4fcc-9b3c-f7ab86c1cb23"
/> | <img width="838" alt="Screenshot 2025-04-01 at 09 26 07"
src="https://github.com/user-attachments/assets/d81a1232-a643-4775-ac83-a1a97bcbc528"
/> |

**Message**
<img width="804" alt="Screenshot 2025-03-25 at 13 59 36"
src="https://github.com/user-attachments/assets/8eaa2f54-aee6-4828-b1d5-15d4d2bfb4c0"
/>

**Exceptions**
If elements have a `aria-label`, `aria-labelledby` or `label`, they are
not flagged.

**Autofix suggestion**
- autofixes are translated with `i18n.translate`
- if `i18n` is not imported yet, an import statement is added
- If a `placeholder` prop is found, it uses that as the `i18n.translate`
default message for `aria-label`
- If the element has children, it uses the text value of the children as
the default message for the `i18n.translate` default message for
`aria-label`

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Milosz Marcinkowski <38698566+miloszmarcinkowski@users.noreply.github.com>
This commit is contained in:
Coen Warmer 2025-04-09 18:16:00 +02:00 committed by GitHub
parent 9d659b76dd
commit 46b4e1fc6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 693 additions and 165 deletions

View file

@ -38,7 +38,7 @@ turns into:
```
<EuiButtonIcon
aria-label={i18n.translate('xpack.apm.discoveryRule.pencilButton.ariaLabel', {
defaultMessage: 'Pencil Button',
defaultMessage: 'Pencil',
})}
iconType="pencil"
onClick={() => {
@ -50,37 +50,28 @@ turns into:
Another example:
```
/* file is in: `x-pack/solutions/observability/plugins/infra/public/components/beta_badge.tsx` */
/* file is in: `x-pack/solutions/observability/plugins/infra/public/components/foo.tsx` */
export const InfraBetaBadge = ({ iconType, tooltipPosition, tooltipContent }: Props) => (
<EuiBetaBadge
label={i18n.translate('xpack.infra.common.tabBetaBadgeLabel', {
defaultMessage: 'Beta',
})}
tooltipContent={tooltipContent}
iconType={iconType}
tooltipPosition={tooltipPosition}
data-test-id="infra-beta-badge"
/>
const Foo = ({ ... }: Props) => (
<EuiFormRow>
<EuiComboBox
...
/>
</EuiFormRow>
);
```
gets transformed into:
```
export const InfraBetaBadge = ({ iconType, tooltipPosition, tooltipContent }: Props) => (
<EuiBetaBadge
aria-label={i18n.translate('xpack.infra.infraBetaBadge.betaBadge.ariaLabel', {
defaultMessage: 'Beta Badge',
})}
label={i18n.translate('xpack.infra.common.tabBetaBadgeLabel', {
defaultMessage: 'Beta',
})}
tooltipContent={tooltipContent}
iconType={iconType}
tooltipPosition={tooltipPosition}
data-test-id="infra-beta-badge"
/>
const Foo = ({ ... }: Props) => (
<EuiFormRow aria-label={i18n.translate('xpack.infra.foo.ariaLabel', {
defaultMessage: 'Foo',
})}>
<EuiComboBox
...
/>
</EuiFormRow>
);
```

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { parse } from '@typescript-eslint/parser';
import { getDefaultMessageFromI18n } from './get_default_message_from_i18n';
describe('getDefaultMessageFromI18n', () => {
it('should return the default message if it exists', () => {
expect(
getDefaultMessageFromI18n(
(parse('i18n.translate("test", { defaultMessage: "Hello world" })') as any).body[0]
)
).toEqual('Hello world');
});
it('should return an empty string if there is no default message', () => {
expect(
getDefaultMessageFromI18n(
(parse('i18n.translate("test", { defaultMessage: "" })') as any).body[0]
)
).toEqual('');
});
it('should return an empty string if the function is not an i18n function', () => {
expect(
getDefaultMessageFromI18n(
(parse('i19n.translate("test", { defaultMessage: "" })') as any).body[0]
)
).toEqual('');
});
});

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import * as TypescriptEsTree from '@typescript-eslint/typescript-estree';
export function getDefaultMessageFromI18n(
i18nNode: TypescriptEsTree.TSESTree.JSXExpression
): string {
const expression =
i18nNode?.expression.type === TypescriptEsTree.TSESTree.AST_NODE_TYPES.CallExpression
? i18nNode.expression
: undefined;
if (
!expression ||
expression.callee.type !== TypescriptEsTree.TSESTree.AST_NODE_TYPES.MemberExpression
) {
return '';
}
const opts = expression.arguments?.find(
(arg) => arg.type === TypescriptEsTree.TSESTree.AST_NODE_TYPES.ObjectExpression
);
if (opts?.type !== TypescriptEsTree.TSESTree.AST_NODE_TYPES.ObjectExpression) {
return '';
}
const defaultMessageArg = opts.properties.find(
(prop) =>
prop.type === TypescriptEsTree.TSESTree.AST_NODE_TYPES.Property &&
prop.key.type === TypescriptEsTree.TSESTree.AST_NODE_TYPES.Identifier &&
prop.key.name === 'defaultMessage'
);
return defaultMessageArg?.type === TypescriptEsTree.TSESTree.AST_NODE_TYPES.Property &&
defaultMessageArg?.value?.type === TypescriptEsTree.TSESTree.AST_NODE_TYPES.Literal
? String(defaultMessageArg.value.value)
: '';
}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getFunctionName } from './get_function_name';
describe('getFunctionName', () => {
it('should return the function name if the function is defined as a variable assignment', () => {
const mockNode = {
parent: {
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: 'myFunction',
},
},
};
expect(getFunctionName(mockNode as any)).toEqual(mockNode.parent.id.name);
});
it('should return the function name if the function is defined as a function declaration', () => {
const mockNode = {
parent: {
type: 'FunctionDeclaration',
id: {
type: 'Identifier',
name: 'myFunction',
},
},
};
expect(getFunctionName(mockNode as any)).toEqual('myFunction');
});
it('should return an empty string if the function name is not found', () => {
const mockNode = {
parent: {
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: '',
},
},
};
expect(getFunctionName(mockNode as any)).toEqual('');
});
it('should return an empty string if the function is not a variable assignment or function declaration', () => {
const mockNode = {
parent: {
type: 'SomeOtherType',
},
};
expect(getFunctionName(mockNode as any)).toEqual('');
});
});

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getIntentFromNode } from './get_intent_from_node';
describe('getIntentFromNode', () => {
it('should return the intent if it exists', () => {
const mockNode = {
parent: {
type: 'JSXElement',
id: {
type: 'Identifier',
name: 'myIntent',
},
children: [
{
type: 'JSXText',
value: 'hello',
},
{
type: 'JSXText',
value: 'world',
},
],
},
};
expect(getIntentFromNode(mockNode as any)).toEqual('hello world');
});
it('should return an empty string if there is parent element', () => {
const mockNode = {
parent: {},
};
expect(getIntentFromNode(mockNode as any)).toEqual('');
});
it('should return an empty string if there are no children to the parent element', () => {
const mockNode = {
parent: {
type: 'JSXElement',
id: {
type: 'Identifier',
name: 'myIntent',
},
children: [],
},
};
expect(getIntentFromNode(mockNode as any)).toEqual('');
const mockNodeAlt = {
parent: {
type: 'JSXElement',
id: {
type: 'Identifier',
name: 'myIntent',
},
},
};
expect(getIntentFromNode(mockNodeAlt as any)).toEqual('');
});
});

View file

@ -19,11 +19,15 @@ import { TSESTree } from '@typescript-eslint/typescript-estree';
* Translated text via {i18n.translate} call -> uses passed options object key `defaultMessage`
*/
export function getIntentFromNode(originalNode: TSESTree.JSXOpeningElement): string {
const parent = originalNode.parent as TSESTree.JSXElement;
if (!originalNode.parent) return '';
const node = Array.isArray(parent.children) ? parent.children : [];
if (originalNode.parent.type !== 'JSXElement') return '';
if (node.length === 0) {
const { parent } = originalNode;
const nodes = Array.isArray(parent.children) ? parent.children : [];
if (nodes.length === 0) {
return '';
}
@ -33,7 +37,7 @@ export function getIntentFromNode(originalNode: TSESTree.JSXOpeningElement): str
keeping the code readable. In the cases where types are explicitly set to
variables, it was done to help the compiler when it couldn't infer the type.
*/
const intent = node.reduce((acc: string, currentNode) => {
const intent = nodes.reduce((acc: string, currentNode) => {
switch (currentNode.type) {
case 'JSXText':
// When node is a string primitive

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { parse } from '@typescript-eslint/parser';
import { getPropValues } from './get_prop_values';
describe('getPropValues', () => {
it('should return the prop values from the node', () => {
const code = parse(
`<MyComponent prop1="bar" prop2={i18n.translate('bar', {})} prop3="baz" />`,
{
ecmaFeatures: {
jsx: true,
},
}
);
const element = (code.body[0] as any).expression as any;
expect(
getPropValues({
jsxOpeningElement: element.openingElement,
propNames: ['prop1', 'prop2'],
sourceCode: {
getScope: () =>
({
attributes: [],
} as any),
} as any,
})
).toEqual({
prop1: { type: 'Literal', value: 'bar', raw: '"bar"' },
prop2: element.openingElement.attributes[1].value,
});
});
it('should return an empty object if there are no props found', () => {
const code = parse(`<MyComponent />`, {
ecmaFeatures: {
jsx: true,
},
});
expect(
getPropValues({
jsxOpeningElement: ((code.body[0] as any).expression as any).openingElement,
propNames: ['prop1', 'prop2'],
sourceCode: {
getScope: () =>
({
attributes: [],
} as any),
} as any,
})
).toEqual({});
});
});

View file

@ -7,30 +7,39 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { Scope } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree';
import * as EsLint from 'eslint';
import * as TypescriptEsTree from '@typescript-eslint/typescript-estree';
export function getPropValues({
jsxOpeningElement,
propNames,
node,
getScope,
sourceCode,
}: {
jsxOpeningElement: TypescriptEsTree.TSESTree.JSXOpeningElement;
propNames: string[];
node: TSESTree.JSXOpeningElement;
getScope: () => Scope.Scope;
}): Record<(typeof propNames)[number], string> {
sourceCode: EsLint.SourceCode;
}): Record<
(typeof propNames)[number],
TypescriptEsTree.TSESTree.Literal | TypescriptEsTree.TSESTree.JSXExpression
> {
const scope = sourceCode.getScope(jsxOpeningElement as any);
if (!scope) return {};
// Loop over the input propNames array
const result = propNames.reduce((acc, propName) => {
return propNames.reduce((acc, propName) => {
// Loop over the attributes of the input JSXOpeningElement
for (const prop of node.attributes) {
// If the prop is an JSXAttribute and the name of the prop is equal to the propName, get the value
if (prop.type === AST_NODE_TYPES.JSXAttribute && propName === prop.name.name) {
const value = String('value' in prop && 'value' in prop.value! && prop.value.value) || '';
acc[propName] = value;
for (const prop of jsxOpeningElement.attributes) {
if (
prop.type === TypescriptEsTree.AST_NODE_TYPES.JSXAttribute &&
propName === prop.name.name &&
prop.value
) {
acc[propName] = prop.value; // can be a string or an function
}
// If the prop is a JSXSpreadAttribute, get the value of the spreaded variable
if (prop.type === AST_NODE_TYPES.JSXSpreadAttribute) {
if (prop.type === TypescriptEsTree.AST_NODE_TYPES.JSXSpreadAttribute) {
// If the spreaded variable is an Expression, break
if (!('argument' in prop) || !('name' in prop.argument)) {
break;
@ -43,17 +52,19 @@ export function getPropValues({
const { name } = prop.argument;
// the variable definition of the spreaded variable
const variable = getScope().variables.find((v) => v.name === name);
const variable = scope.variables.find((v) => v.name === name);
// Get the value of the propName from the spreaded variable
const value: string | undefined =
const value =
variable && variable.defs.length > 0
? variable.defs[0].node.init?.properties?.find((property: TSESTree.Property) => {
if ('value' in property.key) {
return propNames.includes(String(property.key.value));
? variable.defs[0].node.init?.properties?.find(
(property: TypescriptEsTree.TSESTree.Property) => {
if ('value' in property.key && property.key.value) {
return property.key.value;
}
return undefined;
}
return undefined;
})
)
: undefined;
if (value) {
@ -62,7 +73,5 @@ export function getPropValues({
}
}
return acc;
}, {} as Record<(typeof propNames)[number], string>);
return result;
}, {} as Record<(typeof propNames)[number], TypescriptEsTree.TSESTree.Literal | TypescriptEsTree.TSESTree.JSXExpression>);
}

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getWrappingElement } from './get_wrapping_element';
describe('getWrappingElement', () => {
it('should return the wrapping element if it exists', () => {
const mockNode = {
parent: {
parent: {
type: 'JSXElement',
openingElement: {
name: {
type: 'JSXIdentifier',
name: 'EuiFormRow',
},
},
},
},
};
expect(getWrappingElement(mockNode as any)).toEqual({
elementName: 'EuiFormRow',
node: mockNode.parent.parent.openingElement,
});
const mockNodeAlt = {
parent: {
parent: {
type: 'JSXElement',
openingElement: {
name: {
type: 'JSXIdentifier',
name: 'div',
},
},
},
},
};
expect(getWrappingElement(mockNodeAlt as any)).toEqual({
elementName: 'div',
node: mockNodeAlt.parent.parent.openingElement,
});
});
it('should return undefined if there is no wrapping element', () => {
const mockNode = {
parent: {},
};
const result = getWrappingElement(mockNode as any);
expect(result).toBeUndefined();
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import * as TypescriptEsTree from '@typescript-eslint/typescript-estree';
export const getWrappingElement = (
jsxOpeningElement: TypescriptEsTree.TSESTree.JSXOpeningElement
): { elementName: string; node: TypescriptEsTree.TSESTree.JSXOpeningElement } | undefined => {
const wrapperOpeningElement = jsxOpeningElement.parent?.parent;
if (
wrapperOpeningElement?.type === TypescriptEsTree.AST_NODE_TYPES.JSXElement &&
wrapperOpeningElement.openingElement.name.type === TypescriptEsTree.AST_NODE_TYPES.JSXIdentifier
) {
return {
elementName: wrapperOpeningElement.openingElement.name.name,
node: wrapperOpeningElement.openingElement,
};
}
return undefined;
};

View file

@ -8,7 +8,13 @@
*/
import { RuleTester } from 'eslint';
import { EuiElementsShouldHaveAriaLabelOrAriaLabelledbyProps } from './eui_elements_should_have_aria_label_or_aria_labelledby_props';
import {
EuiElementsShouldHaveAriaLabelOrAriaLabelledbyProps,
EUI_ELEMENTS_TO_CHECK,
EUI_WRAPPING_ELEMENTS,
A11Y_PROP_NAMES,
} from './eui_elements_should_have_aria_label_or_aria_labelledby_props';
import { lowerCaseFirstChar, sanitizeEuiElementName } from '../helpers/utils';
const tsTester = [
'@typescript-eslint/parser',
@ -39,46 +45,130 @@ const babelTester = [
}),
] as const;
const EUI_ELEMENTS = [
['EuiButtonIcon', 'Button'],
['EuiButtonEmpty', 'Button'],
['EuiBetaBadge', 'Beta Badge'],
['EuiSelect', 'Select'],
['EuiSelectWithWidth', 'Select'],
];
for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => {
tester.run(
'@kbn/eui_elements_should_have_aria_label_or_aria_labelledby_props',
EuiElementsShouldHaveAriaLabelOrAriaLabelledbyProps,
{
valid: EUI_ELEMENTS.map((element) => ({
filename: 'foo.tsx',
code: `<${element[0]} aria-label="foo" />`,
})),
invalid: EUI_ELEMENTS.map((element) => {
return {
valid: [
// unwrapped elements with an existing a11y prop should be left alone
{
filename: 'foo.tsx',
code: `<${element[0]}>Value Thing hello</${element[0]}>`,
code: `<EuiButtonIcon aria-label="foo" />`,
},
{ filename: 'foo.tsx', code: `<EuiButtonIcon aria-labelledby="foo" />` },
{
filename: 'foo.tsx',
code: `<EuiButtonIcon label="foo" />`,
},
// wrapped elements with an existing a11y prop should be left alone
{
filename: 'foo.tsx',
code: `<EuiFormRow aria-label="foo"><EuiButtonIcon /></EuiFormRow>`,
},
{
filename: 'foo.tsx',
code: `<EuiFormRow aria-labelledby="foo"><EuiButtonIcon /></EuiFormRow>`,
},
{
filename: 'foo.tsx',
code: `<EuiFormRow label="foo"><EuiButtonIcon label="foo" /></EuiFormRow>`,
},
].concat(
EUI_ELEMENTS_TO_CHECK.flatMap((element) =>
['unwrapped', ...EUI_WRAPPING_ELEMENTS].flatMap((wrapper) =>
A11Y_PROP_NAMES.map((prop) => ({
filename: 'foo.tsx',
code:
wrapper === 'unwrapped'
? `<${element} ${prop}="foo" />`
: `<${wrapper} ${prop}="foo"><${element} /></${wrapper}>`,
}))
)
)
),
invalid: [
// unwrapped elements with a missing a11y prop should be reported.
{
filename: 'foo.tsx',
code: `<EuiButtonIcon />`,
errors: [
{
line: 1,
message: `<${element[0]}> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
message: `<EuiButtonIcon> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
},
],
output: `<${
element[0]
} aria-label={i18n.translate('app_not_found_in_i18nrc.valueThinghello${element[1].replaceAll(
' ',
''
)}.ariaLabel', { defaultMessage: 'Value Thing hello' })}>Value Thing hello</${
element[0]
}>
output: `<EuiButtonIcon aria-label={i18n.translate('app_not_found_in_i18nrc.button.ariaLabel', { defaultMessage: '' })} />
import { i18n } from '@kbn/i18n';`,
};
}),
},
// if an element has a placeholder prop, use that for the default message of the aria-label.
{
filename: 'foo.tsx',
code: `<EuiComboBox placeholder={i18n.translate('app_not_found_in_i18nrc.comboBox', { defaultMessage: 'Indices and Streams' })} />`,
errors: [
{
line: 1,
message: `<EuiComboBox> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
},
],
output: `<EuiComboBox aria-label={i18n.translate('app_not_found_in_i18nrc.indicesandStreamsComboBox.ariaLabel', { defaultMessage: 'Indices and Streams' })} placeholder={i18n.translate('app_not_found_in_i18nrc.comboBox', { defaultMessage: 'Indices and Streams' })} />
import { i18n } from '@kbn/i18n';`,
},
// wrapped elements with a missing a11y prop should be reported.
{
filename: 'foo.tsx',
code: `<EuiFormRow><EuiButtonIcon /></EuiFormRow>`,
errors: [
{
line: 1,
message: `<EuiButtonIcon> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
},
],
output: `<EuiFormRow aria-label={i18n.translate('app_not_found_in_i18nrc.button.ariaLabel', { defaultMessage: '' })} ><EuiButtonIcon /></EuiFormRow>
import { i18n } from '@kbn/i18n';`,
},
// wrapped elements with a missing a11y prop should be reported.
{
filename: 'foo.tsx',
code: `<EuiFormRow><EuiButtonIcon /></EuiFormRow>`,
errors: [
{
line: 1,
message: `<EuiButtonIcon> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
},
],
output: `<EuiFormRow aria-label={i18n.translate('app_not_found_in_i18nrc.button.ariaLabel', { defaultMessage: '' })} ><EuiButtonIcon /></EuiFormRow>
import { i18n } from '@kbn/i18n';`,
},
].concat(
EUI_ELEMENTS_TO_CHECK.flatMap((element) =>
['unwrapped', ...EUI_WRAPPING_ELEMENTS].flatMap((wrapper) => ({
filename: 'foo.tsx',
code:
wrapper === 'unwrapped'
? `<${element} />`
: `<${wrapper}><${element} /></${wrapper}>`,
errors: [
{
line: 1,
message: `<${element}> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
},
],
output:
wrapper === 'unwrapped'
? `<${element} aria-label={i18n.translate('app_not_found_in_i18nrc.${lowerCaseFirstChar(
sanitizeEuiElementName(element).elementName
)}.ariaLabel', { defaultMessage: '' })} />
import { i18n } from '@kbn/i18n';`
: `<${wrapper} aria-label={i18n.translate('app_not_found_in_i18nrc.${lowerCaseFirstChar(
sanitizeEuiElementName(element).elementName
)}.ariaLabel', { defaultMessage: '' })} ><${element} /></${wrapper}>
import { i18n } from '@kbn/i18n';`,
}))
)
),
}
);
});

View file

@ -7,123 +7,188 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree';
import { Node } from 'estree';
import * as EsLint from 'eslint';
import * as EsTree from 'estree';
import * as TypescriptEsTree from '@typescript-eslint/typescript-estree';
import { getPropValues } from '../helpers/get_prop_values';
import { getFunctionName } from '../helpers/get_function_name';
import { getIntentFromNode } from '../helpers/get_intent_from_node';
import { getI18nIdentifierFromFilePath } from '../helpers/get_i18n_identifier_from_file_path';
import { getFunctionName } from '../helpers/get_function_name';
import { getI18nImportFixer } from '../helpers/get_i18n_import_fixer';
import { getWrappingElement } from '../helpers/get_wrapping_element';
import {
isTruthy,
lowerCaseFirstChar,
sanitizeEuiElementName,
upperCaseFirstChar,
} from '../helpers/utils';
import { getDefaultMessageFromI18n } from '../helpers/get_default_message_from_i18n';
export const EUI_ELEMENTS = [
'EuiButtonIcon',
'EuiButtonEmpty',
export const EUI_ELEMENTS_TO_CHECK = [
'EuiBetaBadge',
'EuiButtonEmpty',
'EuiButtonIcon',
'EuiComboBox',
'EuiSelect',
'EuiSuperSelect',
'EuiSelectWithWidth',
'EuiSuperSelect',
];
const PROP_NAMES = ['aria-label', 'aria-labelledby', 'iconType'];
export const EUI_WRAPPING_ELEMENTS = ['EuiFormRow'];
export const EuiElementsShouldHaveAriaLabelOrAriaLabelledbyProps: Rule.RuleModule = {
export const A11Y_PROP_NAMES = ['aria-label', 'aria-labelledby', 'label'];
export const EuiElementsShouldHaveAriaLabelOrAriaLabelledbyProps: EsLint.Rule.RuleModule = {
meta: {
type: 'suggestion',
fixable: 'code',
},
create(context) {
const { cwd, filename, report, sourceCode } = context;
return {
JSXIdentifier: (node: TSESTree.Node) => {
if (!('name' in node)) {
return;
}
JSXOpeningElement: (node: TypescriptEsTree.TSESTree.JSXOpeningElement) => {
// First do a bunch of checks to see if we should even bother analyzing the node.
if (!('name' in node && 'name' in node.name)) return;
const name = String(node.name);
const range = node.range;
const parent = node.parent;
const { name } = node.name;
if (parent?.type !== AST_NODE_TYPES.JSXOpeningElement || !EUI_ELEMENTS.includes(name)) {
return;
}
// The element is not an element we're interested in
if (!EUI_ELEMENTS_TO_CHECK.includes(String(name))) return;
// 1. Analyze the props of the element to see if we have to do anything
const relevantPropValues = getPropValues({
propNames: PROP_NAMES,
node: parent,
getScope: () => sourceCode.getScope(node as Node),
});
const wrappingElement = getWrappingElement(node);
// Element already has a prop for aria-label or aria-labelledby. We can bail out.
if (relevantPropValues['aria-label'] || relevantPropValues['aria-labelledby']) return;
// Start building the suggestion.
// 2. The intention of the element (i.e. "Select date", "Submit", "Cancel")
const intent =
name === 'EuiButtonIcon' && relevantPropValues.iconType
? relevantPropValues.iconType // For EuiButtonIcon, use the iconType as the intent (i.e. 'pen', 'trash')
: getIntentFromNode(parent);
// 3. The element name (i.e. "Button", "Beta Badge", "Select")
const { elementName } = sanitizeEuiElementName(name);
// Proposed default message
const defaultMessage = upperCaseFirstChar(intent).trim(); // 'Actions Button'
// 4. Set up the translation ID
const i18nAppId = getI18nIdentifierFromFilePath(filename, cwd);
const functionDeclaration = sourceCode.getScope(node as Node).block;
const functionName = getFunctionName(functionDeclaration as TSESTree.FunctionDeclaration);
const translation = [
i18nAppId,
functionName,
`${intent}${upperCaseFirstChar(elementName)}`,
'ariaLabel',
];
const translationId = translation
.filter(Boolean)
.map((el) => lowerCaseFirstChar(el).replaceAll(' ', ''))
.join('.'); // 'xpack.observability.overview.logs.loadMore.ariaLabel'
// 5. Check if i18n has already been imported into the file
const { hasI18nImportLine, i18nImportLine, rangeToAddI18nImportLine, replaceMode } =
getI18nImportFixer({
// The element is wrapped in an element which needs to get the a11y props instead
if (
wrappingElement?.elementName &&
EUI_WRAPPING_ELEMENTS.includes(wrappingElement.elementName)
) {
const props = getPropValues({
jsxOpeningElement: wrappingElement.node,
propNames: A11Y_PROP_NAMES,
sourceCode,
translationFunction: 'i18n.translate',
});
// 6. Report feedback to engineer
report({
node: node as any,
message: `<${name}> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
fix(fixer) {
return [
fixer.insertTextAfterRange(
range,
` aria-label={i18n.translate('${translationId}', { defaultMessage: '${defaultMessage}' })}`
),
!hasI18nImportLine && rangeToAddI18nImportLine
? replaceMode === 'replace'
? fixer.replaceTextRange(rangeToAddI18nImportLine, i18nImportLine)
: fixer.insertTextAfterRange(rangeToAddI18nImportLine, `\n${i18nImportLine}`)
: null,
].filter(isTruthy);
},
// The wrapping element already has an a11y prop set
if (Object.keys(props).length > 0) return;
const reporter = checkNodeForPropNamesAndCreateReporter({
cwd,
filename,
node,
range: wrappingElement.node.name.range,
sourceCode,
});
// The wrapping element does not have an a11y prop set yet, so show the reporter
if (reporter) report(reporter);
return;
}
// The element is not wrapped in an EuiFormRow
const props = getPropValues({
jsxOpeningElement: node,
propNames: A11Y_PROP_NAMES,
sourceCode,
});
// The element already has an a11y prop set
if (Object.keys(props).length > 0) return;
const reporter = checkNodeForPropNamesAndCreateReporter({
cwd,
filename,
node,
range: node.name.range,
sourceCode,
});
// The element does not have an a11y prop set yet, so show the reporter
if (reporter) report(reporter);
},
} as Rule.RuleListener;
} as EsLint.Rule.RuleListener;
},
};
const checkNodeForPropNamesAndCreateReporter = ({
node,
cwd,
filename,
range,
sourceCode,
}: {
node: TypescriptEsTree.TSESTree.JSXOpeningElement;
cwd: string;
filename: string;
range: [number, number];
sourceCode: EsLint.SourceCode;
}): EsLint.Rule.ReportDescriptor | undefined => {
const { name } = node;
if (name.type !== TypescriptEsTree.AST_NODE_TYPES.JSXIdentifier) return;
const props = getPropValues({
jsxOpeningElement: node,
propNames: ['iconType', 'placeholder'],
sourceCode,
});
// The intention of the element (i.e. "Select date", "Submit", "Cancel")
const intent =
props.placeholder &&
props.placeholder.type === TypescriptEsTree.TSESTree.AST_NODE_TYPES.JSXExpressionContainer
? getDefaultMessageFromI18n(props.placeholder)
: name.name === 'EuiButtonIcon' && props.iconType
? String(props.iconType) // For EuiButtonIcon, use the iconType as the intent (i.e. 'pen', 'trash')
: getIntentFromNode(node);
// The element name (i.e. "Button", "Beta Badge", "Select")
const { elementName } = sanitizeEuiElementName(name.name);
// 'Actions Button'
const defaultMessage = upperCaseFirstChar(intent).trim();
const i18nAppId = getI18nIdentifierFromFilePath(filename, cwd);
const functionDeclaration = sourceCode.getScope(node as unknown as EsTree.Node).block;
const functionName = getFunctionName(
functionDeclaration as TypescriptEsTree.TSESTree.FunctionDeclaration
);
// 'xpack.observability.overview.logs.loadMore.ariaLabel'
const translationId = [
i18nAppId,
functionName,
`${intent}${upperCaseFirstChar(elementName)}`,
'ariaLabel',
]
.filter(Boolean)
.map((el) => lowerCaseFirstChar(el).replaceAll(' ', ''))
.join('.');
// Get an additional fixer to add the i18n import line
const { hasI18nImportLine, i18nImportLine, rangeToAddI18nImportLine, replaceMode } =
getI18nImportFixer({
sourceCode,
translationFunction: 'i18n.translate',
});
return {
node: node as any,
message: `<${name.name}> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`,
fix(fixer: EsLint.Rule.RuleFixer) {
return [
fixer.insertTextAfterRange(
range,
` aria-label={i18n.translate('${translationId}', { defaultMessage: '${defaultMessage}' })} `
),
!hasI18nImportLine && rangeToAddI18nImportLine
? replaceMode === 'replace'
? fixer.replaceTextRange(rangeToAddI18nImportLine, i18nImportLine)
: fixer.insertTextAfterRange(rangeToAddI18nImportLine, `\n${i18nImportLine}`)
: null,
].filter(isTruthy);
},
};
};