From e942628e41ca576f85a9c712486ca9d0a00812ca Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 23 Nov 2023 14:17:42 -0800 Subject: [PATCH 01/11] [docs] Set up basic truncation demo - documentating `textWrap` for now, will also demo `truncationProps` once we add that --- .../views/selectable/selectable_example.js | 35 +++++++ .../selectable/selectable_truncation.tsx | 95 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src-docs/src/views/selectable/selectable_truncation.tsx diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js index 5cefe6c4537..fd717e6a6b0 100644 --- a/src-docs/src/views/selectable/selectable_example.js +++ b/src-docs/src/views/selectable/selectable_example.js @@ -38,6 +38,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 +388,38 @@ 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. +

+ + ), + props: { + EuiSelectableOptionsList, + EuiSelectableOptionProps, + }, + snippet: ` setOptions(newOptions)} + listProps={{ + textWrap: 'truncate', + }} +> + {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..d67844d6f12 --- /dev/null +++ b/src-docs/src/views/selectable/selectable_truncation.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonGroup, + 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', + }, + ]); + + type TextWrap = NonNullable; + const [textWrap, setTextWrap] = useState('truncate'); + + 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. + + + )} +
+ + + setOptions(updatedOptions)} + listProps={{ + isVirtualized: textWrap !== 'wrap', + textWrap, + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + + + + ); +}; From b6c51af4ec96d4e408895c72daabc45b25abd92d Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 23 Nov 2023 14:29:44 -0800 Subject: [PATCH 02/11] Set up logic for rendering truncated vs non-truncated text + add logic for conditionally rendering `` if no search is present (removes a `` wrapper and extra logic) + add tests --- .../__snapshots__/selectable.test.tsx.snap | 12 +- .../selectable_list.test.tsx.snap | 2369 ++++++++++------- .../selectable_list/selectable_list.test.tsx | 84 +- .../selectable_list/selectable_list.tsx | 73 +- 4 files changed, 1577 insertions(+), 961 deletions(-) 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_list/__snapshots__/selectable_list.test.tsx.snap b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap index 65d52778ea9..8ed13a6c754 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,22 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Spaaaace - +
+
@@ -246,9 +249,22 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Titan - +
+
@@ -273,9 +289,22 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Io - +
+
@@ -307,9 +336,22 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Mercury - +
+
@@ -334,9 +376,22 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an - - Mars - +
+
@@ -396,9 +451,22 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Titan - +
+
@@ -423,9 +491,22 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Enceladus - +
+
@@ -450,9 +531,22 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Mimas - +
+
@@ -477,9 +571,22 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
+
@@ -504,9 +611,22 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Tethys - +
+
@@ -531,9 +651,22 @@ exports[`EuiSelectableListItem is rendered 1`] = ` - - Hyperion - +
+
@@ -586,9 +719,22 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Titan - +
+
@@ -613,9 +759,22 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Enceladus - +
+
@@ -640,9 +799,22 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Mimas - +
+
- - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
+
@@ -713,9 +898,22 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Tethys - +
+
@@ -740,9 +938,22 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` - - Hyperion - +
+
@@ -795,9 +1006,22 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Titan - +
+
@@ -828,9 +1052,22 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Enceladus - +
+
@@ -861,9 +1098,22 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Mimas - +
+
@@ -894,9 +1144,22 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
+
@@ -927,9 +1190,22 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Tethys - +
+
@@ -960,9 +1236,22 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` - - Hyperion - +
+
@@ -1021,9 +1310,22 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Titan - +
+
@@ -1048,9 +1350,22 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Enceladus - +
+
@@ -1075,11 +1390,24 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Mimas - - - +
+
+ +
  • - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
  • @@ -1129,9 +1470,22 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Tethys - +
    +
    @@ -1156,9 +1510,22 @@ exports[`EuiSelectableListItem props bordered 1`] = ` - - Hyperion - +
    +
    @@ -1211,9 +1578,22 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Titan - +
    +
    @@ -1238,9 +1618,22 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Enceladus - +
    +
    @@ -1265,9 +1658,22 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Mimas - +
    +
    @@ -1292,9 +1698,22 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    @@ -1319,9 +1738,22 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Tethys - +
    +
    @@ -1346,9 +1778,22 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` - - Hyperion - +
    +
    @@ -1401,9 +1846,22 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Titan - +
    +
    @@ -1428,9 +1886,22 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Enceladus - +
    +
    @@ -1455,9 +1926,22 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Mimas - +
    +
    @@ -1482,9 +1966,22 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    @@ -1509,9 +2006,22 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Tethys - +
    +
    @@ -1536,9 +2046,22 @@ exports[`EuiSelectableListItem props height is full 1`] = ` - - Hyperion - +
    +
    @@ -1585,9 +2108,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Titan - + Titan @@ -1611,9 +2132,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Enceladus - + Enceladus @@ -1637,9 +2156,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Mimas - + Mimas @@ -1663,9 +2180,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 +2204,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Tethys - + Tethys @@ -1715,9 +2228,7 @@ exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` - - Hyperion - + Hyperion @@ -1769,9 +2280,22 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Titan - +
    +
    @@ -1796,9 +2320,22 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Enceladus - +
    +
    @@ -1823,9 +2360,22 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Mimas - +
    +
    @@ -1850,9 +2400,22 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    @@ -1877,9 +2440,22 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Tethys - +
    +
    @@ -1904,9 +2480,22 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` - - Hyperion - +
    +
    @@ -1959,9 +2548,22 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Titan - +
    +
    @@ -1986,9 +2588,22 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Enceladus - +
    +
    @@ -2013,9 +2628,22 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Mimas - +
    +
    @@ -2040,9 +2668,22 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    @@ -2067,9 +2708,22 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Tethys - +
    +
    @@ -2094,9 +2748,22 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - - Hyperion - +
    +
    @@ -2345,9 +3012,22 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Titan - +
    +
    @@ -2372,9 +3052,22 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Enceladus - +
    +
    @@ -2399,9 +3092,22 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Mimas - +
    +
    @@ -2426,9 +3132,22 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    @@ -2453,9 +3172,22 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Tethys - +
    +
    @@ -2480,9 +3212,22 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` - - Hyperion - +
    +
    @@ -2492,7 +3237,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = `
    `; -exports[`EuiSelectableListItem props searchValue 1`] = ` +exports[`EuiSelectableListItem props searchable enables correct screen reader instructions 1`] = `
    • - - Titan - +
      +
      +
      + . + To check this option, press Enter. +
    • @@ -2562,9 +3324,28 @@ exports[`EuiSelectableListItem props searchValue 1`] = ` - - Enceladus - +
      +
      +
      + . + To check this option, press Enter. +
      @@ -2589,14 +3370,28 @@ exports[`EuiSelectableListItem props searchValue 1`] = ` - - + - mas - + Mimas + +
    +
    + . + To check this option, press Enter. +
    @@ -2621,9 +3416,28 @@ exports[`EuiSelectableListItem props searchValue 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    +
    + . + To check this option, press Enter. +
    @@ -2648,9 +3462,28 @@ exports[`EuiSelectableListItem props searchValue 1`] = ` - - Tethys - +
    +
    +
    + . + To check this option, press Enter. +
    @@ -2675,9 +3508,28 @@ exports[`EuiSelectableListItem props searchValue 1`] = ` - - Hyperion - +
    +
    +
    + . + To check this option, press Enter. +
    @@ -2687,7 +3539,7 @@ exports[`EuiSelectableListItem props searchValue 1`] = `
    `; -exports[`EuiSelectableListItem props searchValue 2`] = ` +exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
    - - - Titan - +
    +
    @@ -2750,16 +3611,25 @@ exports[`EuiSelectableListItem props searchValue 2`] = ` - - - Enceladus - +
    +
    @@ -2777,21 +3647,25 @@ exports[`EuiSelectableListItem props searchValue 2`] = ` - - - + - mas - + Mimas + +
    @@ -2809,16 +3683,25 @@ exports[`EuiSelectableListItem props searchValue 2`] = ` - - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    @@ -2836,16 +3719,25 @@ exports[`EuiSelectableListItem props searchValue 2`] = ` - - - Tethys - +
    +
    @@ -2863,595 +3755,25 @@ exports[`EuiSelectableListItem props searchValue 2`] = ` - - - - 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 - -
      - . - To check this option, press Enter. -
      -
      -
      -
    • -
    -
    -
    -
    -`; - -exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
    -
    -
    -
      -
    • - - - - Titan - - - -
    • -
    • - - - - Enceladus - - - -
    • -
    • - - - - Mimas - - - -
    • -
    • - - - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - - - -
    • -
    • - - - - Tethys - - - -
    • -
    • - - - - Hyperion - - - -
    • -
    -
    -
    -
    -`; - -exports[`EuiSelectableListItem props singleSelection can be forced so that at least one must be selected 1`] = ` -
    -
    -
    -
      -
    • - - - - - Titan - - - -
    • -
    • - - - - - Enceladus - - - -
    • -
    • - - - - - Mimas - - - -
    • -
    • - - - - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - - - -
    • -
    • - - - - - Tethys - - - -
    • -
    • - - - - Hyperion - +
      +
    • @@ -3461,7 +3783,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 - +
    +
    @@ -3530,9 +3865,22 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Enceladus - +
    +
    @@ -3557,9 +3905,22 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Mimas - +
    +
    @@ -3584,9 +3945,22 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
    +
    @@ -3611,9 +3985,22 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Tethys - +
    +
    @@ -3638,9 +4025,22 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` - - Hyperion - +
    +
    @@ -3650,7 +4050,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 - +
      +
      @@ -3718,11 +4130,24 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Enceladus - +
      +
      @@ -3745,11 +4170,24 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Mimas - +
      +
      @@ -3772,11 +4210,24 @@ 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 - +
      +
      @@ -3799,11 +4250,24 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Tethys - +
      +
      @@ -3826,11 +4290,24 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = ` data-euiicon-type="empty" /> - - Hyperion - +
      +
      @@ -3882,9 +4359,22 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - Mimas - +
      +
      @@ -3909,9 +4399,22 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology - +
      +
      @@ -3936,9 +4439,22 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - Tethys - +
      +
      @@ -3963,9 +4479,22 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` - - 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..c303dcee9ac 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,16 +268,48 @@ 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(); + }); + + test('truncate', () => { + const { container } = render( + ); - expect(container.firstChild).toMatchSnapshot(); + expect( + container.querySelector('.euiSelectableListItem__text--truncate') + ).toBeInTheDocument(); }); }); }); diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index 1f29762d580..71843ab3154 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 { @@ -264,13 +268,14 @@ export class EuiSelectableList extends Component> { allowExclusions, onFocusBadge, paddingSize, - searchValue, showIcons, makeOptionId, renderOption, setActiveOptionIndex, searchable, + searchValue, textWrap, + isVirtualized, } = this.props; if (isGroupLabel) { @@ -291,6 +296,8 @@ export class EuiSelectableList extends Component> { const id = makeOptionId(index); + const _textWrap = isVirtualized ? 'truncate' : textWrap; + return ( extends Component> { showIcons={showIcons} paddingSize={paddingSize} searchable={searchable} - textWrap={textWrap} + 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, _textWrap) + : _textWrap === 'truncate' + ? this.renderTruncatedText(label) + : label} ); }, areEqual); @@ -394,6 +403,48 @@ export class EuiSelectableList extends Component> { ); }; + renderSearchedText = ( + text: string, + textWrap: EuiSelectableOptionsListProps['textWrap'] + ) => { + const { searchValue } = this.props; + + if (textWrap === 'wrap') { + 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) => { + // TODO: Set up `truncationProps` next + return {(text) => text}; + }; + render() { const { className, From fff157adc54c9690684b8322d7ca456d373817b2 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 23 Nov 2023 14:50:01 -0800 Subject: [PATCH 03/11] [optional] Allow configuring `textWrap` per-option not super sure how useful this is, since it requires virtualization to be off, but I want this API to match the upcoming `truncationProps` --- .../selectable/selectable_truncation.tsx | 6 ++++++ .../selectable_list/selectable_list.test.tsx | 19 +++++++++++++++++++ .../selectable_list/selectable_list.tsx | 12 +++++++----- .../selectable_list/selectable_list_item.tsx | 9 +++++++-- .../selectable/selectable_option.tsx | 6 ++++++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src-docs/src/views/selectable/selectable_truncation.tsx b/src-docs/src/views/selectable/selectable_truncation.tsx index d67844d6f12..ab398d8307b 100644 --- a/src-docs/src/views/selectable/selectable_truncation.tsx +++ b/src-docs/src/views/selectable/selectable_truncation.tsx @@ -37,6 +37,12 @@ export default () => { { label: 'Vestibulum lobortis ipsum sit amet tellus scelerisque vestibulum', }, + { + prepend: , + label: + 'This option has `textWrap` settings that will override the parent', + textWrap: 'truncate', + }, ]); type TextWrap = NonNullable; diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx index c303dcee9ac..54c1a938197 100644 --- a/src/components/selectable/selectable_list/selectable_list.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list.test.tsx @@ -298,6 +298,25 @@ describe('EuiSelectableListItem', () => { ).toBeInTheDocument(); }); + it('allows setting `textWrap` per-option', () => { + const { container } = render( + + ); + + expect( + container.querySelectorAll('.euiSelectableListItem__text--truncate') + ).toHaveLength(1); + }); + test('truncate', () => { const { container } = render( extends Component> { setActiveOptionIndex, searchable, searchValue, - textWrap, isVirtualized, } = this.props; @@ -296,7 +295,10 @@ export class EuiSelectableList extends Component> { const id = makeOptionId(index); - const _textWrap = isVirtualized ? 'truncate' : textWrap; + // Text wrapping + const canWrap = !isVirtualized; + const _textWrap = option.textWrap ?? this.props.textWrap; + const textWrap = canWrap ? _textWrap : 'truncate'; return ( extends Component> { showIcons={showIcons} paddingSize={paddingSize} searchable={searchable} - textWrap={_textWrap} + textWrap={textWrap} {...(optionRest as EuiSelectableListItemProps)} > {renderOption @@ -334,8 +336,8 @@ export class EuiSelectableList extends Component> { searchValue ) : searchValue - ? this.renderSearchedText(label, _textWrap) - : _textWrap === 'truncate' + ? this.renderSearchedText(label, textWrap) + : textWrap === 'truncate' ? this.renderTruncatedText(label) : label} 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..5fe4b1bf632 100644 --- a/src/components/selectable/selectable_option.tsx +++ b/src/components/selectable/selectable_option.tsx @@ -58,6 +58,12 @@ 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'; }; type _EuiSelectableGroupLabelOption = Omit< From 3dafd5b85b3409c60a2712be4302a5cb25419ec4 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 23 Nov 2023 16:27:35 -0800 Subject: [PATCH 04/11] Add `truncationProps` configuration + configurable at the option level as well + bonus grammar/link fix on combobox truncationProps docs --- src/components/combo_box/combo_box.tsx | 2 +- .../selectable_list.test.tsx.snap | 1487 +---------------- .../selectable_list/selectable_list.test.tsx | 44 + .../selectable_list/selectable_list.tsx | 58 +- .../selectable/selectable_option.tsx | 10 + 5 files changed, 196 insertions(+), 1405 deletions(-) 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/selectable_list/__snapshots__/selectable_list.test.tsx.snap b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap index 8ed13a6c754..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 @@ -202,22 +202,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an -
      -
      + Spaaaace
      @@ -249,22 +234,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an -
      -
      + Titan
      @@ -289,22 +259,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an -
      -
      + Io
      @@ -336,22 +291,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an -
      -
      + Mercury
      @@ -376,22 +316,7 @@ exports[`EuiSelectableListItem group labels renders with correct aria-setsize an -
      -
      + Mars
      @@ -451,22 +376,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` -
      -
      + Titan
      @@ -491,22 +401,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` -
      -
      + Enceladus
      @@ -531,22 +426,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` -
      -
      + Mimas
      @@ -571,22 +451,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -611,22 +476,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` -
      -
      + Tethys
      @@ -651,22 +501,7 @@ exports[`EuiSelectableListItem is rendered 1`] = ` -
      -
      + Hyperion
      @@ -719,22 +554,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` -
      -
      + Titan
      @@ -759,22 +579,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` -
      -
      + Enceladus
      @@ -799,22 +604,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` -
      -
      + Mimas
      -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -898,22 +673,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` -
      -
      + Tethys
      @@ -938,22 +698,7 @@ exports[`EuiSelectableListItem props activeOptionIndex 1`] = ` -
      -
      + Hyperion
      @@ -1006,22 +751,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` -
      -
      + Titan
      @@ -1052,22 +782,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` -
      -
      + Enceladus
      @@ -1098,22 +813,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` -
      -
      + Mimas
      @@ -1144,22 +844,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -1190,22 +875,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` -
      -
      + Tethys
      @@ -1236,22 +906,7 @@ exports[`EuiSelectableListItem props allowExclusions 1`] = ` -
      -
      + Hyperion
      @@ -1310,22 +965,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` -
      -
      + Titan
      @@ -1350,22 +990,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` -
      -
      + Enceladus
      @@ -1390,22 +1015,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` -
      -
      + Mimas
      @@ -1430,22 +1040,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -1470,22 +1065,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` -
      -
      + Tethys
      @@ -1510,22 +1090,7 @@ exports[`EuiSelectableListItem props bordered 1`] = ` -
      -
      + Hyperion
      @@ -1578,22 +1143,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` -
      -
      + Titan
      @@ -1618,22 +1168,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` -
      -
      + Enceladus
      @@ -1658,22 +1193,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` -
      -
      + Mimas
      @@ -1698,22 +1218,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -1738,22 +1243,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` -
      -
      + Tethys
      @@ -1778,22 +1268,7 @@ exports[`EuiSelectableListItem props height is forced 1`] = ` -
      -
      + Hyperion
      @@ -1846,22 +1321,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` -
      -
      + Titan
      @@ -1886,22 +1346,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` -
      -
      + Enceladus
      @@ -1926,22 +1371,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` -
      -
      + Mimas
      @@ -1966,22 +1396,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -2006,22 +1421,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` -
      -
      + Tethys
      @@ -2046,22 +1446,7 @@ exports[`EuiSelectableListItem props height is full 1`] = ` -
      -
      + Hyperion
      @@ -2280,22 +1665,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` -
      -
      + Titan
      @@ -2320,22 +1690,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` -
      -
      + Enceladus
      @@ -2360,22 +1715,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` -
      -
      + Mimas
      @@ -2400,22 +1740,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -2440,22 +1765,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` -
      -
      + Tethys
      @@ -2480,22 +1790,7 @@ exports[`EuiSelectableListItem props paddingSize none is rendered 1`] = ` -
      -
      + Hyperion
      @@ -2548,22 +1843,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` -
      -
      + Titan
      @@ -2588,22 +1868,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` -
      -
      + Enceladus
      @@ -2628,22 +1893,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` -
      -
      + Mimas
      @@ -2664,26 +1914,11 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` - -
      -
      + /> + + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -2708,22 +1943,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` -
      -
      + Tethys
      @@ -2748,22 +1968,7 @@ exports[`EuiSelectableListItem props paddingSize s is rendered 1`] = ` -
      -
      + Hyperion
      @@ -3012,22 +2217,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` -
      -
      + Titan
      @@ -3052,22 +2242,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` -
      -
      + Enceladus
      @@ -3092,22 +2267,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` -
      -
      + Mimas
      @@ -3132,22 +2292,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -3172,22 +2317,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` -
      -
      + Tethys
      @@ -3212,22 +2342,7 @@ exports[`EuiSelectableListItem props rowHeight 1`] = ` -
      -
      + Hyperion
      @@ -3278,22 +2393,7 @@ exports[`EuiSelectableListItem props searchable enables correct screen reader in -
      -
      + Titan
      @@ -3324,22 +2424,7 @@ exports[`EuiSelectableListItem props searchable enables correct screen reader in -
      -
      + Enceladus
      @@ -3370,22 +2455,7 @@ exports[`EuiSelectableListItem props searchable enables correct screen reader in -
      -
      + Mimas
      @@ -3416,22 +2486,7 @@ exports[`EuiSelectableListItem props searchable enables correct screen reader in -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -3462,22 +2517,7 @@ exports[`EuiSelectableListItem props searchable enables correct screen reader in -
      -
      + Tethys
      @@ -3508,22 +2548,7 @@ exports[`EuiSelectableListItem props searchable enables correct screen reader in -
      -
      + Hyperion
      @@ -3578,22 +2603,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
      -
      + Titan
      @@ -3614,22 +2624,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
      -
      + Enceladus
      @@ -3650,22 +2645,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
      -
      + Mimas
      @@ -3686,22 +2666,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -3722,22 +2687,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
      -
      + Tethys
      @@ -3758,22 +2708,7 @@ exports[`EuiSelectableListItem props showIcons can be turned off 1`] = ` -
      -
      + Hyperion
      @@ -3825,22 +2760,7 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le -
      -
      + Titan
      @@ -3865,22 +2785,7 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le -
      -
      + Enceladus
      @@ -3905,22 +2810,7 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le -
      -
      + Mimas
      @@ -3945,22 +2835,7 @@ 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
      @@ -3985,22 +2860,7 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le -
      -
      + Tethys
      @@ -4025,22 +2885,7 @@ exports[`EuiSelectableListItem props singleSelection can be forced so that at le -
      -
      + Hyperion
      @@ -4092,22 +2937,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` -
      -
      + Titan
      @@ -4132,22 +2962,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` -
      -
      + Enceladus
      @@ -4172,22 +2987,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` -
      -
      + Mimas
      @@ -4212,22 +3012,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -4252,22 +3037,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` -
      -
      + Tethys
      @@ -4292,22 +3062,7 @@ exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = ` -
      -
      + Hyperion
      @@ -4359,22 +3114,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` -
      -
      + Mimas
      @@ -4399,22 +3139,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` -
      -
      + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
      @@ -4439,22 +3164,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` -
      -
      + Tethys
      @@ -4479,22 +3189,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = ` -
      -
      + Hyperion
      diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx index 54c1a938197..778d987a390 100644 --- a/src/components/selectable/selectable_list/selectable_list.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list.test.tsx @@ -331,6 +331,50 @@ describe('EuiSelectableListItem', () => { ).toBeInTheDocument(); }); }); + + describe('truncationProps', () => { + it('renders EuiTextTruncate', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); + }); + + it('defaults to CSS truncation if truncationProps is not passed', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.euiTextTruncate') + ).not.toBeInTheDocument(); + expect( + container.querySelector('.euiSelectableListItem__text--truncate') + ).toBeInTheDocument(); + }); + + it('allows setting `truncationProps` per-option', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); + }); + }); }); describe('group labels', () => { diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index 55ab864fbd9..e62209ba3ba 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -100,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 & { @@ -260,6 +269,7 @@ export class EuiSelectableList extends Component> { key, searchableLabel, data: _data, + truncationProps: _truncationProps, ...optionRest } = option; @@ -300,6 +310,10 @@ export class EuiSelectableList extends Component> { 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) : undefined; + return ( extends Component> { searchValue ) : searchValue - ? this.renderSearchedText(label, textWrap) - : textWrap === 'truncate' - ? this.renderTruncatedText(label) + ? this.renderSearchedText(label, truncationProps) + : truncationProps + ? this.renderTruncatedText(label, truncationProps) : label} ); @@ -405,13 +419,32 @@ export class EuiSelectableList extends Component> { ); }; + getTruncationProps = (option: EuiSelectableOption) => { + // 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; + + // TODO: Performantly calculate a default option width, so that + // each list item doesn't have to generate its own resize observer + + return truncationProps; + }; + renderSearchedText = ( text: string, - textWrap: EuiSelectableOptionsListProps['textWrap'] + truncationProps?: EuiSelectableOptionsListProps['truncationProps'] ) => { const { searchValue } = this.props; - if (textWrap === 'wrap') { + // If truncationProps is undefined, we're using non-virtualized text wrapping + if (!truncationProps) { return {text}; } @@ -423,6 +456,8 @@ export class EuiSelectableList extends Component> { return ( extends Component> { ); }; - renderTruncatedText = (text: string) => { - // TODO: Set up `truncationProps` next - return {(text) => text}; + renderTruncatedText = ( + text: string, + truncationProps?: EuiSelectableOptionsListProps['truncationProps'] + ) => { + return ( + + {(text) => text} + + ); }; render() { @@ -475,6 +516,7 @@ export class EuiSelectableList extends Component> { role, isVirtualized, textWrap, + truncationProps, ...rest } = this.props; diff --git a/src/components/selectable/selectable_option.tsx b/src/components/selectable/selectable_option.tsx index 5fe4b1bf632..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; @@ -64,6 +65,15 @@ export type EuiSelectableOptionBase = CommonProps & { * @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< From 09cfbfd90d4219555167e84589de09973b9b28be Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 23 Nov 2023 16:29:02 -0800 Subject: [PATCH 05/11] [docs] Add truncation props configuration + fix broken link on combobox page --- .../src/views/combo_box/combo_box_example.js | 2 +- .../views/selectable/selectable_example.js | 16 ++++++ .../selectable/selectable_truncation.tsx | 51 ++++++++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) 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 fd717e6a6b0..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'; @@ -403,17 +404,32 @@ export const SelectableExample = { 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} diff --git a/src-docs/src/views/selectable/selectable_truncation.tsx b/src-docs/src/views/selectable/selectable_truncation.tsx index ab398d8307b..7d51e1bc956 100644 --- a/src-docs/src/views/selectable/selectable_truncation.tsx +++ b/src-docs/src/views/selectable/selectable_truncation.tsx @@ -1,9 +1,12 @@ import React, { useState } from 'react'; import { + useGeneratedHtmlId, EuiFlexGroup, EuiFlexItem, EuiButtonGroup, + EuiFieldNumber, + EuiTextTruncationTypes, EuiTitle, EuiSpacer, EuiSelectable, @@ -40,13 +43,20 @@ export default () => { { prepend: , label: - 'This option has `textWrap` settings that will override the parent', + '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 ( <> @@ -76,6 +86,41 @@ export default () => { )} + {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 + /> +
      + )} @@ -86,6 +131,10 @@ export default () => { listProps={{ isVirtualized: textWrap !== 'wrap', textWrap, + truncationProps: { + truncation, + truncationOffset, + }, }} > {(list, search) => ( From d815cdbd6d248f2ed04ca4b77bc908109576de4d Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 24 Nov 2023 10:42:19 -0800 Subject: [PATCH 06/11] [setup] Add a `data` attr to text truncate components w/ resize observers - to help analyze downstream component performance --- src/components/text_truncate/text_truncate.test.tsx | 11 +++++++++-- src/components/text_truncate/text_truncate.tsx | 7 ++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/text_truncate/text_truncate.test.tsx b/src/components/text_truncate/text_truncate.test.tsx index ef90d44d885..e01a720a551 100644 --- a/src/components/text_truncate/text_truncate.test.tsx +++ b/src/components/text_truncate/text_truncate.test.tsx @@ -40,16 +40,23 @@ describe('EuiTextTruncate', () => { 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..1866bef34fe 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -245,7 +245,12 @@ const EuiTextTruncateWithResizeObserver: FunctionComponent< return ( {(ref) => ( - + )} ); From 5a1fe1b3f7e0db6d942b25d2f36b5c6ce77a21f3 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 24 Nov 2023 10:59:15 -0800 Subject: [PATCH 07/11] [workaround] Fix bizarre cross-browser issue where truncation calculation is slightly off the `width` is correct but canvas's `measureText` API is off very slightly on mount for some very frustrating reason the best solution/workaround I came up with was adding a configurable tick/wait (and yes, 1 doesn't work either, has to be 2 for EuiSelectable :/) --- .../selectable_list/selectable_list.tsx | 5 ++++- .../text_truncate/text_truncate.test.tsx | 15 ++++++++++++++ .../text_truncate/text_truncate.tsx | 20 ++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index e62209ba3ba..21d5071f78b 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -482,7 +482,10 @@ export class EuiSelectableList extends Component> { 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} ); diff --git a/src/components/text_truncate/text_truncate.test.tsx b/src/components/text_truncate/text_truncate.test.tsx index e01a720a551..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,6 +38,20 @@ 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(); diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 1866bef34fe..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, From 0a58bd8c2e365290b856828c223e3e893ef95224 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Sat, 25 Nov 2023 11:42:49 -0800 Subject: [PATCH 08/11] [perf] Reduce individual resize observer instantiations for each truncated line - by using `EuiAutoSizer` and attempting to guesstimate the default available width for options that don't have custom `append` or `prepend` nodes [cleanup] Move several `options` based vars to state, to more explicitly limit the amount of times they fire/cause rerenders + fix `EuiAutoSizer` test mock to fire `onResize` on mount (same behavior as actual prod) --- .../selectable/selectable_truncation.tsx | 5 +- .../auto_sizer/auto_sizer.testenv.tsx | 8 +- .../selectable_list/selectable_list.test.tsx | 104 ++++++++++-- .../selectable_list/selectable_list.tsx | 155 +++++++++++++----- 4 files changed, 214 insertions(+), 58 deletions(-) diff --git a/src-docs/src/views/selectable/selectable_truncation.tsx b/src-docs/src/views/selectable/selectable_truncation.tsx index 7d51e1bc956..81863d3f954 100644 --- a/src-docs/src/views/selectable/selectable_truncation.tsx +++ b/src-docs/src/views/selectable/selectable_truncation.tsx @@ -123,7 +123,10 @@ export default () => { )} - + { 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/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx index 778d987a390..140fa0ce454 100644 --- a/src/components/selectable/selectable_list/selectable_list.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list.test.tsx @@ -345,22 +345,6 @@ describe('EuiSelectableListItem', () => { expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); }); - it('defaults to CSS truncation if truncationProps is not passed', () => { - const { container } = render( - - ); - - expect( - container.querySelector('.euiTextTruncate') - ).not.toBeInTheDocument(); - expect( - container.querySelector('.euiSelectableListItem__text--truncate') - ).toBeInTheDocument(); - }); - it('allows setting `truncationProps` per-option', () => { const { container } = render( { }); }); + 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 21d5071f78b..35e67a41858 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -158,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; @@ -219,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( @@ -229,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) => { @@ -304,6 +334,7 @@ export class EuiSelectableList extends Component> { } const id = makeOptionId(index); + const isFocused = activeOptionIndex === index; // Text wrapping const canWrap = !isVirtualized; @@ -312,7 +343,9 @@ export class EuiSelectableList extends Component> { // Truncation config (if any). If none, CSS truncation is used const truncationProps = - textWrap === 'truncate' ? this.getTruncationProps(option) : undefined; + 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} @@ -358,13 +391,12 @@ export class EuiSelectableList extends Component> { ); }, 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', @@ -373,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, @@ -397,7 +429,7 @@ export class EuiSelectableList extends Component> { } return heightIsFull ? ( - + {({ width, height }: EuiAutoSize) => ( {this.ListRow} @@ -405,7 +437,10 @@ export class EuiSelectableList extends Component> { )} ) : ( - + {({ width }: EuiAutoSizeHorizontal) => ( extends Component> { ); }; - getTruncationProps = (option: EuiSelectableOption) => { + 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) { + this.forceVirtualizedListRowRerender(); + } else if (searchable && searchValue) { + this.forceVirtualizedListRowRerender(); + } + }; + + getTruncationProps = (option: EuiSelectableOption, isFocused: boolean) => { // Individual truncation settings should override component-wide settings const truncationProps = { ...this.props.truncationProps, @@ -431,10 +504,17 @@ export class EuiSelectableList extends Component> { this.props.searchValue || Object.keys(truncationProps).length > 0; if (!hasComplexTruncation) return undefined; - // TODO: Performantly calculate a default option width, so that - // each list item doesn't have to generate its own resize observer - - return truncationProps; + // 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 = ( @@ -523,15 +603,10 @@ export class EuiSelectableList extends Component> { ...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 @@ -540,19 +615,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 From 901c6973db9a402bb863b60bd67eb2381d046ae8 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Sat, 25 Nov 2023 12:08:09 -0800 Subject: [PATCH 09/11] Add E2E Cypress specs - some tests copied from EuiComboBox, but EuiSelectable also has extra affordances that need to be tested for (resizing etc) --- src/components/selectable/selectable.spec.tsx | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) 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); From cc7268651d78bf1becf0801d9eb8d9914cd609da Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Sat, 25 Nov 2023 14:13:03 -0800 Subject: [PATCH 10/11] changelog --- changelogs/upcoming/7388.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/upcoming/7388.md 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 From 6cef78be7dd948a985c2a81f520eb0062d8566af Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:23:56 -0800 Subject: [PATCH 11/11] [PR feedback] DRYing Co-authored-by: Tomasz Kajtoch --- src/components/selectable/selectable_list/selectable_list.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index 35e67a41858..7e907e050e9 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -485,9 +485,7 @@ export class EuiSelectableList extends Component< // Potentially force list rows to rerender on dynamic resize as well, // but try to do it as lightly as possible - if (truncationProps) { - this.forceVirtualizedListRowRerender(); - } else if (searchable && searchValue) { + if (truncationProps || (searchable && searchValue)) { this.forceVirtualizedListRowRerender(); } };