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 <EuiButtonIcon
aria-label={i18n.translate('xpack.apm.discoveryRule.pencilButton.ariaLabel', { aria-label={i18n.translate('xpack.apm.discoveryRule.pencilButton.ariaLabel', {
defaultMessage: 'Pencil Button', defaultMessage: 'Pencil',
})} })}
iconType="pencil" iconType="pencil"
onClick={() => { onClick={() => {
@ -50,37 +50,28 @@ turns into:
Another example: 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) => ( const Foo = ({ ... }: Props) => (
<EuiBetaBadge <EuiFormRow>
label={i18n.translate('xpack.infra.common.tabBetaBadgeLabel', { <EuiComboBox
defaultMessage: 'Beta', ...
})} />
tooltipContent={tooltipContent} </EuiFormRow>
iconType={iconType}
tooltipPosition={tooltipPosition}
data-test-id="infra-beta-badge"
/>
); );
``` ```
gets transformed into: gets transformed into:
``` ```
export const InfraBetaBadge = ({ iconType, tooltipPosition, tooltipContent }: Props) => ( const Foo = ({ ... }: Props) => (
<EuiBetaBadge <EuiFormRow aria-label={i18n.translate('xpack.infra.foo.ariaLabel', {
aria-label={i18n.translate('xpack.infra.infraBetaBadge.betaBadge.ariaLabel', { defaultMessage: 'Foo',
defaultMessage: 'Beta Badge', })}>
})} <EuiComboBox
label={i18n.translate('xpack.infra.common.tabBetaBadgeLabel', { ...
defaultMessage: 'Beta', />
})} </EuiFormRow>
tooltipContent={tooltipContent}
iconType={iconType}
tooltipPosition={tooltipPosition}
data-test-id="infra-beta-badge"
/>
); );
``` ```

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` * Translated text via {i18n.translate} call -> uses passed options object key `defaultMessage`
*/ */
export function getIntentFromNode(originalNode: TSESTree.JSXOpeningElement): string { 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 ''; 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 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. 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) { switch (currentNode.type) {
case 'JSXText': case 'JSXText':
// When node is a string primitive // 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". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import type { Scope } from 'eslint'; import * as EsLint from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree'; import * as TypescriptEsTree from '@typescript-eslint/typescript-estree';
export function getPropValues({ export function getPropValues({
jsxOpeningElement,
propNames, propNames,
node, sourceCode,
getScope,
}: { }: {
jsxOpeningElement: TypescriptEsTree.TSESTree.JSXOpeningElement;
propNames: string[]; propNames: string[];
node: TSESTree.JSXOpeningElement; sourceCode: EsLint.SourceCode;
getScope: () => Scope.Scope; }): Record<
}): Record<(typeof propNames)[number], string> { (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 // 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 // Loop over the attributes of the input JSXOpeningElement
for (const prop of node.attributes) { for (const prop of jsxOpeningElement.attributes) {
// If the prop is an JSXAttribute and the name of the prop is equal to the propName, get the value if (
if (prop.type === AST_NODE_TYPES.JSXAttribute && propName === prop.name.name) { prop.type === TypescriptEsTree.AST_NODE_TYPES.JSXAttribute &&
const value = String('value' in prop && 'value' in prop.value! && prop.value.value) || ''; propName === prop.name.name &&
acc[propName] = value; 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 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 the spreaded variable is an Expression, break
if (!('argument' in prop) || !('name' in prop.argument)) { if (!('argument' in prop) || !('name' in prop.argument)) {
break; break;
@ -43,17 +52,19 @@ export function getPropValues({
const { name } = prop.argument; const { name } = prop.argument;
// the variable definition of the spreaded variable // 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 // Get the value of the propName from the spreaded variable
const value: string | undefined = const value =
variable && variable.defs.length > 0 variable && variable.defs.length > 0
? variable.defs[0].node.init?.properties?.find((property: TSESTree.Property) => { ? variable.defs[0].node.init?.properties?.find(
if ('value' in property.key) { (property: TypescriptEsTree.TSESTree.Property) => {
return propNames.includes(String(property.key.value)); if ('value' in property.key && property.key.value) {
return property.key.value;
}
return undefined;
} }
return undefined; )
})
: undefined; : undefined;
if (value) { if (value) {
@ -62,7 +73,5 @@ export function getPropValues({
} }
} }
return acc; return acc;
}, {} as Record<(typeof propNames)[number], string>); }, {} as Record<(typeof propNames)[number], TypescriptEsTree.TSESTree.Literal | TypescriptEsTree.TSESTree.JSXExpression>);
return result;
} }

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 { 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 = [ const tsTester = [
'@typescript-eslint/parser', '@typescript-eslint/parser',
@ -39,46 +45,130 @@ const babelTester = [
}), }),
] as const; ] as const;
const EUI_ELEMENTS = [
['EuiButtonIcon', 'Button'],
['EuiButtonEmpty', 'Button'],
['EuiBetaBadge', 'Beta Badge'],
['EuiSelect', 'Select'],
['EuiSelectWithWidth', 'Select'],
];
for (const [name, tester] of [tsTester, babelTester]) { for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => { describe(name, () => {
tester.run( tester.run(
'@kbn/eui_elements_should_have_aria_label_or_aria_labelledby_props', '@kbn/eui_elements_should_have_aria_label_or_aria_labelledby_props',
EuiElementsShouldHaveAriaLabelOrAriaLabelledbyProps, EuiElementsShouldHaveAriaLabelOrAriaLabelledbyProps,
{ {
valid: EUI_ELEMENTS.map((element) => ({ valid: [
filename: 'foo.tsx', // unwrapped elements with an existing a11y prop should be left alone
code: `<${element[0]} aria-label="foo" />`, {
})),
invalid: EUI_ELEMENTS.map((element) => {
return {
filename: 'foo.tsx', 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: [ errors: [
{ {
line: 1, 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: `<${ output: `<EuiButtonIcon aria-label={i18n.translate('app_not_found_in_i18nrc.button.ariaLabel', { defaultMessage: '' })} />
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]
}>
import { i18n } from '@kbn/i18n';`, 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". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import type { Rule } from 'eslint'; import * as EsLint from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree'; import * as EsTree from 'estree';
import { Node } from 'estree'; import * as TypescriptEsTree from '@typescript-eslint/typescript-estree';
import { getPropValues } from '../helpers/get_prop_values'; import { getPropValues } from '../helpers/get_prop_values';
import { getFunctionName } from '../helpers/get_function_name';
import { getIntentFromNode } from '../helpers/get_intent_from_node'; import { getIntentFromNode } from '../helpers/get_intent_from_node';
import { getI18nIdentifierFromFilePath } from '../helpers/get_i18n_identifier_from_file_path'; 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 { getI18nImportFixer } from '../helpers/get_i18n_import_fixer';
import { getWrappingElement } from '../helpers/get_wrapping_element';
import { import {
isTruthy, isTruthy,
lowerCaseFirstChar, lowerCaseFirstChar,
sanitizeEuiElementName, sanitizeEuiElementName,
upperCaseFirstChar, upperCaseFirstChar,
} from '../helpers/utils'; } from '../helpers/utils';
import { getDefaultMessageFromI18n } from '../helpers/get_default_message_from_i18n';
export const EUI_ELEMENTS = [ export const EUI_ELEMENTS_TO_CHECK = [
'EuiButtonIcon',
'EuiButtonEmpty',
'EuiBetaBadge', 'EuiBetaBadge',
'EuiButtonEmpty',
'EuiButtonIcon',
'EuiComboBox',
'EuiSelect', 'EuiSelect',
'EuiSuperSelect',
'EuiSelectWithWidth', '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: { meta: {
type: 'suggestion', type: 'suggestion',
fixable: 'code', fixable: 'code',
}, },
create(context) { create(context) {
const { cwd, filename, report, sourceCode } = context; const { cwd, filename, report, sourceCode } = context;
return { return {
JSXIdentifier: (node: TSESTree.Node) => { JSXOpeningElement: (node: TypescriptEsTree.TSESTree.JSXOpeningElement) => {
if (!('name' in node)) { // First do a bunch of checks to see if we should even bother analyzing the node.
return; if (!('name' in node && 'name' in node.name)) return;
}
const name = String(node.name); const { name } = node.name;
const range = node.range;
const parent = node.parent;
if (parent?.type !== AST_NODE_TYPES.JSXOpeningElement || !EUI_ELEMENTS.includes(name)) { // The element is not an element we're interested in
return; 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 wrappingElement = getWrappingElement(node);
const relevantPropValues = getPropValues({
propNames: PROP_NAMES,
node: parent,
getScope: () => sourceCode.getScope(node as Node),
});
// Element already has a prop for aria-label or aria-labelledby. We can bail out. // The element is wrapped in an element which needs to get the a11y props instead
if (relevantPropValues['aria-label'] || relevantPropValues['aria-labelledby']) return; if (
wrappingElement?.elementName &&
// Start building the suggestion. EUI_WRAPPING_ELEMENTS.includes(wrappingElement.elementName)
) {
// 2. The intention of the element (i.e. "Select date", "Submit", "Cancel") const props = getPropValues({
const intent = jsxOpeningElement: wrappingElement.node,
name === 'EuiButtonIcon' && relevantPropValues.iconType propNames: A11Y_PROP_NAMES,
? 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({
sourceCode, sourceCode,
translationFunction: 'i18n.translate',
}); });
// 6. Report feedback to engineer // The wrapping element already has an a11y prop set
report({ if (Object.keys(props).length > 0) return;
node: node as any,
message: `<${name}> should have a \`aria-label\` for a11y. Use the autofix suggestion or add your own.`, const reporter = checkNodeForPropNamesAndCreateReporter({
fix(fixer) { cwd,
return [ filename,
fixer.insertTextAfterRange( node,
range, range: wrappingElement.node.name.range,
` aria-label={i18n.translate('${translationId}', { defaultMessage: '${defaultMessage}' })}` sourceCode,
), });
!hasI18nImportLine && rangeToAddI18nImportLine
? replaceMode === 'replace' // The wrapping element does not have an a11y prop set yet, so show the reporter
? fixer.replaceTextRange(rangeToAddI18nImportLine, i18nImportLine) if (reporter) report(reporter);
: fixer.insertTextAfterRange(rangeToAddI18nImportLine, `\n${i18nImportLine}`)
: null, return;
].filter(isTruthy); }
},
// 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);
},
};
};