From f4e7570a38be777496cbff80d56fd4dbc59181c7 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 14 Mar 2024 12:33:27 -0300 Subject: [PATCH 1/3] [EuiFlyout] Performance cleanup (#7462) Co-authored-by: Cee Chen --- changelogs/upcoming/7462.md | 3 ++ .../flyout/__snapshots__/flyout.test.tsx.snap | 2 +- src/components/flyout/flyout.test.tsx | 29 +++++++++++++++ src/components/flyout/flyout.tsx | 35 +++++++++---------- 4 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 changelogs/upcoming/7462.md diff --git a/changelogs/upcoming/7462.md b/changelogs/upcoming/7462.md new file mode 100644 index 00000000000..ef12d56bc8b --- /dev/null +++ b/changelogs/upcoming/7462.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed `EuiFlyout` to not repeatedly remove/add a body class on resize diff --git a/src/components/flyout/__snapshots__/flyout.test.tsx.snap b/src/components/flyout/__snapshots__/flyout.test.tsx.snap index f114f5e1070..61433b78d4b 100644 --- a/src/components/flyout/__snapshots__/flyout.test.tsx.snap +++ b/src/components/flyout/__snapshots__/flyout.test.tsx.snap @@ -1070,7 +1070,7 @@ Array [ exports[`EuiFlyout renders extra screen reader instructions when fixed EuiHeaders headers exist on the page 1`] = `
{ ).toMatchSnapshot(); }); }); + + describe('body class', () => { + it('adds `.euiBody--hasFlyout` class on mount', () => { + render( {}} />); + expect(document.body).toHaveClass('euiBody--hasFlyout'); + }); + + it('removes `.euiBody--hasFlyout` class on unmount', () => { + const { unmount } = render( {}} />); + unmount(); + expect(document.body).not.toHaveClass('euiBody--hasFlyout'); + }); + + // Regression testing + it('should not remove and re-add `.euiBody--hasFlyout` class on resize', async () => { + const add = jest.spyOn(document.body.classList, 'add'); + const remove = jest.spyOn(document.body.classList, 'remove'); + const { rerender } = render( + {}} size={500} /> + ); + + expect(add).toHaveBeenCalledTimes(1); + expect(add).toHaveBeenLastCalledWith('euiBody--hasFlyout'); + + rerender( {}} size={600} />); + expect(add).toHaveBeenCalledTimes(1); + expect(remove).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/src/components/flyout/flyout.tsx b/src/components/flyout/flyout.tsx index 741d9424178..723c0d9c0d0 100644 --- a/src/components/flyout/flyout.tsx +++ b/src/components/flyout/flyout.tsx @@ -214,36 +214,33 @@ export const EuiFlyout = forwardRef( null ); const setRef = useCombinedRefs([setResizeRef, ref]); - // TODO: Allow this hook to be conditional - const dimensions = useResizeObserver(resizeRef); + const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); useEffect(() => { - // This class doesn't actually do anything by EUI, but is nice to add for consumers (JIC) - document.body.classList.add('euiBody--hasFlyout'); - /** * Accomodate for the `isPushed` state by adding padding to the body equal to the width of the element */ if (isPushed) { - if (side === 'right') { - document.body.style.paddingInlineEnd = `${dimensions.width}px`; - } else if (side === 'left') { - document.body.style.paddingInlineStart = `${dimensions.width}px`; - } + const paddingSide = + side === 'left' ? 'paddingInlineStart' : 'paddingInlineEnd'; + + document.body.style[paddingSide] = `${width}px`; + return () => { + document.body.style[paddingSide] = ''; + }; } + }, [isPushed, side, width]); + /** + * This class doesn't actually do anything by EUI, but is nice to add for consumers (JIC) + */ + useEffect(() => { + document.body.classList.add('euiBody--hasFlyout'); return () => { + // Remove the hasFlyout class when the flyout is unmounted document.body.classList.remove('euiBody--hasFlyout'); - - if (isPushed) { - if (side === 'right') { - document.body.style.paddingInlineEnd = ''; - } else if (side === 'left') { - document.body.style.paddingInlineStart = ''; - } - } }; - }, [side, dimensions, isPushed]); + }, []); /** * ESC key closes flyout (always?) From 196b9ac5c6ac0351e178dfd67e8f6f661cf112c3 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 14 Mar 2024 18:48:37 +0100 Subject: [PATCH 2/3] [Storybook] Add util for moving controls to a separate control table category (#7569) --- .storybook/manager-head.html | 7 ++++ .storybook/utils.test.ts | 39 ++++++++++++++++++- .storybook/utils.ts | 17 ++++++++ .../skip_link/skip_link.stories.tsx | 9 ++++- .../collapsed/collapsed_nav_item.stories.tsx | 3 ++ src/components/facet/facet_button.stories.tsx | 17 ++++++++ .../header_breadcrumbs.stories.tsx | 17 +++++++- .../header_links/header_link.stories.tsx | 30 +++++++++++++- 8 files changed, 135 insertions(+), 4 deletions(-) diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html index 2ca734b9e0d..1f83cacd4c8 100644 --- a/.storybook/manager-head.html +++ b/.storybook/manager-head.html @@ -3,4 +3,11 @@ .docblock-argstable code { white-space: pre-wrap !important; } + + /* make the props table category title better readable; align with props table header */ + .docblock-argstable-body tr[title] button + span { + font-size: 13px; + text-transform: initial; + letter-spacing: normal; + } diff --git a/.storybook/utils.test.ts b/.storybook/utils.test.ts index d570363a393..b456646bced 100644 --- a/.storybook/utils.test.ts +++ b/.storybook/utils.test.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -import { hideStorybookControls, disableStorybookControls } from './utils'; +import { + hideStorybookControls, + disableStorybookControls, + moveStorybookControlsToCategory, +} from './utils'; describe('hideStorybookControls', () => { it('outputs the expected `argTypes` object when passed prop name strings', () => { @@ -47,3 +51,36 @@ describe('disableStorybookControls', () => { disableStorybookControls(['hello', 'world', 'error']); }); }); + +describe('moveStorybookControlsToCategory', () => { + it('outputs expected `argTypes` object when passed prop name strings and a custom category', () => { + expect( + moveStorybookControlsToCategory( + ['isDisabled', 'isLoading', 'isInvalid'], + 'New category' + ) + ).toEqual({ + isDisabled: { table: { category: 'New category' } }, + isLoading: { table: { category: 'New category' } }, + isInvalid: { table: { category: 'New category' } }, + }); + }); + + it('sets a default category if none is passed', () => { + expect( + moveStorybookControlsToCategory(['isDisabled', 'isLoading', 'isInvalid']) + ).toEqual({ + isDisabled: { table: { category: 'Additional' } }, + isLoading: { table: { category: 'Additional' } }, + isInvalid: { table: { category: 'Additional' } }, + }); + }); + + it('throws a typescript error if a generic is passed and the prop names do not match', () => { + type TestProps = { hello: boolean; world: boolean }; + // No typescript error + moveStorybookControlsToCategory(['hello', 'world']); + // @ts-expect-error - will fail `yarn lint` if a TS error is *not* produced + moveStorybookControlsToCategory(['hello', 'world', 'error']); + }); +}); diff --git a/.storybook/utils.ts b/.storybook/utils.ts index 517b93421c2..acf94227505 100644 --- a/.storybook/utils.ts +++ b/.storybook/utils.ts @@ -40,6 +40,23 @@ export const disableStorybookControls = ( }; const DISABLE_CONTROL = { control: false }; +/** + * Configure provided args to be listed under a specified + * category in the props table. + * + * Should be passed or spread to `argTypes` + */ +export const moveStorybookControlsToCategory = ( + propNames: Array, + category = 'Additional' +): Record | {} => { + return propNames.reduce( + (obj, name) => ({ ...obj, [name]: { table: { category } } }), + {} + ); +}; +type ControlCategory = { table: { category: string } }; + /** * parameters configurations */ diff --git a/src/components/accessibility/skip_link/skip_link.stories.tsx b/src/components/accessibility/skip_link/skip_link.stories.tsx index 8544dfcbf99..b5bedc2245b 100644 --- a/src/components/accessibility/skip_link/skip_link.stories.tsx +++ b/src/components/accessibility/skip_link/skip_link.stories.tsx @@ -7,13 +7,20 @@ */ import type { Meta, StoryObj } from '@storybook/react'; -import { hideStorybookControls } from '../../../../.storybook/utils'; +import { + hideStorybookControls, + moveStorybookControlsToCategory, +} from '../../../../.storybook/utils'; import { EuiSkipLink, EuiSkipLinkProps } from './skip_link'; const meta: Meta = { title: 'Utilities/EuiSkipLink', component: EuiSkipLink, + argTypes: moveStorybookControlsToCategory( + ['color', 'fill', 'size'], + 'EuiButton props' + ), args: { // Component defaults position: 'static', diff --git a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/collapsed_nav_item.stories.tsx b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/collapsed_nav_item.stories.tsx index 79122316085..f5288fadd44 100644 --- a/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/collapsed_nav_item.stories.tsx +++ b/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/collapsed_nav_item.stories.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { hideStorybookControls } from '../../../../../.storybook/utils'; import { EuiCollapsedNavItem } from './collapsed_nav_item'; const meta: Meta = { @@ -27,6 +28,7 @@ export const Link: Story = {
), + argTypes: hideStorybookControls(['accordionProps', 'isCollapsible', 'items']), args: { title: 'Collapsed nav item', icon: 'home', @@ -41,6 +43,7 @@ export const Accordion: Story = {
), + argTypes: hideStorybookControls(['href', 'linkProps']), args: { title: 'Collapsed nav item', icon: 'home', diff --git a/src/components/facet/facet_button.stories.tsx b/src/components/facet/facet_button.stories.tsx index 06f0463a0f7..2834116b646 100644 --- a/src/components/facet/facet_button.stories.tsx +++ b/src/components/facet/facet_button.stories.tsx @@ -8,6 +8,7 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { moveStorybookControlsToCategory } from '../../../.storybook/utils'; import { EuiFacetButton, EuiFacetButtonProps } from './facet_button'; const meta: Meta = { @@ -16,6 +17,22 @@ const meta: Meta = { argTypes: { // TODO: icon // TODO: minWidth has multiple types + ...moveStorybookControlsToCategory( + [ + 'contentProps', + 'element', + 'fullWidth', + 'iconSide', + 'iconSize', + 'iconType', + 'minWidth', + 'size', + 'style', + 'textProps', + 'type', + ], + 'EuiButtonEmpty props' + ), }, args: { // Component defaults diff --git a/src/components/header/header_breadcrumbs/header_breadcrumbs.stories.tsx b/src/components/header/header_breadcrumbs/header_breadcrumbs.stories.tsx index 50ba43fe3ac..dc203e78845 100644 --- a/src/components/header/header_breadcrumbs/header_breadcrumbs.stories.tsx +++ b/src/components/header/header_breadcrumbs/header_breadcrumbs.stories.tsx @@ -8,19 +8,34 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { moveStorybookControlsToCategory } from '../../../../.storybook/utils'; import { EuiBreadcrumbsProps } from '../../breadcrumbs'; import { EuiHeaderBreadcrumbs } from './header_breadcrumbs'; const meta: Meta = { title: 'Layout/EuiHeader/EuiHeaderBreadcrumbs', component: EuiHeaderBreadcrumbs, + argTypes: { + ...moveStorybookControlsToCategory( + [ + 'aria-label', + 'breadcrumbs', + 'lastBreadcrumbIsCurrentPage', + 'max', + 'responsive', + 'truncate', + 'type', + ], + 'EuiBreadcrumbs props' + ), + }, args: { // Component defaults - type: 'application', max: 4, truncate: true, responsive: { xs: 1, s: 2, m: 4 }, lastBreadcrumbIsCurrentPage: true, + type: 'application', }, }; diff --git a/src/components/header/header_links/header_link.stories.tsx b/src/components/header/header_links/header_link.stories.tsx index 3241bb960dc..bd2b225c6b7 100644 --- a/src/components/header/header_links/header_link.stories.tsx +++ b/src/components/header/header_links/header_link.stories.tsx @@ -8,12 +8,40 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { disableStorybookControls } from '../../../../.storybook/utils'; +import { + disableStorybookControls, + moveStorybookControlsToCategory, +} from '../../../../.storybook/utils'; import { EuiHeaderLink, EuiHeaderLinkProps } from './header_link'; const meta: Meta = { title: 'Layout/EuiHeader/EuiHeaderLink', component: EuiHeaderLink, + argTypes: { + ...moveStorybookControlsToCategory( + [ + 'aria-label', + 'buttonRef', + 'color', + 'contentProps', + 'flush', + 'href', + 'iconSide', + 'iconSize', + 'iconType', + 'isDisabled', + 'isLoading', + 'isSelected', + 'onClick', + 'rel', + 'size', + 'target', + 'textProps', + 'type', + ], + 'EuiButtonEmpty props' + ), + }, // Component defaults args: { isActive: false, From f10c46d86d2e20d6e4f78a16e8c0c5a1c79703b1 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 14 Mar 2024 16:13:01 -0300 Subject: [PATCH 3/3] [EuiButtonGroup] Add support for `EuiToolTip` for button tooltips (#7461) Co-authored-by: Cee Chen --- changelogs/upcoming/7461.md | 1 + src-docs/src/views/button/button_example.js | 59 ++++++++++- .../src/views/button/button_group_tooltips.js | 41 ++++++++ .../button_group/button_group.stories.tsx | 41 +++++++- .../button_group/button_group.styles.ts | 4 +- .../button/button_group/button_group.test.tsx | 97 ++++++++++++++++++- .../button/button_group/button_group.tsx | 19 +++- .../button_group_button.styles.ts | 70 ++++++++----- .../button_group/button_group_button.tsx | 90 +++++++++++++---- 9 files changed, 366 insertions(+), 56 deletions(-) create mode 100644 changelogs/upcoming/7461.md create mode 100644 src-docs/src/views/button/button_group_tooltips.js diff --git a/changelogs/upcoming/7461.md b/changelogs/upcoming/7461.md new file mode 100644 index 00000000000..826fa6edcbb --- /dev/null +++ b/changelogs/upcoming/7461.md @@ -0,0 +1 @@ +- Added the following properties to `EuiButtonGroup`'s `options` configs: `toolTipContent`, `toolTipProps`, and `title`. These new properties allow wrapping buttons in `EuiToolTips`, and additionally customizing or disabling the native browser `title` tooltip. diff --git a/src-docs/src/views/button/button_example.js b/src-docs/src/views/button/button_example.js index 196852c5ecc..ae68720535b 100644 --- a/src-docs/src/views/button/button_example.js +++ b/src-docs/src/views/button/button_example.js @@ -236,6 +236,28 @@ const buttonGroupCompressedSnippet = [ />`, ]; +import ButtonGroupToolTips from './button_group_tooltips'; +const buttonGroupToolTipsSource = require('!!raw-loader!./button_group_tooltips'); +const buttonGroupToolTipsSnippet = [ + ` {}} +/>`, +]; + export const ButtonExample = { title: 'Button', intro: ( @@ -653,7 +675,7 @@ export const ButtonExample = { ], text: ( <> -

Icon only button groups

+

Icon only button groups

If you're just displaying a group of icons, add the prop{' '} isIconOnly. @@ -672,10 +694,9 @@ export const ButtonExample = { code: buttonGroupCompressedSource, }, ], - text: ( <> -

Button groups in forms

+

Button groups in forms

When using button groups within compressed forms, match the form elements by adding {'buttonSize="compressed"'}. @@ -697,6 +718,38 @@ export const ButtonExample = { props: { EuiButtonGroup, EuiButtonGroupOptionProps }, demoPanelProps: { color: 'subdued' }, }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: buttonGroupToolTipsSource, + }, + ], + text: ( + <> +

Button group tooltips

+

+ Buttons within a button group will automatically display a default + browser tooltip containing the button label text. + This can be customized or unset via the title{' '} + property in your options button configuration. +

+

+ To instead display an EuiToolTip around your + button(s), pass the toolTipContent property. You + can also use toolTipProps to customize tooltip + placement, title, and any other prop that{' '} + + EuiToolTip + {' '} + accepts. +

+ + ), + demo: , + snippet: buttonGroupToolTipsSnippet, + props: { EuiButtonGroupOptionProps }, + }, ], guidelines: , }; diff --git a/src-docs/src/views/button/button_group_tooltips.js b/src-docs/src/views/button/button_group_tooltips.js new file mode 100644 index 00000000000..b0fbcc48c23 --- /dev/null +++ b/src-docs/src/views/button/button_group_tooltips.js @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; + +import { EuiButtonGroup } from '../../../../src/components'; + +export default () => { + const toggleButtons = [ + { + id: 'buttonGroup__0', + label: 'Default title', + }, + { + id: 'buttonGroup__1', + label: 'Custom tooltip content', + toolTipContent: 'This is a custom tooltip', + }, + { + id: 'buttonGroup__2', + label: 'Custom tooltip props', + toolTipContent: 'This is another custom tooltip', + toolTipProps: { + title: 'My custom title', + position: 'right', + }, + }, + ]; + + const [toggleIdSelected, setToggleIdSelected] = useState('buttonGroup__1'); + + const onChange = (optionId) => { + setToggleIdSelected(optionId); + }; + + return ( + onChange(id)} + /> + ); +}; diff --git a/src/components/button/button_group/button_group.stories.tsx b/src/components/button/button_group/button_group.stories.tsx index 4db0864a1ef..b6c0b18da76 100644 --- a/src/components/button/button_group/button_group.stories.tsx +++ b/src/components/button/button_group/button_group.stories.tsx @@ -36,9 +36,6 @@ const meta: Meta = { options: { control: 'array', }, - buttonSize: { - control: 'select', - }, }, args: { // Component defaults @@ -109,7 +106,6 @@ const EuiButtonGroupMulti = (props: any) => { @@ -126,3 +122,40 @@ export const MultiSelection: Story = { }, argTypes: disableStorybookControls(['type']), }; + +export const WithTooltips: Story = { + render: ({ ...args }) => , + args: { + legend: 'EuiButtonGroup - tooltip UI testing', + isIconOnly: true, // Start example with icons to demonstrate usefulness of tooltips + options: [ + { + id: 'button1', + iconType: 'securitySignal', + label: 'No tooltip', + }, + { + id: 'button2', + iconType: 'securitySignalResolved', + label: 'Standard tooltip', + toolTipContent: 'Hello world', + }, + { + id: 'customToolTipProps', + iconType: 'securitySignalDetected', + label: 'Custom tooltip', + toolTipContent: 'Custom tooltip position and delay', + toolTipProps: { + position: 'right', + delay: 'long', + title: 'Hello world', + }, + // Consumers could also opt to hide titles if preferred + title: '', + }, + ], + type: 'multi', + idToSelectedMap: { button1: true }, + }, + argTypes: disableStorybookControls(['type']), +}; diff --git a/src/components/button/button_group/button_group.styles.ts b/src/components/button/button_group/button_group.styles.ts index e2527fb35cd..cd934fc9d26 100644 --- a/src/components/button/button_group/button_group.styles.ts +++ b/src/components/button/button_group/button_group.styles.ts @@ -41,8 +41,10 @@ export const euiButtonGroupButtonsStyles = (euiThemeContext: UseEuiTheme) => { fullWidth: css` ${logicalCSS('width', '100%')} - .euiButtonGroupButton { + .euiButtonGroupButton, + .euiButtonGroup__tooltipWrapper { flex: 1; + ${logicalCSS('width', '100%')} } `, // Sizes diff --git a/src/components/button/button_group/button_group.test.tsx b/src/components/button/button_group/button_group.test.tsx index d5c4a5239ce..28b549607d9 100644 --- a/src/components/button/button_group/button_group.test.tsx +++ b/src/components/button/button_group/button_group.test.tsx @@ -8,16 +8,21 @@ import React from 'react'; import { css } from '@emotion/react'; -import { render } from '../../../test/rtl'; +import { fireEvent } from '@testing-library/react'; +import { + render, + waitForEuiToolTipHidden, + waitForEuiToolTipVisible, +} from '../../../test/rtl'; import { requiredProps as commonProps } from '../../../test'; import { shouldRenderCustomStyles } from '../../../test/internal'; +import { BUTTON_COLORS } from '../../../themes/amsterdam/global_styling/mixins'; import { EuiButtonGroup, EuiButtonGroupOptionProps, EuiButtonGroupProps, } from './button_group'; -import { BUTTON_COLORS } from '../../../themes/amsterdam/global_styling/mixins'; const SIZES: Array = [ 's', @@ -216,4 +221,92 @@ describe('EuiButtonGroup', () => { 'text-transform: uppercase' ); }); + + describe('tooltips', () => { + it('shows a tooltip on hover and focus', async () => { + const { getByTestSubject, getByRole } = render( + + ); + fireEvent.mouseOver(getByTestSubject('buttonWithTooltip')); + await waitForEuiToolTipVisible(); + + expect(getByRole('tooltip')).toHaveTextContent('I am a tooltip'); + + fireEvent.mouseOut(getByTestSubject('buttonWithTooltip')); + await waitForEuiToolTipHidden(); + + fireEvent.focus(getByTestSubject('buttonWithTooltip')); + await waitForEuiToolTipVisible(); + fireEvent.blur(getByTestSubject('buttonWithTooltip')); + await waitForEuiToolTipHidden(); + }); + + it('allows customizing the tooltip via `toolTipProps`', async () => { + const { getByTestSubject } = render( + + ); + fireEvent.mouseOver(getByTestSubject('buttonWithTooltip')); + await waitForEuiToolTipVisible(); + + expect(getByTestSubject('toolTipTest')).toHaveAttribute( + 'data-position', + 'right' + ); + }); + + it('allows consumers to unset the `title` in favor of a tooltip', () => { + const reallyLongLabel = + 'This is a really long label that we know will be truncated, so we show a tooltip instead and hide the title'; + + const { getByTestSubject } = render( + + ); + expect(getByTestSubject('buttonWithTooltip')).not.toHaveAttribute( + 'title' + ); + expect(getByTestSubject('button01')).toHaveAttribute( + 'title', + 'Option two' + ); + }); + }); }); diff --git a/src/components/button/button_group/button_group.tsx b/src/components/button/button_group/button_group.tsx index 2cf0064b6c3..c3055990ac2 100644 --- a/src/components/button/button_group/button_group.tsx +++ b/src/components/button/button_group/button_group.tsx @@ -19,6 +19,7 @@ import { EuiScreenReaderOnly } from '../../accessibility'; import { CommonProps } from '../../common'; import { _EuiButtonColor } from '../../../themes/amsterdam/global_styling/mixins'; +import { EuiToolTipProps } from '../../../components/tool_tip'; import { EuiButtonDisplayContentProps } from '../button_display/_button_display_content'; import { EuiButtonGroupButton } from './button_group_button'; import { @@ -46,6 +47,20 @@ export interface EuiButtonGroupOptionProps * The type of the underlying HTML button */ type?: ButtonHTMLAttributes['type']; + /** + * By default, will use the button text for the native browser title. + * + * This can be either customized or unset via `title: ''` if necessary. + */ + title?: ButtonHTMLAttributes['title']; + /** + * Optional custom tooltip content for the button + */ + toolTipContent?: EuiToolTipProps['content']; + /** + * Optional props to pass to the underlying **[EuiToolTip](/#/display/tooltip)** + */ + toolTipProps?: Partial>; } export type EuiButtonGroupProps = CommonProps & { @@ -174,10 +189,10 @@ export const EuiButtonGroup: FunctionComponent = ({
- {options.map((option, index) => { + {options.map((option) => { return ( { padding-inline: ${euiTheme.size.s}; `, // Sizes - s: css` - ${uncompressedBorderRadii(euiTheme.border.radius.small)} - `, - m: css` - ${uncompressedBorderRadii(euiTheme.border.radius.medium)} - `, - uncompressed: css` - &:is(.euiButtonGroupButton-isSelected) { - font-weight: ${euiTheme.font.weight.bold}; - } - - /* "Borders" between buttons - should be present between two of the same colored buttons, - and absent between selected vs non-selected buttons (different colors) */ - - &:not(.euiButtonGroupButton-isSelected) - + .euiButtonGroupButton:not(.euiButtonGroupButton-isSelected) { - box-shadow: -${euiTheme.border.width.thin} 0 0 0 ${transparentize(euiTheme.colors.fullShade, 0.1)}; - } - - &:is(.euiButtonGroupButton-isSelected) - + .euiButtonGroupButton-isSelected { - box-shadow: -${euiTheme.border.width.thin} 0 0 0 ${transparentize(euiTheme.colors.emptyShade, 0.2)}; - } - `, + uncompressed: { + uncompressed: css` + &:is(.euiButtonGroupButton-isSelected) { + font-weight: ${euiTheme.font.weight.bold}; + } + `, + get borders() { + const selectors = + '.euiButtonGroupButton-isSelected, .euiButtonGroup__tooltipWrapper-isSelected'; + const selectedColor = transparentize(euiTheme.colors.emptyShade, 0.2); + const unselectedColor = transparentize(euiTheme.colors.fullShade, 0.1); + const borderWidth = euiTheme.border.width.thin; + + // "Borders" between buttons should be present between two of the same colored buttons, + // and absent between selected vs non-selected buttons (different colors) + return ` + &:not(${selectors}) + *:not(${selectors}) { + box-shadow: -${borderWidth} 0 0 0 ${unselectedColor}; + } + &:is(${selectors}) + *:is(${selectors}) { + box-shadow: -${borderWidth} 0 0 0 ${selectedColor}; + } + `; + }, + get s() { + return css` + ${this.borders} + ${uncompressedBorderRadii(euiTheme.border.radius.small)} + `; + }, + get m() { + return css` + ${this.borders} + ${uncompressedBorderRadii(euiTheme.border.radius.medium)} + `; + }, + hasToolTip: css` + /* Set the border-radius on the tooltip anchor element instead and inherit from that */ + border-radius: inherit; + `, + }, compressed: css` ${logicalCSS('height', compressedButtonHeight)} line-height: ${compressedButtonHeight}; @@ -120,6 +137,11 @@ export const euiButtonGroupButtonStyles = (euiThemeContext: UseEuiTheme) => { )}; background-color: ${euiTheme.colors.disabled}; `, + // Tooltip anchor wrapper + tooltipWrapper: css` + /* Without this on the tooltip anchor, button text truncation doesn't work */ + overflow: hidden; + `, // Content wrapper content: { euiButtonGroupButton__content: css``, diff --git a/src/components/button/button_group/button_group_button.tsx b/src/components/button/button_group/button_group_button.tsx index 19d767eae92..7df986a830d 100644 --- a/src/components/button/button_group/button_group_button.tsx +++ b/src/components/button/button_group/button_group_button.tsx @@ -7,7 +7,12 @@ */ import classNames from 'classnames'; -import React, { FunctionComponent, MouseEventHandler } from 'react'; +import React, { + FunctionComponent, + MouseEventHandler, + ReactElement, +} from 'react'; +import { CSSInterpolation } from '@emotion/css'; import { useEuiTheme } from '../../../services'; import { useEuiButtonColorCSS } from '../../../themes/amsterdam/global_styling/mixins/button'; @@ -20,6 +25,7 @@ import { _compressedButtonFocusColor, _uncompressedButtonFocus, } from './button_group_button.styles'; +import { EuiToolTip } from '../../../components/tool_tip'; type Props = EuiButtonGroupOptionProps & { /** @@ -33,7 +39,7 @@ type Props = EuiButtonGroupOptionProps & { /** * Inherit from EuiButtonGroup */ - size: EuiButtonGroupProps['buttonSize']; + size: NonNullable; /** * Inherit from EuiButtonGroup */ @@ -54,11 +60,14 @@ export const EuiButtonGroupButton: FunctionComponent = ({ value, // Prevent prop from being spread size, color: _color = 'primary', + toolTipContent, + toolTipProps, ...rest }) => { const isCompressed = size === 'compressed'; const color = isDisabled ? 'disabled' : _color; const display = isSelected ? 'fill' : isCompressed ? 'empty' : 'base'; + const hasToolTip = !!toolTipContent; const euiTheme = useEuiTheme(); const buttonColorStyles = useEuiButtonColorCSS({ display })[color]; @@ -70,11 +79,16 @@ export const EuiButtonGroupButton: FunctionComponent = ({ const cssStyles = [ styles.euiButtonGroupButton, isIconOnly && styles.iconOnly, - styles[size!], - !isCompressed && styles.uncompressed, + !isCompressed && + (hasToolTip ? styles.uncompressed.hasToolTip : styles.uncompressed[size]), + isCompressed ? styles.compressed : styles.uncompressed.uncompressed, isDisabled && isSelected ? styles.disabledAndSelected : buttonColorStyles, !isDisabled && focusColorStyles, ]; + const tooltipWrapperStyles = [ + styles.tooltipWrapper, + !isCompressed && styles.uncompressed[size], + ]; const contentStyles = [ styles.content.euiButtonGroupButton__content, isCompressed && styles.content.compressed, @@ -102,23 +116,59 @@ export const EuiButtonGroupButton: FunctionComponent = ({ const [buttonTextRef, innerText] = useInnerText(); return ( - - {label} - + + {label} + + + ); +}; + +const EuiButtonGroupButtonWithToolTip: FunctionComponent< + Pick & { + children: ReactElement; + wrapperCss: CSSInterpolation; + isSelected: boolean; + } +> = ({ toolTipContent, toolTipProps, wrapperCss, isSelected, children }) => { + return toolTipContent ? ( + + {children} + + ) : ( + children ); };