diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Dark_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Dark_Mode.png
new file mode 100644
index 00000000000..64df1901fb9
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Dark_Mode.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_High_Contrast_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_High_Contrast_Mode.png
new file mode 100644
index 00000000000..04d51b31509
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_High_Contrast_Mode.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_High_Contrast_Mode_Dark.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_High_Contrast_Mode_Dark.png
new file mode 100644
index 00000000000..2e683ff7280
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_High_Contrast_Mode_Dark.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Kitchen_Sink.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Kitchen_Sink.png
new file mode 100644
index 00000000000..aa1d1352f02
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Kitchen_Sink.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Playground.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Playground.png
new file mode 100644
index 00000000000..1958d8da8bc
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Playground.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Single_Secondary_Action.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Single_Secondary_Action.png
new file mode 100644
index 00000000000..9364a8d6a2e
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_Single_Secondary_Action.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Popover.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Popover.png
new file mode 100644
index 00000000000..0cba763e830
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Popover.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Tooltip.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Tooltip.png
new file mode 100644
index 00000000000..f2204ebc4e5
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Tooltip.png differ
diff --git a/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Wrapping_Popover.png b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Wrapping_Popover.png
new file mode 100644
index 00000000000..062952d5d54
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Navigation_EuiSplitButton_With_Wrapping_Popover.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Dark_Mode.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Dark_Mode.png
new file mode 100644
index 00000000000..85981365122
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Dark_Mode.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_High_Contrast_Mode.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_High_Contrast_Mode.png
new file mode 100644
index 00000000000..9f60a122afa
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_High_Contrast_Mode.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_High_Contrast_Mode_Dark.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_High_Contrast_Mode_Dark.png
new file mode 100644
index 00000000000..0edbb7464ea
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_High_Contrast_Mode_Dark.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Kitchen_Sink.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Kitchen_Sink.png
new file mode 100644
index 00000000000..d775545b901
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Kitchen_Sink.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Playground.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Playground.png
new file mode 100644
index 00000000000..46100f30ff5
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Playground.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Single_Secondary_Action.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Single_Secondary_Action.png
new file mode 100644
index 00000000000..ccefe57c6ab
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_Single_Secondary_Action.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Popover.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Popover.png
new file mode 100644
index 00000000000..ca77394f938
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Popover.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Tooltip.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Tooltip.png
new file mode 100644
index 00000000000..1cbd45fc82d
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Tooltip.png differ
diff --git a/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Wrapping_Popover.png b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Wrapping_Popover.png
new file mode 100644
index 00000000000..49cc4cb366b
Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Navigation_EuiSplitButton_With_Wrapping_Popover.png differ
diff --git a/packages/eui/changelogs/upcoming/9269.md b/packages/eui/changelogs/upcoming/9269.md
new file mode 100644
index 00000000000..be80b6b2914
--- /dev/null
+++ b/packages/eui/changelogs/upcoming/9269.md
@@ -0,0 +1,2 @@
+- Added `EuiSplitButton` and its respective sub-components `EuiSplitButton.ActionPrimary` and `EuiSplitButton.ActionSecondary`
+
diff --git a/packages/eui/src/components/button/button_icon/button_icon.tsx b/packages/eui/src/components/button/button_icon/button_icon.tsx
index 95a914dd0c4..eb69c6d0795 100644
--- a/packages/eui/src/components/button/button_icon/button_icon.tsx
+++ b/packages/eui/src/components/button/button_icon/button_icon.tsx
@@ -106,7 +106,7 @@ export type EuiButtonIconPropsForButton = {
}
>;
-type Props = ExclusiveUnion<
+export type Props = ExclusiveUnion<
EuiButtonIconPropsForAnchor,
EuiButtonIconPropsForButton
>;
diff --git a/packages/eui/src/components/button/index.ts b/packages/eui/src/components/button/index.ts
index bf8f0bafca6..26614b247ca 100644
--- a/packages/eui/src/components/button/index.ts
+++ b/packages/eui/src/components/button/index.ts
@@ -23,3 +23,10 @@ export type {
EuiButtonGroupProps,
} from './button_group';
export { EuiButtonGroup } from './button_group';
+
+export type {
+ EuiSplitButtonProps,
+ EuiSplitButtonActionPrimaryProps,
+ EuiSplitButtonActionSecondaryProps,
+} from './split_button';
+export { EuiSplitButton } from './split_button';
diff --git a/packages/eui/src/components/button/split_button/__snapshots__/split_button.test.tsx.snap b/packages/eui/src/components/button/split_button/__snapshots__/split_button.test.tsx.snap
new file mode 100644
index 00000000000..cf85bd2e987
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/__snapshots__/split_button.test.tsx.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EuiSplitButton renders 1`] = `
+
+`;
diff --git a/packages/eui/src/components/button/split_button/index.ts b/packages/eui/src/components/button/split_button/index.ts
new file mode 100644
index 00000000000..e29fd84407c
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/index.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.
+ */
+
+export type { EuiSplitButtonProps } from './split_button';
+export { EuiSplitButton } from './split_button';
+export type {
+ EuiSplitButtonActionPrimaryProps,
+ EuiSplitButtonActionSecondaryProps,
+} from './split_button_actions';
+export {
+ EuiSplitButtonActionPrimary,
+ EuiSplitButtonActionSecondary,
+} from './split_button_actions';
diff --git a/packages/eui/src/components/button/split_button/split_button.stories.tsx b/packages/eui/src/components/button/split_button/split_button.stories.tsx
new file mode 100644
index 00000000000..181c3a3a12b
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/split_button.stories.tsx
@@ -0,0 +1,487 @@
+/*
+ * 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 React, { useEffect, useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { css } from '@emotion/react';
+
+import { LOKI_SELECTORS } from '../../../../.storybook/loki';
+import { BUTTON_COLORS } from '../../../global_styling';
+import { EuiSpacer } from '../../spacer';
+import { EuiFlexGroup } from '../../flex';
+import { EuiWrappingPopover } from '../../popover';
+import { EuiContextMenu } from '../../context_menu';
+import { ToolTipDelay } from '../../tool_tip/tool_tip';
+import { EuiSplitButton, EuiSplitButtonProps } from './split_button';
+
+const decorators: Meta['decorators'] = [
+ (Story) => (
+
+
+
+ ),
+];
+
+const meta: Meta = {
+ title: 'Navigation/EuiSplitButton',
+ component: EuiSplitButton,
+ args: {
+ // Component defaults
+ color: 'primary',
+ size: 'm',
+ fill: false,
+ isDisabled: false,
+ hasAriaDisabled: false,
+ isLoading: false,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Playground: Story = {
+ args: {
+ children: [
+ Button,
+ ,
+ ],
+ },
+};
+
+export const SingleSecondaryAction: Story = {
+ args: {
+ children: [
+ Button,
+ ,
+ ],
+ },
+};
+
+export const WithTooltip: Story = {
+ parameters: {
+ loki: {
+ chromeSelector: LOKI_SELECTORS.portal,
+ },
+ },
+ args: {
+ children: [
+
+ Button
+ ,
+ ,
+ ],
+ },
+};
+
+export const WithPopover: Story = {
+ decorators,
+ parameters: {
+ loki: {
+ chromeSelector: LOKI_SELECTORS.portal,
+ },
+ },
+ args: {
+ children: [
+ Button,
+ {},
+ },
+ {
+ name: 'Action 2 (link)',
+ icon: 'user',
+ href: 'http://elastic.co',
+ target: '_blank',
+ },
+ {
+ name: 'Action 3 (tooltip)',
+ icon: 'document',
+ toolTipContent: 'Optional content for a tooltip',
+ toolTipProps: {
+ title: 'Optional tooltip title',
+ position: 'right',
+ },
+ onClick: () => {},
+ },
+ ],
+ },
+ ]}
+ />
+ ),
+ closePopover: () => {},
+ }}
+ />,
+ ],
+ },
+ render: function Render({ children, ...rest }) {
+ const [isPopoverOpen, setIsPopoverOpen] = useState(true);
+
+ const [primaryAction, secondaryAction] = children;
+
+ const popoverProps = {
+ ...secondaryAction.props.popoverProps,
+ isOpen: isPopoverOpen,
+ closePopover: () => setIsPopoverOpen(false),
+ };
+
+ return (
+
+ {primaryAction}
+ {React.cloneElement(secondaryAction, {
+ onClick: () => {
+ setIsPopoverOpen(!isPopoverOpen);
+ },
+ popoverProps: popoverProps,
+ })}
+
+ );
+ },
+};
+
+export const WithWrappingPopover: Story = {
+ decorators,
+ parameters: {
+ loki: {
+ chromeSelector: LOKI_SELECTORS.portal,
+ },
+ codeSnippet: {
+ skip: true,
+ },
+ },
+ args: {
+ children: [
+ Button,
+ ,
+ ],
+ },
+ render: function Render({ children, ...rest }) {
+ const [buttonRef, setButtonRef] = useState(null);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(true);
+
+ const [primaryAction, secondaryAction] = children;
+
+ useEffect(() => {
+ if (!isPopoverOpen) {
+ buttonRef?.focus();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isPopoverOpen]);
+
+ return (
+ <>
+
+ {primaryAction}
+ {React.cloneElement(secondaryAction, {
+ buttonRef: (node: HTMLButtonElement) => setButtonRef(node),
+ onClick: () => setIsPopoverOpen(!isPopoverOpen),
+ })}
+
+ {isPopoverOpen && buttonRef && (
+ setIsPopoverOpen(false)}
+ anchorPosition="downCenter"
+ >
+ Popover content
+
+ )}
+ >
+ );
+ },
+};
+
+/* VRT only */
+
+export const KitchenSink: Story = {
+ tags: ['vrt-only'],
+ parameters: {
+ codeSnippet: {
+ skip: true,
+ },
+ },
+ render: function Render(_args) {
+ const label = 'Button';
+ const secondaryLabel = 'Secondary Button';
+ const primaryIconType = 'faceHappy';
+ const secondaryIconType = 'arrowDown';
+
+ const variants = BUTTON_COLORS;
+
+ const actionPrimary = ({
+ isDisabled = false,
+ isLoading = false,
+ }: {
+ isDisabled?: boolean;
+ isLoading?: boolean;
+ } = {}) => (
+
+ {label}
+
+ );
+
+ const actionPrimaryWithIcon = (
+
+ {label}
+
+ );
+
+ const actionPrimaryIconOnly = (
+
+ );
+
+ const actionSecondary = ({
+ isDisabled = false,
+ isLoading = false,
+ }: {
+ isDisabled?: boolean;
+ isLoading?: boolean;
+ } = {}) => (
+
+ );
+
+ const defaultActions = [actionPrimary(), actionSecondary()];
+ const withIconActions = [actionPrimaryWithIcon, actionSecondary()];
+ const iconOnlyActions = [actionPrimaryIconOnly, actionSecondary()];
+
+ const examples: EuiSplitButtonProps[][] = [
+ variants
+ .map((color) => [
+ {
+ color,
+ fill: false,
+ children: defaultActions,
+ } as EuiSplitButtonProps,
+ {
+ color,
+ fill: true,
+ children: defaultActions,
+ } as EuiSplitButtonProps,
+ ])
+ .flat(),
+ variants
+ .map((color) => [
+ {
+ color,
+ fill: false,
+ children: withIconActions,
+ } as EuiSplitButtonProps,
+ {
+ color,
+ fill: true,
+ children: withIconActions,
+ } as EuiSplitButtonProps,
+ ])
+ .flat(),
+ variants
+ .map((color) => [
+ {
+ color,
+ fill: false,
+ children: iconOnlyActions,
+ } as EuiSplitButtonProps,
+ {
+ color,
+ fill: true,
+ children: iconOnlyActions,
+ } as EuiSplitButtonProps,
+ ])
+ .flat(),
+ [
+ {
+ fill: false,
+ children: [actionPrimary(), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: false,
+ children: [actionPrimary({ isDisabled: true }), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: false,
+ children: [actionPrimary(), actionSecondary({ isDisabled: true })],
+ } as EuiSplitButtonProps,
+
+ {
+ fill: true,
+ children: [actionPrimary(), actionSecondary()],
+ },
+ {
+ fill: true,
+ children: [actionPrimary({ isDisabled: true }), actionSecondary()],
+ },
+ {
+ fill: true,
+ children: [actionPrimary(), actionSecondary({ isDisabled: true })],
+ },
+ {
+ fill: false,
+ color: 'text',
+ children: [actionPrimary(), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: false,
+ color: 'text',
+ children: [actionPrimary({ isDisabled: true }), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: false,
+ color: 'text',
+ children: [actionPrimary(), actionSecondary({ isDisabled: true })],
+ } as EuiSplitButtonProps,
+ {
+ fill: true,
+ color: 'text',
+ children: [actionPrimary(), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: true,
+ color: 'text',
+ children: [actionPrimary({ isDisabled: true }), actionSecondary()],
+ },
+ {
+ fill: true,
+ color: 'text',
+ children: [actionPrimary(), actionSecondary({ isDisabled: true })],
+ },
+ ],
+ [
+ {
+ fill: false,
+ isDisabled: true,
+ children: [actionPrimary(), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: true,
+ isDisabled: true,
+ children: [actionPrimary(), actionSecondary()],
+ },
+ {
+ fill: false,
+ color: 'text',
+ children: [
+ actionPrimary({ isDisabled: true }),
+ actionSecondary({ isDisabled: true }),
+ ],
+ } as EuiSplitButtonProps,
+ {
+ fill: true,
+ color: 'text',
+ children: [
+ actionPrimary({ isDisabled: true }),
+ actionSecondary({ isDisabled: true }),
+ ],
+ },
+ ],
+ [
+ {
+ fill: false,
+ isLoading: true,
+ children: [actionPrimary(), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: true,
+ isLoading: true,
+ children: [actionPrimary(), actionSecondary()],
+ },
+ {
+ fill: false,
+ children: [actionPrimary({ isLoading: true }), actionSecondary()],
+ } as EuiSplitButtonProps,
+ {
+ fill: true,
+ children: [actionPrimary(), actionSecondary({ isLoading: true })],
+ },
+ ],
+ ];
+
+ return (
+
+ {examples.map((example) => {
+ return (
+
+ {example.map((variant) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+ },
+};
+
+export const DarkMode: Story = {
+ ...KitchenSink,
+ tags: ['vrt-only'],
+ globals: { colorMode: 'DARK' },
+};
+
+export const HighContrastMode: Story = {
+ ...KitchenSink,
+ tags: ['vrt-only'],
+ globals: { highContrastMode: true },
+};
+
+export const HighContrastModeDark: Story = {
+ ...KitchenSink,
+ tags: ['vrt-only'],
+ globals: { highContrastMode: true, colorMode: 'DARK' },
+};
diff --git a/packages/eui/src/components/button/split_button/split_button.styles.ts b/packages/eui/src/components/button/split_button/split_button.styles.ts
new file mode 100644
index 00000000000..6604f7dd3de
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/split_button.styles.ts
@@ -0,0 +1,83 @@
+/*
+ * 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 { css } from '@emotion/react';
+
+import { UseEuiTheme } from '../../../services';
+import {
+ euiDisabledSelector,
+ highContrastModeStyles,
+} from '../../../global_styling';
+
+export const euiSplitButtonStyles = (euiThemeContext: UseEuiTheme) => {
+ const { euiTheme } = euiThemeContext;
+
+ const primaryDisabledSelector = `.euiSplitButtonActionPrimary:is(${euiDisabledSelector})`;
+ const secondaryDisabledSelector = `.euiSplitButtonActionSecondary:is(${euiDisabledSelector})`;
+ const dividerSelector = `.euiSplitButton__divider`;
+
+ return {
+ euiSplitButton: css`
+ display: inline-flex;
+ flex-wrap: nowrap;
+
+ &:has(${primaryDisabledSelector}):has(${secondaryDisabledSelector}) {
+ ${dividerSelector} {
+ ${highContrastModeStyles(euiThemeContext, {
+ // When both buttons are disabled set as visual gap
+ none: `
+ border-color: transparent;
+ `,
+ // When both buttons are disabled set a disabled divider color
+ preferred: `
+ border-color: ${euiTheme.colors.borderBaseDisabled};
+ `,
+ })}
+ }
+ }
+ `,
+ // When both buttons are enabled set as visual gap
+ fill: css`
+ &:not(:has(${primaryDisabledSelector})):not(
+ :has(${secondaryDisabledSelector})
+ ) {
+ ${dividerSelector} {
+ border-color: transparent;
+ }
+ }
+ `,
+ };
+};
+
+export const euiSplitButtonActionStyles = {
+ euiSplitButtonActionPrimary: css`
+ z-index: 0;
+ border-inline-end: none;
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ `,
+ euiSplitButtonActionSecondary: css`
+ border-inline-start: none;
+ border-start-start-radius: 0;
+ border-end-start-radius: 0;
+ `,
+};
+
+export const euiSplitButtonDividerStyles = (
+ euiThemeContext: UseEuiTheme,
+ color: string
+) => {
+ const { euiTheme } = euiThemeContext;
+
+ return {
+ divider: css`
+ /* uses a border to ensure proper rendering in Windows high contrast themes */
+ border-inline-start: ${euiTheme.border.width.thin} solid ${color};
+ `,
+ };
+};
diff --git a/packages/eui/src/components/button/split_button/split_button.test.tsx b/packages/eui/src/components/button/split_button/split_button.test.tsx
new file mode 100644
index 00000000000..043adb70ab5
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/split_button.test.tsx
@@ -0,0 +1,355 @@
+/*
+ * 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 React from 'react';
+import { fireEvent } from '@testing-library/react';
+
+import {
+ render,
+ waitForEuiToolTipHidden,
+ waitForEuiToolTipVisible,
+} from '../../../test/rtl';
+import { shouldRenderCustomStyles } from '../../../test/internal';
+import { EuiToolTip } from '../../tool_tip';
+import { EuiSplitButton, EuiSplitButtonProps } from './split_button';
+
+const defaultPrimaryActionProps = {
+ children: 'Primary Action',
+ 'data-test-subj': 'primary-action',
+};
+
+const defaultSecondaryActionProps = {
+ iconType: 'arrowDown',
+ 'aria-label': 'Secondary action',
+ 'data-test-subj': 'secondary-action',
+};
+
+const defaultProps: EuiSplitButtonProps = {
+ children: [
+ ,
+ ,
+ ],
+};
+
+describe('EuiSplitButton', () => {
+ shouldRenderCustomStyles();
+
+ it('renders', () => {
+ const { container } = render();
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ describe('props', () => {
+ describe('color', () => {
+ it('renders', () => {
+ const { getByTestSubject } = render(
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(Array.from(primaryAction.classList).join(' ')).toContain(
+ 'success'
+ );
+ expect(Array.from(secondaryAction.classList).join(' ')).toContain(
+ 'success'
+ );
+ });
+
+ it('overrides child props', () => {
+ const { getByTestSubject } = render(
+
+
+
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(Array.from(primaryAction.classList).join(' ')).toContain(
+ 'success'
+ );
+ expect(Array.from(primaryAction.classList).join(' ')).not.toContain(
+ 'danger'
+ );
+ expect(Array.from(secondaryAction.classList).join(' ')).toContain(
+ 'success'
+ );
+ expect(Array.from(secondaryAction.classList).join(' ')).not.toContain(
+ 'danger'
+ );
+ });
+ });
+
+ describe('fill', () => {
+ it('renders', () => {
+ const { getByTestSubject } = render(
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(Array.from(primaryAction.classList).join(' ')).toContain('fill');
+ expect(Array.from(secondaryAction.classList).join(' ')).toContain(
+ 'fill'
+ );
+ });
+
+ it('overrides child props', () => {
+ const { getByTestSubject } = render(
+
+
+
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+
+ expect(Array.from(primaryAction.classList).join(' ')).toContain('fill');
+ });
+ });
+
+ describe('size', () => {
+ it('renders', () => {
+ const { getByTestSubject } = render(
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(Array.from(primaryAction.classList).join(' ')).toContain('-s-');
+ expect(Array.from(secondaryAction.classList).join(' ')).toContain(
+ '-s-'
+ );
+ });
+
+ it('overrides child props', () => {
+ const { getByTestSubject } = render(
+
+
+
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(Array.from(primaryAction.classList).join(' ')).toContain('-s-');
+ expect(Array.from(primaryAction.classList).join(' ')).not.toContain(
+ '-m-'
+ );
+ expect(Array.from(secondaryAction.classList).join(' ')).toContain(
+ '-s-'
+ );
+ expect(Array.from(secondaryAction.classList).join(' ')).not.toContain(
+ '-m-'
+ );
+ });
+ });
+
+ describe('isDisabled', () => {
+ it('renders', () => {
+ const { getByTestSubject } = render(
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(primaryAction).toBeDisabled();
+ expect(secondaryAction).toBeDisabled();
+ });
+
+ it('child props apply correctly', () => {
+ const { getByTestSubject } = render(
+
+
+
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(primaryAction).toBeDisabled();
+ expect(secondaryAction).toBeDisabled();
+ });
+ });
+
+ describe('isLoading', () => {
+ it('renders', () => {
+ const { getByTestSubject } = render(
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(primaryAction).toBeDisabled();
+ expect(secondaryAction).toBeDisabled();
+
+ const primarySpinner =
+ primaryAction.querySelector('.euiLoadingSpinner');
+ const secondarySpinner =
+ secondaryAction.querySelector('.euiLoadingSpinner');
+
+ expect(primarySpinner).toBeInTheDocument();
+ expect(secondarySpinner).toBeInTheDocument();
+ });
+
+ it('child props apply correctly', () => {
+ const { getByTestSubject } = render(
+
+
+
+
+ );
+
+ const primaryAction = getByTestSubject('primary-action');
+ const secondaryAction = getByTestSubject('secondary-action');
+
+ expect(primaryAction).toBeEuiDisabled();
+ expect(secondaryAction).toBeEuiDisabled();
+
+ const primarySpinner =
+ primaryAction.querySelector('.euiLoadingSpinner');
+ const secondarySpinner =
+ secondaryAction.querySelector('.euiLoadingSpinner');
+
+ expect(primarySpinner).toBeInTheDocument();
+ expect(secondarySpinner).toBeInTheDocument();
+ });
+ });
+
+ describe('children', () => {
+ it('logs an error when invalid `children` are passed', () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {});
+
+ render(
+
+
+
+
+
+
+
+
+ );
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '⚠️ EuiSplitButton: Expected at position 1, got '
+ )
+ );
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '⚠️ EuiSplitButton: Expected at position 2, got '
+ )
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('renders a tooltip', async () => {
+ const { getByTestSubject } = render(
+
+
+
+
+ );
+
+ fireEvent.mouseOver(getByTestSubject('primary-action'));
+ await waitForEuiToolTipVisible();
+
+ expect(getByTestSubject('primary-action-tooltip')).toBeInTheDocument();
+
+ fireEvent.mouseLeave(getByTestSubject('primary-action'));
+ await waitForEuiToolTipHidden();
+
+ fireEvent.mouseOver(getByTestSubject('secondary-action'));
+ await waitForEuiToolTipVisible();
+
+ expect(
+ getByTestSubject('secondary-action-tooltip')
+ ).toBeInTheDocument();
+ });
+
+ it('renders a popover', () => {
+ const closePopover = jest.fn();
+ const { getByTestSubject } = render(
+
+
+
+
+ );
+
+ expect(
+ getByTestSubject('secondary-action-popover')
+ ).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/packages/eui/src/components/button/split_button/split_button.tsx b/packages/eui/src/components/button/split_button/split_button.tsx
new file mode 100644
index 00000000000..afdbf4991b5
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/split_button.tsx
@@ -0,0 +1,176 @@
+/*
+ * 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 classNames from 'classnames';
+import React, {
+ Children,
+ ComponentType,
+ Fragment,
+ FunctionComponent,
+ isValidElement,
+ ReactElement,
+ useMemo,
+} from 'react';
+
+import {
+ EuiDisabledProps,
+ useEuiMemoizedStyles,
+ useEuiTheme,
+ useGeneratedHtmlId,
+} from '../../../services';
+import {
+ getEuiButtonColors,
+ getEuiFilledButtonColors,
+} from '../../../global_styling';
+import { CommonProps } from '../../common';
+import { EuiButtonProps } from '../button';
+import { EuiSplitButtonContext } from './split_button_context';
+import {
+ EuiSplitButtonActionPrimary,
+ EuiSplitButtonActionPrimaryProps,
+ EuiSplitButtonActionSecondary,
+ EuiSplitButtonActionSecondaryProps,
+} from './split_button_actions';
+import {
+ euiSplitButtonDividerStyles,
+ euiSplitButtonStyles,
+} from './split_button.styles';
+
+type EuiSplitButtonCommonProps = EuiDisabledProps & {
+ size?: EuiButtonProps['size'];
+ color?: EuiButtonProps['color'];
+ fill?: EuiButtonProps['fill'];
+ isLoading?: EuiButtonProps['isLoading'];
+};
+
+export type EuiSplitButtonProps = CommonProps &
+ EuiSplitButtonCommonProps & {
+ /* NOTE: This definition is not actually enforced by Typescript.
+ The tuple type ensures 2 children are expected, but the function component type won't be evaluated properly.
+ We use this definition anyway as documentation for users.
+ To advocate correct usage this requires runtime checks for development mode. */
+ children: [
+ ReactElement<
+ EuiSplitButtonActionPrimaryProps,
+ typeof EuiSplitButtonActionPrimary
+ >,
+ ReactElement<
+ EuiSplitButtonActionSecondaryProps,
+ typeof EuiSplitButtonActionSecondary
+ >
+ ];
+ };
+
+export const _EuiSplitButton: FunctionComponent = ({
+ className,
+ children,
+ size = 'm',
+ color = 'primary',
+ fill = false,
+ isDisabled,
+ hasAriaDisabled,
+ isLoading,
+ ...rest
+}) => {
+ const euiThemeContext = useEuiTheme();
+ const { highContrastMode } = euiThemeContext;
+
+ const [primaryAction, secondaryAction] = children;
+ const key = useGeneratedHtmlId({ suffix: 'EuiSplitButton' });
+
+ const commonProps = {
+ size,
+ color,
+ fill,
+ isDisabled,
+ hasAriaDisabled,
+ isLoading,
+ };
+
+ const buttonFilledColors = getEuiFilledButtonColors(
+ euiThemeContext,
+ isDisabled ? 'disabled' : color
+ );
+ const buttonColors = getEuiButtonColors(
+ euiThemeContext,
+ isDisabled ? 'disabled' : color
+ );
+
+ const classes = classNames('euiSplitButton', className);
+ const styles = useEuiMemoizedStyles(euiSplitButtonStyles);
+ const cssStyles = [styles.euiSplitButton, fill && styles.fill];
+ const dividerStyles = useMemo(
+ () =>
+ euiSplitButtonDividerStyles(
+ euiThemeContext,
+ !fill
+ ? buttonColors.borderColor
+ : highContrastMode && fill
+ ? buttonFilledColors.backgroundColor
+ : 'transparent'
+ ),
+ [
+ euiThemeContext,
+ highContrastMode,
+ fill,
+ buttonFilledColors.backgroundColor,
+ buttonColors.borderColor,
+ ]
+ );
+
+ // NOTE: dev-mode-only runtime check to evaluate if correct child components are passed
+ if (process.env.NODE_ENV !== 'production') {
+ const childArray = Children.toArray(children);
+ const expectedTypes = [
+ ['EuiSplitButton.ActionPrimary', 'EuiSplitButtonActionPrimary'],
+ ['EuiSplitButton.ActionSecondary', 'EuiSplitButtonActionSecondary'],
+ ];
+
+ childArray.forEach((child, index) => {
+ if (!isValidElement(child)) return;
+
+ const componentName = getComponentName(child.type);
+ const expectedComponents = expectedTypes[index];
+
+ if (!expectedComponents.includes(componentName)) {
+ console.warn(
+ `⚠️ EuiSplitButton: Expected <${expectedComponents[0]}> at position ${
+ index + 1
+ }, got <${componentName}>. You might be using a wrapper. Using e.g. React.memo() or React.lazy() is valid, other component wrappers are not and will break styling.
+ To verify expected usage, please check the documentation: https://eui.elastic.co/docs/components/navigation/buttons/split-button/`
+ );
+ }
+ });
+ }
+
+ return (
+
+
+ {primaryAction}
+
+ {secondaryAction}
+
+
+ );
+};
+
+export const EuiSplitButton = Object.assign(_EuiSplitButton, {
+ ActionPrimary: EuiSplitButtonActionPrimary,
+ ActionSecondary: EuiSplitButtonActionSecondary,
+});
+
+/* internal utils */
+
+const getComponentName = (type: ComponentType | string): string => {
+ if (typeof type === 'string') return type;
+ return type.displayName || type.name || 'Unknown';
+};
diff --git a/packages/eui/src/components/button/split_button/split_button_actions.tsx b/packages/eui/src/components/button/split_button/split_button_actions.tsx
new file mode 100644
index 00000000000..1ace6d8326a
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/split_button_actions.tsx
@@ -0,0 +1,129 @@
+/*
+ * 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 React, { FunctionComponent, useContext } from 'react';
+import classNames from 'classnames';
+
+import { EuiDisabledProps } from '../../../services';
+import { EuiPopover } from '../../popover';
+import { EuiToolTip, EuiToolTipProps } from '../../tool_tip';
+import { EuiButton, Props as EuiButtonProps } from '../button';
+import { isButtonDisabled } from '../button_display/_button_display';
+import { EuiButtonIcon } from '../button_icon';
+import { type Props as EuiButtonIconProps } from '../button_icon/button_icon';
+import { euiSplitButtonActionStyles } from './split_button.styles';
+import { EuiSplitButtonContext } from './split_button_context';
+
+type EuiSplitButtonAction = T & {
+ /**
+ * Enables rendering an `EuiToolTip` with the passed props.
+ */
+ tooltipProps?: Partial>;
+};
+
+export type EuiSplitButtonActionPrimaryProps = EuiDisabledProps &
+ EuiSplitButtonAction & {
+ /**
+ * Toggles the render as `EuiButtonIcon`.
+ */
+ isIconOnly?: boolean;
+ };
+
+export type EuiSplitButtonActionSecondaryProps = EuiDisabledProps &
+ EuiSplitButtonAction & {
+ /**
+ * Enables rendering an `EuiPopover` with the passed props.
+ * When passed the secondary action icon will be fixed to `arrowDown`.
+ */
+ popoverProps?: Omit;
+ };
+
+export const EuiSplitButtonActionPrimary: FunctionComponent<
+ EuiSplitButtonActionPrimaryProps
+> = ({ className, isIconOnly, tooltipProps, ...rest }) => {
+ const { fill, isDisabled, isLoading, ...sharedRest } = useContext(
+ EuiSplitButtonContext
+ );
+ const _isDisabled = !!isDisabled || isButtonDisabled(rest);
+ const _isLoading = !!isLoading || !!rest.isLoading;
+ const display = (fill ? 'fill' : 'base') as EuiButtonIconProps['display'];
+
+ const classes = classNames('euiSplitButtonActionPrimary', className);
+ const styles = euiSplitButtonActionStyles;
+
+ const actionProps = {
+ ...rest,
+ ...sharedRest,
+ isDisabled: _isDisabled,
+ isLoading: _isLoading,
+ css: [styles.euiSplitButtonActionPrimary],
+ className: classes,
+ };
+
+ const actionButtonProps = {
+ ...actionProps,
+ fill,
+ } as EuiButtonProps;
+
+ const actionIconProps = {
+ ...actionProps,
+ display,
+ } as EuiButtonIconProps;
+
+ const button = isIconOnly ? (
+
+ ) : (
+
+ );
+
+ return tooltipProps ? (
+ {button}
+ ) : (
+ button
+ );
+};
+
+export const EuiSplitButtonActionSecondary: FunctionComponent<
+ EuiSplitButtonActionSecondaryProps
+> = ({ className, popoverProps, tooltipProps, ...rest }) => {
+ const { fill, isDisabled, isLoading, ...sharedRest } = useContext(
+ EuiSplitButtonContext
+ );
+
+ const _isDisabled = !!isDisabled || isButtonDisabled(rest);
+ const _isLoading = !!isLoading || !!rest.isLoading;
+ const display = (fill ? 'fill' : 'base') as EuiButtonIconProps['display'];
+
+ const classes = classNames('euiSplitButtonActionSecondary', className);
+ const styles = euiSplitButtonActionStyles;
+
+ const actionProps = {
+ ...rest,
+ ...sharedRest,
+ display,
+ // enforce arrowDown icon when a popover is rendered
+ iconType: popoverProps != null ? 'arrowDown' : rest.iconType,
+ isDisabled: _isDisabled,
+ isLoading: _isLoading,
+ css: [styles.euiSplitButtonActionSecondary],
+ className: classes,
+ };
+
+ const button = ;
+ const action = tooltipProps ? (
+ {button}
+ ) : (
+ button
+ );
+
+ return popoverProps ? (
+
+ ) : (
+ action
+ );
+};
diff --git a/packages/eui/src/components/button/split_button/split_button_context.ts b/packages/eui/src/components/button/split_button/split_button_context.ts
new file mode 100644
index 00000000000..f221bdc18ac
--- /dev/null
+++ b/packages/eui/src/components/button/split_button/split_button_context.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 { createContext } from 'react';
+
+import { EuiDisabledProps } from '../../../services';
+import { Props as EuiButtonProps } from '../button';
+
+export const EuiSplitButtonContext = createContext<
+ EuiDisabledProps & {
+ size: NonNullable;
+ color: NonNullable;
+ fill: EuiButtonProps['fill'];
+ isLoading?: EuiButtonProps['isLoading'];
+ }
+>({
+ size: 'm',
+ color: 'primary',
+ fill: false,
+});
diff --git a/packages/eui/src/global_styling/mixins/_button.ts b/packages/eui/src/global_styling/mixins/_button.ts
index 5ff00c829d8..2867bb984a0 100644
--- a/packages/eui/src/global_styling/mixins/_button.ts
+++ b/packages/eui/src/global_styling/mixins/_button.ts
@@ -52,6 +52,7 @@ type ButtonVariantColors = {
background: string;
backgroundHover: string;
backgroundActive: string;
+ borderColor: string;
};
const getButtonVariantTokenValues = (
@@ -105,16 +106,15 @@ const getButtonVariantTokenValues = (
background: euiTheme.components.buttons[backgroundTokenName],
backgroundHover: euiTheme.components.buttons[backgroundHoverTokenName],
backgroundActive: euiTheme.components.buttons[backgroundActiveTokenName],
+ borderColor: highContrastMode
+ ? foreground
+ : color === 'text'
+ ? euiTheme.colors.borderBasePlain
+ : 'transparent',
};
};
-/**
- * Creates the `base` version of button styles with proper text contrast.
- * @param euiThemeContext
- * @param color One of the named button colors or 'disabled'
- * @returns Style object `{ backgroundColor, color }`
- */
-export const euiButtonColor = (
+export const getEuiButtonColors = (
euiThemeContext: UseEuiTheme,
color: _EuiExtendedButtonColor | 'disabled'
) => {
@@ -133,17 +133,30 @@ export const euiButtonColor = (
? foreground
: makeHighContrastColor(foreground)(background),
backgroundColor: background,
- ..._highContrastBorder(euiThemeContext, foreground),
+ borderColor: buttonColors.borderColor,
};
};
/**
- * Creates the `fill` version of buttons styles with proper text contrast.
+ * Creates the `base` version of button styles with proper text contrast.
* @param euiThemeContext
* @param color One of the named button colors or 'disabled'
* @returns Style object `{ backgroundColor, color }`
*/
-export const euiButtonFillColor = (
+export const euiButtonColor = (
+ euiThemeContext: UseEuiTheme,
+ color: _EuiExtendedButtonColor | 'disabled'
+) => {
+ const buttonColors = getEuiButtonColors(euiThemeContext, color);
+
+ return {
+ color: buttonColors.color,
+ backgroundColor: buttonColors.backgroundColor,
+ ..._highContrastBorder(euiThemeContext, buttonColors.borderColor),
+ };
+};
+
+export const getEuiFilledButtonColors = (
euiThemeContext: UseEuiTheme,
color: _EuiExtendedButtonColor | 'disabled'
) => {
@@ -156,12 +169,34 @@ export const euiButtonFillColor = (
const foreground = buttonColors.color;
const background = buttonColors.background;
+ return {
+ color: foreground,
+ backgroundColor: background,
+ borderColor: color === 'disabled' ? foreground : background,
+ };
+};
+
+/**
+ * Creates the `fill` version of buttons styles with proper text contrast.
+ * @param euiThemeContext
+ * @param color One of the named button colors or 'disabled'
+ * @returns Style object `{ backgroundColor, color }`
+ */
+export const euiButtonFillColor = (
+ euiThemeContext: UseEuiTheme,
+ color: _EuiExtendedButtonColor | 'disabled'
+) => {
+ const buttonColors = getEuiFilledButtonColors(euiThemeContext, color);
+
+ const foreground = buttonColors.color;
+ const background = buttonColors.backgroundColor;
+
return {
color: foreground,
backgroundColor: background,
..._highContrastBorder(
euiThemeContext,
- color === 'disabled' ? foreground : background // The border is necessary for Windows high contrast themes, which ignore background-color
+ buttonColors.borderColor // The border is necessary for Windows high contrast themes, which ignore background-color
),
};
};
diff --git a/packages/website/docs/components/navigation/buttons/button.mdx b/packages/website/docs/components/navigation/buttons/button.mdx
index c7be537a7bc..5353f0a3827 100644
--- a/packages/website/docs/components/navigation/buttons/button.mdx
+++ b/packages/website/docs/components/navigation/buttons/button.mdx
@@ -902,84 +902,6 @@ Button placement should always reflect the surrounding context. Place the primar
-### Split buttons
-
-EUI does not specifically support split buttons. Instead, use separate `EuiButton` and `EuiButtonIcon` components, matching their `display` and `size` props for consistency.
-
-```tsx interactive
-import React, { useState } from 'react';
-
-import {
- EuiButton,
- EuiButtonIcon,
- EuiFlexGroup,
- EuiFlexItem,
- EuiContextMenuPanel,
- EuiContextMenuItem,
- EuiPopover,
- useGeneratedHtmlId,
-} from '@elastic/eui';
-
-export default () => {
- const [isPopoverOpen, setPopover] = useState(false);
- const splitButtonPopoverId = useGeneratedHtmlId({
- prefix: 'splitButtonPopover',
- });
-
- const onButtonClick = () => {
- setPopover(!isPopoverOpen);
- };
-
- const closePopover = () => {
- setPopover(false);
- };
-
- const items = [
-
- Copy
- ,
-
- Edit
- ,
-
- Share
- ,
- ];
-
- return (
- <>
-
-
-
- Save
-
-
-
-
- }
- isOpen={isPopoverOpen}
- closePopover={closePopover}
- panelPaddingSize="none"
- anchorPosition="downLeft"
- >
-
-
-
-
- >
- );
-};
-```
-
### Popover buttons
Combine multiple actions into a single button with a popover menu to reduce group size.
diff --git a/packages/website/docs/components/navigation/buttons/split-button.mdx b/packages/website/docs/components/navigation/buttons/split-button.mdx
new file mode 100644
index 00000000000..1b6a9d952a8
--- /dev/null
+++ b/packages/website/docs/components/navigation/buttons/split-button.mdx
@@ -0,0 +1,320 @@
+---
+sidebar_position: 2
+sidebar_label: Split button
+keywords: [EuiSplitButton]
+---
+
+# Split button
+
+Split buttons combine a primary and secondary action into a visual button group. The main button performs the most common action, while the secondary button may perform an additional action
+or open a dropdown popover with related secondary actions. This pattern is ideal when you have one primary action that users perform frequently, along with one or several related alternatives.
+
+`EuiSplitButton` is composed of two sub-components:
+- `EuiSplitButton.ActionPrimary`: The main action button
+- `EuiSplitButton.ActionSecondary`: The secondary action button
+
+`EuiSplitButton.ActionPrimary` is by default rendered as a standard button with text, but it can be rendered as icon button by setting `isIconOnly={true}` and passing a `iconType`.
+`EuiSplitButton.ActionSecondary` is always rendered as an icon button.
+
+:::info `EuiSplitButton` only allows `EuiSplitButton.ActionPrimary` and `EuiSplitButton.ActionSecondary` as children.
+You may use wrappers that don't change the DOM structure (like `React.memo()` or `React.lazy()`) to wrap these components. Other component wrappers are not supported and will break styling.
+:::
+
+```tsx interactive
+import React, { useState } from 'react';
+import { css } from '@emotion/react';
+import {
+ EuiButton,
+ EuiSplitButton,
+ EuiContextMenu,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiSelect,
+ EuiSwitch,
+} from '@elastic/eui';
+import { COLORS } from '@elastic/eui/es/components/button/button';
+
+export default () => {
+ const [isFilled, setIsFilled] = useState(false);
+ const [isSizeSmall, setIsSizeSmall] = useState(false);
+
+ const [isDisabled, setIsDisabled] = useState(false);
+ const [isPrimaryDisabled, setIsPrimaryDisabled] = useState(false);
+ const [isSecondaryDisabled, setIsSecondaryDisabled] = useState(false);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [isPrimaryLoading, setIsPrimaryLoading] = useState(false);
+ const [isSecondaryLoading, setIsSecondaryLoading] = useState(false);
+
+ const [isPrimaryIconOnly, setIsPrimaryIconOnly] = useState(false);
+
+ const [isFirstPopoverOpen, setIsFirstPopoverOpen] = useState(false);
+
+ // While `accentSecondary` is currently available on the component, it is likely to be removed
+ // `neutral` and `risk` are only tentatively added but might be removed
+ const filteredColors = COLORS.filter((name) => !['accentSecondary', 'neutral', 'risk'].includes(name));
+ const buttonColorsOptions = filteredColors.map((name) => {
+ return {
+ value: name,
+ text: name,
+ };
+ });
+
+ const [buttonColor, setButtonColor] = useState(buttonColorsOptions[2].value);
+
+ const onChangeButtonColor = (e) => {
+ setButtonColor(e.target.value);
+ };
+
+ return (
+ <>
+
+
+ onChangeButtonColor(e)}
+ compressed
+ aria-label="Button colors"
+ />
+
+
+ setIsFilled(!isFilled)}
+ />
+
+
+ setIsSizeSmall(!isSizeSmall)}
+ />
+
+
+ setIsDisabled(!isDisabled)}
+ />
+
+
+ setIsLoading(!isLoading)}
+ />
+
+
+
+
+
+
+
+ setIsPrimaryIconOnly(!isPrimaryIconOnly)}
+ />
+
+
+ setIsPrimaryDisabled(!isPrimaryDisabled)}
+ />
+
+
+ setIsPrimaryLoading(!isPrimaryLoading)}
+ />
+
+
+
+
+
+
+
+ setIsSecondaryDisabled(!isSecondaryDisabled)}
+ />
+
+
+ setIsSecondaryLoading(!isSecondaryLoading)}
+ />
+
+
+
+
+
+
+
+ Refresh
+
+
+
+ >
+ );
+}
+```
+
+Both sub-components accept the same props as `EuiButton` and `EuiButtonIcon` respectively.
+Some specific props are however controlled by the parent `EuiSplitButton` component to ensure expected visual output.
+These common props are: `size`, `color`, and `fill`. Additionally, you can control `isDisabled` and `isLoading` states for both actions combined as well as individually.
+The parent prop will always override the sub-component prop when both are provided.
+
+## Tooltips and popovers
+
+To apply a tooltip to either action, you can pass `tooltipProps` which will render an `EuiToolTip` wrapping the button.
+For `EuiSplitButton.ActionSecondary` only, you can also pass `popoverProps` to render an `EuiPopover`.
+
+```tsx interactive
+import React, { useState } from 'react';
+import { css } from '@emotion/react';
+import {
+ EuiButton,
+ EuiSplitButton,
+ EuiContextMenu,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiSelect,
+ EuiSwitch,
+} from '@elastic/eui';
+import { COLORS } from '@elastic/eui/es/components/button/button';
+
+export default () => {
+ const [isFirstPopoverOpen, setIsFirstPopoverOpen] = useState(false);
+
+ const menu = (
+ {},
+ },
+ {
+ name: 'Action 2 (link)',
+ icon: 'user',
+ href: 'http://elastic.co',
+ target: '_blank',
+ },
+ {
+ name: 'Action 3 (tooltip)',
+ icon: 'document',
+ toolTipContent: 'Optional content for a tooltip',
+ toolTipProps: {
+ title: 'Optional tooltip title',
+ position: 'right',
+ },
+ onClick: () => {},
+ },
+ ],
+ },
+ ]}
+ />
+ );
+
+ return (
+
+
+ Save
+
+ setIsFirstPopoverOpen(!isFirstPopoverOpen)}
+ popoverProps={{
+ isOpen: isFirstPopoverOpen,
+ closePopover: () => setIsFirstPopoverOpen(false),
+ panelPaddingSize: 's',
+ children: menu,
+ }}
+ />
+
+ );
+}
+```
+
+## Usage
+
+:::accessibility Accessibility
+Using `` or `` requires `aria-label` to be set to ensure an accessible label for the icon-only
+button is available to screen readers.
+:::
+
+### Single primary and secondary action
+
+When there is only one primary and one secondary action, the `onClick` handler can be passed directly to each action and perform individual actions for each action button.
+
+### Multiple secondary actions
+
+When there are multiple secondary actions use the `popoverProps` prop on `EuiSplitButton.ActionSecondary` to display your custom subset of secondary actions in a `EuiPopover` component.
+
+## Props
+
+import docgen from '@elastic/eui-docgen/dist/components/button/split_button/';
+import docgenActions from '@elastic/eui-docgen/dist/components/button/split_button/';
+
+
+
+
\ No newline at end of file