mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
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:
parent
9d659b76dd
commit
46b4e1fc6d
12 changed files with 693 additions and 165 deletions
|
@ -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>
|
||||
);
|
||||
```
|
||||
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
: '';
|
||||
}
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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({});
|
||||
});
|
||||
});
|
|
@ -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>);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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';`,
|
||||
}))
|
||||
)
|
||||
),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue