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 ea232ba2ded..62c78c8d76a 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -9,6 +9,7 @@ import { EuiCode, EuiComboBox, EuiText, + EuiTextTruncate, EuiCallOut, } from '../../../../src/components'; @@ -82,6 +83,21 @@ const renderOptionSnippet = ``; +import Truncation from './truncation'; +const truncationSource = require('!!raw-loader!./truncation'); +const truncationSnippet = ``; + import Groups from './groups'; const groupsSource = require('!!raw-loader!./groups'); const groupsSnippet = ` + By default, EuiComboBox truncates long option text at + the end of the string. You can use truncationProps{' '} + and almost any prop that{' '} + + EuiTextTruncate + {' '} + accepts to configure this behavior. This can be configured at the{' '} + EuiComboBox level, as well as by each individual + option. +

+ ), + props: { EuiComboBox, EuiComboBoxOptionOption, EuiTextTruncate }, + snippet: truncationSnippet, + demo: , + }, { title: 'Groups', source: [ diff --git a/src-docs/src/views/combo_box/truncation.tsx b/src-docs/src/views/combo_box/truncation.tsx new file mode 100644 index 00000000000..2719051ff4d --- /dev/null +++ b/src-docs/src/views/combo_box/truncation.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; + +import { + useGeneratedHtmlId, + EuiFlexGroup, + EuiFlexItem, + EuiButtonGroup, + EuiFieldNumber, + EuiTextTruncationTypes, + EuiTitle, + EuiSpacer, + EuiComboBox, + EuiComboBoxOptionOption, +} from '../../../../src'; + +const options: EuiComboBoxOptionOption[] = [ + { + label: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, lorem ispum', + }, + { + label: + 'Phasellus enim turpis, molestie ut nisi ut, suscipit tristique libero', + }, + { + label: 'Ut sagittis interdum nisi, pellentesque laoreet arcu blandit a', + }, + { + label: 'Fusce sed viverra nisl', + }, + { + label: 'Donec maximus est justo, eget semper lorem lacinia nec', + }, + { + label: 'Vestibulum lobortis ipsum sit amet tellus scelerisque vestibulum', + }, + { + label: 'This combobox option has an individual `truncationProps` override', + // Option `truncationProps` will override EuiComboBox `truncationProps` + truncationProps: { + truncation: 'start', + truncationOffset: 5, + }, + }, +]; + +export default () => { + const [selectedOptions, setSelected] = useState( + [] + ); + const onChange = (selectedOptions: EuiComboBoxOptionOption[]) => { + setSelected(selectedOptions); + }; + + const [truncation, setTruncation] = useState('end'); + const [truncationOffset, setTruncationOffset] = useState(0); + const offsetId = useGeneratedHtmlId(); + + return ( + <> + + + +

Truncation type

+
+ + setTruncation(id as EuiTextTruncationTypes)} + options={[ + { id: 'start', label: 'start ' }, + { id: 'end', label: 'end' }, + { id: 'startEnd', label: 'startEnd' }, + { id: 'middle', label: 'middle' }, + ]} + color="primary" + /> +
+ {(truncation === 'start' || truncation === 'end') && ( + + +

Truncation offset

+
+ + setTruncationOffset(Number(e.target.value))} + compressed + /> +
+ )} +
+ + + + ); +}; diff --git a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap index d3d455f538a..0f3649cd923 100644 --- a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap +++ b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap @@ -535,7 +535,6 @@ exports[`props option.prepend & option.append renders in the options dropdown 1` id="generated-id__option-0" role="option" style="position: absolute; left: 0px; top: 0px; height: 29px; width: 100%;" - title="1" type="button" > - 1 +
+
@@ -571,7 +585,6 @@ exports[`props option.prepend & option.append renders in the options dropdown 1` id="generated-id__option-1" role="option" style="position: absolute; left: 0px; top: 29px; height: 29px; width: 100%;" - title="2" type="button" > - 2 +
+
- Titan +
+
@@ -716,7 +758,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-1" role="option" style="position: absolute; left: 0px; top: 29px; height: 29px; width: 100%;" - title="Enceladus" type="button" > - Enceladus +
+
@@ -743,7 +799,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-2" role="option" style="position: absolute; left: 0px; top: 58px; height: 29px; width: 100%;" - title="Mimas" type="button" > - Mimas +
+
@@ -770,7 +840,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-3" role="option" style="position: absolute; left: 0px; top: 87px; height: 29px; width: 100%;" - title="Dione" type="button" > - Dione +
+
@@ -797,7 +881,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-4" role="option" style="position: absolute; left: 0px; top: 116px; height: 29px; width: 100%;" - title="Iapetus" type="button" > - Iapetus +
+
@@ -824,7 +922,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-5" role="option" style="position: absolute; left: 0px; top: 145px; height: 29px; width: 100%;" - title="Phoebe" type="button" > - Phoebe +
+
@@ -851,7 +963,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-6" role="option" style="position: absolute; left: 0px; top: 174px; height: 29px; width: 100%;" - title="Rhea" type="button" > - Rhea +
+
@@ -878,7 +1004,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-7" role="option" style="position: absolute; left: 0px; top: 203px; height: 29px; width: 100%;" - title="Pandora is one of Saturn's moons, named for a Titaness of Greek mythology" type="button" > - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology +
+
@@ -905,7 +1045,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-8" role="option" style="position: absolute; left: 0px; top: 232px; height: 29px; width: 100%;" - title="Tethys" type="button" > - Tethys +
+
diff --git a/src/components/combo_box/combo_box.spec.tsx b/src/components/combo_box/combo_box.spec.tsx index 3266c16d207..6fe737d7003 100644 --- a/src/components/combo_box/combo_box.spec.tsx +++ b/src/components/combo_box/combo_box.spec.tsx @@ -13,6 +13,19 @@ import React, { useState } from 'react'; import { EuiComboBox } from './index'; +// CI doesn't have access to the Inter font, so we need to manually include it +// for truncation font calculations to work correctly +before(() => { + const linkElem = document.createElement('link'); + linkElem.setAttribute('rel', 'stylesheet'); + linkElem.setAttribute( + 'href', + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap' + ); + document.head.appendChild(linkElem); + cy.wait(1000); // Wait a second to give the font time to load/swap in +}); + describe('EuiComboBox', () => { describe('Focus management', () => { it('keeps focus on the input box when clicking a disabled item', () => { @@ -37,6 +50,97 @@ describe('EuiComboBox', () => { }); }); + describe('truncation', () => { + const sharedProps = { + style: { width: 200 }, + options: [ + { label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }, + ], + }; + + it('defaults to end truncation', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum dolor sit a…' + ); + }); + + it('allows customizing truncationProps', () => { + cy.realMount( + + ); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum …piscing elit.' + ); + }); + + it('allows individual option truncationProps to override parent truncationProps', () => { + cy.realMount( + + ); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem…tur adipiscing elit.' + ); + }); + + describe('when searching', () => { + it('uses start & end truncation position', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('sit'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + '…sum dolor sit amet, co…' + ); + }); + + it('does not truncate the start when the found search is near the start', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('ipsum'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum dolor sit a…' + ); + }); + + it('does not truncate the end when the found search is near the end', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('eli'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + '…nsectetur adipiscing elit.' + ); + }); + + it('marks the full available text if the search input is longer than the truncated text', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('Lorem ipsum dolor sit amet'); + cy.get('.euiMark').should('have.text', '…rem ipsum dolor sit am…'); + }); + }); + }); + describe('Backspace to delete last pill', () => { const options = [ { label: 'Item 1' }, diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index eb2bf1624a2..e2151d9ed57 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -10,6 +10,7 @@ import React, { ReactNode } from 'react'; import { act, fireEvent } from '@testing-library/react'; import { shallow, mount } from 'enzyme'; import { render } from '../../test/rtl'; +import { shouldRenderCustomStyles } from '../../test/internal'; import { requiredProps, findTestSubject, @@ -64,6 +65,17 @@ const options: TitanOption[] = [ ]; describe('EuiComboBox', () => { + shouldRenderCustomStyles(); + + shouldRenderCustomStyles(, { + skip: { parentTest: true }, + childProps: ['truncationProps', 'options[0]'], + renderCallback: async ({ getByTestSubject, findAllByTestSubject }) => { + fireEvent.click(getByTestSubject('comboBoxToggleListButton')); + await findAllByTestSubject('truncatedText'); + }, + }); + test('is rendered', () => { const { container } = render(); diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index 2858aa20e1c..9e967f97600 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -18,11 +18,16 @@ import React, { RefCallback, } from 'react'; import classNames from 'classnames'; +import AutosizeInput from 'react-input-autosize'; import { findPopoverPosition, htmlIdGenerator, keys } from '../../services'; +import { getElementZIndex } from '../../services/popover'; +import { CommonProps } from '../common'; import { EuiPortal } from '../portal'; import { EuiI18n } from '../i18n'; -import { EuiComboBoxOptionsList } from './combo_box_options_list'; +import { EuiFormControlLayoutProps } from '../form'; +import { EuiFilterSelectItemClass } from '../filter_group/filter_select_item'; +import type { EuiTextTruncateProps } from '../text_truncate'; import { getMatchingOptions, @@ -44,11 +49,7 @@ import { EuiComboBoxOptionsListPosition, EuiComboBoxSingleSelectionShape, } from './types'; -import { EuiFilterSelectItemClass } from '../filter_group/filter_select_item'; -import AutosizeInput from 'react-input-autosize'; -import { CommonProps } from '../common'; -import { EuiFormControlLayoutProps } from '../form'; -import { getElementZIndex } from '../../services/popover'; +import { EuiComboBoxOptionsList } from './combo_box_options_list'; type DrillProps = Pick< EuiComboBoxOptionsListProps, @@ -156,6 +157,16 @@ export interface _EuiComboBoxProps * supplied by `aria-label` or from [EuiFormRow](/#/forms/form-layouts). */ 'aria-labelledby'?: string; + /** + * By default, EuiComboBox will truncate option labels at the end of + * the string. You can use pass in a custom truncation configuration that + * accepts any prop that [EuiTextTruncate](/#/utilities/text-truncate) prop + * except for `text` and `children`. + * + * Note: when searching, custom truncation props are ignored. The highlighted search + * text will always take precedence. + */ + truncationProps?: Partial>; } /** @@ -921,6 +932,7 @@ export class EuiComboBox extends Component< delimiter, append, autoFocus, + truncationProps, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest @@ -1007,6 +1019,7 @@ export class EuiComboBox extends Component< getSelectedOptionForSearchValue } listboxAriaLabel={listboxAriaLabel} + truncationProps={truncationProps} /> )} 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 744375462bc..a9f8e1ddde4 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 @@ -193,8 +193,16 @@ export class EuiComboBoxInput extends Component< const pills = selectedOptions ? selectedOptions.map((option) => { - const { key, label, color, onClick, append, prepend, ...rest } = - option; + const { + key, + label, + color, + onClick, + append, + prepend, + truncationProps, + ...rest + } = option; const pillOnClose = isDisabled || singleSelection || onClick ? undefined 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 de519ad32b4..c554226e268 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 @@ -21,6 +21,7 @@ import { import { EuiFlexGroup, EuiFlexItem } from '../../flex'; import { EuiHighlight } from '../../highlight'; +import { EuiMark } from '../../mark'; import { EuiText } from '../../text'; import { EuiLoadingSpinner } from '../../loading'; import { EuiComboBoxTitle } from './combo_box_title'; @@ -31,6 +32,12 @@ import { FilterChecked, } from '../../filter_group/filter_select_item'; import { htmlIdGenerator } from '../../../services'; +import { CommonProps } from '../../common'; +import { EuiBadge } from '../../badge'; +import { EuiPopoverPanel } from '../../popover/popover_panel'; +import { EuiTextTruncate } from '../../text_truncate'; + +import type { _EuiComboBoxProps } from '../combo_box'; import { EuiComboBoxOptionOption, EuiComboBoxOptionsListPosition, @@ -39,15 +46,9 @@ import { RefInstance, UpdatePositionHandler, } from '../types'; -import { CommonProps } from '../../common'; -import { EuiBadge } from '../../badge'; -import { EuiPopoverPanel } from '../../popover/popover_panel'; - -const OPTION_CONTENT_CLASSNAME = 'euiComboBoxOption__content'; export type EuiComboBoxOptionsListProps = CommonProps & ComponentProps & { - 'data-test-subj': string; activeOptionIndex?: number; areAllOptionsSelected?: boolean; listboxAriaLabel: string; @@ -98,6 +99,7 @@ export type EuiComboBoxOptionsListProps = CommonProps & singleSelection?: boolean | EuiComboBoxSingleSelectionShape; delimiter?: string; zIndex?: number; + truncationProps?: _EuiComboBoxProps['truncationProps']; }; const hitEnterBadge = ( @@ -211,8 +213,16 @@ export class EuiComboBoxOptionsList extends Component< ListRow = ({ data, index, style }: ListChildComponentProps) => { const option = data[index]; - const { key, isGroupLabelOption, label, value, prepend, append, ...rest } = - option; + const { + key, + isGroupLabelOption, + label, + value, + prepend, + append, + truncationProps: _truncationProps, + ...rest + } = option; const { singleSelection, selectedOptions, @@ -223,6 +233,11 @@ export class EuiComboBoxOptionsList extends Component< searchValue, rootId, } = this.props; + // Individual truncation settings should override component prop + const truncationProps = { + ...this.props.truncationProps, + ..._truncationProps, + }; if (isGroupLabelOption) { return ( @@ -260,30 +275,21 @@ export class EuiComboBoxOptionsList extends Component< checked={checked} showIcons={singleSelection ? true : false} id={rootId(`_option-${index}`)} - title={label} {...rest} > {prepend && ( {prepend} )} - {renderOption ? ( - - {renderOption( - option, - searchValue, - 'euiComboBoxOption__renderOption' - )} - - ) : ( - - {label} - - )} + + {renderOption + ? renderOption( + option, + searchValue, + 'euiComboBoxOption__renderOption' + ) + : this.renderTruncatedOption(label, truncationProps)} + {append && ( {append} )} @@ -293,6 +299,66 @@ export class EuiComboBoxOptionsList extends Component< ); }; + optionWidth: number | undefined; + setOptionWidth = (width: number) => { + this.optionWidth = width; + }; + + renderTruncatedOption = ( + text: string, + truncationProps: EuiComboBoxOptionsListProps['truncationProps'] + ) => { + if (!this.props.searchValue) { + return ( + + {(text) => text} + + ); + } + + const searchValue = this.props.searchValue.trim(); + const searchPositionStart = this.props.isCaseSensitive + ? text.indexOf(searchValue) + : text.toLowerCase().indexOf(searchValue.toLowerCase()); + const searchPositionCenter = + searchPositionStart + Math.floor(searchValue.length / 2); + + return ( + + {(text) => ( + <> + {text.length >= searchValue.length ? ( + + {text} + + ) : ( + // If the available truncated text is shorter than the full search string, + // just highlight the entire truncated text + {text} + )} + + )} + + ); + }; + render() { const { 'data-test-subj': dataTestSubj, @@ -325,6 +391,7 @@ export class EuiComboBoxOptionsList extends Component< delimiter, zIndex, style, + truncationProps, listboxAriaLabel, ...rest } = this.props; diff --git a/src/components/combo_box/types.ts b/src/components/combo_box/types.ts index 8510f659809..0049587d0e7 100644 --- a/src/components/combo_box/types.ts +++ b/src/components/combo_box/types.ts @@ -9,6 +9,8 @@ import { ButtonHTMLAttributes, ReactNode } from 'react'; import { CommonProps } from '../common'; +import type { _EuiComboBoxProps } from './combo_box'; + // note similarity to `Option` in `components/selectable/types.tsx` export interface EuiComboBoxOptionOption< T = string | number | string[] | undefined @@ -21,6 +23,7 @@ export interface EuiComboBoxOptionOption< value?: T; prepend?: ReactNode; append?: ReactNode; + truncationProps?: _EuiComboBoxProps['truncationProps']; } export type UpdatePositionHandler = ( diff --git a/upcoming_changelogs/7028.md b/upcoming_changelogs/7028.md new file mode 100644 index 00000000000..66b98468613 --- /dev/null +++ b/upcoming_changelogs/7028.md @@ -0,0 +1,6 @@ +- Updated `EuiComboBox` to allow configuring text truncation behavior via `truncationProps`. These props can be set on the entire combobox as well as on on individual dropdown options. + +**Bug fixes** + +- `EuiComboBox` now always shows the highlighted search text, even on truncated text +