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} + + ); +}; + +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