From 34153a7e9c4b8fa82450522e47c1d58cf3be4921 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 22:52:36 +0200 Subject: [PATCH 01/21] feat(EuiToolTip): add controlled isOpen support --- src/components/tool_tip/tool_tip.test.tsx | 20 ++++++++++++++++++++ src/components/tool_tip/tool_tip.tsx | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/components/tool_tip/tool_tip.test.tsx b/src/components/tool_tip/tool_tip.test.tsx index f534910f8cd..7a11a5c0bb8 100644 --- a/src/components/tool_tip/tool_tip.test.tsx +++ b/src/components/tool_tip/tool_tip.test.tsx @@ -182,6 +182,26 @@ describe('EuiToolTip', () => { }); }); + describe('isOpen', () => { + it('shows/hides the tooltip', async () => { + const { rerender } = render( + + + + ); + + await waitForEuiToolTipVisible(); + + rerender( + + + + ); + + await waitForEuiToolTipHidden(); + }); + }); + describe('ref methods', () => { // Although we don't publicly recommend it, consumers may need to reach into EuiToolTip // class methods to manually control visibility state via `show/hideToolTip`. diff --git a/src/components/tool_tip/tool_tip.tsx b/src/components/tool_tip/tool_tip.tsx index b9bdb19a436..65ed118beae 100644 --- a/src/components/tool_tip/tool_tip.tsx +++ b/src/components/tool_tip/tool_tip.tsx @@ -98,6 +98,10 @@ export interface EuiToolTipProps extends CommonProps { * Suggested position. If there is not enough room for it this will be changed. */ position: ToolTipPositions; + /** + * For controlled use to show/hide the tooltip + */ + isOpen?: boolean; /** * When `true`, the tooltip's position is re-calculated when the user * scrolls. This supports having fixed-position tooltip anchors. @@ -153,6 +157,10 @@ export class EuiToolTip extends Component { if (this.props.repositionOnScroll) { window.addEventListener('scroll', this.positionToolTip, true); } + + if (this.props.isOpen) { + this.showToolTip(); + } } componentWillUnmount() { @@ -166,6 +174,14 @@ export class EuiToolTip extends Component { requestAnimationFrame(this.testAnchor); } + if (prevProps.isOpen !== this.props.isOpen) { + if (this.props.isOpen) { + this.showToolTip(); + } else { + this.hideToolTip(); + } + } + // update scroll listener if (prevProps.repositionOnScroll !== this.props.repositionOnScroll) { if (this.props.repositionOnScroll) { @@ -307,6 +323,7 @@ export class EuiToolTip extends Component { delay, display, repositionOnScroll, + isOpen, ...rest } = this.props; From 63721a905415a674ef9fc8f848b5cf2cde91778c Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:04:08 +0200 Subject: [PATCH 02/21] feat(EuiComboBox): add EuiToolTip support on option --- src/components/combo_box/types.ts | 9 +++++ .../filter_group/filter_select_item.tsx | 35 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) 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..cb2a21bf441 100644 --- a/src/components/filter_group/filter_select_item.tsx +++ b/src/components/filter_group/filter_select_item.tsx @@ -13,6 +13,7 @@ import { withEuiTheme, WithEuiThemeProps } from '../../services'; import { CommonProps } from '../common'; import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiToolTip, EuiToolTipProps } from '../tool_tip'; import { EuiIcon } from '../icon'; import { euiFilterSelectItemStyles } from './filter_select_item.styles'; @@ -24,6 +25,14 @@ export interface EuiFilterSelectItemProps checked?: FilterChecked; showIcons?: boolean; isFocused?: boolean; + /** + * Optional custom tooltip content for the button + */ + toolTipContent?: EuiToolTipProps['content']; + /** + * Optional props to pass to the underlying **[EuiToolTip](/#/display/tooltip)** + */ + toolTipProps?: Partial>; } const resolveIconAndColor = (checked?: FilterChecked) => { @@ -79,6 +88,9 @@ export class EuiFilterSelectItemClass extends Component< checked, isFocused, showIcons, + toolTipContent, + toolTipProps, + style, ...rest } = this.props; @@ -90,6 +102,9 @@ export class EuiFilterSelectItemClass extends Component< const classes = classNames('euiFilterSelectItem', className); + const hasToolTip = + !disabled && React.isValidElement(children) && toolTipContent; + let iconNode; if (showIcons) { const { icon, color } = resolveIconAndColor(checked); @@ -100,7 +115,7 @@ export class EuiFilterSelectItemClass extends Component< ); } - return ( + const optionItem = ( ); + + return hasToolTip ? ( + // This extra wrapper is needed to ensure that the tooltip has a correct context + // for positioning while also ensuring to wrap the interactive option + + + {optionItem} + + + ) : ( + optionItem + ); } } From 0734902f5d369c380a5b485fd098c6661aa42b60 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:04:40 +0200 Subject: [PATCH 03/21] test(EuiComboBox): add tests for toolTipContent and toolTipProps --- src/components/combo_box/combo_box.test.tsx | 722 +++++++++++--------- 1 file changed, 385 insertions(+), 337 deletions(-) diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index c62797a2c3b..10643adf2cc 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,426 +222,468 @@ describe('EuiComboBox', () => { }); }); - describe('placeholder', () => { - it('renders', () => { - const { getByTestSubject } = render( - - ); - const searchInput = getByTestSubject('comboBoxSearchInput'); + describe('toolTipContent & tooltipProps', () => { + const options = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + toolTipContent: 'I am a tooltip!', + toolTipProps: { + 'data-test-subj': 'optionToolTip', + }, + }, + { + label: 'Enceladus', + }, + { + label: 'Mimas', + }, + ]; - expect(searchInput).toHaveAttribute('placeholder', 'Select something'); - expect(searchInput).toHaveStyle('inline-size: 100%'); - }); + it('renders a tooltip with applied props on mouseover', async () => { + const { getByTestSubject } = render(); - it('does not render the placeholder if a selection has been made', () => { - const { getByTestSubject } = render( - - ); - const searchInput = getByTestSubject('comboBoxSearchInput'); - expect(searchInput).not.toHaveAttribute('placeholder'); - }); + await showEuiComboBoxOptions(); - it('does not render the placeholder if a search value exists', () => { - const { getByTestSubject } = render( - - ); - const searchInput = getByTestSubject('comboBoxSearchInput'); - expect(searchInput).toHaveAttribute('placeholder'); + fireEvent.mouseOver(getByTestSubject('titanOption')); + await waitForEuiToolTipVisible(); - fireEvent.change(searchInput, { target: { value: 'some search' } }); - expect(searchInput).not.toHaveAttribute('placeholder'); + expect(getByTestSubject('optionToolTip')).toBeInTheDocument(); + expect(getByTestSubject('optionToolTip')).toHaveTextContent( + 'I am a tooltip!' + ); }); - }); - test('isDisabled', () => { - const { container, queryByTestSubject, queryByTitle } = render( - - ); - - 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(); - }); - - test('fullWidth', () => { - // TODO: Should likely be a visual screenshot test - const { container } = render( - - ); - - expect(container.innerHTML).toContain('euiFormControlLayout--fullWidth'); - expect(container.innerHTML).toContain('euiComboBox--fullWidth'); - expect(container.innerHTML).toContain( - 'euiComboBox__inputWrap--fullWidth' - ); - }); - - test('autoFocus', () => { - const { getByTestSubject } = render( - - ); - - expect(document.activeElement).toBe( - getByTestSubject('comboBoxSearchInput') - ); - }); - - test('aria-label / aria-labelledby renders on the input, not on the wrapper', () => { - const { getByTestSubject } = 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(); - - const { getByRole } = render( - - ); - expect(inputRefCallback).toHaveBeenCalledTimes(1); - - expect(getByRole('combobox')).toBe(inputRefCallback.mock.calls[0][0]); - }); - - 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); - }); - }); - - 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(); - - const dropdownOptions = baseElement.querySelectorAll( - '.euiFilterSelectItem' - ); - expect( - dropdownOptions[0]!.querySelector('[data-euiicon-type="check"]') - ).toBeFalsy(); - expect( - dropdownOptions[1]!.querySelector('[data-euiicon-type="check"]') - ).toBeTruthy(); - }); + it('renders a tooltip with applied props on focus', async () => { + const { getByTestSubject } = render(); + await showEuiComboBoxOptions(); - 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.keyDown(input, { key: keys.ARROW_DOWN }); - const input = getByTestSubject('comboBoxSearchInput'); - fireEvent.change(input, { target: { value: 'red' } }); - fireEvent.keyDown(input, { key: 'Enter' }); + await waitForEuiToolTipVisible(); - expect(onChange).toHaveBeenCalledWith([{ label: 'Red' }]); - }); + expect(getByTestSubject('optionToolTip')).toBeInTheDocument(); + expect(getByTestSubject('optionToolTip')).toHaveTextContent( + 'I am a tooltip!' + ); + }); - it('accounts for group labels', () => { - const onChange = jest.fn(); + describe('placeholder', () => { + it('renders', () => { const { getByTestSubject } = render( ); + const searchInput = getByTestSubject('comboBoxSearchInput'); - const input = getByTestSubject('comboBoxSearchInput'); - fireEvent.change(input, { target: { value: 'blue' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(onChange).toHaveBeenCalledWith([{ label: 'Blue' }]); + expect(searchInput).toHaveAttribute( + 'placeholder', + 'Select something' + ); + expect(searchInput).toHaveStyle('inline-size: 100%'); }); - }); - - describe('when `onCreateOption` is passed', () => { - it('fires the callback when there is input', () => { - const onCreateOptionHandler = jest.fn(); + it('does not render the placeholder if a selection has been made', () => { 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); + const searchInput = getByTestSubject('comboBoxSearchInput'); + expect(searchInput).not.toHaveAttribute('placeholder'); }); - it('does not fire the callback when there is no input', () => { - const onCreateOptionHandler = jest.fn(); - + it('does not render the placeholder if a search value exists', () => { const { getByTestSubject } = render( - + ); - const input = getByTestSubject('comboBoxSearchInput'); + const searchInput = getByTestSubject('comboBoxSearchInput'); + expect(searchInput).toHaveAttribute('placeholder'); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(onCreateOptionHandler).not.toHaveBeenCalled(); + fireEvent.change(searchInput, { target: { value: 'some search' } }); + expect(searchInput).not.toHaveAttribute('placeholder'); }); }); - }); - 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( -
- -
+ test('isDisabled', () => { + const { container, queryByTestSubject, queryByTitle } = render( + ); - await showEuiComboBoxOptions(); - const mockEvent = { key: keys.TAB, shiftKey: true }; - fireEvent.keyDown(getByTestSubject('comboBoxSearchInput'), mockEvent); + expect(container.firstElementChild!.className).toContain('-isDisabled'); + expect(queryByTestSubject('comboBoxSearchInput')).toBeDisabled(); - // If the TAB keydown bubbled up to the wrapper, then a browser DOM would shift the focus - expect(keyDownBubbled).toHaveBeenCalledWith( - expect.objectContaining(mockEvent) - ); + expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); + expect(queryByTestSubject('comboBoxToggleListButton')).toBeFalsy(); + expect( + queryByTitle('Remove Titan from selection in this group') + ).toBeFalsy(); }); - it('calls onCreateOption', () => { - const onCreateOptionHandler = jest.fn(); - - const { getByTestSubject } = render( + test('fullWidth', () => { + // TODO: Should likely be a visual screenshot test + const { container } = 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( -
- -
+ expect(container.innerHTML).toContain( + 'euiFormControlLayout--fullWidth' + ); + expect(container.innerHTML).toContain('euiComboBox--fullWidth'); + expect(container.innerHTML).toContain( + 'euiComboBox__inputWrap--fullWidth' ); - 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', () => { + test('autoFocus', () => { const { getByTestSubject } = render( - + ); - expect(getByTestSubject('comboBoxClearButton')).toBeInTheDocument(); - }); - - it('does not render when no options are selected', () => { - const { queryByTestSubject } = render( - + expect(document.activeElement).toBe( + getByTestSubject('comboBoxSearchInput') ); - - expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); }); - it('does not render when isClearable is false', () => { - const { queryByTestSubject } = render( + test('aria-label / aria-labelledby renders on the input, not on the wrapper', () => { + const { getByTestSubject } = render( ); + const input = getByTestSubject('comboBoxSearchInput'); - expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); + expect(input).toHaveAttribute('aria-label', 'Test label'); + expect(input).toHaveAttribute('aria-labelledby', 'test-heading-id'); }); - it('calls the onChange callback with empty array', () => { - const onChangeHandler = jest.fn(); + test('inputRef', () => { + const inputRefCallback = jest.fn(); - const { getByTestSubject } = render( - + const { getByRole } = render( + ); - fireEvent.click(getByTestSubject('comboBoxClearButton')); + expect(inputRefCallback).toHaveBeenCalledTimes(1); - expect(onChangeHandler).toHaveBeenCalledTimes(1); - expect(onChangeHandler).toHaveBeenCalledWith([]); + expect(getByRole('combobox')).toBe(inputRefCallback.mock.calls[0][0]); }); - it('focuses the input', () => { - const { getByTestSubject } = render( - {}} - /> + test('onSearchChange', () => { + const onSearchChange = jest.fn(); + const { getByTestSubject, queryAllByRole } = render( + ); - fireEvent.click(getByTestSubject('comboBoxClearButton')); + 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); }); }); - describe('sortMatchesBy', () => { - const sortMatchesByOptions = [ - { label: 'Something is Disabled' }, - ...options, + 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(); - test('"none"', () => { - const { getByTestSubject, getAllByRole } = render( - - ); - fireEvent.change(getByTestSubject('comboBoxSearchInput'), { - target: { value: 'di' }, + const dropdownOptions = baseElement.querySelectorAll( + '.euiFilterSelectItem' + ); + expect( + dropdownOptions[0]!.querySelector('[data-euiicon-type="check"]') + ).toBeFalsy(); + expect( + dropdownOptions[1]!.querySelector('[data-euiicon-type="check"]') + ).toBeTruthy(); + }); + + 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' }]); + }); }); - const foundOptions = getAllByRole('option'); - expect(foundOptions).toHaveLength(2); - expect(foundOptions[0]).toHaveTextContent('Something is Disabled'); - expect(foundOptions[1]).toHaveTextContent('Dione'); + 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(); + }); + }); }); - test('"startsWith"', () => { - const { getByTestSubject, getAllByRole } = render( - - ); - fireEvent.change(getByTestSubject('comboBoxSearchInput'), { - target: { value: 'di' }, + 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( +
+ +
+ ); + 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(); + }); + + it('does not render when no options are selected', () => { + const { queryByTestSubject } = render( + + ); + + expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); + }); + + it('does not render when isClearable is false', () => { + const { queryByTestSubject } = render( + + ); + + expect(queryByTestSubject('comboBoxClearButton')).toBeFalsy(); + }); + + it('calls the onChange callback with empty array', () => { + const onChangeHandler = jest.fn(); + + const { getByTestSubject } = render( + + ); + fireEvent.click(getByTestSubject('comboBoxClearButton')); + + expect(onChangeHandler).toHaveBeenCalledTimes(1); + expect(onChangeHandler).toHaveBeenCalledWith([]); + }); + + it('focuses the input', () => { + const { getByTestSubject } = render( + {}} + /> + ); + fireEvent.click(getByTestSubject('comboBoxClearButton')); - const foundOptions = getAllByRole('option'); - expect(foundOptions).toHaveLength(2); - expect(foundOptions[0]).toHaveTextContent('Dione'); - expect(foundOptions[1]).toHaveTextContent('Something is Disabled'); + expect(document.activeElement).toBe( + getByTestSubject('comboBoxSearchInput') + ); + }); }); - }); - describe('isCaseSensitive', () => { - const isCaseSensitiveOptions = [{ label: 'Case sensitivity' }]; + describe('sortMatchesBy', () => { + const sortMatchesByOptions = [ + { label: 'Something is Disabled' }, + ...options, + ]; - test('false', () => { - const { getByTestSubject, queryAllByRole } = render( - - ); - fireEvent.change(getByTestSubject('comboBoxSearchInput'), { - target: { value: 'case' }, + 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'); }); - expect(queryAllByRole('option')).toHaveLength(1); + 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'); + }); }); - test('true', () => { - const { getByTestSubject, queryAllByRole } = render( - - ); - const input = getByTestSubject('comboBoxSearchInput'); + describe('isCaseSensitive', () => { + const isCaseSensitiveOptions = [{ label: 'Case sensitivity' }]; - fireEvent.change(input, { target: { value: 'case' } }); - expect(queryAllByRole('option')).toHaveLength(0); + test('false', () => { + const { getByTestSubject, queryAllByRole } = render( + + ); + fireEvent.change(getByTestSubject('comboBoxSearchInput'), { + target: { value: 'case' }, + }); - fireEvent.change(input, { target: { value: 'Case' } }); - expect(queryAllByRole('option')).toHaveLength(1); + expect(queryAllByRole('option')).toHaveLength(1); + }); + + 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(1); + }); }); }); }); From 5813c357bfced6273cd024f80ac9bbcf8deccc46 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:05:24 +0200 Subject: [PATCH 04/21] fix(EuiComboBox): fix isLoading layout for mobile --- .../combo_box/combo_box_options_list/combo_box_options_list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 = ( - + From c3e19f87458c59a64fa82b165d61e7b9f77d5fe4 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:06:01 +0200 Subject: [PATCH 05/21] docs(storybook): add separate tooltip story for ComboBox --- .../combo_box/combo_box.stories.tsx | 122 +++++++++++++----- 1 file changed, 87 insertions(+), 35 deletions(-) diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx index c283edaf250..9f60a833567 100644 --- a/src/components/combo_box/combo_box.stories.tsx +++ b/src/components/combo_box/combo_box.stories.tsx @@ -10,15 +10,23 @@ import React, { useCallback, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { action } from '@storybook/addon-actions'; +import { hideStorybookControls } from '../../../.storybook/utils'; +import { ToolTipPositions } from '../tool_tip'; import { EuiComboBox, EuiComboBoxProps } from './combo_box'; import { EuiComboBoxOptionMatcher } from './types'; import { EuiCode } from '../code'; +const toolTipProps = { + toolTipContent: 'This is a tooltip!', + toolTipProps: { position: 'bottom' as ToolTipPositions }, + 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 +72,85 @@ 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 = { + args: { + options: options.map((option) => ({ ...option, ...toolTipProps })), }, + render: (args) => , +}; +// hide props as they are not relevant for testing the story args +hideStorybookControls(WithTooltip, [ + 'append', + 'aria-label', + 'aria-labelledby', + 'async', + 'autoFocus', + 'compressed', + 'customOptionText', + 'delimiter', + 'id', + 'inputPopoverProps', + 'inputRef', + 'isCaseSensitive', + 'isClearable', + 'isDisabled', + 'isInvalid', + 'isLoading', + 'noSuggestions', + 'onBlur', + 'onChange', + 'onCreateOption', + 'onFocus', + 'onKeyDown', + 'onSearchChange', + 'placeholder', + 'prepend', + 'renderOption', + 'rowHeight', + 'singleSelection', + 'sortMatchesBy', + 'truncationProps', +]); + +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 ( + + ); }; export const CustomMatcher: Story = { From 7615fa132fa6ee28b8fbd14d2a9b6fb912b88197 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:15:15 +0200 Subject: [PATCH 06/21] test(EuiComboBox): update input options placement --- src/components/combo_box/combo_box.test.tsx | 49 ++++++++++++++------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index 10643adf2cc..34e9d3ddea3 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -223,24 +223,24 @@ describe('EuiComboBox', () => { }); describe('toolTipContent & tooltipProps', () => { - const options = [ - { - label: 'Titan', - 'data-test-subj': 'titanOption', - toolTipContent: 'I am a tooltip!', - toolTipProps: { - 'data-test-subj': 'optionToolTip', + 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', - }, - ]; + { + label: 'Enceladus', + }, + { + label: 'Mimas', + }, + ]; - it('renders a tooltip with applied props on mouseover', async () => { const { getByTestSubject } = render(); await showEuiComboBoxOptions(); @@ -255,6 +255,23 @@ describe('EuiComboBox', () => { }); it('renders a tooltip with applied props on focus', 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(); From ad0a3c5fd8d1b11c535a5cb620422b838809c437 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:43:28 +0200 Subject: [PATCH 07/21] refactor(EuiComboBox): prevent tooltip props from being applied on input --- src/components/combo_box/combo_box_input/combo_box_input.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 = From 3c66b4359c44e5247c550659df862fbc54e0e7a2 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:43:58 +0200 Subject: [PATCH 08/21] docs(EuiComboBox): add EuiDocs example --- .../src/views/combo_box/combo_box_example.js | 39 ++++++++ src-docs/src/views/combo_box/tool_tips.js | 99 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src-docs/src/views/combo_box/tool_tips.js 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.js b/src-docs/src/views/combo_box/tool_tips.js new file mode 100644 index 00000000000..fa2f3aec2b7 --- /dev/null +++ b/src-docs/src/views/combo_box/tool_tips.js @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; + +import { EuiComboBox } from '../../../../src/components'; +import { DisplayToggles } from '../form_controls/display_toggles'; + +const optionsStatic = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Enceladus is disabled', + disabled: true, + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Mimas', + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Dione', + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Iapetus', + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Phoebe', + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Rhea', + toolTipContent: 'Lorem ipsum', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Tethys', + toolTipContent: 'Lorem ipsum', + }, + { + label: 'Hyperion', + toolTipContent: 'Lorem ipsum', + }, +]; +export default () => { + const [options, setOptions] = useState(optionsStatic); + const [selectedOptions, setSelected] = useState([options[2], options[4]]); + + const onChange = (selectedOptions) => { + setSelected(selectedOptions); + }; + + const onCreateOption = (searchValue, flattenedOptions = []) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if ( + flattenedOptions.findIndex( + (option) => option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + ) { + setOptions([...options, newOption]); + } + + // Select the option. + setSelected([...selectedOptions, newOption]); + }; + + return ( + /* DisplayToggles wrapper for Docs only */ + + + + ); +}; From 7b5236cc7f46696b82bf3d32746d555028f7bb77 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:48:57 +0200 Subject: [PATCH 09/21] chore: add changelog --- changelogs/upcoming/7700.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelogs/upcoming/7700.md diff --git a/changelogs/upcoming/7700.md b/changelogs/upcoming/7700.md new file mode 100644 index 00000000000..e22e6165dc2 --- /dev/null +++ b/changelogs/upcoming/7700.md @@ -0,0 +1,6 @@ +- Added support for `EuiTooltip` on options of `EuiComboBox` + +**Bug fixes** + +- Fixed a visual layout bug for `EuiComboBox` with `isLoading` in mobile views + From 664dc88a29fee78ad3ef0cae8e88536c40c41357 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 2 May 2024 12:20:24 +0200 Subject: [PATCH 10/21] refactor: revert adding isOpen on EuiToolTip and use ref API for show/hide instead for options - dry out prop types - update tests --- .../filter_group/filter_select_item.tsx | 24 +++++++++++-------- src/components/tool_tip/tool_tip.test.tsx | 20 ---------------- src/components/tool_tip/tool_tip.tsx | 17 ------------- 3 files changed, 14 insertions(+), 47 deletions(-) diff --git a/src/components/filter_group/filter_select_item.tsx b/src/components/filter_group/filter_select_item.tsx index cb2a21bf441..618a846c3ca 100644 --- a/src/components/filter_group/filter_select_item.tsx +++ b/src/components/filter_group/filter_select_item.tsx @@ -6,15 +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, EuiToolTipProps } from '../tool_tip'; +import { EuiToolTip } from '../tool_tip'; import { EuiIcon } from '../icon'; +import { EuiComboBoxOptionOption } from '../combo_box'; import { euiFilterSelectItemStyles } from './filter_select_item.styles'; @@ -25,14 +26,8 @@ export interface EuiFilterSelectItemProps checked?: FilterChecked; showIcons?: boolean; isFocused?: boolean; - /** - * Optional custom tooltip content for the button - */ - toolTipContent?: EuiToolTipProps['content']; - /** - * Optional props to pass to the underlying **[EuiToolTip](/#/display/tooltip)** - */ - toolTipProps?: Partial>; + toolTipContent?: EuiComboBoxOptionOption['toolTipContent']; + toolTipProps?: EuiComboBoxOptionOption['toolTipProps']; } const resolveIconAndColor = (checked?: FilterChecked) => { @@ -64,6 +59,7 @@ export class EuiFilterSelectItemClass extends Component< }; buttonRef: HTMLButtonElement | null = null; + tooltipRef = createRef(); state = { hasFocus: false, @@ -75,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; }; diff --git a/src/components/tool_tip/tool_tip.test.tsx b/src/components/tool_tip/tool_tip.test.tsx index 7a11a5c0bb8..f534910f8cd 100644 --- a/src/components/tool_tip/tool_tip.test.tsx +++ b/src/components/tool_tip/tool_tip.test.tsx @@ -182,26 +182,6 @@ describe('EuiToolTip', () => { }); }); - describe('isOpen', () => { - it('shows/hides the tooltip', async () => { - const { rerender } = render( - - - - ); - - await waitForEuiToolTipVisible(); - - rerender( - - - - ); - - await waitForEuiToolTipHidden(); - }); - }); - describe('ref methods', () => { // Although we don't publicly recommend it, consumers may need to reach into EuiToolTip // class methods to manually control visibility state via `show/hideToolTip`. diff --git a/src/components/tool_tip/tool_tip.tsx b/src/components/tool_tip/tool_tip.tsx index 65ed118beae..b9bdb19a436 100644 --- a/src/components/tool_tip/tool_tip.tsx +++ b/src/components/tool_tip/tool_tip.tsx @@ -98,10 +98,6 @@ export interface EuiToolTipProps extends CommonProps { * Suggested position. If there is not enough room for it this will be changed. */ position: ToolTipPositions; - /** - * For controlled use to show/hide the tooltip - */ - isOpen?: boolean; /** * When `true`, the tooltip's position is re-calculated when the user * scrolls. This supports having fixed-position tooltip anchors. @@ -157,10 +153,6 @@ export class EuiToolTip extends Component { if (this.props.repositionOnScroll) { window.addEventListener('scroll', this.positionToolTip, true); } - - if (this.props.isOpen) { - this.showToolTip(); - } } componentWillUnmount() { @@ -174,14 +166,6 @@ export class EuiToolTip extends Component { requestAnimationFrame(this.testAnchor); } - if (prevProps.isOpen !== this.props.isOpen) { - if (this.props.isOpen) { - this.showToolTip(); - } else { - this.hideToolTip(); - } - } - // update scroll listener if (prevProps.repositionOnScroll !== this.props.repositionOnScroll) { if (this.props.repositionOnScroll) { @@ -323,7 +307,6 @@ export class EuiToolTip extends Component { delay, display, repositionOnScroll, - isOpen, ...rest } = this.props; From ffc84a9e3fb869301757ce9f13cadc9f9023173b Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 2 May 2024 12:21:12 +0200 Subject: [PATCH 11/21] refactor: use remove tooltip wrapper in favor of using anchorProps for styles --- .../filter_group/filter_select_item.tsx | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/components/filter_group/filter_select_item.tsx b/src/components/filter_group/filter_select_item.tsx index 618a846c3ca..dbd24c108d9 100644 --- a/src/components/filter_group/filter_select_item.tsx +++ b/src/components/filter_group/filter_select_item.tsx @@ -106,8 +106,23 @@ export class EuiFilterSelectItemClass extends Component< const classes = classNames('euiFilterSelectItem', className); - const hasToolTip = - !disabled && React.isValidElement(children) && toolTipContent; + const hasToolTip = !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) { @@ -150,18 +165,16 @@ export class EuiFilterSelectItemClass extends Component< ); return hasToolTip ? ( - // This extra wrapper is needed to ensure that the tooltip has a correct context - // for positioning while also ensuring to wrap the interactive option - - - {optionItem} - - + + {optionItem} + ) : ( optionItem ); From 90197c5b096c456301487f08efd87cfb8cf7f9cd Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 2 May 2024 12:22:00 +0200 Subject: [PATCH 12/21] chore: add PR feedback --- changelogs/upcoming/7700.md | 2 +- .../combo_box/combo_box.stories.tsx | 41 +++---------------- src/components/combo_box/combo_box.test.tsx | 2 +- 3 files changed, 8 insertions(+), 37 deletions(-) diff --git a/changelogs/upcoming/7700.md b/changelogs/upcoming/7700.md index e22e6165dc2..8a77f98a47a 100644 --- a/changelogs/upcoming/7700.md +++ b/changelogs/upcoming/7700.md @@ -1,4 +1,4 @@ -- Added support for `EuiTooltip` on options of `EuiComboBox` +- 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** diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx index 9f60a833567..dba7d675efe 100644 --- a/src/components/combo_box/combo_box.stories.tsx +++ b/src/components/combo_box/combo_box.stories.tsx @@ -10,7 +10,6 @@ import React, { useCallback, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { hideStorybookControls } from '../../../.storybook/utils'; import { ToolTipPositions } from '../tool_tip'; import { EuiComboBox, EuiComboBoxProps } from './combo_box'; import { EuiComboBoxOptionMatcher } from './types'; @@ -18,7 +17,7 @@ import { EuiCode } from '../code'; const toolTipProps = { toolTipContent: 'This is a tooltip!', - toolTipProps: { position: 'bottom' as ToolTipPositions }, + toolTipProps: { position: 'left' as ToolTipPositions }, value: 4, }; @@ -76,44 +75,16 @@ export const Playground: Story = { }; export const WithTooltip: Story = { + parameters: { + controls: { + include: ['fullWidth', 'options', 'selectedOptions'], + }, + }, args: { options: options.map((option) => ({ ...option, ...toolTipProps })), }, render: (args) => , }; -// hide props as they are not relevant for testing the story args -hideStorybookControls(WithTooltip, [ - 'append', - 'aria-label', - 'aria-labelledby', - 'async', - 'autoFocus', - 'compressed', - 'customOptionText', - 'delimiter', - 'id', - 'inputPopoverProps', - 'inputRef', - 'isCaseSensitive', - 'isClearable', - 'isDisabled', - 'isInvalid', - 'isLoading', - 'noSuggestions', - 'onBlur', - 'onChange', - 'onCreateOption', - 'onFocus', - 'onKeyDown', - 'onSearchChange', - 'placeholder', - 'prepend', - 'renderOption', - 'rowHeight', - 'singleSelection', - 'sortMatchesBy', - 'truncationProps', -]); const StatefulComboBox = ({ singleSelection, diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index 34e9d3ddea3..00533c38e36 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -254,7 +254,7 @@ describe('EuiComboBox', () => { ); }); - it('renders a tooltip with applied props on focus', async () => { + it('renders a tooltip with applied props on keyboard navigation', async () => { const options = [ { label: 'Titan', From fee66ff325c06a751399649dcbb9c6c5696254f2 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 2 May 2024 12:22:32 +0200 Subject: [PATCH 13/21] docs: update EuiComboBox tooltip docs example --- src-docs/src/views/combo_box/tool_tips.js | 99 ---------------------- src-docs/src/views/combo_box/tool_tips.tsx | 43 ++++++++++ 2 files changed, 43 insertions(+), 99 deletions(-) delete mode 100644 src-docs/src/views/combo_box/tool_tips.js create mode 100644 src-docs/src/views/combo_box/tool_tips.tsx diff --git a/src-docs/src/views/combo_box/tool_tips.js b/src-docs/src/views/combo_box/tool_tips.js deleted file mode 100644 index fa2f3aec2b7..00000000000 --- a/src-docs/src/views/combo_box/tool_tips.js +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useState } from 'react'; - -import { EuiComboBox } from '../../../../src/components'; -import { DisplayToggles } from '../form_controls/display_toggles'; - -const optionsStatic = [ - { - label: 'Titan', - 'data-test-subj': 'titanOption', - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Enceladus is disabled', - disabled: true, - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Mimas', - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Dione', - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Iapetus', - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Phoebe', - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Rhea', - toolTipContent: 'Lorem ipsum', - }, - { - label: - "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Tethys', - toolTipContent: 'Lorem ipsum', - }, - { - label: 'Hyperion', - toolTipContent: 'Lorem ipsum', - }, -]; -export default () => { - const [options, setOptions] = useState(optionsStatic); - const [selectedOptions, setSelected] = useState([options[2], options[4]]); - - const onChange = (selectedOptions) => { - setSelected(selectedOptions); - }; - - const onCreateOption = (searchValue, flattenedOptions = []) => { - const normalizedSearchValue = searchValue.trim().toLowerCase(); - - if (!normalizedSearchValue) { - return; - } - - const newOption = { - label: searchValue, - }; - - // Create the option if it doesn't exist. - if ( - flattenedOptions.findIndex( - (option) => option.label.trim().toLowerCase() === normalizedSearchValue - ) === -1 - ) { - setOptions([...options, newOption]); - } - - // Select the option. - setSelected([...selectedOptions, newOption]); - }; - - return ( - /* DisplayToggles wrapper for Docs only */ - - - - ); -}; 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 ( + + ); +}; From 67a9db73b2c4124d916338be3452ac134f420a27 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 2 May 2024 13:13:36 +0200 Subject: [PATCH 14/21] chore: cleanup wrongly removed type check --- src/components/filter_group/filter_select_item.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/filter_group/filter_select_item.tsx b/src/components/filter_group/filter_select_item.tsx index dbd24c108d9..11c65a68cc2 100644 --- a/src/components/filter_group/filter_select_item.tsx +++ b/src/components/filter_group/filter_select_item.tsx @@ -106,7 +106,8 @@ export class EuiFilterSelectItemClass extends Component< const classes = classNames('euiFilterSelectItem', className); - const hasToolTip = !disabled && toolTipContent; + const hasToolTip = + !disabled && React.isValidElement(children) && toolTipContent; let anchorProps = undefined; if (hasToolTip) { From b8ebf6159ca5539aa9f7fcdb953f846fd4c1b330 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 2 May 2024 17:32:31 +0200 Subject: [PATCH 15/21] chore: add comment --- src/components/filter_group/filter_select_item.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/filter_group/filter_select_item.tsx b/src/components/filter_group/filter_select_item.tsx index 11c65a68cc2..492069a9347 100644 --- a/src/components/filter_group/filter_select_item.tsx +++ b/src/components/filter_group/filter_select_item.tsx @@ -107,7 +107,10 @@ export class EuiFilterSelectItemClass extends Component< const classes = classNames('euiFilterSelectItem', className); const hasToolTip = - !disabled && React.isValidElement(children) && toolTipContent; + // 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) { From 7e48f0ab18f1e522cf6f6a9862c0768ffb81dd62 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 2 May 2024 11:54:30 -0700 Subject: [PATCH 16/21] Fix Firefox behavior by checking for `scrollTarget.contains` + clean up comments + remove now-unnecessary setTimeout workaround, this one is stronker --- src/components/popover/input_popover.tsx | 35 +++++++++++++----------- 1 file changed, 19 insertions(+), 16 deletions(-) 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]); From aa016c1980c763bc40a42dcd5684266d40893bd6 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 2 May 2024 12:48:08 -0700 Subject: [PATCH 17/21] Update/add Cypress tests --- src/components/popover/input_popover.spec.tsx | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) 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'); From dbd8866e47fec8903a7d200ebf6aceb982a143d5 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 3 May 2024 09:54:11 +0200 Subject: [PATCH 18/21] test(VRT): add base snapshots for EuiComboBox --- ...me_desktop_Forms_EuiComboBox_With_Tooltip.png | Bin 0 -> 2171 bytes ...ome_mobile_Forms_EuiComboBox_With_Tooltip.png | Bin 0 -> 4950 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .loki/reference/chrome_desktop_Forms_EuiComboBox_With_Tooltip.png create mode 100644 .loki/reference/chrome_mobile_Forms_EuiComboBox_With_Tooltip.png 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 0000000000000000000000000000000000000000..b78bb812930527b67a1e9f111844bec9a8c77f73 GIT binary patch literal 2171 zcmV->2!!{EP)l00001b5ch_0Itp) z=>Px#1am@3R0s$N2z&@+hyVZxB}qgLWqb3OGHG`L)UdE z%5a|W`V_>+njlFM#E@6f-MxdbSd55|HAOm3A|i@neI6hDeihb)d60a56_O+|c@PoN zq!QxH5JJG`S0Q2QdMqQ<0MSUr7%ShOI1P9_fku$YZl zv>+ZmeM6BBg@`DsOqxLa0d>wMj}aELaZYyM5fM!!q%ja8A_||52X1`P)P|nE0rd3r z0|2bncvur+u_7fINegWej&OW4HVZ2`IT@EPUj_i2KYt!=ZEZ7k=`lJxJF#igCahVr zCT#a0A__NE)$s4yn|S}+C%ARH7403}@c9Gq`2%S0=*F$vt$6?3C%9UB6Pgx%8{CFv z8gE%yS=bUte}6wRGc%Eqkr7h;;^Jb|*4BMAuB5jcDo&wm6a$cD8R;z8T_re&_)XjJTDJdy| z-EPN;6DN?Hn+uoAg=5E#;mVaOxO3+YT3T9g;J^U@fGo=h1Olk3sX<9e3G(yvQCeDx z3l}axk|fmB)Qq`)TwEMdQ&U5ZCp|qK)z#I=$;m-;b2FNon~{@~gX-#Pq^GCPSi6ac zLe&7&Xi27>{czxypDEwl#9o=LLd<;?Xk7)C#)Z#3SW@Y=U;Hz9BPkMP#p2Pci= zt+x+i+xA^(Yn$G)Hte&o+-^7a@86F@hYkS%R8_^|#fyXA$HWY;@RlSg`1i=?k*=

YXTY*@D%m;TcLRnx%;O961t>+m5TQt`6DR*{G_jg57S1-EK!!RTZ+cvr$)9 zH*0Ya5si4bTPjEZzCZwe|L_sxkE7uAT_`Gi10Q|-PyFV$zk}Z&fY0Z{FW)T2#Y>k_ zRQSeYB@ZA>Zy-52Impe;#lC&}P*_-q`ucigWMn|oG^C}a;qc+Z)4q0jc{%d)^P%fH z0)YT577JFdUX5$lu8mv&`0?X#xm;Mje0i{J)v8rEb?OvOo;=BcbRwE{^KFUv@_y&2 zvPIvp;l;HMT)0vXApzf6zXt9e585BN$NV-_JDl5~C<+e!^&PzVt752{hEG5J423@{ zzz*lO$8Lvcz1OQhlN9&J3OAeDv1a+=M}9w7)HmM8%9KTs4ugoE0#|Eqj+%uf34tHH zya5-k)}ynl7XXm7(1s0b9XRukOVGZyP$b=vg7q(~oYMXNe(WR;{N--|fL|2-6tBLT z``B&xZ-<9lnGg}pwiVAki~5_*!PVuOuEX`=CFoyE6de!TW0Hp~8?fTJXQzC>AMVJ* z`gIwA5J+1Q`Z9>g@IWadqFI(0AB&9VSBxst$61c9JyfqooEp7RXCJ%rK zgor32(j6&SU`q(O{sLP99BE6#7}3-*fe;ZzfDi)NFKs}&BW1!FS3&^KJ5rGS(uSy8 z!#3#{h{ zMMOl?6Hrvh1__eIY=mbZ5b1b`i0BF785mw%E}2ckE3ErGz6ggzL`2c9&*Os-fZ1e# z81gE*s_N+O9fZLk5Eo;D*<^V1iCIKMG^ePl2A^MrXV4E#0~T24K?pINKt?M0{0h82 x1&X4LyR|S85zRe92*|Poi&=)*^#Aqe{{vJ`$bz@&A+!Jh002ovPDHLkV1gVV3$FkG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eba304a0eb9b24c3680b2980af4c47fde93372ce GIT binary patch literal 4950 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 KQkjBv@c#hj!l@hp literal 0 HcmV?d00001 From 8c1b6934d8be73a8daec32ec9c734d4184972eb4 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Mon, 29 Apr 2024 16:55:13 +0200 Subject: [PATCH 19/21] feat: Add `optionMatcher` prop to `EuiSelectable` and `EuiComboBox` components (#7709) --- changelogs/upcoming/7709.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelogs/upcoming/7709.md 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 From c50b604148147494219dd88050309983980ce5f9 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Thu, 18 Apr 2024 23:06:01 +0200 Subject: [PATCH 20/21] docs(storybook): add separate tooltip story for ComboBox --- .../combo_box/combo_box.stories.tsx | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx index dba7d675efe..27827b45387 100644 --- a/src/components/combo_box/combo_box.stories.tsx +++ b/src/components/combo_box/combo_box.stories.tsx @@ -86,44 +86,6 @@ export const WithTooltip: Story = { render: (args) => , }; -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 ( - - ); -}; - export const CustomMatcher: Story = { render: function Render({ singleSelection, onCreateOption, ...args }) { const [selectedOptions, setSelectedOptions] = useState( @@ -165,3 +127,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 ( + + ); +}; From 9929c3e3d89ece2723a1b7a16046bfb6cb4a12d5 Mon Sep 17 00:00:00 2001 From: Lene Gadewoll Date: Fri, 3 May 2024 18:39:14 +0200 Subject: [PATCH 21/21] chore: cleanup -use const as type - revert newline changes --- src/components/combo_box/combo_box.stories.tsx | 3 +-- src/components/combo_box/combo_box.test.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx index 27827b45387..771f277889c 100644 --- a/src/components/combo_box/combo_box.stories.tsx +++ b/src/components/combo_box/combo_box.stories.tsx @@ -10,14 +10,13 @@ import React, { useCallback, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { ToolTipPositions } from '../tool_tip'; 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 ToolTipPositions }, + toolTipProps: { position: 'left' as const }, value: 4, }; diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index 00533c38e36..251222921e6 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -525,21 +525,26 @@ describe('EuiComboBox', () => { 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(
); 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(); });