diff --git a/.loki/reference/chrome_desktop_Forms_EuiComboBox_With_Tooltip.png b/.loki/reference/chrome_desktop_Forms_EuiComboBox_With_Tooltip.png new file mode 100644 index 00000000000..b78bb812930 Binary files /dev/null and b/.loki/reference/chrome_desktop_Forms_EuiComboBox_With_Tooltip.png differ diff --git a/.loki/reference/chrome_mobile_Forms_EuiComboBox_With_Tooltip.png b/.loki/reference/chrome_mobile_Forms_EuiComboBox_With_Tooltip.png new file mode 100644 index 00000000000..eba304a0eb9 Binary files /dev/null and b/.loki/reference/chrome_mobile_Forms_EuiComboBox_With_Tooltip.png differ diff --git a/changelogs/upcoming/7700.md b/changelogs/upcoming/7700.md new file mode 100644 index 00000000000..8a77f98a47a --- /dev/null +++ b/changelogs/upcoming/7700.md @@ -0,0 +1,6 @@ +- Updated `EuiComboBox`'s `options` to support including tooltip details for selectable options. Use `toolTipContent` to render tooltip information, and `toolTipProps` to optionally customize the tooltip rendering behavior + +**Bug fixes** + +- Fixed a visual layout bug for `EuiComboBox` with `isLoading` in mobile views + diff --git a/changelogs/upcoming/7709.md b/changelogs/upcoming/7709.md new file mode 100644 index 00000000000..a1bf329af06 --- /dev/null +++ b/changelogs/upcoming/7709.md @@ -0,0 +1 @@ +- Added a new, optional `optionMatcher` prop to `EuiSelectable` and `EuiComboBox` allowing passing a custom option matcher function to these components and controlling option filtering for given search string diff --git a/src-docs/src/views/combo_box/combo_box_example.js b/src-docs/src/views/combo_box/combo_box_example.js index 010edf189ca..69c6c90e2f5 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -83,6 +83,26 @@ const renderOptionSnippet = ``; +import ToolTips from './tool_tips'; +const toolTipsSource = require('!!raw-loader!./tool_tips'); +const toolTipsSnippet = ``; + import Truncation from './truncation'; const truncationSource = require('!!raw-loader!./truncation'); const truncationSnippet = ` + You can add tooltips to the options by passing{' '} + toolTipContent. Use toolTipProps{' '} + to pass additional EuiToolTipProps to the tooltip. +

+ ), + props: { EuiComboBox, EuiComboBoxOptionOption }, + snippet: toolTipsSnippet, + demo: , + }, { title: 'Truncation', source: [ diff --git a/src-docs/src/views/combo_box/tool_tips.tsx b/src-docs/src/views/combo_box/tool_tips.tsx new file mode 100644 index 00000000000..20a30d2c7ce --- /dev/null +++ b/src-docs/src/views/combo_box/tool_tips.tsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; + +import { + EuiComboBox, + EuiComboBoxOptionOption, +} from '../../../../src/components'; + +const options: Array> = [ + { + 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' }, + }, +]; +export default () => { + const [selectedOptions, setSelected] = useState([options[2]]); + + const onChange = ( + selectedOptions: Array> + ) => { + setSelected(selectedOptions); + }; + + return ( + + ); +}; diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx index c283edaf250..771f277889c 100644 --- a/src/components/combo_box/combo_box.stories.tsx +++ b/src/components/combo_box/combo_box.stories.tsx @@ -14,11 +14,17 @@ import { EuiComboBox, EuiComboBoxProps } from './combo_box'; import { EuiComboBoxOptionMatcher } from './types'; import { EuiCode } from '../code'; +const toolTipProps = { + toolTipContent: 'This is a tooltip!', + toolTipProps: { position: 'left' as const }, + value: 4, +}; + const options = [ { label: 'Item 1' }, { label: 'Item 2' }, { label: 'Item 3' }, - { label: 'Item 4' }, + { label: 'Item 4', disabled: true }, { label: 'Item 5' }, ]; @@ -64,41 +70,19 @@ export default meta; type Story = StoryObj>; export const Playground: Story = { - render: function Render({ singleSelection, onCreateOption, ...args }) { - const [selectedOptions, setSelectedOptions] = useState( - args.selectedOptions - ); - const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { - setSelectedOptions(options); - action('onChange')(options, ...args); - }; - const _onCreateOption: EuiComboBoxProps<{}>['onCreateOption'] = ( - searchValue, - ...args - ) => { - const createdOption = { label: searchValue }; - setSelectedOptions((prevState) => - !prevState || singleSelection - ? [createdOption] - : [...prevState, createdOption] - ); - action('onCreateOption')(searchValue, ...args); - }; - return ( - - ); + render: (args) => , +}; + +export const WithTooltip: Story = { + parameters: { + controls: { + include: ['fullWidth', 'options', 'selectedOptions'], + }, }, + args: { + options: options.map((option) => ({ ...option, ...toolTipProps })), + }, + render: (args) => , }; export const CustomMatcher: Story = { @@ -142,3 +126,41 @@ export const CustomMatcher: Story = { ); }, }; + +const StatefulComboBox = ({ + singleSelection, + onCreateOption, + ...args +}: EuiComboBoxProps<{}>) => { + const [selectedOptions, setSelectedOptions] = useState(args.selectedOptions); + const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { + setSelectedOptions(options); + action('onChange')(options, ...args); + }; + const _onCreateOption: EuiComboBoxProps<{}>['onCreateOption'] = ( + searchValue, + ...args + ) => { + const createdOption = { label: searchValue }; + setSelectedOptions((prevState) => + !prevState || singleSelection + ? [createdOption] + : [...prevState, createdOption] + ); + action('onCreateOption')(searchValue, ...args); + }; + return ( + + ); +}; diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index c62797a2c3b..251222921e6 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -8,7 +8,11 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; -import { render, showEuiComboBoxOptions } from '../../test/rtl'; +import { + render, + showEuiComboBoxOptions, + waitForEuiToolTipVisible, +} from '../../test/rtl'; import { shouldRenderCustomStyles, testOnReactVersion, @@ -22,6 +26,8 @@ import type { EuiComboBoxOptionOption } from './types'; interface Options { 'data-test-subj'?: string; label: string; + toolTipContent?: string; + toolTipProps?: {}; } const options: Options[] = [ { @@ -216,200 +222,327 @@ describe('EuiComboBox', () => { }); }); - describe('placeholder', () => { - it('renders', () => { - const { getByTestSubject } = render( + describe('toolTipContent & tooltipProps', () => { + it('renders a tooltip with applied props on mouseover', async () => { + const options = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + toolTipContent: 'I am a tooltip!', + toolTipProps: { + 'data-test-subj': 'optionToolTip', + }, + }, + { + label: 'Enceladus', + }, + { + label: 'Mimas', + }, + ]; + + const { getByTestSubject } = render(); + + await showEuiComboBoxOptions(); + + fireEvent.mouseOver(getByTestSubject('titanOption')); + await waitForEuiToolTipVisible(); + + expect(getByTestSubject('optionToolTip')).toBeInTheDocument(); + expect(getByTestSubject('optionToolTip')).toHaveTextContent( + 'I am a tooltip!' + ); + }); + + it('renders a tooltip with applied props on keyboard navigation', async () => { + const options = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + toolTipContent: 'I am a tooltip!', + toolTipProps: { + 'data-test-subj': 'optionToolTip', + }, + }, + { + label: 'Enceladus', + }, + { + label: 'Mimas', + }, + ]; + + const { getByTestSubject } = render(); + await showEuiComboBoxOptions(); + + const input = getByTestSubject('comboBoxSearchInput'); + fireEvent.keyDown(input, { key: keys.ARROW_DOWN }); + + await waitForEuiToolTipVisible(); + + expect(getByTestSubject('optionToolTip')).toBeInTheDocument(); + expect(getByTestSubject('optionToolTip')).toHaveTextContent( + 'I am a tooltip!' + ); + }); + + describe('placeholder', () => { + it('renders', () => { + const { getByTestSubject } = render( + + ); + const searchInput = getByTestSubject('comboBoxSearchInput'); + + expect(searchInput).toHaveAttribute( + 'placeholder', + 'Select something' + ); + expect(searchInput).toHaveStyle('inline-size: 100%'); + }); + + it('does not render the placeholder if a selection has been made', () => { + const { getByTestSubject } = render( + + ); + const searchInput = getByTestSubject('comboBoxSearchInput'); + expect(searchInput).not.toHaveAttribute('placeholder'); + }); + + it('does not render the placeholder if a search value exists', () => { + const { getByTestSubject } = render( + + ); + const searchInput = getByTestSubject('comboBoxSearchInput'); + expect(searchInput).toHaveAttribute('placeholder'); + + fireEvent.change(searchInput, { target: { value: 'some search' } }); + expect(searchInput).not.toHaveAttribute('placeholder'); + }); + }); + + test('isDisabled', () => { + const { container, queryByTestSubject, queryByTitle } = render( ); - const searchInput = getByTestSubject('comboBoxSearchInput'); - expect(searchInput).toHaveAttribute('placeholder', 'Select something'); - expect(searchInput).toHaveStyle('inline-size: 100%'); + expect(container.firstElementChild!.className).toContain('-isDisabled'); + expect(queryByTestSubject('comboBoxSearchInput')).toBeDisabled(); + + expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); + expect(queryByTestSubject('comboBoxToggleListButton')).toBeFalsy(); + expect( + queryByTitle('Remove Titan from selection in this group') + ).toBeFalsy(); }); - it('does not render the placeholder if a selection has been made', () => { - const { getByTestSubject } = render( + test('fullWidth', () => { + // TODO: Should likely be a visual screenshot test + const { container } = render( ); - const searchInput = getByTestSubject('comboBoxSearchInput'); - expect(searchInput).not.toHaveAttribute('placeholder'); + + expect(container.innerHTML).toContain( + 'euiFormControlLayout--fullWidth' + ); + expect(container.innerHTML).toContain('euiComboBox--fullWidth'); + expect(container.innerHTML).toContain( + 'euiComboBox__inputWrap--fullWidth' + ); }); - it('does not render the placeholder if a search value exists', () => { + test('autoFocus', () => { const { getByTestSubject } = render( - + ); - const searchInput = getByTestSubject('comboBoxSearchInput'); - expect(searchInput).toHaveAttribute('placeholder'); - fireEvent.change(searchInput, { target: { value: 'some search' } }); - expect(searchInput).not.toHaveAttribute('placeholder'); + expect(document.activeElement).toBe( + getByTestSubject('comboBoxSearchInput') + ); }); - }); - test('isDisabled', () => { - const { container, queryByTestSubject, queryByTitle } = render( - - ); + test('aria-label / aria-labelledby renders on the input, not on the wrapper', () => { + const { getByTestSubject } = render( + + ); + const input = getByTestSubject('comboBoxSearchInput'); - expect(container.firstElementChild!.className).toContain('-isDisabled'); - expect(queryByTestSubject('comboBoxSearchInput')).toBeDisabled(); + expect(input).toHaveAttribute('aria-label', 'Test label'); + expect(input).toHaveAttribute('aria-labelledby', 'test-heading-id'); + }); - expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); - expect(queryByTestSubject('comboBoxToggleListButton')).toBeFalsy(); - expect( - queryByTitle('Remove Titan from selection in this group') - ).toBeFalsy(); - }); + test('inputRef', () => { + const inputRefCallback = jest.fn(); - test('fullWidth', () => { - // TODO: Should likely be a visual screenshot test - const { container } = render( - - ); + const { getByRole } = render( + + ); + expect(inputRefCallback).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toContain('euiFormControlLayout--fullWidth'); - expect(container.innerHTML).toContain('euiComboBox--fullWidth'); - expect(container.innerHTML).toContain( - 'euiComboBox__inputWrap--fullWidth' - ); - }); + expect(getByRole('combobox')).toBe(inputRefCallback.mock.calls[0][0]); + }); - test('autoFocus', () => { - const { getByTestSubject } = render( - - ); + test('onSearchChange', () => { + const onSearchChange = jest.fn(); + const { getByTestSubject, queryAllByRole } = render( + + ); + const input = getByTestSubject('comboBoxSearchInput'); - expect(document.activeElement).toBe( - getByTestSubject('comboBoxSearchInput') - ); + fireEvent.change(input, { target: { value: 'no results' } }); + expect(onSearchChange).toHaveBeenCalledWith('no results', false); + expect(queryAllByRole('option')).toHaveLength(0); + + fireEvent.change(input, { target: { value: 'titan' } }); + expect(onSearchChange).toHaveBeenCalledWith('titan', true); + expect(queryAllByRole('option')).toHaveLength(2); + }); }); - test('aria-label / aria-labelledby renders on the input, not on the wrapper', () => { - const { getByTestSubject } = render( + it('does not show multiple checkmarks with duplicate labels', async () => { + const options = [ + { label: 'Titan', key: 'titan1' }, + { label: 'Titan', key: 'titan2' }, + { label: 'Tethys' }, + ]; + const { baseElement } = render( ); - const input = getByTestSubject('comboBoxSearchInput'); - - expect(input).toHaveAttribute('aria-label', 'Test label'); - expect(input).toHaveAttribute('aria-labelledby', 'test-heading-id'); - }); - - test('inputRef', () => { - const inputRefCallback = jest.fn(); + await showEuiComboBoxOptions(); - const { getByRole } = render( - + const dropdownOptions = baseElement.querySelectorAll( + '.euiFilterSelectItem' ); - expect(inputRefCallback).toHaveBeenCalledTimes(1); - - expect(getByRole('combobox')).toBe(inputRefCallback.mock.calls[0][0]); + expect( + dropdownOptions[0]!.querySelector('[data-euiicon-type="check"]') + ).toBeFalsy(); + expect( + dropdownOptions[1]!.querySelector('[data-euiicon-type="check"]') + ).toBeTruthy(); }); - test('onSearchChange', () => { - const onSearchChange = jest.fn(); - const { getByTestSubject, queryAllByRole } = render( - - ); - const input = getByTestSubject('comboBoxSearchInput'); - - fireEvent.change(input, { target: { value: 'no results' } }); - expect(onSearchChange).toHaveBeenCalledWith('no results', false); - expect(queryAllByRole('option')).toHaveLength(0); - - fireEvent.change(input, { target: { value: 'titan' } }); - expect(onSearchChange).toHaveBeenCalledWith('titan', true); - expect(queryAllByRole('option')).toHaveLength(2); - }); - }); + describe('behavior', () => { + describe('hitting "Enter"', () => { + describe('when the search input matches a value', () => { + it('selects the option', () => { + const onChange = jest.fn(); + const { getByTestSubject } = render( + + ); + + const input = getByTestSubject('comboBoxSearchInput'); + fireEvent.change(input, { target: { value: 'red' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith([{ label: 'Red' }]); + }); + + it('accounts for group labels', () => { + const onChange = jest.fn(); + const { getByTestSubject } = render( + + ); + + const input = getByTestSubject('comboBoxSearchInput'); + fireEvent.change(input, { target: { value: 'blue' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith([{ label: 'Blue' }]); + }); + }); - it('does not show multiple checkmarks with duplicate labels', async () => { - const options = [ - { label: 'Titan', key: 'titan1' }, - { label: 'Titan', key: 'titan2' }, - { label: 'Tethys' }, - ]; - const { baseElement } = render( - - ); - await showEuiComboBoxOptions(); + describe('when `onCreateOption` is passed', () => { + it('fires the callback when there is input', () => { + const onCreateOptionHandler = jest.fn(); + + const { getByTestSubject } = render( + + ); + const input = getByTestSubject('comboBoxSearchInput'); + + fireEvent.change(input, { target: { value: 'foo' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onCreateOptionHandler).toHaveBeenCalledTimes(1); + expect(onCreateOptionHandler).toHaveBeenCalledWith('foo', options); + }); + + it('does not fire the callback when there is no input', () => { + const onCreateOptionHandler = jest.fn(); + + const { getByTestSubject } = render( + + ); + const input = getByTestSubject('comboBoxSearchInput'); + + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onCreateOptionHandler).not.toHaveBeenCalled(); + }); + }); + }); - const dropdownOptions = baseElement.querySelectorAll( - '.euiFilterSelectItem' - ); - expect( - dropdownOptions[0]!.querySelector('[data-euiicon-type="check"]') - ).toBeFalsy(); - expect( - dropdownOptions[1]!.querySelector('[data-euiicon-type="check"]') - ).toBeTruthy(); - }); + describe('tabbing off the search input', () => { + it("closes the options list if the user isn't navigating the options", async () => { + const keyDownBubbled = jest.fn(); - describe('behavior', () => { - describe('hitting "Enter"', () => { - describe('when the search input matches a value', () => { - it('selects the option', () => { - const onChange = jest.fn(); const { getByTestSubject } = render( - +
+ +
); + await showEuiComboBoxOptions(); - const input = getByTestSubject('comboBoxSearchInput'); - fireEvent.change(input, { target: { value: 'red' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(onChange).toHaveBeenCalledWith([{ label: 'Red' }]); - }); + const mockEvent = { key: keys.TAB, shiftKey: true }; + fireEvent.keyDown(getByTestSubject('comboBoxSearchInput'), mockEvent); - it('accounts for group labels', () => { - const onChange = jest.fn(); - const { getByTestSubject } = render( - + // If the TAB keydown bubbled up to the wrapper, then a browser DOM would shift the focus + expect(keyDownBubbled).toHaveBeenCalledWith( + expect.objectContaining(mockEvent) ); - - const input = getByTestSubject('comboBoxSearchInput'); - fireEvent.change(input, { target: { value: 'blue' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(onChange).toHaveBeenCalledWith([{ label: 'Blue' }]); }); - }); - describe('when `onCreateOption` is passed', () => { - it('fires the callback when there is input', () => { + it('calls onCreateOption', () => { const onCreateOptionHandler = jest.fn(); const { getByTestSubject } = render( @@ -422,220 +555,163 @@ describe('EuiComboBox', () => { const input = getByTestSubject('comboBoxSearchInput'); fireEvent.change(input, { target: { value: 'foo' } }); - fireEvent.keyDown(input, { key: 'Enter' }); + fireEvent.blur(input); expect(onCreateOptionHandler).toHaveBeenCalledTimes(1); expect(onCreateOptionHandler).toHaveBeenCalledWith('foo', options); }); - it('does not fire the callback when there is no input', () => { - const onCreateOptionHandler = jest.fn(); + it('does nothing if the user is navigating the options', async () => { + const keyDownBubbled = jest.fn(); const { getByTestSubject } = render( - +
+ +
); - const input = getByTestSubject('comboBoxSearchInput'); + await showEuiComboBoxOptions(); - fireEvent.keyDown(input, { key: 'Enter' }); + // Navigate to an option then tab off + const input = getByTestSubject('comboBoxSearchInput'); + fireEvent.keyDown(input, { key: keys.ARROW_DOWN }); + fireEvent.keyDown(input, { key: keys.TAB }); - expect(onCreateOptionHandler).not.toHaveBeenCalled(); + // If the TAB keydown did not bubble to the wrapper, then the tab event was prevented + expect(keyDownBubbled).not.toHaveBeenCalled(); }); }); - }); - - describe('tabbing off the search input', () => { - it("closes the options list if the user isn't navigating the options", async () => { - const keyDownBubbled = jest.fn(); - - const { getByTestSubject } = render( -
- -
- ); - await showEuiComboBoxOptions(); - - const mockEvent = { key: keys.TAB, shiftKey: true }; - fireEvent.keyDown(getByTestSubject('comboBoxSearchInput'), mockEvent); - - // If the TAB keydown bubbled up to the wrapper, then a browser DOM would shift the focus - expect(keyDownBubbled).toHaveBeenCalledWith( - expect.objectContaining(mockEvent) - ); - }); - it('calls onCreateOption', () => { - const onCreateOptionHandler = jest.fn(); - - const { getByTestSubject } = render( - - ); - const input = getByTestSubject('comboBoxSearchInput'); - - fireEvent.change(input, { target: { value: 'foo' } }); - fireEvent.blur(input); - - expect(onCreateOptionHandler).toHaveBeenCalledTimes(1); - expect(onCreateOptionHandler).toHaveBeenCalledWith('foo', options); - }); - - it('does nothing if the user is navigating the options', async () => { - const keyDownBubbled = jest.fn(); - - const { getByTestSubject } = render( -
+ describe('clear button', () => { + it('renders when options are selected', () => { + const { getByTestSubject } = render( -
- ); - await showEuiComboBoxOptions(); - - // Navigate to an option then tab off - const input = getByTestSubject('comboBoxSearchInput'); - fireEvent.keyDown(input, { key: keys.ARROW_DOWN }); - fireEvent.keyDown(input, { key: keys.TAB }); - - // If the TAB keydown did not bubble to the wrapper, then the tab event was prevented - expect(keyDownBubbled).not.toHaveBeenCalled(); - }); - }); - - describe('clear button', () => { - it('renders when options are selected', () => { - const { getByTestSubject } = render( - - ); + ); - expect(getByTestSubject('comboBoxClearButton')).toBeInTheDocument(); - }); + expect(getByTestSubject('comboBoxClearButton')).toBeInTheDocument(); + }); - it('does not render when no options are selected', () => { - const { queryByTestSubject } = render( - - ); + it('does not render when no options are selected', () => { + const { queryByTestSubject } = render( + + ); - expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); - }); + expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); + }); - it('does not render when isClearable is false', () => { - const { queryByTestSubject } = render( - - ); + it('does not render when isClearable is false', () => { + const { queryByTestSubject } = render( + + ); - expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); - }); + expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); + }); - it('calls the onChange callback with empty array', () => { - const onChangeHandler = jest.fn(); + it('calls the onChange callback with empty array', () => { + const onChangeHandler = jest.fn(); - const { getByTestSubject } = render( - - ); - fireEvent.click(getByTestSubject('comboBoxClearButton')); + const { getByTestSubject } = render( + + ); + fireEvent.click(getByTestSubject('comboBoxClearButton')); - expect(onChangeHandler).toHaveBeenCalledTimes(1); - expect(onChangeHandler).toHaveBeenCalledWith([]); - }); + expect(onChangeHandler).toHaveBeenCalledTimes(1); + expect(onChangeHandler).toHaveBeenCalledWith([]); + }); - it('focuses the input', () => { - const { getByTestSubject } = render( - {}} - /> - ); - fireEvent.click(getByTestSubject('comboBoxClearButton')); + it('focuses the input', () => { + const { getByTestSubject } = render( + {}} + /> + ); + fireEvent.click(getByTestSubject('comboBoxClearButton')); - expect(document.activeElement).toBe( - getByTestSubject('comboBoxSearchInput') - ); + expect(document.activeElement).toBe( + getByTestSubject('comboBoxSearchInput') + ); + }); }); - }); - describe('sortMatchesBy', () => { - const sortMatchesByOptions = [ - { label: 'Something is Disabled' }, - ...options, - ]; + describe('sortMatchesBy', () => { + const sortMatchesByOptions = [ + { label: 'Something is Disabled' }, + ...options, + ]; - test('"none"', () => { - const { getByTestSubject, getAllByRole } = render( - - ); - fireEvent.change(getByTestSubject('comboBoxSearchInput'), { - target: { value: 'di' }, + test('"none"', () => { + const { getByTestSubject, getAllByRole } = render( + + ); + fireEvent.change(getByTestSubject('comboBoxSearchInput'), { + target: { value: 'di' }, + }); + + const foundOptions = getAllByRole('option'); + expect(foundOptions).toHaveLength(2); + expect(foundOptions[0]).toHaveTextContent('Something is Disabled'); + expect(foundOptions[1]).toHaveTextContent('Dione'); }); - const foundOptions = getAllByRole('option'); - expect(foundOptions).toHaveLength(2); - expect(foundOptions[0]).toHaveTextContent('Something is Disabled'); - expect(foundOptions[1]).toHaveTextContent('Dione'); - }); - - test('"startsWith"', () => { - const { getByTestSubject, getAllByRole } = render( - - ); - fireEvent.change(getByTestSubject('comboBoxSearchInput'), { - target: { value: 'di' }, + test('"startsWith"', () => { + const { getByTestSubject, getAllByRole } = render( + + ); + fireEvent.change(getByTestSubject('comboBoxSearchInput'), { + target: { value: 'di' }, + }); + + const foundOptions = getAllByRole('option'); + expect(foundOptions).toHaveLength(2); + expect(foundOptions[0]).toHaveTextContent('Dione'); + expect(foundOptions[1]).toHaveTextContent('Something is Disabled'); }); - - const foundOptions = getAllByRole('option'); - expect(foundOptions).toHaveLength(2); - expect(foundOptions[0]).toHaveTextContent('Dione'); - expect(foundOptions[1]).toHaveTextContent('Something is Disabled'); }); - }); - describe('isCaseSensitive', () => { - const isCaseSensitiveOptions = [{ label: 'Case sensitivity' }]; + describe('isCaseSensitive', () => { + const isCaseSensitiveOptions = [{ label: 'Case sensitivity' }]; - test('false', () => { - const { getByTestSubject, queryAllByRole } = render( - - ); - fireEvent.change(getByTestSubject('comboBoxSearchInput'), { - target: { value: 'case' }, - }); + test('false', () => { + const { getByTestSubject, queryAllByRole } = render( + + ); + fireEvent.change(getByTestSubject('comboBoxSearchInput'), { + target: { value: 'case' }, + }); - expect(queryAllByRole('option')).toHaveLength(1); - }); + expect(queryAllByRole('option')).toHaveLength(1); + }); - test('true', () => { - const { getByTestSubject, queryAllByRole } = render( - - ); - const input = getByTestSubject('comboBoxSearchInput'); + test('true', () => { + const { getByTestSubject, queryAllByRole } = render( + + ); + const input = getByTestSubject('comboBoxSearchInput'); - fireEvent.change(input, { target: { value: 'case' } }); - expect(queryAllByRole('option')).toHaveLength(0); + fireEvent.change(input, { target: { value: 'case' } }); + expect(queryAllByRole('option')).toHaveLength(0); - fireEvent.change(input, { target: { value: 'Case' } }); - expect(queryAllByRole('option')).toHaveLength(1); + fireEvent.change(input, { target: { value: 'Case' } }); + expect(queryAllByRole('option')).toHaveLength(1); + }); }); }); }); diff --git a/src/components/combo_box/combo_box_input/combo_box_input.tsx b/src/components/combo_box/combo_box_input/combo_box_input.tsx index 4453cf2379b..2b60ad1d59d 100644 --- a/src/components/combo_box/combo_box_input/combo_box_input.tsx +++ b/src/components/combo_box/combo_box_input/combo_box_input.tsx @@ -190,6 +190,8 @@ export class EuiComboBoxInput extends Component< append, prepend, truncationProps, + toolTipContent, + toolTipProps, ...rest } = option; const pillOnClose = diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx index f32adcd125c..aef427c28ab 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx @@ -327,7 +327,7 @@ export class EuiComboBoxOptionsList extends Component< if (isLoading) { emptyStateContent = ( - + diff --git a/src/components/combo_box/types.ts b/src/components/combo_box/types.ts index df687fc95ae..ecd524811ca 100644 --- a/src/components/combo_box/types.ts +++ b/src/components/combo_box/types.ts @@ -10,6 +10,7 @@ import { ButtonHTMLAttributes, ReactNode } from 'react'; import { CommonProps } from '../common'; import type { _EuiComboBoxProps } from './combo_box'; +import { EuiToolTipProps } from '../tool_tip'; // note similarity to `Option` in `components/selectable/types.tsx` export interface EuiComboBoxOptionOption< @@ -24,6 +25,14 @@ export interface EuiComboBoxOptionOption< prepend?: ReactNode; append?: ReactNode; truncationProps?: _EuiComboBoxProps['truncationProps']; + /** + * Optional custom tooltip content for the button + */ + toolTipContent?: EuiToolTipProps['content']; + /** + * Optional props to pass to the underlying **[EuiToolTip](/#/display/tooltip)** + */ + toolTipProps?: Partial>; } export type OptionHandler = (option: EuiComboBoxOptionOption) => void; diff --git a/src/components/filter_group/filter_select_item.tsx b/src/components/filter_group/filter_select_item.tsx index 9725e2f6978..492069a9347 100644 --- a/src/components/filter_group/filter_select_item.tsx +++ b/src/components/filter_group/filter_select_item.tsx @@ -6,14 +6,16 @@ * Side Public License, v 1. */ -import React, { ButtonHTMLAttributes, Component } from 'react'; +import React, { ButtonHTMLAttributes, Component, createRef } from 'react'; import classNames from 'classnames'; import { withEuiTheme, WithEuiThemeProps } from '../../services'; import { CommonProps } from '../common'; import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiToolTip } from '../tool_tip'; import { EuiIcon } from '../icon'; +import { EuiComboBoxOptionOption } from '../combo_box'; import { euiFilterSelectItemStyles } from './filter_select_item.styles'; @@ -24,6 +26,8 @@ export interface EuiFilterSelectItemProps checked?: FilterChecked; showIcons?: boolean; isFocused?: boolean; + toolTipContent?: EuiComboBoxOptionOption['toolTipContent']; + toolTipProps?: EuiComboBoxOptionOption['toolTipProps']; } const resolveIconAndColor = (checked?: FilterChecked) => { @@ -55,6 +59,7 @@ export class EuiFilterSelectItemClass extends Component< }; buttonRef: HTMLButtonElement | null = null; + tooltipRef = createRef(); state = { hasFocus: false, @@ -66,6 +71,14 @@ export class EuiFilterSelectItemClass extends Component< } }; + toggleToolTip = (isFocused: boolean) => { + if (isFocused) { + this.tooltipRef?.current?.showToolTip(); + } else { + this.tooltipRef?.current?.hideToolTip(); + } + }; + hasFocus = () => { return this.state.hasFocus; }; @@ -79,6 +92,9 @@ export class EuiFilterSelectItemClass extends Component< checked, isFocused, showIcons, + toolTipContent, + toolTipProps, + style, ...rest } = this.props; @@ -90,6 +106,28 @@ export class EuiFilterSelectItemClass extends Component< const classes = classNames('euiFilterSelectItem', className); + const hasToolTip = + // we're using isValidElement here as EuiToolTipAnchor uses + // cloneElement to enhance the element with required attributes + React.isValidElement(children) && !disabled && toolTipContent; + + let anchorProps = undefined; + + if (hasToolTip) { + const anchorStyles = toolTipProps?.anchorProps?.style + ? { ...toolTipProps?.anchorProps?.style, ...style } + : style; + + anchorProps = toolTipProps?.anchorProps + ? { + ...toolTipProps.anchorProps, + style: anchorStyles, + } + : { style }; + + this.toggleToolTip(isFocused ?? false); + } + let iconNode; if (showIcons) { const { icon, color } = resolveIconAndColor(checked); @@ -100,7 +138,7 @@ export class EuiFilterSelectItemClass extends Component< ); } - return ( + const optionItem = ( ); + + return hasToolTip ? ( + + {optionItem} + + ) : ( + optionItem + ); } } diff --git a/src/components/popover/input_popover.spec.tsx b/src/components/popover/input_popover.spec.tsx index 10f17ca37d1..8a3b0a18938 100644 --- a/src/components/popover/input_popover.spec.tsx +++ b/src/components/popover/input_popover.spec.tsx @@ -236,44 +236,55 @@ describe('EuiPopover', () => { const [isOpen, setIsOpen] = useState(true); return ( -
- setIsOpen(false)} - input={ - setIsOpen(true)} - rows={1} - defaultValue={`hello\nworld`} - /> - } - > -
+
+ setIsOpen(false)} + input={ + setIsOpen(true)} + rows={1} + defaultValue={`hello\nworld`} + /> + } > -
Popover content
-
- +
+
Popover content
+
+ +
+
+
+
); }; it('closes the popover when the user scrolls outside of the component', () => { cy.mount(); - cy.wait(500); // Wait for the setTimeout in the useEffect // Scrolling the input or popover should not close the popover cy.get('[data-test-subj="inputWithScroll"]').scrollTo('bottom'); cy.get('[data-popover-panel]').should('exist'); + // Scrolling an element that doesn't contain/affect the input should not close the popover + cy.get('[data-test-subj="scrollingSibling"]').scrollTo('bottom'); + cy.get('[data-popover-panel]').should('exist'); + cy.get('[data-test-subj="popoverWithScroll"]').scrollTo('bottom'); cy.wait(500); // Wait a tick for false positives cy.get('[data-popover-panel]').should('exist'); - // Scrolling anywhere else should close the popover + // Scrolling the actual body should close the popover cy.scrollTo('bottom'); cy.get('[data-popover-panel]').should('not.exist'); diff --git a/src/components/popover/input_popover.tsx b/src/components/popover/input_popover.tsx index 99fdbead25b..c79ba2d0b40 100644 --- a/src/components/popover/input_popover.tsx +++ b/src/components/popover/input_popover.tsx @@ -169,33 +169,36 @@ export const EuiInputPopover: FunctionComponent = ({ useEffect(() => { // When the popover opens, add a scroll listener to the page (& remove it after) if (closeOnScroll && panelEl) { - // Close the popover, but only if the scroll event occurs outside the input or the popover itself const closePopoverOnScroll = (event: Event) => { - if (!panelEl || !inputEl || !event.target) return; const scrollTarget = event.target as Node; - if ( - panelEl.contains(scrollTarget) === false && - inputEl.contains(scrollTarget) === false - ) { - closePopover(); + // Basic existence check + if (!panelEl || !inputEl || !scrollTarget) { + return; } + // Do not close the popover if the input or popover itself was scrolled + if (panelEl.contains(scrollTarget) || inputEl.contains(scrollTarget)) { + return; + } + // Firefox will trigger a scroll event in many common situations (e.g. docs side nav) + // when the options list div is appended to the DOM. To work around this, we should + // check if the element that scrolled actually contains/will affect the input + if (!scrollTarget.contains(inputEl)) { + return; + } + + closePopover(); }; - // Firefox will trigger a scroll event in many common situations when the options list div is appended - // to the DOM; in testing it was always within 100ms, but setting a timeout here for 500ms to be safe - const timeoutId = setTimeout(() => { - window.addEventListener('scroll', closePopoverOnScroll, { - passive: true, // for better performance as we won't call preventDefault - capture: true, // scroll events don't bubble, they must be captured instead - }); - }, 500); + window.addEventListener('scroll', closePopoverOnScroll, { + passive: true, // for better performance as we won't call preventDefault + capture: true, // scroll events don't bubble, they must be captured instead + }); return () => { window.removeEventListener('scroll', closePopoverOnScroll, { capture: true, }); - clearTimeout(timeoutId); }; } }, [closeOnScroll, closePopover, panelEl, inputEl]);