kibana/packages/kbn-eslint-plugin-eui-a11y/helpers/get_intent_from_node.ts
Coen Warmer 46b4e1fc6d
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>
2025-04-09 18:16:00 +02:00

132 lines
4.5 KiB
TypeScript

/*
* 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 { TSESTree } from '@typescript-eslint/typescript-estree';
/*
Attempts to get a string representation of the intent
out of an array of nodes.
Currently supported node types in the array:
* String literal text (JSXText)
* Translated text via <FormattedMessage> component -> uses prop `defaultMessage`
* Translated text via {i18n.translate} call -> uses passed options object key `defaultMessage`
*/
export function getIntentFromNode(originalNode: TSESTree.JSXOpeningElement): string {
if (!originalNode.parent) return '';
if (originalNode.parent.type !== 'JSXElement') return '';
const { parent } = originalNode;
const nodes = Array.isArray(parent.children) ? parent.children : [];
if (nodes.length === 0) {
return '';
}
/*
In order to satisfy TS we need to do quite a bit of defensive programming.
This is my best attempt at providing the minimum amount of typeguards and
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 = nodes.reduce((acc: string, currentNode) => {
switch (currentNode.type) {
case 'JSXText':
// When node is a string primitive
return `${acc}${currentNode.value} `;
case 'JSXElement':
// Determining whether node is of form `<FormattedMessage defaultMessage="message" />`
const jsxOpeningElement: TSESTree.JSXTagNameExpression = currentNode.openingElement.name;
const attributes: Array<TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute> =
currentNode.openingElement.attributes;
if (!('name' in jsxOpeningElement) || jsxOpeningElement.name !== 'FormattedMessage') {
return '';
}
const defaultMessageProp = attributes.find(
(attribute) => 'name' in attribute && attribute.name.name === 'defaultMessage'
);
if (
!defaultMessageProp ||
!('value' in defaultMessageProp) ||
!('type' in defaultMessageProp.value!) ||
defaultMessageProp.value.type !== 'Literal' ||
typeof defaultMessageProp.value.value !== 'string'
) {
return '';
}
return `${acc}${defaultMessageProp.value.value} `;
case 'JSXExpressionContainer':
// Determining whether node is of form `{i18n.translate('foo', { defaultMessage: 'message'})}`
const expression: TSESTree.JSXEmptyExpression | TSESTree.Expression =
currentNode.expression;
if (!('arguments' in expression)) {
return '';
}
const args: TSESTree.CallExpressionArgument[] = expression.arguments;
const callee: TSESTree.LeftHandSideExpression = expression.callee;
if (!('object' in callee)) {
return '';
}
const object: TSESTree.Expression = callee.object;
const property: TSESTree.Expression | TSESTree.PrivateIdentifier = callee.property;
if (!('name' in object) || !('name' in property)) {
return '';
}
if (object.name !== 'i18n' || property.name !== 'translate') {
return '';
}
const callExpressionArgument: TSESTree.CallExpressionArgument | undefined = args.find(
(arg) => arg.type === 'ObjectExpression'
);
if (!callExpressionArgument || callExpressionArgument.type !== 'ObjectExpression') {
return '';
}
const defaultMessageValue: TSESTree.ObjectLiteralElement | undefined =
callExpressionArgument.properties.find(
(prop) =>
prop.type === 'Property' && 'name' in prop.key && prop.key.name === 'defaultMessage'
);
if (
!defaultMessageValue ||
!('value' in defaultMessageValue) ||
defaultMessageValue.value.type !== 'Literal' ||
typeof defaultMessageValue.value.value !== 'string'
) {
return '';
}
return `${acc}${defaultMessageValue.value.value} `;
default:
break;
}
return acc;
}, '');
return intent.trim();
}