diff --git a/packages/eui/changelogs/upcoming/8836.md b/packages/eui/changelogs/upcoming/8836.md new file mode 100644 index 00000000000..de4fe6076ca --- /dev/null +++ b/packages/eui/changelogs/upcoming/8836.md @@ -0,0 +1,3 @@ +**New features** + +- Added `EuiButtonSplit` component for creating split buttons with a primary action and dropdown menu diff --git a/packages/eui/src/components/button/button_split/__snapshots__/button_split.test.tsx.snap b/packages/eui/src/components/button/button_split/__snapshots__/button_split.test.tsx.snap new file mode 100644 index 00000000000..873d953d059 --- /dev/null +++ b/packages/eui/src/components/button/button_split/__snapshots__/button_split.test.tsx.snap @@ -0,0 +1,208 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiButtonSplit is rendered 1`] = ` + + + +
+ +
+
+
+`; + +exports[`EuiButtonSplit props renders with isDisabled 1`] = ` + + + +
+ +
+
+
+`; + +exports[`EuiButtonSplit props text color styling renders non-text color with fill=false (has margin, has left border) 1`] = ` + + + +
+ +
+
+
+`; + +exports[`EuiButtonSplit props text color styling renders text color with fill=false (no margin, no left border) 1`] = ` + + + +
+ +
+
+
+`; + +exports[`EuiButtonSplit props text color styling renders text color with fill=true (has margin, has left border) 1`] = ` + + + +
+ +
+
+
+`; diff --git a/packages/eui/src/components/button/button_split/button_split.stories.tsx b/packages/eui/src/components/button/button_split/button_split.stories.tsx new file mode 100644 index 00000000000..982131c5f5f --- /dev/null +++ b/packages/eui/src/components/button/button_split/button_split.stories.tsx @@ -0,0 +1,93 @@ +/* + * 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 type { Meta, StoryObj } from '@storybook/react'; +import { EuiButtonSplit, EuiButtonSplitProps } from './button_split'; +import { EuiListGroup } from '../../list_group'; + +const meta: Meta = { + title: 'Navigation/EuiButtonSplit', + component: EuiButtonSplit, + args: { + color: 'text', + fill: false, + size: 's', + isDisabled: false, + buttonProps: { + children: 'Add panel', + onClick: () => alert('Main button clicked!'), + }, + iconButtonProps: { + iconType: 'arrowDown', + 'aria-label': 'More actions', + }, + popoverMenu: (closePopover) => ( + { + alert('Open Lens clicked!'); + closePopover(); + }, + iconType: 'visualizeApp', + }, + { + label: 'Open Maps', + onClick: () => { + alert('Open Maps clicked!'); + closePopover(); + }, + iconType: 'gisApp', + }, + ]} + showToolTips={false} + /> + ), + // panelPaddingSize intentionally omitted to show default + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: {}, + parameters: { + docs: { + description: { + story: ` +This split button demonstrates custom border radius and spacing: +- The right edge of the left button and the left edge of the right button have zero border radius, making them join seamlessly. +- There is a 1px space between the two buttons for visual clarity. + +**panelPaddingSize** can be set to control the popover's padding. The default is 'm'. Example with custom padding: + +\`\`\`tsx +} + panelPaddingSize="l" +/> +\`\`\` + `, + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/eui/src/components/button/button_split/button_split.styles.ts b/packages/eui/src/components/button/button_split/button_split.styles.ts new file mode 100644 index 00000000000..979ab67426e --- /dev/null +++ b/packages/eui/src/components/button/button_split/button_split.styles.ts @@ -0,0 +1,35 @@ +/* + * 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 { logicalCSS } from '../../../global_styling/functions'; + +export const euiButtonSplitStyles = () => { + return { + euiButtonSplit: css` + display: inline-flex; + align-items: stretch; + `, + leftButton: css` + ${logicalCSS('border-top-right-radius', '0 !important')} + ${logicalCSS('border-bottom-right-radius', '0 !important')} + `, + rightSpan: (color: string, fill?: boolean) => css` + display: flex; + align-items: stretch; + ${color !== 'text' || fill ? 'margin-left: 1px;' : ''} + `, + iconButton: (color: string, fill?: boolean) => css` + ${logicalCSS('border-top-left-radius', '0 !important')} + ${logicalCSS('border-bottom-left-radius', '0 !important')} + ${color === 'text' && !fill + ? logicalCSS('border-left', 'none !important') + : ''} + `, + }; +}; diff --git a/packages/eui/src/components/button/button_split/button_split.test.tsx b/packages/eui/src/components/button/button_split/button_split.test.tsx new file mode 100644 index 00000000000..d0ddf20493e --- /dev/null +++ b/packages/eui/src/components/button/button_split/button_split.test.tsx @@ -0,0 +1,164 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import { render } from '../../../test/rtl'; +import { requiredProps } from '../../../test/required_props'; +import { shouldRenderCustomStyles } from '../../../test/internal'; + +import { EuiButtonSplit } from './button_split'; + +const defaultButtonProps = { + children: 'Main', + onClick: jest.fn(), +}; +const defaultIconButtonProps = { + iconType: 'arrowDown', + 'aria-label': 'More actions', +}; +const popoverMenu = jest.fn((_closePopover) =>
Popover menu
); + +describe('EuiButtonSplit', () => { + shouldRenderCustomStyles( + + ); + + test('is rendered', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + describe('popover open/close logic', () => { + it('opens and closes the popover on icon button click', async () => { + const popoverMenu = jest.fn((_closePopover) =>
Popover menu
); + const { getByLabelText, queryByText } = render( + + ); + // Popover menu should not be in the DOM initially + expect(queryByText('Popover menu')).toBeNull(); + // Click icon button to open + fireEvent.click(getByLabelText('More actions')); + expect(queryByText('Popover menu')).toBeInTheDocument(); + // Click icon button again to close + fireEvent.click(getByLabelText('More actions')); + await waitFor(() => { + expect(queryByText('Popover menu')).toBeNull(); + }); + }); + + it('closes the popover when a menu item calls closePopover', async () => { + const TestMenu = (closePopover: () => void) => ( + + ); + const { getByLabelText, getByText, queryByText } = render( + + ); + fireEvent.click(getByLabelText('More actions')); + expect(getByText('Close me')).toBeInTheDocument(); + fireEvent.click(getByText('Close me')); + await waitFor(() => { + expect(queryByText('Close me')).toBeNull(); + }); + }); + }); + + describe('props', () => { + it('renders with isDisabled', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('calls main button onClick', () => { + const onClick = jest.fn(); + const { getByText } = render( + + ); + fireEvent.click(getByText('Main')); + expect(onClick).toHaveBeenCalled(); + }); + + describe('text color styling', () => { + it('renders text color with fill=false (no margin, no left border)', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders text color with fill=true (has margin, has left border)', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders non-text color with fill=false (has margin, has left border)', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/packages/eui/src/components/button/button_split/button_split.tsx b/packages/eui/src/components/button/button_split/button_split.tsx new file mode 100644 index 00000000000..fc01741ff0c --- /dev/null +++ b/packages/eui/src/components/button/button_split/button_split.tsx @@ -0,0 +1,101 @@ +/* + * 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, useState } from 'react'; +import { euiButtonSplitStyles } from './button_split.styles'; +import { EuiButton, EuiButtonPropsForButton, EuiButtonProps } from '../button'; +import { EuiButtonIcon, EuiButtonIconProps } from '../button_icon/button_icon'; +import { EuiPopover, EuiPopoverProps } from '../../popover'; +import classNames from 'classnames'; + +export interface EuiButtonSplitProps { + /** Shared color for both the main button and icon button */ + color: EuiButtonProps['color']; + /** Shared fill for both buttons */ + fill?: EuiButtonProps['fill']; + /** Shared size for both buttons */ + size?: EuiButtonProps['size']; + /** Shared isDisabled for both buttons */ + isDisabled?: EuiButtonProps['isDisabled']; + /** Props for the main button (left side), except shared props. Only button props are allowed, not anchor props. */ + buttonProps: Omit< + EuiButtonPropsForButton, + 'color' | 'fill' | 'size' | 'isDisabled' + >; + /** Props for the icon button (right side), except shared props */ + iconButtonProps: Omit< + EuiButtonIconProps, + 'color' | 'display' | 'size' | 'isDisabled' + >; + /** Render prop for the menu contents to show in the popover */ + popoverMenu: (closePopover: () => void) => React.ReactNode; + /** Optional className for the split button wrapper */ + className?: string; + /** Optional style for the split button wrapper */ + style?: React.CSSProperties; + /** Optional padding size for the popover panel */ + panelPaddingSize?: EuiPopoverProps['panelPaddingSize']; +} + +export type EuiButtonSplitPropsForButton = EuiButtonSplitProps; + +export const EuiButtonSplit: FunctionComponent = ({ + color, + fill, + size = 'm', + isDisabled, + buttonProps, + iconButtonProps, + popoverMenu, + className = '', + style = {}, + panelPaddingSize = 's', +}) => { + const [isPopoverOpen, setPopoverOpen] = useState(false); + const onButtonClick = () => setPopoverOpen((isOpen) => !isOpen); + const closePopover = () => setPopoverOpen(false); + const styles = euiButtonSplitStyles(); + + return ( + + + + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize={panelPaddingSize} + anchorPosition="downRight" + > + {popoverMenu(closePopover)} + + + + ); +}; diff --git a/packages/eui/src/components/button/button_split/index.ts b/packages/eui/src/components/button/button_split/index.ts new file mode 100644 index 00000000000..50b4a941767 --- /dev/null +++ b/packages/eui/src/components/button/button_split/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { + EuiButtonSplitProps, + EuiButtonSplitPropsForButton, +} from './button_split'; +export { EuiButtonSplit } from './button_split'; diff --git a/packages/eui/src/components/button/index.ts b/packages/eui/src/components/button/index.ts index bf8f0bafca6..e3e045a46cd 100644 --- a/packages/eui/src/components/button/index.ts +++ b/packages/eui/src/components/button/index.ts @@ -12,6 +12,12 @@ export { COLORS, EuiButton } from './button'; export type { EuiButtonEmptyProps, EuiButtonEmptySizes } from './button_empty'; export { EuiButtonEmpty } from './button_empty'; +export type { + EuiButtonSplitProps, + EuiButtonSplitPropsForButton, +} from './button_split'; +export { EuiButtonSplit } from './button_split'; + export type { EuiButtonIconProps, EuiButtonIconPropsForButton, diff --git a/packages/website/docs/components/navigation/buttons/button.mdx b/packages/website/docs/components/navigation/buttons/button.mdx index f62a7d601e4..bf1cf8857f2 100644 --- a/packages/website/docs/components/navigation/buttons/button.mdx +++ b/packages/website/docs/components/navigation/buttons/button.mdx @@ -7,7 +7,7 @@ keywords: [EuiButton] --- ```mdx-code-block -import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiButtonIcon, EuiIcon, EuiTable, EuiTableBody, EuiTableHeader, EuiTableHeaderCell, EuiTableRow, EuiTableRowCell } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiButtonIcon, EuiButtonSplit, EuiIcon, EuiTable, EuiTableBody, EuiTableHeader, EuiTableHeaderCell, EuiTableRow, EuiTableRowCell } from '@elastic/eui'; ``` # Button @@ -180,6 +180,94 @@ export default () => ( ); ``` +### Split + +Split buttons combine a **primary action** with a dropdown menu of additional actions. The main button performs the most common action, while the dropdown arrow provides access to related secondary actions. This pattern is ideal when you have one primary action that users perform frequently, along with several related alternatives. + +```tsx interactive +import React from 'react'; + +import { + EuiButtonSplit, + EuiContextMenuItem, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +export default () => { + const onPrimaryClick = () => { + console.log('Primary action clicked'); + }; + + const popoverMenu = (closePopover) => [ + + Save as... + , + + Export + , + + Share + , + ]; + + return ( + + + + + + + + + + + + + + ); +}; +``` + ### Icon `EuiButtonIcon` is an **icon-only button**. Use `display` and `size` props to match standard buttons. The default appearance is `xs`, `empty`. Use only for common, intuitive actions with **immediately understood icons** (e.g., trash can for delete). `aria-label` is required for accessibility. Icon color inherits from the button's color property. Icon color is inherited from the button color property and, therefore, does not need to be set separately. @@ -739,84 +827,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. @@ -978,4 +988,5 @@ import docgen from '@elastic/eui-docgen/dist/components/button'; +