diff --git a/.loki/reference/chrome_desktop_Forms_EuiSelectable_Playground.png b/.loki/reference/chrome_desktop_Forms_EuiSelectable_Playground.png new file mode 100644 index 00000000000..a56a1d996f8 Binary files /dev/null and b/.loki/reference/chrome_desktop_Forms_EuiSelectable_Playground.png differ diff --git a/.loki/reference/chrome_desktop_Forms_EuiSelectable_With_Tooltip.png b/.loki/reference/chrome_desktop_Forms_EuiSelectable_With_Tooltip.png new file mode 100644 index 00000000000..a56a1d996f8 Binary files /dev/null and b/.loki/reference/chrome_desktop_Forms_EuiSelectable_With_Tooltip.png differ diff --git a/.loki/reference/chrome_mobile_Forms_EuiSelectable_Playground.png b/.loki/reference/chrome_mobile_Forms_EuiSelectable_Playground.png new file mode 100644 index 00000000000..7fac77a3b49 Binary files /dev/null and b/.loki/reference/chrome_mobile_Forms_EuiSelectable_Playground.png differ diff --git a/.loki/reference/chrome_mobile_Forms_EuiSelectable_With_Tooltip.png b/.loki/reference/chrome_mobile_Forms_EuiSelectable_With_Tooltip.png new file mode 100644 index 00000000000..7fac77a3b49 Binary files /dev/null and b/.loki/reference/chrome_mobile_Forms_EuiSelectable_With_Tooltip.png differ diff --git a/changelogs/upcoming/7715.md b/changelogs/upcoming/7715.md new file mode 100644 index 00000000000..f4b60264dfb --- /dev/null +++ b/changelogs/upcoming/7715.md @@ -0,0 +1,6 @@ +- Added support for `toolTipContent` and `toolTipProps` props on `EuiSelectable` options + +**Bug fixes** + +- Fixed issue with unmounted component state updates on requestAnimationFrame for `EuiSelectable` + diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js index 301af959245..027f37ba7fe 100644 --- a/src-docs/src/views/selectable/selectable_example.js +++ b/src-docs/src/views/selectable/selectable_example.js @@ -9,6 +9,7 @@ import { EuiSelectableMessage, EuiText, EuiTextTruncate, + EuiToolTip, EuiCallOut, EuiLink, } from '../../../../src'; @@ -42,6 +43,9 @@ const selectableSizingSource = require('!!raw-loader!./selectable_sizing'); import Truncation from './selectable_truncation'; const truncationSource = require('!!raw-loader!./selectable_truncation'); +import SelectableToolTips from './selectable_tool_tips'; +const selectableToolTipsSource = require('!!raw-loader!./selectable_tool_tips'); + import SelectableCustomRender from './selectable_custom_render'; const selectableCustomRenderSource = require('!!raw-loader!./selectable_custom_render'); @@ -439,6 +443,57 @@ export const SelectableExample = { `, demo: , }, + + { + title: 'Tooltips', + source: [ + { + type: GuideSectionTypes.TSX, + code: selectableToolTipsSource, + }, + ], + text: ( + <> +

+ If you have longer information that you need to make available to + users outside of truncated text, one approach could be adding + tooltip descriptions to individual options by passing{' '} + toolTipContent. +

+

+ You can additionally customize individual tooltip behavior by + passing toolTipProps, which accepts any + configuration that{' '} + + EuiToolTip + {' '} + accepts. +

+ + ), + props: { + EuiSelectableOptionProps, + EuiToolTip, + }, + demo: , + snippet: ` setOptions(newOptions)} +> + {list => list} +`, + }, { title: 'Rendering the options', source: [ diff --git a/src-docs/src/views/selectable/selectable_tool_tips.tsx b/src-docs/src/views/selectable/selectable_tool_tips.tsx new file mode 100644 index 00000000000..2536041f47e --- /dev/null +++ b/src-docs/src/views/selectable/selectable_tool_tips.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; + +import { EuiSelectable, EuiSelectableOption } from '../../../../src'; + +export default () => { + const [options, setOptions] = useState([ + { + label: 'Titan', + toolTipContent: + 'Titan is the largest moon of Saturn and the second-largest in the Solar System', + }, + { + label: 'Pandora', + toolTipContent: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + }, + { + label: 'Iapetus', + toolTipContent: "Iapetus is the outermost of Saturn's large moons", + toolTipProps: { position: 'bottom' }, + }, + ]); + + return ( + setOptions(newOptions)} + > + {(list) => list} + + ); +}; diff --git a/src/components/selectable/selectable.stories.tsx b/src/components/selectable/selectable.stories.tsx new file mode 100644 index 00000000000..467189b76bb --- /dev/null +++ b/src/components/selectable/selectable.stories.tsx @@ -0,0 +1,150 @@ +/* + * 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, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { + enableFunctionToggleControls, + hideStorybookControls, +} from '../../../.storybook/utils'; + +import { EuiSelectableOption } from './selectable_option'; +import { + EuiSelectable, + EuiSelectableOnChangeEvent, + EuiSelectableProps, +} from './selectable'; + +const toolTipProps = { + toolTipContent: 'This is a tooltip!', + toolTipProps: { position: 'left' as const }, + value: 4, +}; + +const options: EuiSelectableOption[] = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus is disabled', + disabled: true, + }, + { + label: 'Mimas', + checked: 'on', + }, + { + label: 'Dione', + }, + { + label: 'Iapetus', + checked: 'on', + }, + { + label: 'Phoebe', + }, + { + label: 'Rhea', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + }, + { + label: 'Tethys', + }, + { + label: 'Hyperion', + }, +]; + +const meta: Meta = { + title: 'Forms/EuiSelectable', + component: EuiSelectable, + argTypes: { + singleSelection: { control: 'radio', options: [true, false, 'always'] }, + emptyMessage: { control: 'text' }, + loadingMessage: { control: 'text' }, + noMatchesMessage: { control: 'text' }, + selectableScreenReaderText: { control: 'text' }, + }, + args: { + searchable: false, + singleSelection: false, + isPreFiltered: false, + }, +}; +hideStorybookControls(meta, ['aria-label']); + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + args: { + options, + // setting up for easier testing/QA + allowExclusions: false, + isLoading: false, + emptyMessage: '', + loadingMessage: '', + noMatchesMessage: '', + selectableScreenReaderText: '', + searchProps: { + 'data-test-subj': 'selectableSearchHere', + }, + }, + render: ({ ...args }: EuiSelectableProps) => , +}; +enableFunctionToggleControls(Playground, ['onChange', 'onActiveOptionChange']); + +export const WithTooltip: Story = { + parameters: { + controls: { + include: ['options', 'singleSelection', 'searchable'], + }, + }, + args: { + options: options.map((option) => ({ ...option, ...toolTipProps })), + searchable: false, + }, + render: ({ ...args }: EuiSelectableProps) => , +}; + +const StatefulSelectable = ({ + options, + onChange, + ...rest +}: EuiSelectableProps) => { + const [selectableOptions, setOptions] = useState(options); + + const handleOnChange = ( + options: EuiSelectableOption[], + event: EuiSelectableOnChangeEvent, + changedOption: EuiSelectableOption + ) => { + setOptions(options); + onChange?.(options, event, changedOption); + }; + + return ( + + {(list, search) => ( + <> + {search} + {list} + + )} + + ); +}; diff --git a/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap b/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap index e14f6d4dfa9..793ebda150a 100644 --- a/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap +++ b/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap @@ -506,3 +506,143 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` `; + +exports[`EuiSelectableListItem props tooltip behavior on mouseover 1`] = ` + +
+
  • + + + + + Item content + + + +
  • +
    +
    + + +`; + +exports[`EuiSelectableListItem props tooltip behavior when isFocused 1`] = ` + +
    +
  • + + + + + Item content + + + + + + +
  • +
    +
    + + +`; diff --git a/src/components/selectable/selectable_list/_selectable_list_item.scss b/src/components/selectable/selectable_list/_selectable_list_item.scss index 9ff1966fef4..0c67fcf57f8 100644 --- a/src/components/selectable/selectable_list/_selectable_list_item.scss +++ b/src/components/selectable/selectable_list/_selectable_list_item.scss @@ -56,3 +56,7 @@ @include euiTextTruncate; } } + +.euiSelectableListItem__tooltipAnchor { + width: 100%; +} diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx index aa6484b7d88..0dba8ccea82 100644 --- a/src/components/selectable/selectable_list/selectable_list.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list.test.tsx @@ -78,13 +78,11 @@ describe('EuiSelectableListItem', () => { searchValue="Mi" /> ); - expect(container.querySelector('.euiMark')).toHaveTextContent('Mi'); expect( container.querySelector('.euiTextTruncate') ).not.toBeInTheDocument(); }); - it('renders an EuiTextTruncate component when truncating text', () => { const { container, getByTestSubject } = render( { searchValue="titan" /> ); - expect(getByTestSubject('titanOption')).toContainElement( container.querySelector('.euiTextTruncate') ); }); - it('does not highlight/mark the current `searchValue` if `isPreFiltered.highlightSearch` is false', () => { const { container } = render( { searchValue="Mi" /> ); - expect(container.querySelector('.euiMark')).not.toBeInTheDocument(); }); }); diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index 874344e333e..b2e7dd4e1d4 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -180,6 +180,8 @@ export class EuiSelectableList extends Component< isVirtualized: true, }; + private animationFrameId: number | undefined; + constructor(props: EuiSelectableListProps) { super(props); @@ -196,6 +198,15 @@ export class EuiSelectableList extends Component< listRef: FixedSizeList | null = null; listBoxRef: HTMLUListElement | null = null; + componentWillUnmount(): void { + // ensure requestAnimationFrame is canceled on unmount as + // it could potentially run on a next tick otherwise + if (this.animationFrameId !== undefined) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + } + setListRef = (ref: FixedSizeList | null) => { this.listRef = ref; @@ -387,7 +398,6 @@ export class EuiSelectableList extends Component< event.persist(); // NOTE: This is needed for React v16 backwards compatibility this.onAddOrRemoveOption(option, event); }} - ref={ref ? ref.bind(null, index) : undefined} isFocused={isFocused} title={searchableLabel || label} checked={checked} @@ -508,7 +518,7 @@ export class EuiSelectableList extends Component< this.focusBadgeOffset = this.props.onFocusBadge === false ? 0 : 46; // Wait a tick for the listbox ref to update before proceeding - requestAnimationFrame(() => { + this.animationFrameId = requestAnimationFrame(() => { const scrollbarOffset = this.listBoxRef ? containerWidth - this.listBoxRef.offsetWidth : 0; diff --git a/src/components/selectable/selectable_list/selectable_list_item.test.tsx b/src/components/selectable/selectable_list/selectable_list_item.test.tsx index 20342768c88..d537beb473e 100644 --- a/src/components/selectable/selectable_list/selectable_list_item.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list_item.test.tsx @@ -7,10 +7,11 @@ */ import React from 'react'; -import { render } from '../../../test/rtl'; +import { render, waitForEuiToolTipVisible } from '../../../test/rtl'; import { requiredProps } from '../../../test/required_props'; import { EuiSelectableListItem, PADDING_SIZES } from './selectable_list_item'; +import { fireEvent } from '@testing-library/react'; describe('EuiSelectableListItem', () => { test('is rendered', () => { @@ -162,5 +163,52 @@ describe('EuiSelectableListItem', () => { expect(container.firstChild).toMatchSnapshot(); }); }); + + test('tooltip behavior on mouseover', async () => { + const { baseElement, getByTestSubject } = render( + + Item content + + ); + + const tooltipAnchor = baseElement.querySelector( + '.euiSelectableListItem__tooltipAnchor' + ); + fireEvent.mouseOver(tooltipAnchor!); + await waitForEuiToolTipVisible(); + + expect(getByTestSubject('listItemToolTip')).toBeInTheDocument(); + expect(baseElement).toMatchSnapshot(); + }); + + test('tooltip behavior when isFocused', async () => { + const { baseElement, getByTestSubject } = render( + + Item content + + ); + + await waitForEuiToolTipVisible(); + + expect(getByTestSubject('listItemToolTip')).toBeInTheDocument(); + expect(baseElement).toMatchSnapshot(); + }); }); }); diff --git a/src/components/selectable/selectable_list/selectable_list_item.tsx b/src/components/selectable/selectable_list/selectable_list_item.tsx index cd41811fee8..20c1295de2d 100644 --- a/src/components/selectable/selectable_list/selectable_list_item.tsx +++ b/src/components/selectable/selectable_list/selectable_list_item.tsx @@ -7,13 +7,21 @@ */ import classNames from 'classnames'; -import React, { Component, LiHTMLAttributes } from 'react'; +import React, { + FunctionComponent, + LiHTMLAttributes, + ReactElement, + useState, + useEffect, + useMemo, +} from 'react'; import { CommonProps, keysOf } from '../../common'; import { EuiI18n } from '../../i18n'; import { EuiIcon, IconColor, IconType } from '../../icon'; import { EuiScreenReaderOnly } from '../../accessibility'; import { EuiBadge, EuiBadgeProps } from '../../badge'; +import { EuiToolTip } from '../../tool_tip'; import type { EuiSelectableOption, @@ -90,84 +98,53 @@ export type EuiSelectableListItemProps = LiHTMLAttributes & * Wrapping only works if virtualization is off. */ textWrap?: EuiSelectableOption['textWrap']; + /** + * Optional custom tooltip content for the button + */ + toolTipContent?: EuiSelectableOption['toolTipContent']; + /** + * Optional props to pass to the underlying **[EuiToolTip](/#/display/tooltip)** + */ + toolTipProps?: EuiSelectableOption['toolTipProps']; }; -export class EuiSelectableListItem extends Component { - static defaultProps = { - showIcons: true, - onFocusBadge: true, - textWrap: 'truncate', - }; - - constructor(props: EuiSelectableListItemProps) { - super(props); - } - - // aria-checked is intended to be used with role="checkbox" but - // the MDN documentation lists it as a possibility for role="option". - // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-checked - // and https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/option_role - isChecked = (role: string, checked: EuiSelectableOptionCheckedType) => { - const rolesThatCanBeMixed = ['option', 'checkbox', 'menuitemcheckbox']; - const rolesThatCanBeChecked = [ - ...rolesThatCanBeMixed, - 'radio', - 'menuitemradio', - 'switch', - ]; - if (!rolesThatCanBeChecked.includes(role)) return undefined; - - switch (checked) { - case 'on': - case 'off': - return true; - case 'mixed': - if (rolesThatCanBeMixed.includes(role)) { - return 'mixed'; - } else { - return false; - } - default: - return false; - } - }; - - render() { - const { - children, - className, - disabled, - checked, - isFocused, - showIcons, - prepend, - append, - allowExclusions, - onFocusBadge, - paddingSize = 's', - role = 'option', - searchable, - textWrap, - ...rest - } = this.props; +export const EuiSelectableListItem: FunctionComponent< + EuiSelectableListItemProps +> = ({ + children, + className, + disabled, + checked, + isFocused, + showIcons = true, + prepend, + append, + allowExclusions, + onFocusBadge = true, + paddingSize = 's', + role = 'option', + searchable, + textWrap = 'truncate', + toolTipContent, + toolTipProps, + 'aria-describedby': _ariaDescribedBy, + ...rest +}) => { + const classes = classNames( + 'euiSelectableListItem', + { 'euiSelectableListItem-isFocused': isFocused }, + paddingSizeToClassNameMap[paddingSize], + className + ); - const classes = classNames( - 'euiSelectableListItem', - { - 'euiSelectableListItem-isFocused': isFocused, - }, - paddingSizeToClassNameMap[paddingSize], - className - ); - - const textClasses = classNames('euiSelectableListItem__text', { - [`euiSelectableListItem__text--${textWrap}`]: textWrap, - }); + const textClasses = classNames('euiSelectableListItem__text', { + [`euiSelectableListItem__text--${textWrap}`]: textWrap, + }); - let optionIcon: React.ReactNode; + const optionIcon = useMemo(() => { if (showIcons) { const { icon, color } = resolveIconAndColor(checked); - optionIcon = ( + return ( /> ); } + }, [showIcons, checked]); + + const prependNode = useMemo(() => { + if (prepend) { + return {prepend}; + } + }, [prepend]); + + const onFocusBadgeNode = useMemo(() => { + const defaultOnFocusBadgeProps: EuiBadgeProps = { + 'aria-hidden': true, + iconType: 'returnKey', + iconSide: 'left', + color: 'hollow', + }; + + if (onFocusBadge === true) { + return ( + + ); + } else if (typeof onFocusBadge !== 'boolean' && !!onFocusBadge) { + const { children, className, ...restBadgeProps } = onFocusBadge; + return ( + + {children} + + ); + } + }, [onFocusBadge]); + const showOnFocusBadge = !!(isFocused && !disabled && onFocusBadgeNode); + + const appendNode = useMemo(() => { + if (append || showOnFocusBadge) { + return ( + + {append} {showOnFocusBadge ? onFocusBadgeNode : null} + + ); + } + }, [append, showOnFocusBadge, onFocusBadgeNode]); + const screenReaderText = useMemo(() => { let state: React.ReactNode; let instructions: React.ReactNode; + const screenReaderStrings = { checked: { state: ( @@ -276,57 +305,7 @@ export class EuiSelectableListItem extends Component break; } - let prependNode: React.ReactNode; - if (prepend) { - prependNode = ( - {prepend} - ); - } - - let appendNode: React.ReactNode; - if (append || !!onFocusBadge) { - let onFocusBadgeNode: React.ReactNode; - const defaultOnFocusBadgeProps: EuiBadgeProps = { - 'aria-hidden': true, - iconType: 'returnKey', - iconSide: 'left', - color: 'hollow', - }; - - if (onFocusBadge === true) { - onFocusBadgeNode = ( - - ); - } else if (typeof onFocusBadge !== 'boolean' && !!onFocusBadge) { - const { children, className, ...restBadgeProps } = onFocusBadge; - onFocusBadgeNode = ( - - {children} - - ); - } - - // Only display the append wrapper if append exists or isFocused - if (append || (isFocused && !disabled)) { - appendNode = ( - - {append} {isFocused && !disabled ? onFocusBadgeNode : null} - - ); - } - } - - const screenReaderText = (state || instructions) && ( + return state || instructions ? (
    {state || instructions ? '. ' : null} @@ -335,27 +314,96 @@ export class EuiSelectableListItem extends Component {instructions}
    - ); + ) : null; + }, [checked, searchable, allowExclusions]); - return ( -
  • - - {optionIcon} - {prependNode} - - {children} - {screenReaderText} - - {appendNode} - -
  • - ); - } -} + // aria-checked is intended to be used with role="checkbox" but + // the MDN documentation lists it as a possibility for role="option". + // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-checked + // and https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/option_role + const ariaChecked = useMemo(() => { + const rolesThatCanBeMixed = ['option', 'checkbox', 'menuitemcheckbox']; + const rolesThatCanBeChecked = [ + ...rolesThatCanBeMixed, + 'radio', + 'menuitemradio', + 'switch', + ]; + if (!rolesThatCanBeChecked.includes(role)) return undefined; + + switch (checked) { + case 'on': + case 'off': + return true; + case 'mixed': + if (rolesThatCanBeMixed.includes(role)) { + return 'mixed'; + } else { + return false; + } + default: + return false; + } + }, [role, checked]); + + const hasToolTip = !!toolTipContent && !disabled; + const [tooltipRef, setTooltipRef] = useState(null); // Needs to be state and not a ref to trigger useEffect + const [ariaDescribedBy, setAriaDescribedBy] = useState(_ariaDescribedBy); + + // Manually trigger the tooltip on keyboard focus + useEffect(() => { + if (!tooltipRef) return; + + if (isFocused) { + tooltipRef.showToolTip(); + } else { + tooltipRef.hideToolTip(); + } + }, [isFocused, tooltipRef]); + + // Manually set the `aria-describedby` id on the
  • wrapper + useEffect(() => { + if (tooltipRef) { + const tooltipId = tooltipRef.state.id; + setAriaDescribedBy(classNames(tooltipId, _ariaDescribedBy)); + } + }, [tooltipRef, _ariaDescribedBy]); + + const content: ReactElement = ( + + {optionIcon} + {prependNode} + + {children} + {screenReaderText} + + {appendNode} + + ); + + return ( +
  • + {hasToolTip ? ( + + {content} + + ) : ( + content + )} +
  • + ); +}; diff --git a/src/components/selectable/selectable_option.tsx b/src/components/selectable/selectable_option.tsx index 1e1ff0e2093..70ac21e2529 100644 --- a/src/components/selectable/selectable_option.tsx +++ b/src/components/selectable/selectable_option.tsx @@ -9,6 +9,7 @@ import React, { HTMLAttributes } from 'react'; import { CommonProps, ExclusiveUnion } from '../common'; import type { EuiTextTruncateProps } from '../text_truncate'; +import { EuiToolTipProps } from '../tool_tip'; export type EuiSelectableOptionCheckedType = 'on' | 'off' | 'mixed' | undefined; @@ -74,6 +75,14 @@ export type EuiSelectableOptionBase = CommonProps & { * text will always take precedence. */ truncationProps?: Partial>; + /** + * Optional custom tooltip content for the button + */ + toolTipContent?: EuiToolTipProps['content']; + /** + * Optional props to pass to the underlying **[EuiToolTip](/#/display/tooltip)** + */ + toolTipProps?: Partial>; }; type _EuiSelectableGroupLabelOption = Omit<