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 = (
(this.buttonRef = ref)}
role="option"
@@ -110,6 +148,7 @@ export class EuiFilterSelectItemClass extends Component<
css={cssStyles}
disabled={disabled}
aria-disabled={disabled}
+ style={!hasToolTip ? style : undefined}
{...rest}
>
);
+
+ 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
-
-
+
+
+
+
);
};
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]);