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 d4f0eef2928..b2733522ab1 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 ); };