diff --git a/changelogs/upcoming/7388.md b/changelogs/upcoming/7388.md new file mode 100644 index 00000000000..ab99f5ff337 --- /dev/null +++ b/changelogs/upcoming/7388.md @@ -0,0 +1,2 @@ +- `EuiSelectable` now allows configurable text truncation via `listProps.truncationProps` +- `EuiTextTruncate` now supports a new `calculationDelayMs` prop for working around font loading or layout shifting scenarios 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 62c78c8d76a..7c20ef00fca 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -474,7 +474,7 @@ export const ComboBoxExample = { 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{' '} diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js index 5cefe6c4537..fe757177681 100644 --- a/src-docs/src/views/selectable/selectable_example.js +++ b/src-docs/src/views/selectable/selectable_example.js @@ -8,6 +8,7 @@ import { EuiSelectable, EuiSelectableMessage, EuiText, + EuiTextTruncate, EuiCallOut, EuiLink, } from '../../../../src'; @@ -38,6 +39,9 @@ const selectableMessagesSource = require('!!raw-loader!./selectable_messages'); import SelectableSizing from './selectable_sizing'; const selectableSizingSource = require('!!raw-loader!./selectable_sizing'); +import Truncation from './selectable_truncation'; +const truncationSource = require('!!raw-loader!./selectable_truncation'); + import SelectableCustomRender from './selectable_custom_render'; const selectableCustomRenderSource = require('!!raw-loader!./selectable_custom_render'); @@ -385,6 +389,53 @@ export const SelectableExample = { {list => list} `, }, + { + title: 'Truncation', + source: [ + { + type: GuideSectionTypes.TSX, + code: truncationSource, + }, + ], + text: ( + <> +

+ EuiSelectable defaults to{' '} + listProps.textWrap="truncate", which truncates + long option text at the end of the string. +

+

+ You can use listProps.truncationProps and almost + any prop that{' '} + + EuiTextTruncate + {' '} + accepts to configure this behavior. This can be configured at the{' '} + EuiSelectable level, as well as by each individual + option. +

+ + ), + props: { + EuiSelectableOptionsList, + EuiSelectableOptionProps, + EuiTextTruncate, + }, + snippet: ` setOptions(newOptions)} + listProps={{ + textWrap: 'truncate', + truncationProps: { + truncation: 'start', + truncationOffset: 5, + }, + }} +> + {list => list} +`, + demo: , + }, { title: 'Rendering the options', source: [ diff --git a/src-docs/src/views/selectable/selectable_truncation.tsx b/src-docs/src/views/selectable/selectable_truncation.tsx new file mode 100644 index 00000000000..81863d3f954 --- /dev/null +++ b/src-docs/src/views/selectable/selectable_truncation.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; + +import { + useGeneratedHtmlId, + EuiFlexGroup, + EuiFlexItem, + EuiButtonGroup, + EuiFieldNumber, + EuiTextTruncationTypes, + EuiTitle, + EuiSpacer, + EuiSelectable, + EuiSelectableOptionsListProps, + EuiSelectableOption, + EuiPanel, + EuiIcon, + EuiText, +} from '../../../../src'; + +export default () => { + const [options, setOptions] = useState([ + { + label: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, lorem ispum', + checked: 'on', + }, + { + 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', + }, + { + prepend: , + label: + 'This option has `textWrap` and `truncationProps` settings that will override the parent', + textWrap: 'truncate', + truncationProps: { + truncation: 'start', + truncationOffset: 5, + }, + }, + ]); + + type TextWrap = NonNullable; + const [textWrap, setTextWrap] = useState('truncate'); + const [truncation, setTruncation] = useState('end'); + const [truncationOffset, setTruncationOffset] = useState(0); + const offsetId = useGeneratedHtmlId(); + + return ( + <> + + + +

Text wrap

+
+ + setTextWrap(id as TextWrap)} + options={[ + { id: 'truncate', label: 'truncate' }, + { id: 'wrap', label: 'wrap' }, + ]} + color="primary" + /> +
+ {textWrap === 'wrap' && ( + + + Wrapping text requires disabling + virtualization. We do not recommend this for large (50+) amounts + of options. + + + )} + {textWrap === 'truncate' && ( + + +

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" + /> +
+ )} + {textWrap === 'truncate' && + (truncation === 'start' || truncation === 'end') && ( + + +

Truncation offset

+
+ + setTruncationOffset(Number(e.target.value))} + compressed + /> +
+ )} +
+ + + setOptions(updatedOptions)} + listProps={{ + isVirtualized: textWrap !== 'wrap', + textWrap, + truncationProps: { + truncation, + truncationOffset, + }, + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + + + + ); +}; diff --git a/src/components/auto_sizer/auto_sizer.testenv.tsx b/src/components/auto_sizer/auto_sizer.testenv.tsx index 12d86eaa1f3..87c510d4c2a 100644 --- a/src/components/auto_sizer/auto_sizer.testenv.tsx +++ b/src/components/auto_sizer/auto_sizer.testenv.tsx @@ -6,16 +6,22 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useEffect } from 'react'; export const EuiAutoSizer = ({ children, defaultHeight, defaultWidth, + onResize, }: any) => { const childrenParams = { height: defaultHeight ?? 600, width: defaultWidth ?? 600, }; + + useEffect(() => { + onResize?.(childrenParams); + }, [onResize, defaultHeight, defaultWidth]); // eslint-disable-line react-hooks/exhaustive-deps + return
{children(childrenParams)}
; }; diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index 2894e5c433b..560d2a619f0 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -155,7 +155,7 @@ export interface _EuiComboBoxProps /** * 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 + * accepts any [EuiTextTruncate](/#/utilities/text-truncation) prop, * except for `text` and `children`. * * Note: when searching, custom truncation props are ignored. The highlighted search diff --git a/src/components/selectable/__snapshots__/selectable.test.tsx.snap b/src/components/selectable/__snapshots__/selectable.test.tsx.snap index c11eeba93ca..be1512d2ef5 100644 --- a/src/components/selectable/__snapshots__/selectable.test.tsx.snap +++ b/src/components/selectable/__snapshots__/selectable.test.tsx.snap @@ -178,9 +178,7 @@ exports[`EuiSelectable errorMessage prop does not render the message when not de - - Titan - + Titan @@ -205,9 +203,7 @@ exports[`EuiSelectable errorMessage prop does not render the message when not de - - Enceladus - + Enceladus @@ -232,9 +228,7 @@ exports[`EuiSelectable errorMessage prop does not render the message when not de - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology diff --git a/src/components/selectable/selectable.spec.tsx b/src/components/selectable/selectable.spec.tsx index dbf10288af3..30cb60cb987 100644 --- a/src/components/selectable/selectable.spec.tsx +++ b/src/components/selectable/selectable.spec.tsx @@ -253,6 +253,137 @@ describe('EuiSelectable', () => { }); }); + describe('truncation', () => { + const sharedProps = { + style: { width: 240 }, + options: [ + { + label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + checked: 'on' as const, + }, + ], + }; + + it('defaults to CSS truncation', () => { + cy.realMount(); + cy.get('.euiTextTruncate').should('not.exist'); + }); + + describe('when truncationProps are passed', () => { + const truncationProps = { + ...sharedProps, + listProps: { truncationProps: { truncation: 'middle' as const } }, + }; + + it('renders EuiTextTruncate when truncationProps are passed', () => { + cy.realMount(); + cy.get('.euiTextTruncate').should('exist'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum d…ipiscing elit.' + ); + }); + + it('correctly accounts for the keyboard focus badge', () => { + cy.realMount(); + + cy.realPress('Tab'); + cy.get('.euiSelectableListItem__onFocusBadge').should('exist'); + + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsu…cing elit.' + ); + }); + + it('handles custom append/prepend nodes', () => { + cy.realMount( + + ); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem i…ng elit.' + ); + }); + + it('updates on container resize', () => { + cy.realMount( + + ); + cy.viewport(100, 100); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lor…lit.' + ); + }); + + it('allows individual option truncationProps to override parent truncationProps', () => { + cy.realMount( + + ); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lor…sectetur adipiscing elit.' + ); + }); + }); + + describe('when searching', () => { + it('uses start & end truncation position', () => { + cy.realMount(); + cy.get('input[type="search"]').realClick(); + cy.realType('sit'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + '…m dolor sit amet, …' + ); + }); + + it('does not truncate the start when the found search is near the start', () => { + cy.realMount(); + cy.get('input[type="search"]').realClick(); + cy.realType('ipsum'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum dolor …' + ); + }); + + it('does not truncate the end when the found search is near the end', () => { + cy.realMount(); + cy.get('input[type="search"]').realClick(); + cy.realType('eli'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + '…tetur adipiscing elit.' + ); + }); + + it('marks the full available text if the search input is longer than the truncated text', () => { + cy.realMount(); + cy.get('input[type="search"]').realClick(); + cy.realType('Lorem ipsum dolor sit amet'); + cy.get('.euiMark').should('have.text', '…m ipsum dolor sit …'); + }); + }); + }); + describe('nested in `EuiPopover` component', () => { const EuiSelectableNested = () => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); diff --git a/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap index 65d52778ea9..a388f1c50ad 100644 --- a/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap +++ b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap @@ -43,9 +43,7 @@ exports[`EuiSelectableListItem group labels handles updating aria attrs correctl - - Titan - + Titan @@ -69,9 +67,7 @@ exports[`EuiSelectableListItem group labels handles updating aria attrs correctl - - Io - + Io @@ -101,9 +97,7 @@ exports[`EuiSelectableListItem group labels handles updating aria attrs correctl - - Mercury - + Mercury @@ -127,9 +121,7 @@ exports[`EuiSelectableListItem group labels handles updating aria attrs correctl - - Mars - + Mars @@ -159,9 +151,7 @@ exports[`EuiSelectableListItem group labels handles updating aria attrs correctl - - Sol - + Sol @@ -212,9 +202,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Spaaaace - + Spaaaace @@ -246,9 +234,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Titan - + Titan @@ -273,9 +259,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Io - + Io @@ -307,9 +291,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Mercury - + Mercury @@ -334,9 +316,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Mars - + Mars @@ -396,9 +376,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Titan - + Titan @@ -423,9 +401,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Enceladus - + Enceladus @@ -450,9 +426,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Mimas - + Mimas @@ -477,9 +451,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -504,9 +476,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Tethys - + Tethys @@ -531,9 +501,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Hyperion - + Hyperion @@ -586,9 +554,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Titan - + Titan @@ -613,9 +579,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Enceladus - + Enceladus @@ -640,9 +604,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Mimas - + Mimas - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -713,9 +673,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Tethys - + Tethys @@ -740,9 +698,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Hyperion - + Hyperion @@ -795,9 +751,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Titan - + Titan
@@ -828,9 +782,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Enceladus - + Enceladus
@@ -861,9 +813,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Mimas - + Mimas
@@ -894,9 +844,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
@@ -927,9 +875,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Tethys - + Tethys
@@ -960,9 +906,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Hyperion - + Hyperion
@@ -1021,9 +965,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Titan - + Titan @@ -1048,9 +990,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Enceladus - + Enceladus @@ -1075,9 +1015,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Mimas - + Mimas @@ -1102,9 +1040,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -1129,9 +1065,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Tethys - + Tethys @@ -1156,9 +1090,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Hyperion - + Hyperion @@ -1211,9 +1143,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Titan - + Titan @@ -1238,9 +1168,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Enceladus - + Enceladus @@ -1265,9 +1193,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Mimas - + Mimas @@ -1292,9 +1218,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -1319,9 +1243,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Tethys - + Tethys @@ -1346,9 +1268,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Hyperion - + Hyperion @@ -1401,9 +1321,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Titan - + Titan @@ -1428,9 +1346,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Enceladus - + Enceladus @@ -1455,9 +1371,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Mimas - + Mimas @@ -1482,9 +1396,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -1509,9 +1421,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Tethys - + Tethys @@ -1536,9 +1446,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Hyperion - + Hyperion @@ -1585,9 +1493,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Titan - + Titan @@ -1611,9 +1517,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Enceladus - + Enceladus @@ -1637,9 +1541,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Mimas - + Mimas @@ -1663,9 +1565,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -1689,9 +1589,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Tethys - + Tethys @@ -1715,9 +1613,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Hyperion - + Hyperion @@ -1769,9 +1665,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Titan - + Titan @@ -1796,9 +1690,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Enceladus - + Enceladus @@ -1823,9 +1715,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Mimas - + Mimas @@ -1850,9 +1740,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -1877,9 +1765,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Tethys - + Tethys @@ -1904,9 +1790,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Hyperion - + Hyperion @@ -1959,9 +1843,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Titan - + Titan @@ -1986,9 +1868,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Enceladus - + Enceladus @@ -2013,9 +1893,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Mimas - + Mimas @@ -2040,9 +1918,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -2067,9 +1943,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Tethys - + Tethys @@ -2094,9 +1968,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Hyperion - + Hyperion @@ -2345,9 +2217,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Titan - + Titan @@ -2372,9 +2242,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Enceladus - + Enceladus @@ -2399,9 +2267,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Mimas - + Mimas @@ -2426,9 +2292,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -2453,9 +2317,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Tethys - + Tethys @@ -2480,9 +2342,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Hyperion - + Hyperion @@ -2492,7 +2352,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = `
`; -exports[`EuiSelectableListItem props searchValue 1`] = ` +exports[`EuiSelectableListItem props searchable enables correct screen reader instructions 1`] = `
  • - - Titan - - - -
  • -
  • - - - - - Enceladus - - - -
  • -
  • - - - - - - Mi - - mas - - - -
  • -
  • - - - - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - - - -
  • -
  • - - - - - Tethys - - - -
  • -
  • - - - - - Hyperion - - - -
  • -
-
-
-
-`; - -exports[`EuiSelectableListItem props searchValue 2`] = ` -
-
-
-
    -
  • - - - - - Titan - - - -
  • -
  • - - - - - Enceladus - - - -
  • -
  • - - - - - - Mi - - mas - - - -
  • -
  • - - - - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - - - -
  • -
  • - - - - - Tethys - - - -
  • -
  • - - - - - Hyperion - - - -
  • -
-
-
-
-`; - -exports[`EuiSelectableListItem props searchable enables correct screen reader instructions 1`] = ` -
-
-
-
    -
  • - - - - - Titan - -
    - . - To check this option, press Enter. -
    -
    -
    -
  • -
  • - - - - - Enceladus - -
    - . - To check this option, press Enter. -
    -
    -
    -
  • -
  • - - - - - Mimas - -
    - . - To check this option, press Enter. -
    -
    -
    -
  • -
  • - - - - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - -
    - . - To check this option, press Enter. -
    -
    -
    -
  • -
  • - - - - - Tethys - -
    - . - To check this option, press Enter. -
    -
    -
    -
  • -
  • - - - - - Hyperion - + Titan
    @@ -3100,57 +2403,6 @@ exports[`EuiSelectableListItem props searchable enables correct screen reader in
  • -
-
-
-
-`; - -exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
-
-
-
    -
  • - - - - Titan - - - -
  • + - - Enceladus - + Enceladus +
    + . + To check this option, press Enter. +
  • @@ -3188,12 +2448,20 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` + - - Mimas - + Mimas +
    + . + To check this option, press Enter. +
    @@ -3211,12 +2479,20 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` + - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology +
    + . + To check this option, press Enter. +
    @@ -3234,12 +2510,20 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` + - - Tethys - + Tethys +
    + . + To check this option, press Enter. +
    @@ -3257,12 +2541,20 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` + - - Hyperion - + Hyperion +
    + . + To check this option, press Enter. +
    @@ -3272,7 +2564,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
`; -exports[`EuiSelectableListItem props singleSelection can be forced so that at least one must be selected 1`] = ` +exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
    - - - Titan - + Titan @@ -3334,16 +2621,10 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le - - - Enceladus - + Enceladus @@ -3361,16 +2642,10 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le - - - Mimas - + Mimas @@ -3388,16 +2663,10 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le - - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -3415,16 +2684,10 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le - - - Tethys - + Tethys @@ -3442,16 +2705,10 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le - - - Hyperion - + Hyperion @@ -3461,7 +2718,7 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le
`; -exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` +exports[`EuiSelectableListItem props singleSelection can be forced so that at least one must be selected 1`] = `
- - Titan - + Titan @@ -3530,9 +2785,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Enceladus - + Enceladus @@ -3557,9 +2810,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Mimas - + Mimas @@ -3584,9 +2835,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -3611,9 +2860,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Tethys - + Tethys @@ -3638,9 +2885,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Hyperion - + Hyperion @@ -3650,7 +2895,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = `
`; -exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` +exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = `
    - - Titan - + Titan @@ -3718,11 +2960,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Enceladus - + Enceladus @@ -3745,11 +2985,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Mimas - + Mimas @@ -3772,11 +3010,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -3799,11 +3035,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Tethys - + Tethys @@ -3826,11 +3060,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Hyperion - + Hyperion @@ -3882,9 +3114,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - Mimas - + Mimas @@ -3909,9 +3139,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -3936,9 +3164,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - Tethys - + Tethys @@ -3963,9 +3189,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - Hyperion - + Hyperion diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx index 6d671212cba..140fa0ce454 100644 --- a/src/components/selectable/selectable_list/selectable_list.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list.test.tsx @@ -67,28 +67,38 @@ describe('EuiSelectableListItem', () => { expect(container.firstChild).toMatchSnapshot(); }); - test('searchValue', () => { - const { container } = render( - - ); + describe('searchValue', () => { + it('renders just a EuiHighlight component when wrapping text', () => { + const { container } = render( + + ); - expect(container.firstChild).toMatchSnapshot(); - }); + expect(container.querySelector('.euiMark')).toHaveTextContent('Mi'); + expect( + container.querySelector('.euiTextTruncate') + ).not.toBeInTheDocument(); + }); - test('searchValue', () => { - const { container } = render( - - ); + it('renders an EuiTextTruncate component when truncating text', () => { + const { container, getByTestSubject } = render( + + ); - expect(container.firstChild).toMatchSnapshot(); + expect(getByTestSubject('titanOption')).toContainElement( + container.querySelector('.euiTextTruncate') + ); + }); }); test('renderOption', () => { @@ -258,20 +268,187 @@ describe('EuiSelectableListItem', () => { }); describe('textWrap', () => { - test('can be "wrap"', () => { + test('wrap', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.euiSelectableListItem__text--truncate') + ).not.toBeInTheDocument(); + }); + + it('does not allow wrapping text if virtualization is on', () => { const { container } = render( + ); + + expect( + container.querySelector('.euiSelectableListItem__text--truncate') + ).toBeInTheDocument(); + }); + + it('allows setting `textWrap` per-option', () => { + const { container } = render( + + ); + + expect( + container.querySelectorAll('.euiSelectableListItem__text--truncate') + ).toHaveLength(1); + }); + + test('truncate', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.euiSelectableListItem__text--truncate') + ).toBeInTheDocument(); + }); + }); + + describe('truncationProps', () => { + it('renders EuiTextTruncate', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); + }); + + it('allows setting `truncationProps` per-option', () => { + const { container } = render( + ); - expect(container.firstChild).toMatchSnapshot(); + expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); }); }); }); + describe('truncation performance optimization', () => { + it('does not render EuiTextTruncate if not virtualized and text is wrapping', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.euiTextTruncate') + ).not.toBeInTheDocument(); + }); + + it('does not render EuiTextTruncate, and defaults to CSS truncation, if no truncationProps have been passed', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.euiTextTruncate') + ).not.toBeInTheDocument(); + expect( + container.querySelector('.euiSelectableListItem__text--truncate') + ).toBeInTheDocument(); + }); + + it('attempts to use a default optimized option width calculated from the wrapping EuiAutoSizer', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); + expect( + container.querySelector('[data-resize-observer]') + ).not.toBeInTheDocument(); + }); + + it('falls back to individual resize observers if options have append/prepend nodes', () => { + const { container } = render( + + ); + + expect(container.querySelectorAll('.euiTextTruncate')).toHaveLength(3); + expect(container.querySelectorAll('[data-resize-observer]')).toHaveLength( + 2 + ); + }); + + it('falls back to individual resize observers if individual options are truncated', () => { + const { container } = render( + + ); + + expect(container.querySelectorAll('.euiTextTruncate')).toHaveLength(2); + expect(container.querySelectorAll('[data-resize-observer]')).toHaveLength( + 2 + ); + }); + }); + describe('group labels', () => { const optionsWithGroupLabels: EuiSelectableOption[] = [ { label: 'Spaaaace' }, diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index 1f29762d580..7e907e050e9 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -20,6 +20,7 @@ import { ListChildComponentProps as ReactWindowListChildComponentProps, areEqual, } from 'react-window'; + import { CommonProps, ExclusiveUnion } from '../../common'; import { EuiAutoSizer, @@ -27,6 +28,9 @@ import { EuiAutoSizeHorizontal, } from '../../auto_sizer'; import { EuiHighlight } from '../../highlight'; +import { EuiMark } from '../../mark'; +import { EuiTextTruncate } from '../../text_truncate'; + import { EuiSelectableOption } from '../selectable_option'; import { EuiSelectableOnChangeEvent } from '../selectable'; import { @@ -96,6 +100,15 @@ export type EuiSelectableOptionsListProps = CommonProps & * Wrapping only works if virtualization is off. */ textWrap?: EuiSelectableListItemProps['textWrap']; + /** + * If textWrap is set to `truncate`, you can pass a custom truncation configuration + * that accepts any [EuiTextTruncate](/#/utilities/text-truncation) prop except for + * `text` and `children`. + * + * Note: when searching, custom truncation props are ignored. The highlighted search + * text will always take precedence. + */ + truncationProps?: EuiSelectableOption['truncationProps']; } & EuiSelectableOptionsListVirtualizedProps; export type EuiSelectableListProps = EuiSelectableOptionsListProps & { @@ -145,13 +158,37 @@ export type EuiSelectableListProps = EuiSelectableOptionsListProps & { setActiveOptionIndex: (index: number, cb?: () => void) => void; }; -export class EuiSelectableList extends Component> { +type State = { + defaultOptionWidth: number; + optionArray: Array>; + itemData: Record>; + ariaPosInSetMap: Record; + ariaSetSize: number; +}; + +export class EuiSelectableList extends Component< + EuiSelectableListProps, + State +> { static defaultProps = { rowHeight: 32, searchValue: '', isVirtualized: true, }; + constructor(props: EuiSelectableListProps) { + super(props); + + const optionArray = props.visibleOptions || props.options; + + this.state = { + defaultOptionWidth: 0, + optionArray: optionArray, + itemData: { ...optionArray }, + ...this.calculateAriaSetAttrs(optionArray), + }; + } + listRef: FixedSizeList | null = null; listBoxRef: HTMLUListElement | null = null; @@ -206,8 +243,8 @@ export class EuiSelectableList extends Component> { } }; - componentDidUpdate() { - const { activeOptionIndex } = this.props; + componentDidUpdate(prevProps: EuiSelectableListProps) { + const { activeOptionIndex, visibleOptions, options } = this.props; if (this.listBoxRef && this.props.searchable !== true) { this.listBoxRef.setAttribute( @@ -216,30 +253,36 @@ export class EuiSelectableList extends Component> { ); } - if (this.listRef && typeof this.props.activeOptionIndex !== 'undefined') { - this.listRef.scrollToItem(this.props.activeOptionIndex, 'auto'); + if (this.listRef && typeof activeOptionIndex !== 'undefined') { + this.listRef.scrollToItem(activeOptionIndex, 'auto'); } - } - constructor(props: EuiSelectableListProps) { - super(props); + if ( + prevProps.visibleOptions !== visibleOptions || + prevProps.options !== options + ) { + const optionArray = visibleOptions || options; + this.setState({ + optionArray, + itemData: { ...optionArray }, + ...this.calculateAriaSetAttrs(optionArray), + }); + } } - ariaSetSize = 0; - ariaPosInSetMap: Record = {}; - - calculateAriaSetAttrs = (optionArray: Array>) => { - this.ariaPosInSetMap = {}; + // This utility is necessary to exclude group labels from the aria set count + calculateAriaSetAttrs = (optionArray: State['optionArray']) => { + const ariaPosInSetMap: State['ariaPosInSetMap'] = {}; let latestAriaPosIndex = 0; optionArray.forEach((option, index) => { if (!option.isGroupLabel) { latestAriaPosIndex++; - this.ariaPosInSetMap[index] = latestAriaPosIndex; + ariaPosInSetMap[index] = latestAriaPosIndex; } }); - this.ariaSetSize = latestAriaPosIndex; + return { ariaPosInSetMap, ariaSetSize: latestAriaPosIndex }; }; ListRow = memo(({ data, index, style }: ListChildComponentProps) => { @@ -256,6 +299,7 @@ export class EuiSelectableList extends Component> { key, searchableLabel, data: _data, + truncationProps: _truncationProps, ...optionRest } = option; @@ -264,13 +308,13 @@ export class EuiSelectableList extends Component> { allowExclusions, onFocusBadge, paddingSize, - searchValue, showIcons, makeOptionId, renderOption, setActiveOptionIndex, searchable, - textWrap, + searchValue, + isVirtualized, } = this.props; if (isGroupLabel) { @@ -290,6 +334,18 @@ export class EuiSelectableList extends Component> { } const id = makeOptionId(index); + const isFocused = activeOptionIndex === index; + + // Text wrapping + const canWrap = !isVirtualized; + const _textWrap = option.textWrap ?? this.props.textWrap; + const textWrap = canWrap ? _textWrap : 'truncate'; + + // Truncation config (if any). If none, CSS truncation is used + const truncationProps = + textWrap === 'truncate' + ? this.getTruncationProps(option, isFocused) + : undefined; return ( extends Component> { this.onAddOrRemoveOption(option, event); }} ref={ref ? ref.bind(null, index) : undefined} - isFocused={activeOptionIndex === index} + isFocused={isFocused} title={searchableLabel || label} checked={checked} disabled={disabled} prepend={prepend} append={append} - aria-posinset={this.ariaPosInSetMap[index]} - aria-setsize={this.ariaSetSize} + aria-posinset={this.state.ariaPosInSetMap[index]} + aria-setsize={this.state.ariaSetSize} onFocusBadge={onFocusBadge} allowExclusions={allowExclusions} showIcons={showIcons} @@ -320,26 +376,27 @@ export class EuiSelectableList extends Component> { textWrap={textWrap} {...(optionRest as EuiSelectableListItemProps)} > - {renderOption ? ( - renderOption( - // @ts-ignore complex - { ..._option, ...optionData }, - this.props.searchValue - ) - ) : ( - {label} - )} + {renderOption + ? renderOption( + // @ts-ignore complex + { ..._option, ...optionData }, + searchValue + ) + : searchValue + ? this.renderSearchedText(label, truncationProps) + : truncationProps + ? this.renderTruncatedText(label, truncationProps) + : label} ); }, areEqual); - renderVirtualizedList = ( - heightIsFull: boolean, - optionArray: EuiSelectableOption[] - ) => { + renderVirtualizedList = () => { if (!this.props.isVirtualized) return null; + const { optionArray, itemData } = this.state; const { windowProps, height: forcedHeight, rowHeight } = this.props; + const heightIsFull = forcedHeight === 'full'; const virtualizationProps = { className: 'euiSelectableList__list', @@ -348,7 +405,7 @@ export class EuiSelectableList extends Component> { innerRef: this.setListBoxRef, innerElementType: 'ul', itemCount: optionArray.length, - itemData: optionArray, + itemData: itemData, itemSize: rowHeight, 'data-skip-axe': 'scrollable-region-focusable', ...windowProps, @@ -372,7 +429,7 @@ export class EuiSelectableList extends Component> { } return heightIsFull ? ( - + {({ width, height }: EuiAutoSize) => ( {this.ListRow} @@ -380,7 +437,10 @@ export class EuiSelectableList extends Component> { )} ) : ( - + {({ width }: EuiAutoSizeHorizontal) => ( extends Component> { ); }; + forceVirtualizedListRowRerender = () => { + this.setState({ itemData: { ...this.state.optionArray } }); + }; + + // EuiTextTruncate is expensive perf-wise - we use several utilities here to + // offset its performance cost + + // and creates a resize observer for + // each individual item. This logic tries to offset this performance hit by + // guesstimating a default width for each option + focusBadgeOffset = 0; + + calculateDefaultOptionWidth = ({ + width: containerWidth, + }: EuiAutoSizeHorizontal) => { + const { truncationProps, searchable, searchValue } = this.props; + + // If it's not likely we'll need to use EuiTextTruncate, don't set state/rerender on every panel resize + const mayTruncate = searchable || truncationProps; + if (!mayTruncate) return; + + const paddingOffset = this.props.paddingSize === 'none' ? 0 : 24; // Defaults to 's' + const checkedIconOffset = this.props.showIcons === false ? 0 : 28; // Defaults to true + this.focusBadgeOffset = this.props.onFocusBadge === false ? 0 : 46; + + this.setState({ + defaultOptionWidth: containerWidth - paddingOffset - checkedIconOffset, + }); + + // Potentially force list rows to rerender on dynamic resize as well, + // but try to do it as lightly as possible + if (truncationProps || (searchable && searchValue)) { + this.forceVirtualizedListRowRerender(); + } + }; + + getTruncationProps = (option: EuiSelectableOption, isFocused: boolean) => { + // Individual truncation settings should override component-wide settings + const truncationProps = { + ...this.props.truncationProps, + ...option.truncationProps, + }; + + // If we're not actually using EuiTextTruncate, no need to continue + const hasComplexTruncation = + this.props.searchValue || Object.keys(truncationProps).length > 0; + if (!hasComplexTruncation) return undefined; + + // Determine whether we can use the optimized default option width + const { defaultOptionWidth } = this.state; + const useDefaultWidth = !option.append && !option.prepend; + const defaultWidth = + useDefaultWidth && defaultOptionWidth + ? isFocused + ? defaultOptionWidth - this.focusBadgeOffset + : defaultOptionWidth + : undefined; + + return { width: defaultWidth, ...truncationProps }; + }; + + renderSearchedText = ( + text: string, + truncationProps?: EuiSelectableOptionsListProps['truncationProps'] + ) => { + const { searchValue } = this.props; + + // If truncationProps is undefined, we're using non-virtualized text wrapping + if (!truncationProps) { + return {text}; + } + + const searchPositionStart = 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} + )} + + )} + + ); + }; + + renderTruncatedText = ( + text: string, + truncationProps?: EuiSelectableOptionsListProps['truncationProps'] + ) => { + return ( + // For some bizarre reason, truncation in EuiSelectable is off on initial mount + // (but not on rerender) for Safari and _some_ truncation types in Firefox :| + // Waiting a tick before calculating truncation seems to smooth over the issue + + {(text) => text} + + ); + }; + render() { const { className, @@ -422,18 +597,14 @@ export class EuiSelectableList extends Component> { role, isVirtualized, textWrap, + truncationProps, ...rest } = this.props; - const optionArray = visibleOptions || options; - this.calculateAriaSetAttrs(optionArray); - - const heightIsFull = forcedHeight === 'full'; - const classes = classNames( 'euiSelectableList', { - 'euiSelectableList-fullHeight': heightIsFull, + 'euiSelectableList-fullHeight': forcedHeight === 'full', 'euiSelectableList-bordered': bordered, }, className @@ -442,19 +613,19 @@ export class EuiSelectableList extends Component> { return (
    {isVirtualized ? ( - this.renderVirtualizedList(heightIsFull, optionArray) + this.renderVirtualizedList() ) : (
      - {optionArray.map((_, index) => + {this.state.optionArray.map((_, index) => React.createElement( this.ListRow, { key: index, - data: optionArray, + data: this.state.optionArray, index, }, null diff --git a/src/components/selectable/selectable_list/selectable_list_item.tsx b/src/components/selectable/selectable_list/selectable_list_item.tsx index ef97942ca42..a603c32bc75 100644 --- a/src/components/selectable/selectable_list/selectable_list_item.tsx +++ b/src/components/selectable/selectable_list/selectable_list_item.tsx @@ -8,13 +8,18 @@ import classNames from 'classnames'; import React, { Component, LiHTMLAttributes } from 'react'; + import { CommonProps, keysOf } from '../../common'; import { EuiI18n } from '../../i18n'; import { EuiIcon, IconColor, IconType } from '../../icon'; -import { EuiSelectableOptionCheckedType } from '../selectable_option'; import { EuiScreenReaderOnly } from '../../accessibility'; import { EuiBadge, EuiBadgeProps } from '../../badge'; +import type { + EuiSelectableOption, + EuiSelectableOptionCheckedType, +} from '../selectable_option'; + function resolveIconAndColor(checked: EuiSelectableOptionCheckedType): { icon: IconType; color?: IconColor; @@ -84,7 +89,7 @@ export type EuiSelectableListItemProps = LiHTMLAttributes & * How to handle long text within the item. * Wrapping only works if virtualization is off. */ - textWrap?: 'truncate' | 'wrap'; + textWrap?: EuiSelectableOption['textWrap']; }; export class EuiSelectableListItem extends Component { diff --git a/src/components/selectable/selectable_option.tsx b/src/components/selectable/selectable_option.tsx index e0cf9cdb265..1e1ff0e2093 100644 --- a/src/components/selectable/selectable_option.tsx +++ b/src/components/selectable/selectable_option.tsx @@ -8,6 +8,7 @@ import React, { HTMLAttributes } from 'react'; import { CommonProps, ExclusiveUnion } from '../common'; +import type { EuiTextTruncateProps } from '../text_truncate'; export type EuiSelectableOptionCheckedType = 'on' | 'off' | 'mixed' | undefined; @@ -58,6 +59,21 @@ export type EuiSelectableOptionBase = CommonProps & { * Bypass `EuiSelectableItem` and avoid DOM attribute warnings. */ data?: { [key: string]: any }; + /** + * How to handle long text within the item. + * Wrapping only works if `isVirtualization` is false. + * @default 'truncate' + */ + textWrap?: 'truncate' | 'wrap'; + /** + * If textWrap is set to `truncate`, you can pass a custom truncation configuration + * that accepts any [EuiTextTruncate](/#/utilities/text-truncation) prop except for + * `text` and `children`. + * + * Note: when searching, custom truncation props are ignored. The highlighted search + * text will always take precedence. + */ + truncationProps?: Partial>; }; type _EuiSelectableGroupLabelOption = Omit< diff --git a/src/components/text_truncate/text_truncate.test.tsx b/src/components/text_truncate/text_truncate.test.tsx index ef90d44d885..640ab4afdbe 100644 --- a/src/components/text_truncate/text_truncate.test.tsx +++ b/src/components/text_truncate/text_truncate.test.tsx @@ -18,6 +18,7 @@ jest.mock('./utils', () => ({ })); import { EuiTextTruncate } from './text_truncate'; +import { act } from '@testing-library/react'; describe('EuiTextTruncate', () => { beforeEach(() => jest.clearAllMocks()); @@ -37,19 +38,40 @@ describe('EuiTextTruncate', () => { expect(container.firstChild).toMatchSnapshot(); }); + it('allows delaying truncation calculation by `calculationDelayMs`', () => { + jest.useFakeTimers(); + + const { queryByTestSubject } = render( + + ); + expect(queryByTestSubject('truncatedText')).not.toBeInTheDocument(); + + act(() => jest.advanceTimersByTime(50)); + expect(queryByTestSubject('truncatedText')).toBeInTheDocument(); + + jest.useRealTimers(); + }); + describe('resize observer', () => { it('does not render a resize observer if a width is passed', () => { const onResize = jest.fn(); - render(); + const { container } = render( + + ); expect(onResize).not.toHaveBeenCalled(); + expect(container.firstChild).not.toHaveAttribute('data-resize-observer'); }); it('renders a resize observer when no width is passed', () => { const onResize = jest.fn(); - render( + const { container } = render( ); expect(onResize).toHaveBeenCalledWith(0); + expect(container.firstChild).toHaveAttribute( + 'data-resize-observer', + 'true' + ); }); }); diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 4054b1dac1b..b2b38355e40 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -14,6 +14,7 @@ import React, { useState, useMemo, useCallback, + useEffect, } from 'react'; import classNames from 'classnames'; @@ -92,6 +93,12 @@ export type EuiTextTruncateProps = Omit< * or highlighting */ children?: (truncatedString: string) => ReactNode; + /** + * For some edge case scenarios, EuiTextTruncate's calculations may be off until + * fonts are done loading or layout is done shifting or settling. Adding a delay + * may help resolve any rendering issues. + */ + calculationDelayMs?: number; }; export const EuiTextTruncate: FunctionComponent = ({ @@ -119,6 +126,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< truncationOffset: _truncationOffset = 0, truncationPosition, ellipsis = '…', + calculationDelayMs, containerRef, className, ...rest @@ -127,6 +135,14 @@ const EuiTextTruncateWithWidth: FunctionComponent< const [containerEl, setContainerEl] = useState(null); const refs = useCombinedRefs([setContainerEl, containerRef]); + // If necessary, wait a tick on mount before truncating + const [ready, setReady] = useState(!calculationDelayMs); + useEffect(() => { + if (calculationDelayMs) { + setTimeout(() => setReady(true), calculationDelayMs); + } + }, [calculationDelayMs]); + // Handle exceptions where we need to override the passed props const { truncation, truncationOffset } = useMemo(() => { let truncation = _truncation; @@ -148,7 +164,8 @@ const EuiTextTruncateWithWidth: FunctionComponent< const truncatedText = useMemo(() => { let truncatedText = ''; - if (!containerEl || !width) return truncatedText; + if (!ready || !containerEl) return text; + if (!width) return truncatedText; const utils = new TruncationUtils({ fullText: text, @@ -184,6 +201,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< } return truncatedText; }, [ + ready, width, text, truncation, @@ -245,7 +263,12 @@ const EuiTextTruncateWithResizeObserver: FunctionComponent< return ( {(ref) => ( - + )} );