diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index 23612721f11..2d3fb6a896d 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -328,6 +328,86 @@ it('shows tooltip on focus', async () => {
```
+### `@elastic/eui/button-group-no-invalid-children`
+
+Enforce that `EuiButtonGroup` children (when using the Children API) are valid button components.
+
+Valid direct children are:
+- `variant="default"`: `EuiButton`, `EuiButtonEmpty`, and `EuiButtonIcon`
+
+Besides those button components, these three wrapper components are also allowed: `EuiPopover`, `EuiToolTip` and `EuiCopy`.
+
+#### Examples
+
+```tsx
+// ✗ Bad - non-button elements
+
+ Not a button
+ ...
+
+
+// ✓ Good - direct buttons
+
+ Save
+ Cancel
+
+
+// ✓ Good - icon button with tooltip
+
+ Save
+
+
+
+
+
+// ✓ Good - EuiCopy with render prop (expression or block body)
+
+
+ {(copy) => Copy}
+
+
+
+// ✓ Good - .map() with expression or block body
+
+ {buttons.map((b) => {b.label})}
+
+
+// ✓ Good - EuiPopover with a button trigger
+
+ More}
+ isOpen={isOpen}
+ closePopover={closePopover}
+ >
+ Panel content
+
+
+
+// ✓ Good - EuiPopover with an EuiToolTip-wrapped icon trigger
+
+
+
+
+ }
+ isOpen={isOpen}
+ closePopover={closePopover}
+ >
+ Panel content
+
+
+```
+
+#### Custom button wrapper components
+
+If a project-specific button component (e.g. ``) is used as a child and the rule cannot resolve it statically, it reports `invalidUnresolvableChild` which suggests suppressing the rule inline with a comment:
+
+```tsx
+// eslint-disable-next-line @elastic/eui/button-group-no-invalid-children -- SaveButton returns EuiButton
+
+```
+
## Testing
### Running unit tests
diff --git a/packages/eslint-plugin/changelogs/upcoming/9849.md b/packages/eslint-plugin/changelogs/upcoming/9849.md
new file mode 100644
index 00000000000..e4b3a79d03c
--- /dev/null
+++ b/packages/eslint-plugin/changelogs/upcoming/9849.md
@@ -0,0 +1 @@
+- Added `button-group-no-invalid-children` rule
\ No newline at end of file
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index 7d446d9b344..bc18365226c 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -27,6 +27,7 @@ import { EuiBadgeAccessibilityRules } from './rules/a11y/badge_accessibility_rul
import { EuiIconAccessibilityRules } from './rules/a11y/icon_accessibility_rules';
import { TooltipNoInteractiveContent } from './rules/a11y/tooltip_no_interactive_content';
import { TooltipButtonIconWrap } from './rules/a11y/tooltip_button_icon_wrap';
+import { ButtonGroupNoInvalidChildren } from './rules/button_group_no_invalid_children';
const config = {
rules: {
@@ -52,6 +53,7 @@ const config = {
'require-href-for-link': RequireHrefForLink,
'tooltip-no-interactive-content': TooltipNoInteractiveContent,
'tooltip-button-icon-wrap': TooltipButtonIconWrap,
+ 'button-group-no-invalid-children': ButtonGroupNoInvalidChildren,
},
configs: {
recommended: {
@@ -78,6 +80,7 @@ const config = {
'@elastic/eui/require-href-for-link': 'warn',
'@elastic/eui/tooltip-no-interactive-content': 'warn',
'@elastic/eui/tooltip-button-icon-wrap': 'warn',
+ '@elastic/eui/button-group-no-invalid-children': 'warn',
},
},
},
diff --git a/packages/eslint-plugin/src/rules/button_group_no_invalid_children.test.ts b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.test.ts
new file mode 100644
index 00000000000..3d3be39ba6f
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.test.ts
@@ -0,0 +1,1230 @@
+/*
+ * 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 { ButtonGroupNoInvalidChildren } from './button_group_no_invalid_children';
+
+const languageOptions = {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+};
+
+const ruleTester = new RuleTester();
+
+ruleTester.run(
+ 'button-group-no-invalid-children',
+ ButtonGroupNoInvalidChildren,
+ {
+ valid: [
+ // Direct children
+ {
+ name: 'EuiButton child',
+ code: dedent`
+
+ Save
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiButtonEmpty child',
+ code: dedent`
+
+ Cancel
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiButtonIcon child',
+ code: dedent`
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'mixed valid direct children',
+ code: dedent`
+
+ Save
+ Cancel
+
+
+ `,
+ languageOptions,
+ },
+
+ // Wrappers
+ {
+ name: 'EuiButtonIcon wrapped in EuiToolTip',
+ code: dedent`
+
+ Save
+
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiButton wrapped in EuiToolTip',
+ code: dedent`
+
+
+ Save
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiPopover as direct child — JSX children (panel content) are not validated',
+ code: dedent`
+
+ Normal
+ Open} isOpen={false} closePopover={() => {}}>
+ Panel content with non-button elements
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiPopover with EuiToolTip-wrapped trigger in button prop is accepted',
+ code: dedent`
+
+ }
+ isOpen={false}
+ closePopover={() => {}}
+ >
+ Panel content
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiPopover button prop as a resolved variable with valid button is accepted',
+ code: dedent`
+ const trigger = Open;
+
+ {}}>
+ Panel content
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiPopover button prop as a conditional with valid buttons is accepted',
+ code: dedent`
+
+ : More}
+ isOpen={false}
+ closePopover={() => {}}
+ >
+ Panel content
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiCopy with valid render-prop button is accepted',
+ code: dedent`
+
+ Normal
+
+ {(copy) => Copy}
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'EuiCopy block-body render prop with valid button is accepted',
+ code: dedent`
+
+
+ {(copy) => { return Copy; }}
+
+
+ `,
+ languageOptions,
+ },
+
+ // Fragments
+ {
+ name: 'shorthand fragment wrapping valid buttons',
+ code: dedent`
+
+ <>
+ Save
+ Cancel
+ >
+
+ `,
+ languageOptions,
+ },
+ {
+ name: ' wrapping valid buttons',
+ code: dedent`
+
+
+ Save
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: ' wrapping valid buttons',
+ code: dedent`
+
+
+ Save
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'nested fragments wrapping valid buttons',
+ code: dedent`
+
+ <>
+
+ Save
+
+ >
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'fragment wrapping valid button inside EuiToolTip',
+ code: dedent`
+
+
+ <>
+
+ >
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'conditional inside EuiToolTip with valid branches',
+ code: dedent`
+
+
+ {isIcon
+ ?
+ : Delete}
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'variable inside EuiToolTip resolves to a valid button',
+ code: dedent`
+ const icon = ;
+
+
+ {icon}
+
+
+ `,
+ languageOptions,
+ },
+
+ // Text nodes
+ {
+ name: 'whitespace text nodes between buttons are silently ignored',
+ code: dedent`
+
+ {' '}
+ Save
+ {' '}
+
+ `,
+ languageOptions,
+ },
+
+ // Conditional rendering
+ {
+ name: '{condition && } is valid',
+ code: dedent`
+
+ Save
+ {canDelete && }
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'ternary with both valid branches',
+ code: dedent`
+
+ {isAdmin ? Delete : Cancel}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'ternary with null alternate branch',
+ code: dedent`
+
+ Save
+ {canDelete ? : null}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: '{unresolvable || } — unresolvable left side is skipped, valid right side passes',
+ code: dedent`
+
+ {fallback || Save}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: '{unresolvable ?? } — unresolvable left side is skipped, valid right side passes',
+ code: dedent`
+
+ {fallback ?? Save}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: '{resolvedButton || } — valid resolved left side passes',
+ code: dedent`
+ const resolvedButton = Cancel;
+
+ {resolvedButton || Save}
+
+ `,
+ languageOptions,
+ },
+
+ // Array notation
+ {
+ name: 'array of valid buttons',
+ code: dedent`
+
+ {[
+ Save,
+ Cancel,
+ ]}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'array containing a valid wrapper (EuiToolTip)',
+ code: dedent`
+
+ {[
+ Save,
+
+
+ ,
+ ]}
+
+ `,
+ languageOptions,
+ },
+
+ // Variable resolution
+ {
+ name: 'const variable holding a valid button is resolved and accepted',
+ code: dedent`
+ const button = Save;
+
+ {button}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'const variable holding a valid button array is resolved and accepted',
+ code: dedent`
+ const buttons = [
+ Save,
+ Cancel,
+ ];
+
+ {buttons}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'reassigned variable is skipped — cannot be safely resolved',
+ code: dedent`
+ let button = Not a button;
+ button = Save;
+
+ {button}
+
+ `,
+ languageOptions,
+ },
+
+ // Local arrow-function
+ {
+ name: 'local arrow-fn component with valid return is resolved transparently',
+ code: dedent`
+ const ActionButtons = () => Save;
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'local arrow-fn component returning a fragment of valid buttons',
+ code: dedent`
+ const ActionButtons = () => (
+ <>
+ Save
+ Cancel
+ >
+ );
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'local block-body arrow-fn component with valid return is resolved and accepted',
+ code: dedent`
+ const ActionButtons = () => { return Save; };
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'local block-body arrow-fn component with early return is resolved and accepted',
+ code: dedent`
+ const ActionButtons = ({ isIcon }) => {
+ if (isIcon) return ;
+ return Add;
+ };
+
+
+
+ `,
+ languageOptions,
+ },
+
+ // Static bail-outs (not resolvable at analysis time)
+ {
+ name: 'function call child — cannot statically resolve',
+ code: dedent`
+
+ {renderButtons()}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'options API (self-closing) is ignored',
+ code: dedent`
+ {}}
+ />
+ `,
+ languageOptions,
+ },
+
+ // Variants
+ {
+ name: 'variant="default" with valid children is accepted',
+ code: dedent`
+
+ Save
+ Cancel
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'variant="segmented" is not yet validated — invalid children pass through',
+ code: dedent`
+
+ Not a button
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'variant="selection" is not yet validated — invalid children pass through',
+ code: dedent`
+
+ Not a button
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'dynamic variant is conservatively skipped — invalid children pass through',
+ code: dedent`
+
+ Not a button
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'spread props on child — cannot statically determine',
+ code: dedent`
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'spread props on wrapper child — cannot statically determine',
+ code: dedent`
+
+
+
+
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'map() with expression-body valid button is accepted',
+ code: dedent`
+
+ {buttons.map((b) => {b.label})}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'map() with expression-body spread props is skipped — cannot statically determine element identity',
+ code: dedent`
+
+ {buttons.map((b) => )}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'map() with block-body single return of valid button is accepted',
+ code: dedent`
+
+ {buttons.map((b) => {
+ return {b.label};
+ })}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'map() with block-body early null return and valid button is accepted',
+ code: dedent`
+
+ {buttons.map((b) => {
+ if (b.hidden) return null;
+ return {b.label};
+ })}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'map() with block-body if/else returning different valid buttons is accepted',
+ code: dedent`
+
+ {buttons.map((b) => {
+ if (b.type === 'icon') return ;
+ return {b.label};
+ })}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'variable holding a map() result with valid buttons is accepted',
+ code: dedent`
+ const buttons = items.map((item) => {item.label});
+
+ {buttons}
+
+ `,
+ languageOptions,
+ },
+ {
+ name: 'member-expression child (e.g. namespace component) is skipped',
+ code: dedent`
+
+
+
+ `,
+ languageOptions,
+ },
+
+ // Switch statement
+ {
+ name: 'local arrow-fn with switch returning valid buttons is accepted',
+ code: dedent`
+ const ActionButton = ({ variant }) => {
+ switch (variant) {
+ case 'icon': return ;
+ default: return Add;
+ }
+ };
+
+
+
+ `,
+ languageOptions,
+ },
+ ],
+
+ invalid: [
+ // Direct children
+ {
+ name: 'plain div child',
+ code: dedent`
+
+ Not a button
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'div' } }],
+ },
+ {
+ name: 'EuiFlexGroup child',
+ code: dedent`
+
+
+ Save
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiFlexGroup' } }],
+ },
+ {
+ name: 'multiple invalid children reported individually',
+ code: dedent`
+
+ text
+ Valid
+ Also invalid
+
+ `,
+ languageOptions,
+ errors: [
+ { messageId: 'invalidChild', data: { name: 'span' } },
+ { messageId: 'invalidChild', data: { name: 'EuiText' } },
+ ],
+ },
+
+ // EuiCopy
+ {
+ name: 'invalid element returned by EuiCopy render prop',
+ code: dedent`
+
+
+ {(copy) => Not a button}
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiCopy' },
+ },
+ ],
+ },
+ {
+ name: 'EuiCopy block-body render prop with invalid return is reported',
+ code: dedent`
+
+
+ {(copy) => { return Not a button; }}
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiCopy' },
+ },
+ ],
+ },
+ {
+ name: 'EuiCopy block-body render prop with invalid branch in if/else is reported',
+ code: dedent`
+
+
+ {(copy) => {
+ if (isBad) return Not a button;
+ return Copy;
+ }}
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiCopy' },
+ },
+ ],
+ },
+
+ // EuiPopover
+ {
+ name: 'invalid element in EuiPopover button prop',
+ code: dedent`
+
+ Not a button} isOpen={false} closePopover={() => {}}>
+ Panel content
+
+
+ `,
+ languageOptions,
+ errors: [
+ { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } },
+ ],
+ },
+ {
+ name: 'HTML element in EuiPopover button prop',
+ code: dedent`
+
+ Not a button} isOpen={false} closePopover={() => {}}>
+ Panel content
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidPopoverButton', data: { name: 'div' } }],
+ },
+ {
+ name: 'EuiToolTip in EuiPopover button prop with invalid child',
+ code: dedent`
+
+ Not a button}
+ isOpen={false}
+ closePopover={() => {}}
+ >
+ Panel content
+
+
+ `,
+ languageOptions,
+ errors: [
+ { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } },
+ ],
+ },
+ {
+ name: 'EuiPopover button prop as resolved variable with invalid element is reported',
+ code: dedent`
+ const trigger = Not a button;
+
+ {}}>
+ Panel content
+
+
+ `,
+ languageOptions,
+ errors: [
+ { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } },
+ ],
+ },
+ {
+ name: 'EuiPopover button prop as conditional with one invalid branch is reported',
+ code: dedent`
+
+ : Bad}
+ isOpen={false}
+ closePopover={() => {}}
+ >
+ Panel content
+
+
+ `,
+ languageOptions,
+ errors: [
+ { messageId: 'invalidPopoverButton', data: { name: 'EuiText' } },
+ ],
+ },
+
+ // Invalid wrapper children
+ {
+ name: 'non-button inside EuiToolTip',
+ code: dedent`
+
+
+
+
+
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiFlexGroup', wrapper: 'EuiToolTip' },
+ },
+ ],
+ },
+
+ // EuiToolTip
+ {
+ name: 'conditional inside EuiToolTip with one invalid branch',
+ code: dedent`
+
+
+ {isIcon
+ ?
+ : Not a button}
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiToolTip' },
+ },
+ ],
+ },
+ {
+ name: 'variable inside EuiToolTip resolves to an invalid element',
+ code: dedent`
+ const icon = Not a button;
+
+
+ {icon}
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiToolTip' },
+ },
+ ],
+ },
+
+ // Fragments
+ {
+ name: 'shorthand fragment wrapping invalid element',
+ code: dedent`
+
+ <>
+ Save
+ Not allowed
+ >
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: ' wrapping invalid element',
+ code: dedent`
+
+
+ Not a button
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'div' } }],
+ },
+ {
+ name: ' wrapping invalid element',
+ code: dedent`
+
+
+ Not allowed
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'fragment inside EuiToolTip wrapping invalid element',
+ code: dedent`
+
+
+ <>
+ Not a button
+ >
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiToolTip' },
+ },
+ ],
+ },
+
+ // Conditional children
+ {
+ name: '{condition && } right side is flagged',
+ code: dedent`
+
+ Save
+ {show && Not allowed}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'ternary with one invalid branch',
+ code: dedent`
+
+ {isAdmin ? Delete : No access}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'ternary with both invalid branches reported separately',
+ code: dedent`
+
+ {show ? A : B}
+
+ `,
+ languageOptions,
+ errors: [
+ { messageId: 'invalidChild', data: { name: 'EuiText' } },
+ { messageId: 'invalidChild', data: { name: 'EuiBadge' } },
+ ],
+ },
+
+ {
+ name: '{unresolvable || } invalid right side is flagged',
+ code: dedent`
+
+ {fallback || Not a button}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: '{unresolvable ?? } invalid right side is flagged',
+ code: dedent`
+
+ {fallback ?? Not a button}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: '{resolvedInvalidButton || } resolved invalid left side is flagged',
+ code: dedent`
+ const resolvedButton = Not a button;
+
+ {resolvedButton || Save}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+
+ // Unresolvable custom component
+ {
+ name: 'unresolvable custom component inside EuiToolTip uses invalidUnresolvableWrapperChild',
+ code: dedent`
+
+
+
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidUnresolvableWrapperChild',
+ data: { name: 'SaveButton', wrapper: 'EuiToolTip' },
+ },
+ ],
+ },
+ {
+ name: 'unresolvable custom component inside EuiCopy render prop uses invalidUnresolvableWrapperChild',
+ code: dedent`
+
+
+ {(copy) => }
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidUnresolvableWrapperChild',
+ data: { name: 'SaveButton', wrapper: 'EuiCopy' },
+ },
+ ],
+ },
+
+ // Variable resolution
+ {
+ name: 'const variable holding an invalid element is resolved and reported',
+ code: dedent`
+ const button = Not a button;
+
+ {button}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'const variable holding an array with an invalid element is resolved and reported',
+ code: dedent`
+ const buttons = [
+ Save,
+ Not a button,
+ ];
+
+ {buttons}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+
+ // Local arrow-function
+ {
+ name: 'local arrow-fn component with invalid return is resolved and reported at the invalid element',
+ code: dedent`
+ const ActionButtons = () => Not a button;
+
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'local block-body arrow-fn component with invalid return is resolved and reported at the invalid element',
+ code: dedent`
+ const ActionButtons = () => { return Not a button; };
+
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'local block-body arrow-fn component with invalid branch is resolved and reported',
+ code: dedent`
+ const ActionButtons = ({ isInvalid }) => {
+ if (isInvalid) return Bad;
+ return Good;
+ };
+
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'imported custom component cannot be resolved — uses invalidUnresolvableChild message',
+ code: dedent`
+
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidUnresolvableChild',
+ data: { name: 'SaveButton' },
+ },
+ ],
+ },
+
+ // Array notation
+ {
+ name: 'array containing an invalid element',
+ code: dedent`
+
+ {[
+ Save,
+ Not allowed,
+ ]}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'array containing a wrapper (EuiToolTip) with an invalid inner element',
+ code: dedent`
+
+ {[
+
+ Not a button
+ ,
+ ]}
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiToolTip' },
+ },
+ ],
+ },
+
+ // .map()
+ {
+ name: 'map() with expression-body returning invalid element is reported',
+ code: dedent`
+
+ {buttons.map((b) => {b.label}
)}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'div' } }],
+ },
+ {
+ name: 'map() with block-body returning invalid element is reported',
+ code: dedent`
+
+ {buttons.map((b) => {
+ return {b.label}
;
+ })}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'div' } }],
+ },
+ {
+ name: 'map() with block-body invalid branch is reported',
+ code: dedent`
+
+ {buttons.map((b) => {
+ if (b.type === 'bad') return {b.label};
+ return {b.label};
+ })}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ {
+ name: 'variable holding map() result with invalid element is reported',
+ code: dedent`
+ const buttons = items.map((item) => {item.label}
);
+
+ {buttons}
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'div' } }],
+ },
+
+ // Spread on wrapper
+ {
+ name: 'EuiToolTip with spread props still validates its children',
+ code: dedent`
+
+
+ Not a button
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiToolTip' },
+ },
+ ],
+ },
+ {
+ name: 'EuiCopy with spread props still validates its render-prop child',
+ code: dedent`
+
+
+ {(copy) => Not a button}
+
+
+ `,
+ languageOptions,
+ errors: [
+ {
+ messageId: 'invalidWrapperChild',
+ data: { name: 'EuiText', wrapper: 'EuiCopy' },
+ },
+ ],
+ },
+
+ // Switch statement
+ {
+ name: 'local arrow-fn with switch containing invalid branch is reported',
+ code: dedent`
+ const ActionButton = ({ variant }) => {
+ switch (variant) {
+ case 'bad': return Not a button;
+ default: return Good;
+ }
+ };
+
+
+
+ `,
+ languageOptions,
+ errors: [{ messageId: 'invalidChild', data: { name: 'EuiText' } }],
+ },
+ ],
+ }
+);
diff --git a/packages/eslint-plugin/src/rules/button_group_no_invalid_children.ts b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.ts
new file mode 100644
index 00000000000..9d454250807
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/button_group_no_invalid_children.ts
@@ -0,0 +1,216 @@
+/*
+ * 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 { type TSESTree, type TSESLint, ESLintUtils } from '@typescript-eslint/utils';
+import { hasSpread } from '../utils/has_spread';
+import { flatMap } from '../utils/flat_map';
+import { getElementName } from '../utils/get_element_name';
+import { collectJsxChildren } from '../utils/collect_jsx_children';
+import { findAttrValue } from '../utils/get_attr_value';
+
+const BUTTON_GROUP = 'EuiButtonGroup';
+const VALID_BUTTONS = new Set(['EuiButton', 'EuiButtonEmpty', 'EuiButtonIcon']);
+const VALID_WRAPPERS = new Set(['EuiToolTip', 'EuiPopover', 'EuiCopy']);
+
+function joinSet(set: Set): string {
+ const items: string[] = [];
+ set.forEach((v) => items.push(v));
+ return items.join(', ');
+}
+
+const VALID_BUTTONS_LIST = joinSet(VALID_BUTTONS);
+const VALID_WRAPPERS_LIST = joinSet(VALID_WRAPPERS);
+
+function isCustomComponent(name: string): boolean {
+ return name[0] === name[0].toUpperCase() && !name.startsWith('Eui');
+}
+
+function reportInvalidWrapperChildren<
+ TContext extends TSESLint.RuleContext
+>(
+ wrapper: TSESTree.JSXElement,
+ wrapperName: string,
+ context: TContext
+): void {
+ const children = flatMap(wrapper.children, (c) =>
+ collectJsxChildren(c, context.sourceCode)
+ );
+ for (const wc of children) {
+ if (hasSpread(wc.openingElement.attributes)) continue;
+ const wcName = getElementName(wc.openingElement);
+ if (wcName === null || VALID_BUTTONS.has(wcName)) continue;
+ context.report({
+ node: wc.openingElement,
+ messageId: isCustomComponent(wcName)
+ ? 'invalidUnresolvableWrapperChild'
+ : 'invalidWrapperChild',
+ data: { name: wcName, wrapper: wrapperName },
+ });
+ }
+}
+
+/* Rule */
+
+export const ButtonGroupNoInvalidChildren = ESLintUtils.RuleCreator.withoutDocs(
+ {
+ create(context) {
+ return {
+ JSXElement(node) {
+ const { openingElement } = node;
+
+ if (
+ openingElement.name.type !== 'JSXIdentifier' ||
+ openingElement.name.name !== BUTTON_GROUP
+ ) {
+ return;
+ }
+
+ // Only validate when the variant is "default" (or absent — the default).
+ // Rules for "segmented" and "selection" are added in later chunks.
+ // Dynamic variant values (variables) are conservatively skipped.
+ const variant = findAttrValue(
+ context,
+ openingElement.attributes,
+ 'variant'
+ );
+ if (variant !== undefined && variant !== 'default') return;
+
+ const children = flatMap(node.children, (c) =>
+ collectJsxChildren(c, context.sourceCode)
+ );
+ if (children.length === 0) return;
+
+ for (const child of children) {
+ const name = getElementName(child.openingElement);
+ if (name === null) continue;
+
+ if (VALID_BUTTONS.has(name)) continue;
+
+ if (VALID_WRAPPERS.has(name)) {
+ if (name === 'EuiToolTip') {
+ // Validate JSX children (expanding fragments/conditionals).
+ reportInvalidWrapperChildren(child, name, context);
+ } else if (name === 'EuiPopover') {
+ // The trigger is the `button` prop, not JSX children (panel
+ // content). The prop value may be a JSXExpressionContainer or
+ // a bare JSX element; collectJsxChildren handles both.
+ // EuiToolTip wrapping the trigger button is also supported.
+ const buttonProp = child.openingElement.attributes.find(
+ (attr): attr is TSESTree.JSXAttribute =>
+ attr.type === 'JSXAttribute' &&
+ attr.name.type === 'JSXIdentifier' &&
+ attr.name.name === 'button'
+ );
+
+ if (buttonProp?.value != null) {
+ const triggerElements = collectJsxChildren(
+ buttonProp.value as TSESTree.Node,
+ context.sourceCode
+ );
+
+ for (const te of triggerElements) {
+ if (hasSpread(te.openingElement.attributes)) continue;
+
+ const teName = getElementName(te.openingElement);
+
+ if (teName === null || VALID_BUTTONS.has(teName)) continue;
+
+ if (teName === 'EuiToolTip') {
+ // EuiToolTip wrapping the trigger — validate its children.
+ const tooltipChildren = flatMap(te.children, (c) =>
+ collectJsxChildren(c, context.sourceCode)
+ );
+
+ for (const ttc of tooltipChildren) {
+ if (hasSpread(ttc.openingElement.attributes)) continue;
+
+ const ttcName = getElementName(ttc.openingElement);
+
+ if (ttcName === null || VALID_BUTTONS.has(ttcName))
+ continue;
+
+ context.report({
+ node: ttc.openingElement,
+ messageId: 'invalidPopoverButton',
+ data: { name: ttcName },
+ });
+ }
+ continue;
+ }
+ context.report({
+ node: te.openingElement,
+ messageId: 'invalidPopoverButton',
+ data: { name: teName },
+ });
+ }
+ }
+ } else if (name === 'EuiCopy') {
+ // Children is a render prop: {(copy) => }
+ // ArrowFunctionExpression with expression body is expanded by
+ // collectJsxChildren, so validation works the same as EuiToolTip.
+ reportInvalidWrapperChildren(child, name, context);
+ }
+ continue;
+ }
+
+ // For elements that aren't known valid buttons or wrappers, a spread
+ // prevents static classification; skip conservatively.
+ if (hasSpread(child.openingElement.attributes)) continue;
+
+ context.report({
+ node: child.openingElement,
+ messageId: isCustomComponent(name)
+ ? 'invalidUnresolvableChild'
+ : 'invalidChild',
+ data: { name },
+ });
+ }
+ },
+ };
+ },
+ meta: {
+ type: 'problem',
+ docs: {
+ description: `Enforce that EuiButtonGroup children are ${VALID_BUTTONS_LIST}, or a supported wrapper (${VALID_WRAPPERS_LIST})`,
+ },
+ schema: [],
+ messages: {
+ invalidChild: [
+ `{{ name }} is not a valid child of EuiButtonGroup.`,
+ `Allowed children: ${VALID_BUTTONS_LIST}.`,
+ `Allowed wrappers: ${VALID_WRAPPERS_LIST}.`,
+ ].join(' '),
+ invalidUnresolvableChild: [
+ `{{ name }} cannot be verified as a valid child of EuiButtonGroup.`,
+ `Allowed children: ${VALID_BUTTONS_LIST}.`,
+ `Allowed wrappers: ${VALID_WRAPPERS_LIST}.`,
+ `If {{ name }} is a shared button wrapper component only containing`,
+ `valid button children, suppress this rule inline with a comment`,
+ `explaining why it's valid.`,
+ `// eslint-disable-next-line @elastic/eui/button-group-no-invalid-children -- SaveButton only wraps EuiButton`,
+ ].join(' '),
+ invalidPopoverButton: [
+ `{{ name }} is not a valid trigger button for EuiPopover in EuiButtonGroup.`,
+ `The \`button\` prop must be ${VALID_BUTTONS_LIST}.`,
+ ].join(' '),
+ invalidWrapperChild: [
+ `{{ name }} inside {{ wrapper }} is not a valid button for EuiButtonGroup.`,
+ `The wrapper must contain ${VALID_BUTTONS_LIST}.`,
+ ].join(' '),
+ invalidUnresolvableWrapperChild: [
+ `{{ name }} inside {{ wrapper }} cannot be verified as a valid button for EuiButtonGroup.`,
+ `The wrapper must contain ${VALID_BUTTONS_LIST}.`,
+ `If {{ name }} is a shared button wrapper component, suppress this rule inline`,
+ `with a comment explaining why it renders valid button children:`,
+ `// eslint-disable-next-line @elastic/eui/button-group-no-invalid-children -- SaveButton wraps EuiButton`,
+ ].join(' '),
+ },
+ },
+ defaultOptions: [],
+ }
+);
diff --git a/packages/eslint-plugin/src/utils/collect_jsx_children.ts b/packages/eslint-plugin/src/utils/collect_jsx_children.ts
new file mode 100644
index 00000000000..471659b8fae
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/collect_jsx_children.ts
@@ -0,0 +1,198 @@
+/*
+ * 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 { type TSESTree, type TSESLint } from '@typescript-eslint/utils';
+import { flatMap } from './flat_map';
+import { isFragment } from './is_fragment';
+
+// A variable is only safely resolvable if it is never reassigned. A second
+// write means the value at the point of use can't be determined statically.
+function isReassigned(variable: TSESLint.Scope.Variable): boolean {
+ return variable.references.filter((ref) => ref.isWrite()).length > 1;
+}
+
+/**
+ * Resolves an expression-context Identifier to the node its variable was
+ * initialized with (e.g. the `` in `const btn = `).
+ * Returns null for anything that can't be resolved safely: imports, function
+ * parameters, reassigned variables, or missing initializers.
+ */
+export function resolveIdentifierValue(
+ sourceCode: TSESLint.SourceCode,
+ node: TSESTree.Identifier
+): TSESTree.Node | null {
+ let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(node);
+ while (scope) {
+ const ref = scope.references.find((r) => r.identifier === node);
+ if (ref) {
+ const variable = ref.resolved;
+ if (!variable || isReassigned(variable)) return null;
+ const def = variable.defs[0];
+ if (!def || def.type !== 'Variable' || !def.node.init) return null;
+ return def.node.init;
+ }
+ scope = scope.upper;
+ }
+ return null;
+}
+
+/**
+ * Collects all return-statement arguments within a block body, recursing into
+ * `if`/`else` branches and `switch` cases. Does not recurse into nested
+ * functions — those have their own return scope. Used to validate block-body
+ * arrow functions.
+ */
+export function collectReturnValues(
+ node: TSESTree.Node
+): TSESTree.Expression[] {
+ switch (node.type) {
+ case 'BlockStatement':
+ return flatMap(node.body, collectReturnValues);
+ case 'ReturnStatement':
+ return node.argument ? [node.argument] : [];
+ case 'IfStatement':
+ return [
+ ...collectReturnValues(node.consequent),
+ ...(node.alternate ? collectReturnValues(node.alternate) : []),
+ ];
+ case 'SwitchStatement':
+ return flatMap(node.cases, (c) =>
+ flatMap(c.consequent, collectReturnValues)
+ );
+ default:
+ return [];
+ }
+}
+
+/**
+ * Resolves a JSX component name (e.g. `Buttons` in ``) to the
+ * nodes returned by its local arrow-function definition, if one exists.
+ *
+ * Handles both expression-body (`() => `) and block-body
+ * (`() => { return ; }`) arrow functions. Imports, class components,
+ * and reassigned variables return null — those stay opaque.
+ */
+export function resolveLocalComponent(
+ sourceCode: TSESLint.SourceCode,
+ name: string,
+ refNode: TSESTree.Node
+): TSESTree.Node[] | null {
+ let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(refNode);
+ while (scope) {
+ const variable = scope.variables.find((v) => v.name === name);
+ if (variable) {
+ if (isReassigned(variable)) return null;
+ const def = variable.defs[0];
+ if (!def || def.type !== 'Variable' || !def.node.init) return null;
+ const init = def.node.init;
+ if (init.type !== 'ArrowFunctionExpression') return null;
+ if (init.body.type !== 'BlockStatement') return [init.body];
+ const returns = collectReturnValues(init.body);
+ return returns.length > 0 ? returns : null;
+ }
+ scope = scope.upper;
+ }
+ return null;
+}
+
+/**
+ * Recursively collects concrete JSXElement nodes to validate, expanding:
+ * - `<>...>`, ``, `` (transparent grouping)
+ * - `{expr}` (JSXExpressionContainer)
+ * - `{a && }` (LogicalExpression `&&` → right side only)
+ * - `{a || }`, `{a ?? }` (LogicalExpression `||`/`??` → both sides)
+ * - `{c ? : }` (ConditionalExpression → both branches)
+ * - `{[, ]}` (ArrayExpression → each element)
+ * - `{variable}` (Identifier → resolved via scope)
+ * - `` (local arrow-fn component, expression or block body)
+ * - `{(arg) => }` (ArrowFunctionExpression, expression or block body)
+ * - `{arr.map(fn)}` (CallExpression `.map()` with arrow-fn callback)
+ *
+ * Patterns that can't be resolved statically (arbitrary function calls, imported
+ * variables, non-arrow `.map()` callbacks) produce an empty list and are silently
+ * skipped.
+ */
+export function collectJsxChildren(
+ node: TSESTree.Node,
+ sourceCode: TSESLint.SourceCode
+): TSESTree.JSXElement[] {
+ return collectChildren(node, sourceCode, new Set());
+}
+
+function collectChildren(
+ node: TSESTree.Node,
+ sourceCode: TSESLint.SourceCode,
+ visited: Set
+): TSESTree.JSXElement[] {
+ const collect = (n: TSESTree.Node) => collectChildren(n, sourceCode, visited);
+
+ switch (node.type) {
+ case 'JSXElement': {
+ if (isFragment(node.openingElement)) {
+ return flatMap(node.children, collect);
+ }
+ const { name } = node.openingElement;
+ if (name.type === 'JSXIdentifier') {
+ if (visited.has(name.name)) return [];
+
+ const resolved = resolveLocalComponent(
+ sourceCode,
+ name.name,
+ node.openingElement
+ );
+ if (resolved !== null) {
+ visited.add(name.name);
+
+ const result = flatMap(resolved, collect);
+
+ visited.delete(name.name);
+ return result;
+ }
+ }
+ return [node];
+ }
+ case 'JSXFragment':
+ return flatMap(node.children, collect);
+ case 'JSXExpressionContainer':
+ if (node.expression.type === 'JSXEmptyExpression') return [];
+ return collect(node.expression);
+ case 'LogicalExpression':
+ if (node.operator === '&&') return collect(node.right);
+ return [...collect(node.left), ...collect(node.right)];
+ case 'ConditionalExpression':
+ return [...collect(node.consequent), ...collect(node.alternate)];
+ case 'ArrayExpression':
+ return flatMap(node.elements, (el) =>
+ el && el.type !== 'SpreadElement' ? collect(el) : []
+ );
+ case 'Identifier': {
+ const resolved = resolveIdentifierValue(sourceCode, node);
+ return resolved ? collect(resolved) : [];
+ }
+ case 'ArrowFunctionExpression':
+ if (node.body.type !== 'BlockStatement') return collect(node.body);
+ return flatMap(collectReturnValues(node.body), collect);
+ case 'CallExpression': {
+ const { callee, arguments: args } = node;
+ if (
+ callee.type === 'MemberExpression' &&
+ callee.property.type === 'Identifier' &&
+ callee.property.name === 'map' &&
+ args.length >= 1 &&
+ args[0].type === 'ArrowFunctionExpression'
+ ) {
+ const fn = args[0];
+ if (fn.body.type !== 'BlockStatement') return collect(fn.body);
+ return flatMap(collectReturnValues(fn.body), collect);
+ }
+ return [];
+ }
+ default:
+ return [];
+ }
+}
diff --git a/packages/eslint-plugin/src/utils/flat_map.ts b/packages/eslint-plugin/src/utils/flat_map.ts
new file mode 100644
index 00000000000..6a1907dbf66
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/flat_map.ts
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+
+// `Array.prototype.flatMap` requires ES2019 lib; this shim keeps rules that
+// need it within the package's es5 lib setting without widening it for others.
+export function flatMap(arr: readonly T[], fn: (item: T) => U[]): U[] {
+ const out: U[] = [];
+ for (const item of arr) {
+ const items = fn(item);
+ for (const i of items) out.push(i);
+ }
+ return out;
+}
diff --git a/packages/eslint-plugin/src/utils/is_fragment.ts b/packages/eslint-plugin/src/utils/is_fragment.ts
new file mode 100644
index 00000000000..db0e10ad7c2
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/is_fragment.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 { type TSESTree } from '@typescript-eslint/utils';
+
+/**
+ * Returns true if the opening element is a React fragment: `` or
+ * ``. The `<>` shorthand is represented as a `JSXFragment`
+ * node (not a `JSXOpeningElement`) and is handled separately by callers.
+ */
+export function isFragment(opening: TSESTree.JSXOpeningElement): boolean {
+ const { name } = opening;
+ if (name.type === 'JSXIdentifier' && name.name === 'Fragment') return true;
+ return (
+ name.type === 'JSXMemberExpression' &&
+ name.object.type === 'JSXIdentifier' &&
+ name.object.name === 'React' &&
+ name.property.type === 'JSXIdentifier' &&
+ name.property.name === 'Fragment'
+ );
+}