diff --git a/packages/eslint-plugin/changelogs/upcoming/9236.md b/packages/eslint-plugin/changelogs/upcoming/9236.md new file mode 100644 index 00000000000..888a6d948e0 --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/9236.md @@ -0,0 +1 @@ +- Added `no-static-z-index` rule \ No newline at end of file diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 168704d760d..be612c85e24 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -7,54 +7,56 @@ */ +import { AccessibleInteractiveElements } from './rules/a11y/accessible_interactive_element'; +import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount'; +import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_props'; import { HrefOnClick } from './rules/href_or_on_click'; -import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports'; import { NoCssColor } from './rules/no_css_color'; - +import { NoRestrictedEuiImports } from './rules/no_restricted_eui_imports'; +import { NoStaticZIndex } from './rules/no_static_z_index'; +import { NoUnnamedInteractiveElement } from './rules/a11y/no_unnamed_interactive_element'; +import { NoUnnamedRadioGroup } from './rules/a11y/no_unnamed_radio_group'; +import { PreferEuiIconTip } from './rules/a11y/prefer_eui_icon_tip'; import { RequireAriaLabelForModals } from './rules/a11y/require_aria_label_for_modals'; -import { ConsistentIsInvalidProps } from './rules/a11y/consistent_is_invalid_props'; +import { RequireTableCaption } from './rules/a11y/require_table_caption'; import { ScreenReaderOutputDisabledTooltip } from './rules/a11y/sr_output_disabled_tooltip'; -import { PreferEuiIconTip } from './rules/a11y/prefer_eui_icon_tip'; -import { NoUnnamedRadioGroup } from './rules/a11y/no_unnamed_radio_group'; -import { NoUnnamedInteractiveElement } from './rules/a11y/no_unnamed_interactive_element'; import { TooltipFocusableAnchor } from './rules/a11y/tooltip_focusable_anchor'; -import { CallOutAnnounceOnMount } from './rules/a11y/callout_announce_on_mount'; -import { AccessibleInteractiveElements } from './rules/a11y/accessible_interactive_element'; -import { RequireTableCaption } from './rules/a11y/require_table_caption'; const config = { rules: { + 'accessible-interactive-element': AccessibleInteractiveElements, + 'callout-announce-on-mount': CallOutAnnounceOnMount, + 'consistent-is-invalid-props': ConsistentIsInvalidProps, 'href-or-on-click': HrefOnClick, - 'no-restricted-eui-imports': NoRestrictedEuiImports, 'no-css-color': NoCssColor, + 'no-restricted-eui-imports': NoRestrictedEuiImports, + 'no-static-z-index': NoStaticZIndex, + 'no-unnamed-interactive-element': NoUnnamedInteractiveElement, + 'no-unnamed-radio-group' : NoUnnamedRadioGroup, + 'prefer-eui-icon-tip': PreferEuiIconTip, 'require-aria-label-for-modals': RequireAriaLabelForModals, - 'consistent-is-invalid-props': ConsistentIsInvalidProps, + 'require-table-caption': RequireTableCaption, 'sr-output-disabled-tooltip': ScreenReaderOutputDisabledTooltip, - 'prefer-eui-icon-tip': PreferEuiIconTip, - 'no-unnamed-radio-group' : NoUnnamedRadioGroup, - 'callout-announce-on-mount': CallOutAnnounceOnMount, - 'no-unnamed-interactive-element': NoUnnamedInteractiveElement, 'tooltip-focusable-anchor': TooltipFocusableAnchor, - 'accessible-interactive-element': AccessibleInteractiveElements, - 'require-table-caption': RequireTableCaption, }, configs: { recommended: { plugins: ['@elastic/eslint-plugin-eui'], rules: { + '@elastic/eui/accessible-interactive-element': 'warn', + '@elastic/eui/callout-announce-on-mount': 'warn', + '@elastic/eui/consistent-is-invalid-props': 'warn', '@elastic/eui/href-or-on-click': 'warn', - '@elastic/eui/no-restricted-eui-imports': 'warn', '@elastic/eui/no-css-color': 'warn', + '@elastic/eui/no-restricted-eui-imports': 'warn', + '@elastic/eui/no-static-z-index': 'warn', + '@elastic/eui/no-unnamed-interactive-element': 'warn', + '@elastic/eui/no-unnamed-radio-group': 'warn', + '@elastic/eui/prefer-eui-icon-tip': 'warn', '@elastic/eui/require-aria-label-for-modals': 'warn', - '@elastic/eui/consistent-is-invalid-props': 'warn', + '@elastic/eui/require-table-caption': 'warn', '@elastic/eui/sr-output-disabled-tooltip': 'warn', - '@elastic/eui/prefer-eui-icon-tip': 'warn', - '@elastic/eui/no-unnamed-radio-group': 'warn', - '@elastic/eui/callout-announce-on-mount': 'warn', - '@elastic/eui/no-unnamed-interactive-element': 'warn', '@elastic/eui/tooltip-focusable-anchor': 'warn', - '@elastic/eui/accessible-interactive-element': 'warn', - '@elastic/eui/require-table-caption': 'warn', }, }, }, diff --git a/packages/eslint-plugin/src/rules/no_css_color.ts b/packages/eslint-plugin/src/rules/no_css_color.ts index bd657b98c7f..6be2f515381 100644 --- a/packages/eslint-plugin/src/rules/no_css_color.ts +++ b/packages/eslint-plugin/src/rules/no_css_color.ts @@ -10,6 +10,7 @@ import { CSSStyleDeclaration } from 'cssstyle'; import { TSESTree, ESLintUtils } from '@typescript-eslint/utils'; import { resolveMemberExpressionRoot } from '../utils/resolve_member_expression_root'; +import { getPropertyName } from '../utils/get_property_name'; import { ReportDescriptor, RuleContext, @@ -64,7 +65,7 @@ const checkPropertySpecifiesInvalidCSSColor = ([property, value]: string[]) => { 'initial', 'unset', 'revert', - 'revert-layer' + 'revert-layer', ]; const normalizedColorValue = colorValue.toLowerCase().trim(); @@ -85,18 +86,17 @@ const raiseReportIfPropertyHasInvalidCssColor = ( ) => { let didReport = false; - if ( - propertyNode.key.type === 'Identifier' && - !htmlElementColorDeclarationRegex.test(propertyNode.key.name) - ) { + const propertyName = getPropertyName(propertyNode); + + if (!propertyName || !htmlElementColorDeclarationRegex.test(propertyName)) { return didReport; } if (propertyNode.value.type === 'Literal') { if ( (didReport = checkPropertySpecifiesInvalidCSSColor([ - // @ts-expect-error the key name is present in this scenario - propertyNode.key.name, + propertyName, + // @ts-expect-error the value is present in this scenario propertyNode.value.value, ])) ) { @@ -201,7 +201,10 @@ const handleObjectProperties = ( ).name; const spreadElementDeclaration = context.sourceCode - .getScope((propertyParentNode!.value as TSESTree.JSXExpressionContainer).expression!) + .getScope( + (propertyParentNode!.value as TSESTree.JSXExpressionContainer) + .expression! + ) .references.find( (ref: { identifier: { name: string } }) => ref.identifier.name === spreadElementIdentifierName @@ -461,7 +464,8 @@ export const NoCssColor = ESLintUtils.RuleCreator.withoutDocs({ let declarationPropertiesNode: TSESTree.Property[] = []; if (node.value.expression.body.type === 'ObjectExpression') { - declarationPropertiesNode = node.value.expression.body.properties as TSESTree.Property[]; + declarationPropertiesNode = node.value.expression.body + .properties as TSESTree.Property[]; } if (node.value.expression.body.type === 'BlockStatement') { diff --git a/packages/eslint-plugin/src/rules/no_static_z_index.test.ts b/packages/eslint-plugin/src/rules/no_static_z_index.test.ts new file mode 100644 index 00000000000..74e856b3f56 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no_static_z_index.test.ts @@ -0,0 +1,331 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import dedent from 'dedent'; +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { NoStaticZIndex } from './no_static_z_index'; + +const languageOptions = { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-static-z-index', NoStaticZIndex, { + valid: [ + { + // Valid: Using euiTheme variable in inline style + filename: 'test.tsx', + code: dedent` + import React from 'react'; + import { EuiCode } from '@elastic/eui'; + + function TestComponent() { + const euiTheme = { levels: { mask: 1000 } }; + return ( + test + ) + }`, + languageOptions, + }, + { + // Valid: CSS keyword 'auto' + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + return ( +
test
+ ) + }`, + languageOptions, + }, + { + // Valid: Emotion css with euiTheme variable + filename: 'test.tsx', + code: dedent` + import React from 'react'; + import { css } from '@emotion/react'; + + function TestComponent() { + const theme = { zIndex: { modal: 2000 } }; + return ( +
test
+ ) + }`, + languageOptions, + }, + { + // Valid: Object style with variable + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + const zIndexValue = someDynamicValue; + const style = { zIndex: zIndexValue }; + return
test
+ }`, + languageOptions, + }, + { + // Valid: Commented out z-index in css template literal + filename: 'test.tsx', + code: dedent` + import React from 'react'; + import { css } from '@emotion/react'; + + function TestComponent() { + return ( +
test
+ ) + }`, + languageOptions, + }, + ], + + invalid: [ + { + // Invalid: Inline style with static number + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + return ( +
test
+ ) + }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: Inline style with static string number + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + return ( +
test
+ ) + }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: Emotion css prop with static value + filename: 'test.tsx', + code: dedent` + import React from 'react'; + import { css } from '@emotion/react'; + + function TestComponent() { + return ( +
test
+ ) + }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndex' }], + }, + { + // Invalid: Variable with static value used in style + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + const myStyle = { zIndex: 10 }; + return
test
+ }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecificDeclaredVariable' }], + }, + { + // Invalid: Variable with static value used in css prop (object style) + filename: 'test.tsx', + code: dedent` + import React from 'react'; + import { css } from '@emotion/react'; + + const myCss = css({ zIndex: 100 }); + + function TestComponent() { + return
test
+ }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecificDeclaredVariable' }], + }, + { + // Invalid: css template literal with static z-index + filename: 'test.tsx', + code: dedent` + import { css } from '@emotion/css'; + + const codeCss = css\` z-index: 10; \` + `, + languageOptions, + errors: [{ messageId: 'noStaticZIndex' }], + }, + { + // Invalid: css template literal with nested static z-index + filename: 'test.tsx', + code: dedent` + import { css } from '@emotion/react'; + + const codeCss = css\` + &:hover { + z-index: 10; + } + \` + `, + languageOptions, + errors: [{ messageId: 'noStaticZIndex' }], + }, + { + // Invalid: css object with static z-index + filename: 'test.tsx', + code: dedent` + import { css } from '@emotion/react'; + + function TestComponent() { + return
test
+ } + `, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: arrow function returning object with static z-index + filename: 'test.tsx', + code: dedent` + import { css } from '@emotion/react'; + + function TestComponent() { + return
({ zIndex: 5 })}>test
+ } + `, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: css with multiple arguments, one with static z-index + filename: 'test.tsx', + code: dedent` + import { css } from '@emotion/react'; + + function TestComponent() { + return
test
+ } + `, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: Variable with static value used in css prop (object style, nested) + filename: 'test.tsx', + code: dedent` + import React from 'react'; + import { css } from '@emotion/react'; + + const myCss = css({ container: { zIndex: 100 } }); + + function TestComponent() { + return
test
+ }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecificDeclaredVariable' }], + }, + { + // Invalid: css array, one with static z-index + filename: 'test.tsx', + code: dedent` + import { css } from '@emotion/react'; + + function TestComponent() { + return
test
+ } + `, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: Conditional expression with static z-index + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + const isTrue = true; + return ( +
test
+ ) + }`, + languageOptions, + errors: [ + { messageId: 'noStaticZIndexSpecific' }, + { messageId: 'noStaticZIndexSpecific' }, + ], + }, + { + // Invalid: Logical expression with static z-index + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + const isTrue = true; + return ( +
test
+ ) + }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: TSAsExpression with static z-index + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + return ( +
test
+ ) + }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + { + // Invalid: UnaryExpression with static z-index + filename: 'test.tsx', + code: dedent` + import React from 'react'; + + function TestComponent() { + return ( +
test
+ ) + }`, + languageOptions, + errors: [{ messageId: 'noStaticZIndexSpecific' }], + }, + ], +}); diff --git a/packages/eslint-plugin/src/rules/no_static_z_index.ts b/packages/eslint-plugin/src/rules/no_static_z_index.ts new file mode 100644 index 00000000000..7269134ea24 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no_static_z_index.ts @@ -0,0 +1,515 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { TSESTree, ESLintUtils } from '@typescript-eslint/utils'; +import { + ReportDescriptor, + RuleContext, +} from '@typescript-eslint/utils/ts-eslint'; +import { getPropertyName } from '../utils/get_property_name'; + +type MessageIds = + | 'noStaticZIndex' + | 'noStaticZIndexSpecific' + | 'noStaticZIndexSpecificDeclaredVariable'; + +const propertiesCheckingZIndex = ['zIndex', 'z-index']; + +const zIndexDeclarationRegex = RegExp( + String.raw`(${propertiesCheckingZIndex.join('|')})` +); + +// Regex to find z-index declarations in CSS strings +// Matches: +// 1. z-index property name (case insensitive) +// 2. colon +// 3. value (captured group 1) until semicolon, closing brace, or !important +const cssZIndexRegex = /z-index\s*:\s*([^;!}]+)/gi; + +const checkPropertySpecifiesInvalidZIndex = ( + property: string, + value: unknown +) => { + if (!property || value === undefined || value === null) return false; + + const normalizedProperty = property.trim(); + const isZIndex = propertiesCheckingZIndex.includes(normalizedProperty); + + if (!isZIndex) return false; + + return isInvalidZIndexValue(value); +}; + +const isInvalidZIndexValue = (value: unknown): boolean => { + const allowedCssKeywords = [ + 'auto', + 'inherit', + 'initial', + 'unset', + 'revert', + 'revert-layer', + ]; + + const stringValue = String(value).trim().toLowerCase(); + + if (allowedCssKeywords.includes(stringValue)) { + return false; + } + + // Check if it's a number (positive, negative, or zero) + // This regex allows integers. z-index only accepts integers. + if (/^-?\d+$/.test(stringValue)) { + return true; + } + + return false; +}; + +const raiseReportIfPropertyHasInvalidZIndex = ( + context: RuleContext, + propertyNode: TSESTree.Property, + messageToReport: ReportDescriptor +) => { + let didReport = false; + const propertyName = getPropertyName(propertyNode); + + if (!propertyName || !zIndexDeclarationRegex.test(propertyName)) { + return didReport; + } + + const visitNode = (node: TSESTree.Node) => { + // Handle Literal values: zIndex: 10, 'z-index': '10' + if (node.type === 'Literal') { + if (checkPropertySpecifiesInvalidZIndex(propertyName, node.value)) { + didReport = true; + context.report({ + ...messageToReport, + loc: node.loc, + }); + } + } + // Handle Identifier values: zIndex: someVar + else if (node.type === 'Identifier') { + const identifierName = node.name; + const identifierDeclaration = context.sourceCode + .getScope(node) + .variables.find((variable) => variable.name === identifierName); + + const identifierDeclarationInit = + identifierDeclaration?.defs[0]?.node.type === 'VariableDeclarator' + ? identifierDeclaration.defs[0].node.init + : undefined; + + if ( + identifierDeclarationInit?.type === 'Literal' && + checkPropertySpecifiesInvalidZIndex( + propertyName, + identifierDeclarationInit.value + ) + ) { + didReport = true; + context.report({ + loc: node.loc, + messageId: 'noStaticZIndexSpecificDeclaredVariable', + data: { + property: propertyName, + line: String(node.loc.start.line), + variableName: node.name, + }, + }); + } + } else if (node.type === 'ConditionalExpression') { + visitNode(node.consequent); + visitNode(node.alternate); + } else if (node.type === 'LogicalExpression') { + visitNode(node.left); + visitNode(node.right); + } else if (node.type === 'TSAsExpression') { + visitNode(node.expression); + } else if (node.type === 'UnaryExpression') { + if (node.operator === '-') { + if ( + node.argument.type === 'Literal' && + typeof node.argument.value === 'number' + ) { + if ( + checkPropertySpecifiesInvalidZIndex( + propertyName, + -node.argument.value + ) + ) { + didReport = true; + context.report({ + ...messageToReport, + loc: node.loc, + }); + } + } else { + visitNode(node.argument); + } + } + } + }; + + if (propertyNode.value) { + visitNode(propertyNode.value); + } + + return didReport; +}; + +const handleObjectProperties = ( + context: RuleContext, + propertyParentNode: TSESTree.JSXAttribute | TSESTree.Node, + property: TSESTree.ObjectLiteralElement, + reportMessage: ReportDescriptor +) => { + if (property.type === 'Property') { + if (property.value.type === 'ObjectExpression') { + property.value.properties.forEach((nestedProperty) => { + const nestedReportMessage = { + ...reportMessage, + loc: nestedProperty.loc, + }; + + if (nestedReportMessage.data) { + const newData: Record = { + ...nestedReportMessage.data, + property: getPropertyName(nestedProperty) || 'unknown', + }; + + if ('line' in newData) { + newData.line = String(nestedProperty.loc.start.line); + } + + nestedReportMessage.data = newData; + } + + handleObjectProperties( + context, + propertyParentNode, + nestedProperty, + nestedReportMessage + ); + }); + } else { + raiseReportIfPropertyHasInvalidZIndex(context, property, reportMessage); + } + } else if (property.type === 'SpreadElement') { + if (property.argument.type !== 'Identifier') { + return; + } + const spreadElementIdentifierName = property.argument.name; + + let scopeNode: TSESTree.Node = propertyParentNode; + if ( + propertyParentNode.type === 'JSXAttribute' && + propertyParentNode.value?.type === 'JSXExpressionContainer' + ) { + scopeNode = propertyParentNode.value.expression; + } + + const spreadElementDeclaration = context.sourceCode + .getScope(scopeNode) + .references.find( + (ref) => ref.identifier.name === spreadElementIdentifierName + )?.resolved; + + if (!spreadElementDeclaration) { + return; + } + + const propertyName = getPropertyName(property) || 'spread'; + + reportMessage = { + loc: propertyParentNode.loc, + messageId: 'noStaticZIndexSpecificDeclaredVariable', + data: { + property: propertyName, + variableName: spreadElementIdentifierName, + line: String(property.loc.start.line), + }, + }; + + const spreadElementDeclarationNode = + spreadElementDeclaration.defs[0]?.node.type === 'VariableDeclarator' + ? spreadElementDeclaration.defs[0].node.init + : undefined; + + if (spreadElementDeclarationNode?.type === 'ObjectExpression') { + spreadElementDeclarationNode.properties.forEach((spreadProperty) => { + handleObjectProperties( + context, + propertyParentNode, + spreadProperty, + reportMessage + ); + }); + } + } +}; + +const checkTemplateLiteralForZIndex = ( + context: RuleContext, + node: TSESTree.TemplateLiteral +) => { + for (let i = 0; i < node.quasis.length; i++) { + const declarationTemplateNode = node.quasis[i]; + const rawValue = declarationTemplateNode.value.raw; + // Strip comments + const valueWithoutComments = rawValue.replace(/\/\*[\s\S]*?\*\//g, ''); + + let match; + // reset regex state + cssZIndexRegex.lastIndex = 0; + + while ((match = cssZIndexRegex.exec(valueWithoutComments)) !== null) { + const value = match[1].trim(); + if (isInvalidZIndexValue(value)) { + context.report({ + node: declarationTemplateNode, + messageId: 'noStaticZIndex', + }); + } + } + } +}; + +export const NoStaticZIndex = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + return { + TaggedTemplateExpression(node) { + if ( + node.tag.type !== 'Identifier' || + (node.tag.type === 'Identifier' && node.tag.name !== 'css') + ) { + return; + } + + checkTemplateLiteralForZIndex(context, node.quasi); + }, + JSXAttribute(node: TSESTree.JSXAttribute) { + if (!(node.name.name === 'style' || node.name.name === 'css')) { + return; + } + + if (!node.value || node.value.type !== 'JSXExpressionContainer') { + return; + } + + const expression = node.value.expression; + + // Handle identifier expression: style={someStyle} + if (expression.type === 'Identifier') { + const styleVariableName = expression.name; + const nodeScope = context.sourceCode.getScope(expression); + + const variableDeclarationMatches = nodeScope.references.find( + (ref) => ref.identifier.name === styleVariableName + )?.resolved; + + let variableInitializationNode; + + if ( + variableDeclarationMatches?.defs[0]?.node.type === + 'VariableDeclarator' && + variableDeclarationMatches.defs[0].node.init + ) { + variableInitializationNode = + variableDeclarationMatches.defs[0].node.init; + + if (variableInitializationNode.type === 'ObjectExpression') { + variableInitializationNode.properties.forEach((property) => { + handleObjectProperties(context, node, property, { + loc: property.loc, + messageId: 'noStaticZIndexSpecificDeclaredVariable', + data: { + property: getPropertyName(property) || 'unknown', + variableName: styleVariableName, + line: String(property.loc.start.line), + }, + }); + }); + } else if ( + variableInitializationNode.type === 'CallExpression' && + variableInitializationNode.callee.type === 'Identifier' && + variableInitializationNode.callee.name === 'css' + ) { + variableInitializationNode.arguments.forEach((argument) => { + if (argument.type === 'ObjectExpression') { + argument.properties.forEach((property) => { + handleObjectProperties(context, node, property, { + loc: property.loc, + messageId: 'noStaticZIndexSpecificDeclaredVariable', + data: { + property: getPropertyName(property) || 'unknown', + variableName: styleVariableName, + line: String(property.loc.start.line), + }, + }); + }); + } + }); + } + } + return; + } + + // Handle inline object: style={{ zIndex: 10 }} + if (expression.type === 'ObjectExpression') { + const declarationPropertiesNode = expression.properties; + + declarationPropertiesNode?.forEach((property) => { + handleObjectProperties(context, node, property, { + loc: property.loc, + messageId: 'noStaticZIndexSpecific', + data: { + property: getPropertyName(property) || 'unknown', + }, + }); + }); + + return; + } + + // Handle inline CallExpression: css={css({ zIndex: 10 })} + if ( + expression.type === 'CallExpression' && + expression.callee.type === 'Identifier' && + expression.callee.name === 'css' + ) { + expression.arguments.forEach((argument) => { + if (argument.type === 'ObjectExpression') { + argument.properties.forEach((property) => { + handleObjectProperties(context, node, property, { + loc: node.loc, + messageId: 'noStaticZIndexSpecific', + data: { + property: getPropertyName(property) || 'unknown', + }, + }); + }); + } + }); + return; + } + + // Handle inline ArrayExpression: css={[...]} + if (expression.type === 'ArrayExpression') { + expression.elements.forEach((element) => { + if (!element) return; + + if (element.type === 'ObjectExpression') { + element.properties.forEach((property) => { + handleObjectProperties(context, node, property, { + loc: property.loc, + messageId: 'noStaticZIndexSpecific', + data: { + property: getPropertyName(property) || 'unknown', + }, + }); + }); + } else if ( + element.type === 'CallExpression' && + element.callee.type === 'Identifier' && + element.callee.name === 'css' + ) { + element.arguments.forEach((argument) => { + if (argument.type === 'ObjectExpression') { + argument.properties.forEach((property) => { + handleObjectProperties(context, node, property, { + loc: property.loc, + messageId: 'noStaticZIndexSpecific', + data: { + property: getPropertyName(property) || 'unknown', + }, + }); + }); + } + }); + } + }); + } + + // Handle css prop with template literal or function + if (node.name.name === 'css') { + // css={`...`} + if (expression.type === 'TemplateLiteral') { + checkTemplateLiteralForZIndex(context, expression); + return; + } + + // css={() => ({ ... })} or css={function() { return { ... } }} + if ( + expression.type === 'FunctionExpression' || + expression.type === 'ArrowFunctionExpression' + ) { + let declarationPropertiesNode: TSESTree.ObjectLiteralElement[] = []; + + if (expression.body.type === 'ObjectExpression') { + declarationPropertiesNode = expression.body.properties; + } + + if (expression.body.type === 'BlockStatement') { + const functionReturnStatementNode = expression.body.body?.find( + (_node) => { + return _node.type === 'ReturnStatement'; + } + ); + + if ( + functionReturnStatementNode?.type === 'ReturnStatement' && + functionReturnStatementNode.argument?.type === + 'ObjectExpression' + ) { + declarationPropertiesNode = + functionReturnStatementNode.argument.properties.filter( + (property): property is TSESTree.Property => + property.type === 'Property' + ); + } + } + + if (!declarationPropertiesNode.length) { + return; + } + + declarationPropertiesNode.forEach((property) => { + handleObjectProperties(context, node, property, { + loc: property.loc, + messageId: 'noStaticZIndexSpecific', + data: { + property: getPropertyName(property) || 'unknown', + }, + }); + }); + + return; + } + } + }, + }; + }, + meta: { + type: 'suggestion', + docs: { + description: + 'Use `z-index` definitions from `euiTheme` as opposed to static values', + }, + messages: { + noStaticZIndexSpecificDeclaredVariable: + 'Avoid using a literal z-index value for "{{property}}", use an EUI theme z-index level instead in declared variable {{variableName}} on line {{line}}', + noStaticZIndexSpecific: + 'Avoid using a literal z-index value for "{{property}}", use an EUI theme z-index level instead', + noStaticZIndex: + 'Avoid using a literal z-index value, use an EUI theme z-index level instead', + }, + schema: [], + }, + defaultOptions: [], +}); diff --git a/packages/eslint-plugin/src/utils/get_property_name.ts b/packages/eslint-plugin/src/utils/get_property_name.ts new file mode 100644 index 00000000000..f143abd8a50 --- /dev/null +++ b/packages/eslint-plugin/src/utils/get_property_name.ts @@ -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 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 or the Server + * Side Public License, v 1. + */ + +import { TSESTree } from '@typescript-eslint/utils'; + +export const getPropertyName = ( + propertyNode: TSESTree.Property | TSESTree.SpreadElement +): string | null => { + if (propertyNode.type === 'Property') { + if (propertyNode.key.type === 'Identifier') { + return propertyNode.key.name; + } + if (propertyNode.key.type === 'Literal') { + return String(propertyNode.key.value); + } + } else if ( + propertyNode.type === 'SpreadElement' && + propertyNode.argument.type === 'Identifier' + ) { + return propertyNode.argument.name; + } + return null; +};