From 23aba4cfb45fa4c3adb05b9231da33373baa90c8 Mon Sep 17 00:00:00 2001 From: dej611 Date: Wed, 2 Aug 2023 14:51:24 +0200 Subject: [PATCH 01/44] :lipstick: Move end truncation in separate class --- .../combo_box/combo_box_options_list/_combo_box_option.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss index 5ff6415f704..1e62b376843 100644 --- a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss +++ b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss @@ -44,9 +44,12 @@ } .euiComboBoxOption__content { - text-overflow: ellipsis; overflow: hidden; white-space: nowrap; flex: 1; text-align: left; + + &.euiComboBoxOption-truncationEnd { + text-overflow: ellipsis; + } } From 00720dc02f139ab9fb6e12d7de50b57383228dc3 Mon Sep 17 00:00:00 2001 From: dej611 Date: Wed, 2 Aug 2023 16:01:56 +0200 Subject: [PATCH 02/44] :heavy_plus_sign: Add canvas mock library for jest --- package.json | 1 + scripts/jest/config.json | 3 ++- yarn.lock | 22 +++++++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9a66c03cd33..ed3e71081c7 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,7 @@ "html-webpack-plugin": "^5.5.0", "inquirer": "^9.1.4", "jest": "^24.1.0", + "jest-canvas-mock": "^2.5.2", "jest-cli": "^24.1.0", "moment": "^2.27.0", "moment-timezone": "^0.5.31", diff --git a/scripts/jest/config.json b/scripts/jest/config.json index e3b879ffce8..e22b7399195 100644 --- a/scripts/jest/config.json +++ b/scripts/jest/config.json @@ -21,7 +21,8 @@ "setupFiles": [ "/scripts/jest/setup/enzyme.js", "/scripts/jest/setup/throw_on_console_error.js", - "/scripts/jest/setup/mocks.js" + "/scripts/jest/setup/mocks.js", + "jest-canvas-mock" ], "setupFilesAfterEnv": [ "/scripts/jest/setup/polyfills.js", diff --git a/yarn.lock b/yarn.lock index 32acc67a64f..ee341263a72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6591,7 +6591,7 @@ color-name@1.1.1: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.1.tgz#4b1415304cf50028ea81643643bd82ea05803689" integrity sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok= -color-name@^1.0.0, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -7193,6 +7193,11 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssfontparser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3" + integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg== + cssnano-preset-default@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" @@ -12185,6 +12190,14 @@ jake@^10.8.5: filelist "^1.0.1" minimatch "^3.0.4" +jest-canvas-mock@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz#7e21ebd75e05ab41c890497f6ba8a77f915d2ad6" + integrity sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A== + dependencies: + cssfontparser "^1.2.1" + moo-color "^1.0.2" + jest-changed-files@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039" @@ -14048,6 +14061,13 @@ moment-timezone@^0.5.31: resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ== +moo-color@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" + integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ== + dependencies: + color-name "^1.1.4" + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" From 8b672042dede76deabbc9ea05e31a1537c955326 Mon Sep 17 00:00:00 2001 From: dej611 Date: Wed, 2 Aug 2023 16:05:12 +0200 Subject: [PATCH 03/44] :sparkles: Add truncation middle feature --- .../__snapshots__/combo_box.test.tsx.snap | 34 +++-- src/components/combo_box/combo_box.tsx | 27 ++++ .../combo_box_options_list.tsx | 16 ++- .../truncated_label.tsx | 135 ++++++++++++++++++ src/components/combo_box/types.ts | 2 + 5 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 src/components/combo_box/combo_box_options_list/truncated_label.tsx diff --git a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap index ed6f78b9154..e01c5876739 100644 --- a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap +++ b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap @@ -4,6 +4,7 @@ exports[`EuiComboBox is rendered 1`] = `
Titan @@ -535,7 +547,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Enceladus @@ -562,7 +574,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Mimas @@ -589,7 +601,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Dione @@ -616,7 +628,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Iapetus @@ -643,7 +655,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Phoebe @@ -670,7 +682,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Rhea @@ -697,7 +709,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -724,7 +736,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Tethys @@ -744,6 +756,7 @@ exports[`props selectedOptions are rendered 1`] = ` className="euiComboBox" onBlur={[Function]} onKeyDown={[Function]} + truncation="end" > * supplied by `aria-label` or from [EuiFormRow](/#/forms/form-layouts). */ 'aria-labelledby'?: string; + /** + * Controls the truncation of the label text. + */ + truncation: EuiComboBoxTruncation; } /** @@ -189,6 +194,7 @@ interface EuiComboBoxState { matchingOptions: Array>; searchValue: string; width: number; + font: string; } const initialSearchValue = ''; @@ -208,6 +214,7 @@ export class EuiComboBox extends Component< prepend: undefined, append: undefined, sortMatchesBy: 'none' as const, + truncation: 'end', }; state: EuiComboBoxState = { @@ -228,6 +235,7 @@ export class EuiComboBox extends Component< }), searchValue: initialSearchValue, width: 0, + font: '', }; _isMounted = false; @@ -242,6 +250,7 @@ export class EuiComboBox extends Component< const comboBoxBounds = this.comboBoxRefInstance.getBoundingClientRect(); this.setState({ width: comboBoxBounds.width, + font: this.getFont(), }); } }; @@ -820,6 +829,22 @@ export class EuiComboBox extends Component< return stateUpdate; } + getFont = () => { + if (this.comboBoxRefInstance) { + const css = window.getComputedStyle(this.comboBoxRefInstance); + return [ + 'font-style', + 'font-variant', + 'font-weight', + 'font-size', + 'font-family', + ] + .map((prop) => css.getPropertyValue(prop)) + .join(' '); + } + return ''; + }; + updateMatchingOptionsIfDifferent = ( newMatchingOptions: Array> ) => { @@ -1007,6 +1032,8 @@ export class EuiComboBox extends Component< getSelectedOptionForSearchValue } listboxAriaLabel={listboxAriaLabel} + font={this.state.font} + truncation={this.props.truncation} /> )} diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx index a76e5a97e70..79eae320930 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx @@ -20,7 +20,6 @@ import { } from 'react-window'; import { EuiFlexGroup, EuiFlexItem } from '../../flex'; -import { EuiHighlight } from '../../highlight'; import { EuiText } from '../../text'; import { EuiLoadingSpinner } from '../../loading'; import { EuiComboBoxTitle } from './combo_box_title'; @@ -34,6 +33,7 @@ import { EuiComboBoxOptionOption, EuiComboBoxOptionsListPosition, EuiComboBoxSingleSelectionShape, + EuiComboBoxTruncation, OptionHandler, RefInstance, UpdatePositionHandler, @@ -41,6 +41,7 @@ import { import { CommonProps } from '../../common'; import { EuiBadge } from '../../badge'; import { EuiPopoverPanel } from '../../popover/popover_panel'; +import { TruncatedLabel } from './truncated_label'; const OPTION_CONTENT_CLASSNAME = 'euiComboBoxOption__content'; @@ -94,6 +95,8 @@ export type EuiComboBoxOptionsListProps = CommonProps & singleSelection?: boolean | EuiComboBoxSingleSelectionShape; delimiter?: string; zIndex?: number; + font: string; + truncation: EuiComboBoxTruncation; }; const hitEnterBadge = ( @@ -268,13 +271,15 @@ export class EuiComboBoxOptionsList extends Component< )} ) : ( - - {label} - + font={this.props.font} + defaultComboboxWidth={this.props.width} + /> )} {optionIsFocused && !optionIsDisabled ? hitEnterBadge : null} @@ -315,6 +320,7 @@ export class EuiComboBoxOptionsList extends Component< zIndex, style, listboxAriaLabel, + font, ...rest } = this.props; diff --git a/src/components/combo_box/combo_box_options_list/truncated_label.tsx b/src/components/combo_box/combo_box_options_list/truncated_label.tsx new file mode 100644 index 00000000000..e0728047323 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/truncated_label.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo } from 'react'; +import { EuiHighlight } from '../../highlight'; +import { EuiComboBoxTruncation } from '../types'; +import classNames from 'classnames'; + +interface TruncatedLabelProps { + label: string; + search: string; + font: string; + defaultComboboxWidth: number; + strict: boolean | undefined; + className: string | undefined; + truncation: EuiComboBoxTruncation; +} + +const separator = `…`; +const LABEL_VISIBLE_LENGTH = 8; // empirically found? +const COMBOBOX_PADDINGS = 40; // empirically found? + +const createContext = () => + document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D; + +// extracted from getTextWidth for performance +let context: CanvasRenderingContext2D | null = null; + +const getTextWidth = (text: string, font: string) => { + if (context == null) { + context = createContext(); + } + const ctx = context; + ctx.font = font; + const metrics = ctx.measureText(text); + return metrics.width; +}; + +const truncateLabel = ( + width: number, + font: string, + label: string, + approximateLength: number, + labelFn: (label: string, length: number) => string +) => { + let output = labelFn(label, approximateLength); + const initialApproximateLength = approximateLength; + + while (getTextWidth(output, font) > width) { + approximateLength = approximateLength - 1; + const newOutput = labelFn(label, approximateLength); + if (newOutput === output) { + break; + } + output = newOutput; + } + console.log({ label, output, initialApproximateLength, approximateLength }); + return output; +}; + +function getLabelFn( + label: string, + search: string, + searchPosition: number, + approximateLen: number +) { + if (!search || searchPosition === -1) { + return (text: string, length: number) => + `${text.substring(0, LABEL_VISIBLE_LENGTH)}${separator}${text.substring( + text.length - (length - LABEL_VISIBLE_LENGTH) + )}`; + } + if (searchPosition === 0) { + // search phrase at the beginning + return (text: string, length: number) => + `${text.substring(0, length)}${separator}`; + } + if (approximateLen > label.length - searchPosition) { + // search phrase close to the end or at the end + return (text: string, length: number) => + `${separator}${text.substring(text.length - length)}`; + } + // search phrase is in the middle + return (text: string, length: number) => + `${separator}${text.substring(searchPosition, length)}${separator}`; +} + +export const TruncatedLabel = function ({ + label, + strict, + className, + search, + font, + defaultComboboxWidth, + truncation, +}: TruncatedLabelProps) { + const textWidth = useMemo(() => getTextWidth(label, font), [label, font]); + const usableWidth = defaultComboboxWidth - COMBOBOX_PADDINGS; + + // Use CSS truncation when available + if (textWidth < usableWidth || truncation === 'end') { + return ( + + {label} + + ); + } + + const searchPosition = label.indexOf(search); + const approximateLen = Math.round((usableWidth * label.length) / textWidth); + const labelFn = getLabelFn(label, search, searchPosition, approximateLen); + + const outputLabel = truncateLabel( + usableWidth, + font, + label, + approximateLen, + labelFn + ); + + return ( + + {outputLabel} + + ); +}; diff --git a/src/components/combo_box/types.ts b/src/components/combo_box/types.ts index 768746639c2..c654c38ad71 100644 --- a/src/components/combo_box/types.ts +++ b/src/components/combo_box/types.ts @@ -33,3 +33,5 @@ export type EuiComboBoxOptionsListPosition = 'top' | 'bottom'; export interface EuiComboBoxSingleSelectionShape { asPlainText?: boolean; } + +export type EuiComboBoxTruncation = 'end' | 'middle'; From 00241ea47bc2b424675f2b91b19146af113b569b Mon Sep 17 00:00:00 2001 From: dej611 Date: Wed, 2 Aug 2023 16:06:48 +0200 Subject: [PATCH 04/44] :memo: Update documentation --- src-docs/src/views/combo_box/combo_box.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src-docs/src/views/combo_box/combo_box.js b/src-docs/src/views/combo_box/combo_box.js index 3801819e0f6..1956647cfb8 100644 --- a/src-docs/src/views/combo_box/combo_box.js +++ b/src-docs/src/views/combo_box/combo_box.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { EuiComboBox } from '../../../../src/components'; +import { EuiComboBox, EuiSwitch } from '../../../../src/components'; import { DisplayToggles } from '../form_controls/display_toggles'; const optionsStatic = [ @@ -40,6 +40,7 @@ const optionsStatic = [ ]; export default () => { const [options, setOptions] = useState(optionsStatic); + const [canTruncate, setCanTruncate] = useState(false); const [selectedOptions, setSelected] = useState([options[2], options[4]]); const onChange = (selectedOptions) => { @@ -72,7 +73,19 @@ export default () => { return ( /* DisplayToggles wrapper for Docs only */ - + setCanTruncate(e.target.checked)} + />, + ]} + > { isClearable={true} data-test-subj="demoComboBox" autoFocus + truncation={canTruncate ? 'middle' : undefined} /> ); From 83bd86addf25d05860a44101f96ba6cdb9e5454b Mon Sep 17 00:00:00 2001 From: dej611 Date: Thu, 3 Aug 2023 12:30:06 +0200 Subject: [PATCH 05/44] :ok_hand: Remove canvas usage + remove dependency --- package.json | 1 - scripts/jest/config.js | 2 +- .../__snapshots__/combo_box.test.tsx.snap | 5 +- .../_combo_box_option.scss | 14 ++--- .../truncated_label.tsx | 54 ++++++++++++++----- 5 files changed, 49 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 5fe9ff83749..359d117a4ea 100644 --- a/package.json +++ b/package.json @@ -212,7 +212,6 @@ "html-webpack-plugin": "^5.5.0", "inquirer": "^9.1.4", "jest": "^24.1.0", - "jest-canvas-mock": "^2.5.2", "jest-cli": "^24.1.0", "moment": "^2.27.0", "moment-timezone": "^0.5.31", diff --git a/scripts/jest/config.js b/scripts/jest/config.js index 3174d93b6cb..ffa7e423415 100644 --- a/scripts/jest/config.js +++ b/scripts/jest/config.js @@ -35,7 +35,7 @@ const config = { setupFiles: [ '/scripts/jest/setup/enzyme.js', '/scripts/jest/setup/throw_on_console_error.js', - '/scripts/jest/setup/mocks.js', + '/scripts/jest/setup/mocks.js' ], setupFilesAfterEnv: [ '/scripts/jest/setup/polyfills.js', diff --git a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap index 08b7c158a0d..5a2ac492090 100644 --- a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap +++ b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap @@ -525,6 +525,7 @@ exports[`props option.prepend & option.append renders in the options dropdown 1` data-popover-panel="true" data-test-subj="comboBoxOptionsList" style="z-index: 100;" + truncation="end" >
1 @@ -594,7 +595,7 @@ exports[`props option.prepend & option.append renders in the options dropdown 1` class="euiComboBoxOption__contentWrapper" > 2 diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss index 09ad36f4b78..7b27f7bb437 100644 --- a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss +++ b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss @@ -38,6 +38,10 @@ white-space: nowrap; flex: 1; text-align: left; + + &.euiComboBoxOption-truncationEnd { + text-overflow: ellipsis; + } } &__emptyStateText { @@ -54,12 +58,6 @@ margin-left: $euiSizeXS; } -.euiComboBoxOption__content { - overflow: hidden; - white-space: nowrap; - flex: 1; - text-align: left; - &__prepend { margin-right: $euiSizeS; } @@ -75,8 +73,4 @@ display: block; } } - - &.euiComboBoxOption-truncationEnd { - text-overflow: ellipsis; - } } \ No newline at end of file diff --git a/src/components/combo_box/combo_box_options_list/truncated_label.tsx b/src/components/combo_box/combo_box_options_list/truncated_label.tsx index e0728047323..6560daba9db 100644 --- a/src/components/combo_box/combo_box_options_list/truncated_label.tsx +++ b/src/components/combo_box/combo_box_options_list/truncated_label.tsx @@ -25,20 +25,43 @@ const separator = `…`; const LABEL_VISIBLE_LENGTH = 8; // empirically found? const COMBOBOX_PADDINGS = 40; // empirically found? -const createContext = () => - document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D; +let divEl: HTMLDivElement | null = null; -// extracted from getTextWidth for performance -let context: CanvasRenderingContext2D | null = null; +const createDivEl = (font: string) => { + const el = document.createElement('div'); + el.style.position = 'absolute'; + el.style.float = 'left'; + el.style.whiteSpace = 'no-wrap'; + el.style.visibility = 'hidden'; + el.style.left = '-1000px'; + el.style.top = '-1000px'; + el.style.font = font; + return el; +}; + +const ensureDivEl = (font: string) => { + if (divEl) { + return; + } + divEl = createDivEl(font); + document.body.appendChild(divEl); +}; + +const cleanup = () => { + if (!divEl) { + return; + } + document.body.removeChild(divEl); + divEl = null; +}; const getTextWidth = (text: string, font: string) => { - if (context == null) { - context = createContext(); + ensureDivEl(font); + if (!divEl) { + return 0; } - const ctx = context; - ctx.font = font; - const metrics = ctx.measureText(text); - return metrics.width; + divEl.textContent = text; + return divEl.clientWidth; }; const truncateLabel = ( @@ -49,7 +72,8 @@ const truncateLabel = ( labelFn: (label: string, length: number) => string ) => { let output = labelFn(label, approximateLength); - const initialApproximateLength = approximateLength; + + ensureDivEl(font); while (getTextWidth(output, font) > width) { approximateLength = approximateLength - 1; @@ -59,7 +83,7 @@ const truncateLabel = ( } output = newOutput; } - console.log({ label, output, initialApproximateLength, approximateLength }); + cleanup(); return output; }; @@ -99,7 +123,11 @@ export const TruncatedLabel = function ({ defaultComboboxWidth, truncation, }: TruncatedLabelProps) { - const textWidth = useMemo(() => getTextWidth(label, font), [label, font]); + // avoid measure if truncation is at the end, CSS will take care of it + const textWidth = useMemo( + () => (truncation === 'end' ? 0 : getTextWidth(label, font)), + [truncation, label, font] + ); const usableWidth = defaultComboboxWidth - COMBOBOX_PADDINGS; // Use CSS truncation when available From ea1be6cbd1cc8f1fafa8d01bb77fa186525d65aa Mon Sep 17 00:00:00 2001 From: dej611 Date: Thu, 3 Aug 2023 12:35:01 +0200 Subject: [PATCH 06/44] :fire: Remove ellipsis --- .../combo_box/combo_box_options_list/_combo_box_option.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss index 7b27f7bb437..a1d2d0f7d48 100644 --- a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss +++ b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss @@ -33,7 +33,6 @@ } &__content { - text-overflow: ellipsis; overflow: hidden; white-space: nowrap; flex: 1; From cb37976bfc0af8b3ac444e7fb59d5391cef008cc Mon Sep 17 00:00:00 2001 From: dej611 Date: Thu, 3 Aug 2023 12:39:53 +0200 Subject: [PATCH 07/44] :recycle: Reduce append operations --- .../truncated_label.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/components/combo_box/combo_box_options_list/truncated_label.tsx b/src/components/combo_box/combo_box_options_list/truncated_label.tsx index 6560daba9db..3ec846906f9 100644 --- a/src/components/combo_box/combo_box_options_list/truncated_label.tsx +++ b/src/components/combo_box/combo_box_options_list/truncated_label.tsx @@ -40,23 +40,19 @@ const createDivEl = (font: string) => { }; const ensureDivEl = (font: string) => { - if (divEl) { - return; + if (!divEl) { + divEl = createDivEl(font); } - divEl = createDivEl(font); document.body.appendChild(divEl); }; const cleanup = () => { - if (!divEl) { - return; + if (divEl && document.body.contains(divEl)) { + document.body.removeChild(divEl); } - document.body.removeChild(divEl); - divEl = null; }; -const getTextWidth = (text: string, font: string) => { - ensureDivEl(font); +const getTextWidth = (text: string) => { if (!divEl) { return 0; } @@ -75,7 +71,7 @@ const truncateLabel = ( ensureDivEl(font); - while (getTextWidth(output, font) > width) { + while (getTextWidth(output) > width) { approximateLength = approximateLength - 1; const newOutput = labelFn(label, approximateLength); if (newOutput === output) { @@ -124,10 +120,16 @@ export const TruncatedLabel = function ({ truncation, }: TruncatedLabelProps) { // avoid measure if truncation is at the end, CSS will take care of it - const textWidth = useMemo( - () => (truncation === 'end' ? 0 : getTextWidth(label, font)), - [truncation, label, font] - ); + const textWidth = useMemo(() => { + if (truncation === 'end') { + return 0; + } + ensureDivEl(font); + const size = getTextWidth(label); + cleanup(); + return size; + }, [truncation, label, font]); + const usableWidth = defaultComboboxWidth - COMBOBOX_PADDINGS; // Use CSS truncation when available From 1aeec8c225ad26a545f266c2201cdae49e8d4a8b Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Sun, 20 Aug 2023 12:03:35 -0700 Subject: [PATCH 08/44] Revert yarn.lock changes --- yarn.lock | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5d0fc70c6e3..b10b69c3a4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7666,7 +7666,7 @@ color-name@1.1.1: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.1.tgz#4b1415304cf50028ea81643643bd82ea05803689" integrity sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok= -color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -8270,11 +8270,6 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -cssfontparser@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3" - integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg== - cssnano-preset-default@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" @@ -13381,14 +13376,6 @@ jake@^10.8.5: filelist "^1.0.1" minimatch "^3.0.4" -jest-canvas-mock@^2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz#7e21ebd75e05ab41c890497f6ba8a77f915d2ad6" - integrity sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A== - dependencies: - cssfontparser "^1.2.1" - moo-color "^1.0.2" - jest-changed-files@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039" @@ -15264,13 +15251,6 @@ moment-timezone@^0.5.31: resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ== -moo-color@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" - integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ== - dependencies: - color-name "^1.1.4" - move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" From e07791d611b1727b72527491ee5ef4702ba57262 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 01:31:46 -0700 Subject: [PATCH 09/44] revert jest config file changes --- scripts/jest/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/jest/config.js b/scripts/jest/config.js index ffa7e423415..3174d93b6cb 100644 --- a/scripts/jest/config.js +++ b/scripts/jest/config.js @@ -35,7 +35,7 @@ const config = { setupFiles: [ '/scripts/jest/setup/enzyme.js', '/scripts/jest/setup/throw_on_console_error.js', - '/scripts/jest/setup/mocks.js' + '/scripts/jest/setup/mocks.js', ], setupFilesAfterEnv: [ '/scripts/jest/setup/polyfills.js', From 6131706259a72a078dedda4fb072d4b9ea63cc08 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 02:09:05 -0700 Subject: [PATCH 10/44] Set up `EuiTextTruncate` component TODO: tests TODO: figure out how to incorporate combobox's search highlighting TODO: update combobox to dogfood this component --- .../text_truncate/text_truncate.stories.tsx | 34 ++++ .../text_truncate/text_truncate.styles.ts | 16 ++ .../text_truncate/text_truncate.tsx | 181 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/components/text_truncate/text_truncate.stories.tsx create mode 100644 src/components/text_truncate/text_truncate.styles.ts create mode 100644 src/components/text_truncate/text_truncate.tsx diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx new file mode 100644 index 00000000000..4d3a01c174e --- /dev/null +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { EuiTextTruncate, EuiTextTruncateProps } from './text_truncate'; + +const meta: Meta = { + title: 'EuiTextTruncate', + component: EuiTextTruncate, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: (props) => ( +
+ {(text) => <>{text}} +
+ ), + args: { + text: 'Lorem ipsum dolor sit amet, test test test test test test', + truncation: 'middle', + truncationOffset: 0, + separator: '...', + }, +}; diff --git a/src/components/text_truncate/text_truncate.styles.ts b/src/components/text_truncate/text_truncate.styles.ts new file mode 100644 index 00000000000..1563dcf339b --- /dev/null +++ b/src/components/text_truncate/text_truncate.styles.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css } from '@emotion/react'; + +export const euiTextTruncateStyles = { + euiTextTruncate: css` + overflow: hidden; + white-space: nowrap; + `, +}; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx new file mode 100644 index 00000000000..75e5826f1a9 --- /dev/null +++ b/src/components/text_truncate/text_truncate.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { + FunctionComponent, + ReactNode, + Ref, + useState, + useMemo, +} from 'react'; + +import { useCombinedRefs } from '../../services'; +import { EuiResizeObserver } from '../observer/resize_observer'; + +import { euiTextTruncateStyles } from './text_truncate.styles'; + +export type EuiTextTruncateProps = { + children: (truncatedString: string) => ReactNode; + text: string; + truncation: 'end' | 'start' | 'startEnd' | 'middle'; // TODO: [start: x, end: y?]; - needs to support combobox search highlight + truncationOffset?: number; // only applies to end and start + separator?: string; + width?: number; // Will allow turning off the automatic resize observer for performance, e.g. in EuiComboBox +}; + +export const EuiTextTruncate: FunctionComponent = ({ + width, + ...props +}) => { + const [containerWidth, setContainerWidth] = useState(0); + + return width ? ( + + ) : ( + setContainerWidth(width)}> + {(containerResizeRef) => ( + + )} + + ); +}; + +const EuiTextTruncateToWidth: FunctionComponent< + EuiTextTruncateProps & { width: number; containerRef?: Ref } +> = ({ + children, + text, + truncation: _truncation = 'end', + truncationOffset: _truncationOffset = 0, + separator = '…', + width, + containerRef, +}) => { + // Note: This needs to be a state and not a ref to trigger a rerender on mount + const [containerEl, setContainerEl] = useState(null); + const refs = useCombinedRefs([setContainerEl, containerRef]); + + const truncationUsesOffset = _truncation === 'end' || _truncation === 'start'; + const truncationOffsetIsTooLarge = + truncationUsesOffset && _truncationOffset >= Math.floor(text.length / 2); + + const truncation = useMemo(() => { + if (truncationOffsetIsTooLarge) { + return 'middle' as const; + } else { + return _truncation; + } + }, [_truncation, truncationOffsetIsTooLarge]); + + const truncationOffset = useMemo(() => { + if (truncationUsesOffset && !truncationOffsetIsTooLarge) { + return _truncationOffset; + } else { + return 0; + } + }, [_truncationOffset, truncationOffsetIsTooLarge, truncationUsesOffset]); + + const truncatedText = useMemo(() => { + if (!containerEl || !width) return ''; + + // Create a temporary DOM element for manipulating text and determining text width + const span = document.createElement('span'); + containerEl.appendChild(span); + + // Quick check to make sure consumers didn't pass in an insane separator + span.textContent = separator; + if (span.offsetWidth >= width * 0.9) { + throw new Error( + 'The separator passed is larger than the available width and cannot be used.' + ); + } + + span.textContent = text; + + // Check if we need to truncate at all + if (width > span.offsetWidth) { + containerEl.removeChild(span); + return text; + } + + const substringOffset = truncationOffset + separator.length + 1; + + switch (truncation) { + case 'end': + const endPosition = text.length - truncationOffset; + const endOffset = span.textContent.substring(endPosition); + const endRemaining = span.textContent.substring(0, endPosition); + span.textContent = `${endRemaining}${separator}${endOffset}`; + + while (span.offsetWidth > width) { + const offset = span.textContent.length - substringOffset; + const trimmedText = span.textContent.substring(0, offset); + span.textContent = `${trimmedText}${separator}${endOffset}`; + } + break; + + case 'start': + const startOffset = span.textContent.substring(0, truncationOffset); + const startRemaining = span.textContent.substring(truncationOffset); + span.textContent = `${startOffset}${separator}${startRemaining}`; + + while (span.offsetWidth > width) { + const trimmedText = span.textContent.substring(substringOffset); + span.textContent = `${startOffset}${separator}${trimmedText}`; + } + break; + + case 'startEnd': + while (span.offsetWidth > width) { + const trimmedMiddle = span.textContent.substring( + substringOffset, + span.textContent.length - substringOffset + ); + span.textContent = `${separator}${trimmedMiddle}${separator}`; + } + break; + + case 'middle': + const middlePosition = Math.floor(text.length / 2); + let firstHalf = text.substring(0, middlePosition); + let secondHalf = text.substring(middlePosition); + let trimfirstHalf = true; + + while (span.offsetWidth > width) { + if (trimfirstHalf) { + firstHalf = firstHalf.substring(0, firstHalf.length - 1); + } else { + secondHalf = secondHalf.substring(1); + } + span.textContent = `${firstHalf}${separator}${secondHalf}`; + trimfirstHalf = !trimfirstHalf; + } + break; + } + + const truncatedText = span.textContent; + containerEl.removeChild(span); + + return truncatedText; + }, [width, text, truncation, truncationOffset, separator, containerEl]); + + return ( +
+ {truncatedText && children(truncatedText)} +
+ ); +}; From ce0bf7e27971572acac2d42c19384b6022f8836b Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 07:01:37 -0700 Subject: [PATCH 11/44] export / rest spread --- src/components/text_truncate/index.ts | 10 ++++++++ .../text_truncate/text_truncate.tsx | 24 ++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 src/components/text_truncate/index.ts diff --git a/src/components/text_truncate/index.ts b/src/components/text_truncate/index.ts new file mode 100644 index 00000000000..3059160a7dd --- /dev/null +++ b/src/components/text_truncate/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { EuiTextTruncateProps } from './text_truncate'; +export { EuiTextTruncate } from './text_truncate'; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 75e5826f1a9..b43903896af 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -7,6 +7,7 @@ */ import React, { + HTMLAttributes, FunctionComponent, ReactNode, Ref, @@ -16,17 +17,22 @@ import React, { import { useCombinedRefs } from '../../services'; import { EuiResizeObserver } from '../observer/resize_observer'; +import type { CommonProps } from '../common'; import { euiTextTruncateStyles } from './text_truncate.styles'; -export type EuiTextTruncateProps = { - children: (truncatedString: string) => ReactNode; - text: string; - truncation: 'end' | 'start' | 'startEnd' | 'middle'; // TODO: [start: x, end: y?]; - needs to support combobox search highlight - truncationOffset?: number; // only applies to end and start - separator?: string; - width?: number; // Will allow turning off the automatic resize observer for performance, e.g. in EuiComboBox -}; +export type EuiTextTruncateProps = Omit< + HTMLAttributes, + 'children' +> & + CommonProps & { + children: (truncatedString: string) => ReactNode; + text: string; + truncation: 'end' | 'start' | 'startEnd' | 'middle'; // TODO: [start: x, end: y?]; - needs to support combobox search highlight + truncationOffset?: number; // only applies to end and start + separator?: string; + width?: number; // Will allow turning off the automatic resize observer for performance, e.g. in EuiComboBox + }; export const EuiTextTruncate: FunctionComponent = ({ width, @@ -59,6 +65,7 @@ const EuiTextTruncateToWidth: FunctionComponent< separator = '…', width, containerRef, + ...rest }) => { // Note: This needs to be a state and not a ref to trigger a rerender on mount const [containerEl, setContainerEl] = useState(null); @@ -174,6 +181,7 @@ const EuiTextTruncateToWidth: FunctionComponent< ref={refs} title={text} aria-label={text} + {...rest} > {truncatedText && children(truncatedText)}
From 79cb3511984995f5f8ba50bd407f24657cb07bd1 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 12:56:01 -0700 Subject: [PATCH 12/44] More component cleanup/changes - Write prop docs - Rename `separator` prop to `ellipsis` - Tweak ellipsis check to return an empty string / not completely throw and simply error instead - Resize observer - separate to its own component for performance/readability, + tweak conditional to check for `undefined` rather falling back if `0` (may matter for initial render) - Rename internal `EuiTextTruncateToWidth` to `EuiTextTruncateWithWidth` --- .../text_truncate/text_truncate.stories.tsx | 2 +- .../text_truncate/text_truncate.tsx | 101 ++++++++++++------ 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index 4d3a01c174e..95196c6a5bb 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -29,6 +29,6 @@ export const Playground: Story = { text: 'Lorem ipsum dolor sit amet, test test test test test test', truncation: 'middle', truncationOffset: 0, - separator: '...', + ellipsis: '...', }, }; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index b43903896af..7efb08ceaae 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -26,44 +26,67 @@ export type EuiTextTruncateProps = Omit< 'children' > & CommonProps & { + /** + * EuiTextTruncate passes back the truncated text as a render prop + * (instead of rendering the truncated string directly). This API allows + * you to use the text more flexibly, e.g. adding custom markup/highlighting + */ children: (truncatedString: string) => ReactNode; + /** + * The full text string to truncate + */ text: string; + /** + * The truncation type desired. Determines where the ellipses are placed. + */ truncation: 'end' | 'start' | 'startEnd' | 'middle'; // TODO: [start: x, end: y?]; - needs to support combobox search highlight - truncationOffset?: number; // only applies to end and start - separator?: string; - width?: number; // Will allow turning off the automatic resize observer for performance, e.g. in EuiComboBox + /** + * This prop **only** affects the `start` and `end` truncation types. + * It allows preserving a certain number of characters of either the + * starting or ending text. + * + * If the passed offset is greater than half of the total text length, + * the truncation will simply default to `middle` instead. + */ + truncationOffset?: number; + /** + * Defaults to the horizontal ellipsis character. + * Can be optionally configured to use other punctuation, + * e.g. spaces, brackets, hyphens, asterisks, etc. + */ + ellipsis?: string; + /** + * By default, EuiTextTruncate will render a resize observer to detect the + * available width it has. For performance reasons (e.g. multiple truncated + * text items within the same container), you may opt to pass in your own + * container width, which will skip initializing a resize observer. + */ + width?: number; }; export const EuiTextTruncate: FunctionComponent = ({ width, ...props }) => { - const [containerWidth, setContainerWidth] = useState(0); - - return width ? ( - + return width != null ? ( + ) : ( - setContainerWidth(width)}> - {(containerResizeRef) => ( - - )} - + ); }; -const EuiTextTruncateToWidth: FunctionComponent< - EuiTextTruncateProps & { width: number; containerRef?: Ref } +const EuiTextTruncateWithWidth: FunctionComponent< + EuiTextTruncateProps & { + width: number; + containerRef?: Ref; + } > = ({ + width, children, text, truncation: _truncation = 'end', truncationOffset: _truncationOffset = 0, - separator = '…', - width, + ellipsis = '…', containerRef, ...rest }) => { @@ -98,12 +121,14 @@ const EuiTextTruncateToWidth: FunctionComponent< const span = document.createElement('span'); containerEl.appendChild(span); - // Quick check to make sure consumers didn't pass in an insane separator - span.textContent = separator; + // Check to make sure the container width can even fit the ellipsis, let alone text + span.textContent = ellipsis; if (span.offsetWidth >= width * 0.9) { - throw new Error( - 'The separator passed is larger than the available width and cannot be used.' + console.error( + 'The truncation ellipsis is larger than the available width. No text can be rendered.' ); + containerEl.removeChild(span); + return ''; } span.textContent = text; @@ -114,30 +139,30 @@ const EuiTextTruncateToWidth: FunctionComponent< return text; } - const substringOffset = truncationOffset + separator.length + 1; + const substringOffset = truncationOffset + ellipsis.length + 1; switch (truncation) { case 'end': const endPosition = text.length - truncationOffset; const endOffset = span.textContent.substring(endPosition); const endRemaining = span.textContent.substring(0, endPosition); - span.textContent = `${endRemaining}${separator}${endOffset}`; + span.textContent = `${endRemaining}${ellipsis}${endOffset}`; while (span.offsetWidth > width) { const offset = span.textContent.length - substringOffset; const trimmedText = span.textContent.substring(0, offset); - span.textContent = `${trimmedText}${separator}${endOffset}`; + span.textContent = `${trimmedText}${ellipsis}${endOffset}`; } break; case 'start': const startOffset = span.textContent.substring(0, truncationOffset); const startRemaining = span.textContent.substring(truncationOffset); - span.textContent = `${startOffset}${separator}${startRemaining}`; + span.textContent = `${startOffset}${ellipsis}${startRemaining}`; while (span.offsetWidth > width) { const trimmedText = span.textContent.substring(substringOffset); - span.textContent = `${startOffset}${separator}${trimmedText}`; + span.textContent = `${startOffset}${ellipsis}${trimmedText}`; } break; @@ -147,7 +172,7 @@ const EuiTextTruncateToWidth: FunctionComponent< substringOffset, span.textContent.length - substringOffset ); - span.textContent = `${separator}${trimmedMiddle}${separator}`; + span.textContent = `${ellipsis}${trimmedMiddle}${ellipsis}`; } break; @@ -173,7 +198,7 @@ const EuiTextTruncateToWidth: FunctionComponent< containerEl.removeChild(span); return truncatedText; - }, [width, text, truncation, truncationOffset, separator, containerEl]); + }, [width, text, truncation, truncationOffset, ellipsis, containerEl]); return (
); }; + +const EuiTextTruncateWithResizeObserver: FunctionComponent< + Omit +> = ({ ...props }) => { + const [width, setWidth] = useState(0); + + return ( + setWidth(width)}> + {(ref) => ( + + )} + + ); +}; From 3f9671fe184d1a6f55675933adf6c45b07c33f88 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 13:02:22 -0700 Subject: [PATCH 13/44] Add `onResize` callback + resize observer story --- .../text_truncate/text_truncate.stories.tsx | 35 +++++++++++++++++-- .../text_truncate/text_truncate.tsx | 28 +++++++++++---- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index 95196c6a5bb..1561e6ac2cb 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -19,16 +19,45 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const componentDefaults = { + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', + truncation: 'middle', +} as const; + export const Playground: Story = { render: (props) => ( -
+
{(text) => <>{text}}
), args: { - text: 'Lorem ipsum dolor sit amet, test test test test test test', - truncation: 'middle', + ...componentDefaults, truncationOffset: 0, ellipsis: '...', + width: 200, + }, +}; + +export const ResizeObserver: Story = { + render: (props) => ( + <> + + Drag the corner of the text to resize, and look in the console log to + see the reported width + +
+
+ {/* // Width here is just for testing resize behavior and isn't meant to be RTL compliant */} +
+ {(text) => text} +
+ + ), + args: { + ...componentDefaults, + onResize: console.log, + }, + argTypes: { + width: { control: false }, }, }; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 7efb08ceaae..44717b2b988 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -13,17 +13,21 @@ import React, { Ref, useState, useMemo, + useCallback, } from 'react'; import { useCombinedRefs } from '../../services'; -import { EuiResizeObserver } from '../observer/resize_observer'; +import { + EuiResizeObserver, + EuiResizeObserverProps, +} from '../observer/resize_observer'; import type { CommonProps } from '../common'; import { euiTextTruncateStyles } from './text_truncate.styles'; export type EuiTextTruncateProps = Omit< HTMLAttributes, - 'children' + 'children' | 'onResize' > & CommonProps & { /** @@ -62,6 +66,11 @@ export type EuiTextTruncateProps = Omit< * container width, which will skip initializing a resize observer. */ width?: number; + /** + * Optional callback that fires when the default resizer observer both mounts and + * registers a size change. This callback will **not** fire if `width` is passed. + */ + onResize?: (width: number) => void; }; export const EuiTextTruncate: FunctionComponent = ({ @@ -76,7 +85,7 @@ export const EuiTextTruncate: FunctionComponent = ({ }; const EuiTextTruncateWithWidth: FunctionComponent< - EuiTextTruncateProps & { + Omit & { width: number; containerRef?: Ref; } @@ -188,7 +197,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< } else { secondHalf = secondHalf.substring(1); } - span.textContent = `${firstHalf}${separator}${secondHalf}`; + span.textContent = `${firstHalf}${ellipsis}${secondHalf}`; trimfirstHalf = !trimfirstHalf; } break; @@ -215,11 +224,18 @@ const EuiTextTruncateWithWidth: FunctionComponent< const EuiTextTruncateWithResizeObserver: FunctionComponent< Omit -> = ({ ...props }) => { +> = ({ onResize: _onResize, ...props }) => { const [width, setWidth] = useState(0); + const onResize: EuiResizeObserverProps['onResize'] = useCallback( + ({ width }) => { + setWidth(width); + _onResize?.(width); + }, + [_onResize] + ); return ( - setWidth(width)}> + {(ref) => ( )} From d5d6f3d2901ac567cd1176d5182a1d6b111de7d3 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 15:50:38 -0700 Subject: [PATCH 14/44] Add startEnd anchor position logic needed for combobox search --- .../text_truncate/text_truncate.stories.tsx | 64 ++++++++++- .../text_truncate/text_truncate.tsx | 105 +++++++++++++++--- 2 files changed, 153 insertions(+), 16 deletions(-) diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index 1561e6ac2cb..f5009034b8b 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -6,9 +6,11 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { EuiHighlight, EuiMark } from '../../components'; + import { EuiTextTruncate, EuiTextTruncateProps } from './text_truncate'; const meta: Meta = { @@ -27,7 +29,7 @@ const componentDefaults = { export const Playground: Story = { render: (props) => (
- {(text) => <>{text}} + {(text) => text}
), args: { @@ -61,3 +63,61 @@ export const ResizeObserver: Story = { width: { control: false }, }, }; + +export const StartEndAnchorForSearch: Story = { + render: (props) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [highlight, setHighlight] = useState(''); + const highlightStartPosition = props.text + .toLowerCase() + .indexOf(highlight.toLowerCase()); + const highlightCenterPosition = + highlightStartPosition + Math.floor(highlight.length / 2); + + return ( + <> + Type into the below textarea to highlight, e.g. "consec" +
+ setHighlight(e.target.value)} + /> +
+
+
+ + {(text) => ( + <> + {text.length > highlight.length ? ( + {text} + ) : ( + {text} + )} + + )} + +
+ + ); + }, + args: { + ...componentDefaults, + width: 200, + truncation: 'startEnd', + startEndAnchor: 30, + }, + argTypes: { + // Disable uncontrollable props + truncation: { table: { disable: true } }, + startEndAnchor: { table: { disable: true } }, + // Disable props that aren't useful for this this demo + truncationOffset: { table: { disable: true } }, + children: { table: { disable: true } }, + onResize: { table: { disable: true } }, + }, +}; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 44717b2b988..e81a52d7d9c 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -43,9 +43,9 @@ export type EuiTextTruncateProps = Omit< /** * The truncation type desired. Determines where the ellipses are placed. */ - truncation: 'end' | 'start' | 'startEnd' | 'middle'; // TODO: [start: x, end: y?]; - needs to support combobox search highlight + truncation: 'end' | 'start' | 'startEnd' | 'middle'; /** - * This prop **only** affects the `start` and `end` truncation types. + * This prop **only** applies to the `start` and `end` truncation types. * It allows preserving a certain number of characters of either the * starting or ending text. * @@ -53,6 +53,21 @@ export type EuiTextTruncateProps = Omit< * the truncation will simply default to `middle` instead. */ truncationOffset?: number; + /** + * This prop **only** applies to the `startEnd` truncation type. + * By default, the `startEnd` truncation will center to the middle + * of the text string - this prop allows customizing that anchor position. + * + * The primary use case for this prop for is search highlighting - if a + * user searches for a specific word in the text, pass the middle index of that + * found word to ensure it is always visible. + * + * This behavior will intelligently detect when anchors are close to the start + * or end of the text, and omit leading or trailing ellipses when necessary. + * If the passed anchor position is greater than the total text length, + * the truncation will simply default to `start` instead. + */ + startEndAnchor?: number; /** * Defaults to the horizontal ellipsis character. * Can be optionally configured to use other punctuation, @@ -95,6 +110,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< text, truncation: _truncation = 'end', truncationOffset: _truncationOffset = 0, + startEndAnchor, ellipsis = '…', containerRef, ...rest @@ -109,11 +125,16 @@ const EuiTextTruncateWithWidth: FunctionComponent< const truncation = useMemo(() => { if (truncationOffsetIsTooLarge) { - return 'middle' as const; - } else { - return _truncation; + return 'middle'; + } else if (_truncation === 'startEnd' && startEndAnchor != null) { + if (startEndAnchor <= 0) { + return 'end'; + } else if (startEndAnchor >= text.length) { + return 'start'; + } } - }, [_truncation, truncationOffsetIsTooLarge]); + return _truncation; + }, [_truncation, truncationOffsetIsTooLarge, startEndAnchor, text.length]); const truncationOffset = useMemo(() => { if (truncationUsesOffset && !truncationOffsetIsTooLarge) { @@ -131,7 +152,10 @@ const EuiTextTruncateWithWidth: FunctionComponent< containerEl.appendChild(span); // Check to make sure the container width can even fit the ellipsis, let alone text - span.textContent = ellipsis; + span.textContent = + truncation === 'startEnd' // startEnd needs a little more space + ? `${ellipsis} ${ellipsis}` + : ellipsis; if (span.offsetWidth >= width * 0.9) { console.error( 'The truncation ellipsis is larger than the available width. No text can be rendered.' @@ -176,12 +200,57 @@ const EuiTextTruncateWithWidth: FunctionComponent< break; case 'startEnd': - while (span.offsetWidth > width) { - const trimmedMiddle = span.textContent.substring( - substringOffset, - span.textContent.length - substringOffset - ); - span.textContent = `${ellipsis}${trimmedMiddle}${ellipsis}`; + if (startEndAnchor == null) { + while (span.offsetWidth > width) { + const trimmedMiddle = span.textContent.substring( + substringOffset, + span.textContent.length - substringOffset + ); + span.textContent = `${ellipsis}${trimmedMiddle}${ellipsis}`; + } + } else { + // If using a non-centered startEnd anchor position, we need to *build* + // the string from scratch instead of *removing* from the full text string, + // to make sure we don't go past the beginning or end of the text + let builtText = ''; + span.textContent = builtText; + + // Ellipses are conditional - if the anchor is towards the beginning or end, + // it's possible they shouldn't render + let startingEllipsis = ellipsis; + let endingEllipsis = ellipsis; + + // Split the text into two at the anchor position + let firstPart = text.substring(0, startEndAnchor); + let secondPart = text.substring(startEndAnchor); + + while (span.offsetWidth <= width) { + // Because this logic builds text outwards vs. removes inwards, the final text + // width ends up a little larger than the container if we don't add this catch + const previousText = span.textContent; + span.textContent = `${startingEllipsis}${builtText}${endingEllipsis}`; + if (span.offsetWidth > width) { + span.textContent = previousText; + break; + } + + if (firstPart.length > 0) { + // Split off and prepend the last character of the first part + const lastChar = firstPart.length - 1; + builtText = `${firstPart.substring(lastChar)}${builtText}`; + firstPart = firstPart.substring(0, lastChar); + } else { + startingEllipsis = ''; + } + + if (secondPart.length > 0) { + // Split off and append first character of the second part + builtText = `${builtText}${secondPart.substring(0, 1)}`; + secondPart = secondPart.substring(1); + } else { + endingEllipsis = ''; + } + } } break; @@ -207,7 +276,15 @@ const EuiTextTruncateWithWidth: FunctionComponent< containerEl.removeChild(span); return truncatedText; - }, [width, text, truncation, truncationOffset, ellipsis, containerEl]); + }, [ + width, + text, + truncation, + truncationOffset, + startEndAnchor, + ellipsis, + containerEl, + ]); return (
Date: Mon, 21 Aug 2023 15:58:10 -0700 Subject: [PATCH 15/44] [DX] Make the render prop optional --- .../text_truncate/text_truncate.stories.tsx | 4 ++-- src/components/text_truncate/text_truncate.tsx | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index f5009034b8b..7e95aad0cf7 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -29,7 +29,7 @@ const componentDefaults = { export const Playground: Story = { render: (props) => (
- {(text) => text} +
), args: { @@ -51,7 +51,7 @@ export const ResizeObserver: Story = {
{/* // Width here is just for testing resize behavior and isn't meant to be RTL compliant */}
- {(text) => text} +
), diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index e81a52d7d9c..f884f3d2305 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -30,12 +30,6 @@ export type EuiTextTruncateProps = Omit< 'children' | 'onResize' > & CommonProps & { - /** - * EuiTextTruncate passes back the truncated text as a render prop - * (instead of rendering the truncated string directly). This API allows - * you to use the text more flexibly, e.g. adding custom markup/highlighting - */ - children: (truncatedString: string) => ReactNode; /** * The full text string to truncate */ @@ -86,6 +80,13 @@ export type EuiTextTruncateProps = Omit< * registers a size change. This callback will **not** fire if `width` is passed. */ onResize?: (width: number) => void; + /** + * By default, EuiTextTruncate will render the truncated string directly. + * You can optionally pass a render prop function to the component, which + * allows for more flexible text rendering, e.g. adding custom markup + * or highlighting + */ + children?: (truncatedString: string) => ReactNode; }; export const EuiTextTruncate: FunctionComponent = ({ @@ -294,7 +295,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< aria-label={text} {...rest} > - {truncatedText && children(truncatedText)} + {children ? children(truncatedText) : truncatedText}
); }; From 0c0703c12f0232e9b466617aa0a1bb64c73d5691 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 19:02:35 -0700 Subject: [PATCH 16/44] [typing] Make `truncation` prop optional since we're already defaulting to `end` --- src/components/text_truncate/index.ts | 5 ++++- src/components/text_truncate/text_truncate.stories.tsx | 2 +- src/components/text_truncate/text_truncate.tsx | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/text_truncate/index.ts b/src/components/text_truncate/index.ts index 3059160a7dd..cd8f9fd3749 100644 --- a/src/components/text_truncate/index.ts +++ b/src/components/text_truncate/index.ts @@ -6,5 +6,8 @@ * Side Public License, v 1. */ -export type { EuiTextTruncateProps } from './text_truncate'; +export type { + EuiTextTruncateProps, + EuiTextTruncationTypes, +} from './text_truncate'; export { EuiTextTruncate } from './text_truncate'; diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index 7e95aad0cf7..82abd50436d 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -23,7 +23,7 @@ type Story = StoryObj; const componentDefaults = { text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', - truncation: 'middle', + truncation: 'end', } as const; export const Playground: Story = { diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index f884f3d2305..d1db28e8619 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -25,6 +25,9 @@ import type { CommonProps } from '../common'; import { euiTextTruncateStyles } from './text_truncate.styles'; +const TRUNCATION_TYPES = ['end', 'start', 'startEnd', 'middle'] as const; +export type EuiTextTruncationTypes = (typeof TRUNCATION_TYPES)[number]; + export type EuiTextTruncateProps = Omit< HTMLAttributes, 'children' | 'onResize' @@ -37,7 +40,7 @@ export type EuiTextTruncateProps = Omit< /** * The truncation type desired. Determines where the ellipses are placed. */ - truncation: 'end' | 'start' | 'startEnd' | 'middle'; + truncation?: EuiTextTruncationTypes; /** * This prop **only** applies to the `start` and `end` truncation types. * It allows preserving a certain number of characters of either the From 572ce0c60aa3128a94e34c605ae2a33ad580f1a6 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 19:31:59 -0700 Subject: [PATCH 17/44] Rename `startEndAnchor` to `truncationPosition` + other cleanup - clean up storybook props DX some - clean up useMemo logic for `truncation` and `truncationOffset` props - prefer moving all logic to a single memo that returns multiple values --- .../text_truncate/text_truncate.stories.tsx | 13 ++-- .../text_truncate/text_truncate.tsx | 65 +++++++++---------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index 82abd50436d..a2b53f4b2c5 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -16,6 +16,10 @@ import { EuiTextTruncate, EuiTextTruncateProps } from './text_truncate'; const meta: Meta = { title: 'EuiTextTruncate', component: EuiTextTruncate, + argTypes: { + truncationOffset: { if: { arg: 'truncation', neq: 'startEnd' } }, // Should also not show on `middle`, but Storybook doesn't currently support multiple if conditions :( + truncationPosition: { if: { arg: 'truncation', eq: 'startEnd' } }, + }, }; export default meta; @@ -24,6 +28,7 @@ type Story = StoryObj; const componentDefaults = { text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', truncation: 'end', + ellipsis: '…', } as const; export const Playground: Story = { @@ -35,7 +40,7 @@ export const Playground: Story = { args: { ...componentDefaults, truncationOffset: 0, - ellipsis: '...', + truncationPosition: 0, width: 200, }, }; @@ -89,7 +94,7 @@ export const StartEndAnchorForSearch: Story = { {(text) => ( <> @@ -109,12 +114,12 @@ export const StartEndAnchorForSearch: Story = { ...componentDefaults, width: 200, truncation: 'startEnd', - startEndAnchor: 30, + truncationPosition: 30, }, argTypes: { // Disable uncontrollable props truncation: { table: { disable: true } }, - startEndAnchor: { table: { disable: true } }, + truncationPosition: { table: { disable: true } }, // Disable props that aren't useful for this this demo truncationOffset: { table: { disable: true } }, children: { table: { disable: true } }, diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index d1db28e8619..56e29f87346 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -52,19 +52,19 @@ export type EuiTextTruncateProps = Omit< truncationOffset?: number; /** * This prop **only** applies to the `startEnd` truncation type. - * By default, the `startEnd` truncation will center to the middle - * of the text string - this prop allows customizing that anchor position. + * It allows customizing the anchor position of the displayed text, + * which otherwise defaults to the middle of the text string. * - * The primary use case for this prop for is search highlighting - if a - * user searches for a specific word in the text, pass the middle index of that + * The primary use case for this prop for is search highlighting - e.g., if + * a user searches for a specific word in the text, pass the index of that * found word to ensure it is always visible. * - * This behavior will intelligently detect when anchors are close to the start + * This behavior will intelligently detect when positions are close to the start * or end of the text, and omit leading or trailing ellipses when necessary. - * If the passed anchor position is greater than the total text length, + * If the passed position is greater than the total text length, * the truncation will simply default to `start` instead. */ - startEndAnchor?: number; + truncationPosition?: number; /** * Defaults to the horizontal ellipsis character. * Can be optionally configured to use other punctuation, @@ -114,7 +114,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< text, truncation: _truncation = 'end', truncationOffset: _truncationOffset = 0, - startEndAnchor, + truncationPosition, ellipsis = '…', containerRef, ...rest @@ -123,30 +123,27 @@ const EuiTextTruncateWithWidth: FunctionComponent< const [containerEl, setContainerEl] = useState(null); const refs = useCombinedRefs([setContainerEl, containerRef]); - const truncationUsesOffset = _truncation === 'end' || _truncation === 'start'; - const truncationOffsetIsTooLarge = - truncationUsesOffset && _truncationOffset >= Math.floor(text.length / 2); - - const truncation = useMemo(() => { - if (truncationOffsetIsTooLarge) { - return 'middle'; - } else if (_truncation === 'startEnd' && startEndAnchor != null) { - if (startEndAnchor <= 0) { - return 'end'; - } else if (startEndAnchor >= text.length) { - return 'start'; + // Handle exceptions where we need to override the passed props + const { truncation, truncationOffset } = useMemo(() => { + let truncation = _truncation; + let truncationOffset = 0; + + if (_truncation === 'end' || _truncation === 'start') { + const offsetIsTooLarge = _truncationOffset >= Math.floor(text.length / 2); + if (offsetIsTooLarge) { + truncation = 'middle'; + } else { + truncationOffset = _truncationOffset; // The only time we respect truncationOffset + } + } else if (_truncation === 'startEnd' && truncationPosition != null) { + if (truncationPosition <= 0) { + truncation = 'end'; + } else if (truncationPosition >= text.length) { + truncation = 'start'; } } - return _truncation; - }, [_truncation, truncationOffsetIsTooLarge, startEndAnchor, text.length]); - - const truncationOffset = useMemo(() => { - if (truncationUsesOffset && !truncationOffsetIsTooLarge) { - return _truncationOffset; - } else { - return 0; - } - }, [_truncationOffset, truncationOffsetIsTooLarge, truncationUsesOffset]); + return { truncation, truncationOffset }; + }, [_truncation, _truncationOffset, truncationPosition, text.length]); const truncatedText = useMemo(() => { if (!containerEl || !width) return ''; @@ -204,7 +201,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< break; case 'startEnd': - if (startEndAnchor == null) { + if (truncationPosition == null) { while (span.offsetWidth > width) { const trimmedMiddle = span.textContent.substring( substringOffset, @@ -225,8 +222,8 @@ const EuiTextTruncateWithWidth: FunctionComponent< let endingEllipsis = ellipsis; // Split the text into two at the anchor position - let firstPart = text.substring(0, startEndAnchor); - let secondPart = text.substring(startEndAnchor); + let firstPart = text.substring(0, truncationPosition); + let secondPart = text.substring(truncationPosition); while (span.offsetWidth <= width) { // Because this logic builds text outwards vs. removes inwards, the final text @@ -285,7 +282,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< text, truncation, truncationOffset, - startEndAnchor, + truncationPosition, ellipsis, containerEl, ]); From 09cc1c3ee5d0e1815c7ef20538138c0aba703cd5 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 19:33:12 -0700 Subject: [PATCH 18/44] Write tests - majority of testing is in Cypress, since JSdom has no concept of dimensions/width --- .../__snapshots__/text_truncate.test.tsx.snap | 12 + .../text_truncate/text_truncate.spec.tsx | 285 ++++++++++++++++++ .../text_truncate/text_truncate.test.tsx | 48 +++ 3 files changed, 345 insertions(+) create mode 100644 src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap create mode 100644 src/components/text_truncate/text_truncate.spec.tsx create mode 100644 src/components/text_truncate/text_truncate.test.tsx diff --git a/src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap b/src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap new file mode 100644 index 00000000000..31be78ada0c --- /dev/null +++ b/src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiTextTruncate renders 1`] = ` +
+ Hello world +
+`; diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx new file mode 100644 index 00000000000..d35221c36fe --- /dev/null +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -0,0 +1,285 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/// +/// +/// + +import React from 'react'; + +import { EuiTextTruncate } from './text_truncate'; + +describe('EuiTextTruncate', () => { + const props = { + id: 'text', + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', + width: 200, + }; + + it('returns the text in full if no truncation is needed', () => { + cy.mount(); + cy.get('#text').should('have.text', 'Hello world'); + }); + + describe('truncation', () => { + const expectedMiddleOutput = 'Lorem ipsum d…adipiscing elit'; + const expectedStartOutput = '…t, consectetur adipiscing elit'; + const expectedEndOutput = 'Lorem ipsum dolor sit amet, …'; + const expectedStartEndOutput = '…lor sit amet, consectetur a…'; + + describe('middle', () => { + it('truncations and inserts ellispes in the middle of the text', () => { + cy.mount(); + cy.get('#text').should('have.text', expectedMiddleOutput); + }); + + it('ignores `truncationOffset` and `truncationPosition`', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', expectedMiddleOutput); + }); + }); + + describe('start', () => { + it('truncates and inserts ellispis at the start of the text', () => { + cy.mount(); + cy.get('#text').should('have.text', expectedStartOutput); + }); + + describe('truncationOffset', () => { + it('preserves starting characters with `truncationOffset`', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', 'Lorem…ectetur adipiscing elit'); + }); + + it('falls back to middle truncation if truncationOffset is too large', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', expectedMiddleOutput); + }); + }); + }); + + describe('end', () => { + it('truncates and inserts ellispis at the end of the text', () => { + cy.mount(); + cy.get('#text').should('have.text', expectedEndOutput); + }); + + describe('truncationOffset', () => { + it('preserves starting characters with `truncationOffset`', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', 'Lorem ipsum dolor …scing elit'); + }); + + it('falls back to middle truncation if truncationOffset is too large', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', expectedMiddleOutput); + }); + }); + }); + + describe('startEnd', () => { + it('truncates and inserts ellipses at both the start and end of the text', () => { + cy.mount(); + cy.get('#text').should('have.text', expectedStartEndOutput); + }); + + it('ignores `truncationOffset`', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', expectedStartEndOutput); + }); + + describe('truncationPosition', () => { + it('allows customizing the anchor at which the truncation is positioned from', () => { + cy.mount( + <> + + + + ); + cy.get('#text1').should('have.text', '…rem ipsum dolor sit amet, …'); + cy.get('#text2').should('have.text', '…amet, consectetur adipisci…'); + }); + + it('does not display the leading ellipsis if the anchor is close enough to the start', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', expectedEndOutput); + }); + + it('does not display the leading ellipsis if the anchor position is <= 0', () => { + cy.mount( + <> + + + + ); + cy.get('#text1').should('have.text', expectedEndOutput); + cy.get('#text2').should('have.text', expectedEndOutput); + }); + + it('does not display the trailing ellipsis if the anchor is close enough to the end', () => { + cy.mount( + + ); + cy.get('#text').should('have.text', expectedStartOutput); + }); + + it('does not display the trailing ellipsis if the anchor position is >= the text length', () => { + cy.mount( + <> + + + + ); + cy.get('#text1').should('have.text', expectedStartOutput); + cy.get('#text2').should('have.text', expectedStartOutput); + }); + }); + }); + }); + + describe('ellipsis', () => { + it('allows customizing the symbols used to represent an ellipsis', () => { + cy.mount( + <> + + + + ); + cy.get('#text1').should('have.text', 'Lorem ipsum [...]dipiscing elit'); + cy.get('#text2').should('have.text', '--lor sit amet, consectetur a--'); + }); + + it("does not render if the container isn't wide enough for the ellipsis", () => { + cy.window().then((win) => { + cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); + }); + cy.mount( + <> + + + + ); + cy.get('#text1').should('have.text', ''); + cy.get('#text2').should('have.text', ''); + + cy.get('@spyConsoleError') + .should( + 'be.calledWith', + 'The truncation ellipsis is larger than the available width. No text can be rendered.' + ) + .should('be.calledTwice'); + }); + }); + + describe('children', () => { + it('allows customizing the rendered text via a render prop', () => { + cy.mount( + + {(text) => {text}} + + ); + cy.get('[data-test-subj="test"]').should('exist'); + }); + }); +}); diff --git a/src/components/text_truncate/text_truncate.test.tsx b/src/components/text_truncate/text_truncate.test.tsx new file mode 100644 index 00000000000..46b3f103ce9 --- /dev/null +++ b/src/components/text_truncate/text_truncate.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '../../test/rtl'; +import { shouldRenderCustomStyles } from '../../test/internal'; +import { requiredProps } from '../../test'; + +import { EuiTextTruncate } from './text_truncate'; + +describe('EuiTextTruncate', () => { + const props = { + text: 'Hello world', + width: 50, + } as const; + + shouldRenderCustomStyles(); + + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('does not render a resize observer if a width is passed', () => { + const onResize = jest.fn(); + render(); + expect(onResize).not.toHaveBeenCalled(); + }); + + it('renders a resize observer when no width is passed', () => { + const onResize = jest.fn(); + render( + + ); + expect(onResize).toHaveBeenCalledWith(0); + }); + + // We can't unit test the actual truncation logic in JSDOM, because + // JSDOM doesn't have `offsetWidth`. See the Cypress spec tests instead +}); From b45b719028cc82a9ea730b46ac8ba71e506e4f62 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 23:12:14 -0700 Subject: [PATCH 19/44] Add docs page and examples for new component + fix resizable story to use inline resize on supported browsers --- src-docs/src/routes.js | 3 + src-docs/src/views/text_truncate/ellipsis.tsx | 25 ++ .../src/views/text_truncate/render_prop.tsx | 57 +++++ .../text_truncate/text_truncate_example.js | 223 ++++++++++++++++++ .../src/views/text_truncate/truncation.tsx | 38 +++ .../views/text_truncate/truncation_offset.tsx | 38 +++ .../text_truncate/truncation_position.tsx | 33 +++ src/components/index.ts | 2 + .../text_truncate/text_truncate.stories.tsx | 11 +- 9 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 src-docs/src/views/text_truncate/ellipsis.tsx create mode 100644 src-docs/src/views/text_truncate/render_prop.tsx create mode 100644 src-docs/src/views/text_truncate/text_truncate_example.js create mode 100644 src-docs/src/views/text_truncate/truncation.tsx create mode 100644 src-docs/src/views/text_truncate/truncation_offset.tsx create mode 100644 src-docs/src/views/text_truncate/truncation_position.tsx diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 6cf6f12b6aa..4dcba4fe14c 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -227,6 +227,8 @@ import { TabsExample } from './views/tabs/tabs_example'; import { TextDiffExample } from './views/text_diff/text_diff_example'; +import { TextTruncateExample } from './views/text_truncate/text_truncate_example'; + import { TextExample } from './views/text/text_example'; import { TimelineExample } from './views/timeline/timeline_example'; @@ -652,6 +654,7 @@ const navigation = [ ResizeObserverExample, ScrollExample, TextDiffExample, + TextTruncateExample, WindowEventExample, ].map((example) => createExample(example)), }, diff --git a/src-docs/src/views/text_truncate/ellipsis.tsx b/src-docs/src/views/text_truncate/ellipsis.tsx new file mode 100644 index 00000000000..74f42c2c87e --- /dev/null +++ b/src-docs/src/views/text_truncate/ellipsis.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { EuiPanel, EuiText, EuiTextTruncate } from '../../../../src'; + +export default () => { + return ( + + + + + + + + ); +}; diff --git a/src-docs/src/views/text_truncate/render_prop.tsx b/src-docs/src/views/text_truncate/render_prop.tsx new file mode 100644 index 00000000000..68f3cfb9683 --- /dev/null +++ b/src-docs/src/views/text_truncate/render_prop.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; + +import { + EuiText, + EuiFormRow, + EuiFieldText, + EuiSpacer, + EuiPanel, + EuiHighlight, + EuiMark, + EuiTextTruncate, +} from '../../../../src'; + +const text = + "But the dog wasn't lazy, it was just practicing mindfulness, so it had a greater sense of life-satisfaction than that fox with all its silly jumping."; + +export default () => { + const [highlight, setHighlight] = useState(''); + const highlightStartPosition = text + .toLowerCase() + .indexOf(highlight.toLowerCase()); + const highlightCenterPosition = + highlightStartPosition + Math.floor(highlight.length / 2); + + return ( + + + setHighlight(e.target.value)} + placeholder={ + 'For example, try typing "lazy", "mindful", "life", or "silly"' + } + /> + + + + + {(truncatedText) => ( + <> + {truncatedText.length > highlight.length ? ( + {truncatedText} + ) : ( + // Highlight everything if the search match is greater than the visible text + {truncatedText} + )} + + )} + + + + ); +}; diff --git a/src-docs/src/views/text_truncate/text_truncate_example.js b/src-docs/src/views/text_truncate/text_truncate_example.js new file mode 100644 index 00000000000..651546622a3 --- /dev/null +++ b/src-docs/src/views/text_truncate/text_truncate_example.js @@ -0,0 +1,223 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { GuideSectionTypes } from '../../components'; + +import { + EuiLink, + EuiCode, + EuiCallOut, + EuiTextTruncate, +} from '../../../../src/components'; + +import Truncation from './truncation'; +const truncationSource = require('!!raw-loader!./truncation'); + +import Ellipsis from './ellipsis'; +const ellipsisSource = require('!!raw-loader!./ellipsis'); + +import TruncationOffset from './truncation_offset'; +const truncationOffsetSource = require('!!raw-loader!./truncation_offset'); + +import TruncationPosition from './truncation_position'; +const truncationPositionSource = require('!!raw-loader!./truncation_position'); + +import RenderProp from './render_prop'; +const renderPropSource = require('!!raw-loader!./render_prop'); + +export const TextTruncateExample = { + title: 'Text truncation', + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: truncationSource, + }, + ], + text: ( + <> +

+ EuiTextTruncate attempts to provide customizable + and size-aware truncation logic (until the happy day that browsers + add more{' '} + + customizable truncation out-of-the-box via CSS + + ). +

+

+ The four truncation styles supported are start,{' '} + end, startEnd, and{' '} + middle. The below demo can also be dynamically + resized to see how different truncation styles respond. +

+ + ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, + }, + { + text: ( + <> + +

+ EuiTextTruncate attempts to mimic the behavior of{' '} + text-overflow: ellipsis as closely as possible, + although there may be edge cases and cross-browser issues, as this + is essentially a browser implementation we are trying to polyfill. +

+
    +
  • + Screen readers should ignore the truncated text and only read + out the full text. +
  • +
  • + Sighted mouse users will be able to briefly hover over the + truncated text and read the full text in a native browser title + tooltip. +
  • +
  • + For mouse users, double clicking to select the truncated line + should allow copying the full untruncated text. +
  • +
+
+ + ), + }, + { + title: 'Custom ellipsis', + source: [ + { + type: GuideSectionTypes.JS, + code: ellipsisSource, + }, + ], + text: ( +

+ By default, EuiTextTruncate uses with the unicode + character for horizontal ellipis. It can be customized via the{' '} + ellipsis prop as necessary (e.g. for specific + languages, extra punctuation, etc). +

+ ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, + }, + { + title: 'Truncation offset', + source: [ + { + type: GuideSectionTypes.JS, + code: truncationOffsetSource, + }, + ], + text: ( +

+ The start and end truncation + types support a truncationOffset property that + allows preserving a specified number of characters at either the start + or end of the text. Increase or decrease the number control below to + see the prop in action. +

+ ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, + }, + { + title: 'Truncation position', + source: [ + { + type: GuideSectionTypes.JS, + code: truncationPositionSource, + }, + ], + text: ( + <> +

+ The startEnd truncation type supports a{' '} + truncationPosition property. By default,{' '} + startEnd anchors the displayed text to the middle + of the string. However, you may prefer to display a specific + subsection of the full text closer to the start or end, which this + prop allows. +

+

+ This behavior will intelligently detect when positions are close to + the start or end of the text, and omit leading or trailing ellipses + when necessary. If the passed position is greater than the total + text length, the truncation will simply default to `start` instead. +

+

+ Increase or decrease the number control below to see the prop in + action. +

+ + ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, + }, + { + title: 'Render prop', + source: [ + { + type: GuideSectionTypes.JS, + code: renderPropSource, + }, + ], + text: ( + <> +

+ By default, EuiTextTruncate will automatically + output the calculated truncated string. You can optionally override + this by passing a render prop function to{' '} + children, which allows for more flexible text + rendering. +

+

+ The primary type of use case in mind for this functionality would be + using{' '} + + EuiHighlight or EuiMark + {' '} + to highlight certain portions of the text. This example also + demonstrates the primary use case for the{' '} + truncationPosition prop. If a user is searching + for a specific word in truncated text, you should pass the index of + that found word to ensure it is always visible, for the best user + experience. +

+ + + ), + demo: , + props: { EuiTextTruncate }, + snippet: ` + {(text) => {text}} +`, + }, + ], +}; diff --git a/src-docs/src/views/text_truncate/truncation.tsx b/src-docs/src/views/text_truncate/truncation.tsx new file mode 100644 index 00000000000..feb506eda92 --- /dev/null +++ b/src-docs/src/views/text_truncate/truncation.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { css } from '@emotion/react'; + +import { EuiPanel, EuiText, EuiTextTruncate } from '../../../../src'; + +export default () => { + return ( + + + + + + + + + ); +}; diff --git a/src-docs/src/views/text_truncate/truncation_offset.tsx b/src-docs/src/views/text_truncate/truncation_offset.tsx new file mode 100644 index 00000000000..574d3fc40c7 --- /dev/null +++ b/src-docs/src/views/text_truncate/truncation_offset.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; + +import { + EuiPanel, + EuiFormRow, + EuiFieldNumber, + EuiSpacer, + EuiText, + EuiTextTruncate, +} from '../../../../src'; + +export default () => { + const [truncationOffset, setTruncationOffset] = useState(3); + return ( + + + setTruncationOffset(Number(e.target.value))} + /> + + + + + + + + ); +}; diff --git a/src-docs/src/views/text_truncate/truncation_position.tsx b/src-docs/src/views/text_truncate/truncation_position.tsx new file mode 100644 index 00000000000..464535d4881 --- /dev/null +++ b/src-docs/src/views/text_truncate/truncation_position.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; + +import { + EuiPanel, + EuiFormRow, + EuiFieldNumber, + EuiSpacer, + EuiText, + EuiTextTruncate, +} from '../../../../src'; + +export default () => { + const [truncationPosition, setTruncationPosition] = useState(45); + return ( + + + setTruncationPosition(Number(e.target.value))} + /> + + + + + + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts index 181db40fa0d..8b1550792ba 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -168,6 +168,8 @@ export * from './text'; export * from './text_diff'; +export * from './text_truncate'; + export * from './timeline'; export * from './title'; diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index a2b53f4b2c5..cf6aea58397 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import { css } from '@emotion/react'; import { EuiHighlight, EuiMark } from '../../components'; @@ -54,8 +55,14 @@ export const ResizeObserver: Story = {

- {/* // Width here is just for testing resize behavior and isn't meant to be RTL compliant */} -
+
From 17047d1d79130b49c6e02dd791531ad4535c3046 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 21 Aug 2023 23:55:24 -0700 Subject: [PATCH 20/44] Fix/workaround two crashing `truncationOffset` loops --- .../views/text_truncate/truncation_offset.tsx | 1 + .../text_truncate/text_truncate.spec.tsx | 42 +++++++++++++++++-- .../text_truncate/text_truncate.tsx | 24 ++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src-docs/src/views/text_truncate/truncation_offset.tsx b/src-docs/src/views/text_truncate/truncation_offset.tsx index 574d3fc40c7..c689c7ba531 100644 --- a/src-docs/src/views/text_truncate/truncation_offset.tsx +++ b/src-docs/src/views/text_truncate/truncation_offset.tsx @@ -18,6 +18,7 @@ export default () => { compressed value={truncationOffset} onChange={(e) => setTruncationOffset(Number(e.target.value))} + max={30} /> diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx index d35221c36fe..ce044df63b6 100644 --- a/src/components/text_truncate/text_truncate.spec.tsx +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -69,7 +69,7 @@ describe('EuiTextTruncate', () => { cy.get('#text').should('have.text', 'Lorem…ectetur adipiscing elit'); }); - it('falls back to middle truncation if truncationOffset is too large', () => { + it('falls back to middle truncation if truncationOffset is too large for the text', () => { cy.mount( { ); cy.get('#text').should('have.text', expectedMiddleOutput); }); + + it('logs an error if the truncationOffset is too large for the container', () => { + cy.window().then((win) => { + cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); + }); + cy.mount( + + ); + cy.get('@spyConsoleError').should( + 'be.calledWith', + 'The passed truncationOffset of 5 is too large for available width.' + ); + }); }); }); @@ -89,7 +107,7 @@ describe('EuiTextTruncate', () => { }); describe('truncationOffset', () => { - it('preserves starting characters with `truncationOffset`', () => { + it('preserves ending characters with `truncationOffset`', () => { cy.mount( { cy.get('#text').should('have.text', 'Lorem ipsum dolor …scing elit'); }); - it('falls back to middle truncation if truncationOffset is too large', () => { + it('falls back to middle truncation if truncationOffset is too large for the text', () => { cy.mount( { ); cy.get('#text').should('have.text', expectedMiddleOutput); }); + + it('logs an error if the truncationOffset is too large for the container', () => { + cy.window().then((win) => { + cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); + }); + cy.mount( + + ); + cy.get('@spyConsoleError').should( + 'be.calledWith', + 'The passed truncationOffset of 10 is too large for available width.' + ); + }); }); }); diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 56e29f87346..ba0334a8bf5 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -133,7 +133,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< if (offsetIsTooLarge) { truncation = 'middle'; } else { - truncationOffset = _truncationOffset; // The only time we respect truncationOffset + truncationOffset = _truncationOffset > 0 ? _truncationOffset : 0; // Negative offsets cause infinite loops } } else if (_truncation === 'startEnd' && truncationPosition != null) { if (truncationPosition <= 0) { @@ -180,6 +180,17 @@ const EuiTextTruncateWithWidth: FunctionComponent< const endPosition = text.length - truncationOffset; const endOffset = span.textContent.substring(endPosition); const endRemaining = span.textContent.substring(0, endPosition); + + // Make sure that the offset alone isn't larger than the actual available width + span.textContent = `${ellipsis}${endOffset}`; + if (span.offsetWidth > width) { + // There isn't a super great way to handle this visually. + // The best we can do is simply render the broken truncation + console.error( + `The passed truncationOffset of ${truncationOffset} is too large for available width.` + ); + break; + } span.textContent = `${endRemaining}${ellipsis}${endOffset}`; while (span.offsetWidth > width) { @@ -192,6 +203,17 @@ const EuiTextTruncateWithWidth: FunctionComponent< case 'start': const startOffset = span.textContent.substring(0, truncationOffset); const startRemaining = span.textContent.substring(truncationOffset); + + // Make sure that the offset alone isn't larger than the actual available width + span.textContent = `${startOffset}${ellipsis}`; + if (span.offsetWidth > width) { + // There isn't a super great way to handle this visually. + // The best we can do is simply render the broken truncation + console.error( + `The passed truncationOffset of ${truncationOffset} is too large for available width.` + ); + break; + } span.textContent = `${startOffset}${ellipsis}${startRemaining}`; while (span.offsetWidth > width) { From 1af3b49c7ef75bbec9695ef886b6929b0a8c0918 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 00:13:36 -0700 Subject: [PATCH 21/44] Improve screen reader and copy UX - at the cost of more repetitive DOM, but I think the tradeoff here is worth it --- .../__snapshots__/text_truncate.test.tsx.snap | 7 +- .../text_truncate/text_truncate.spec.tsx | 81 +++++++++++++------ .../text_truncate/text_truncate.styles.ts | 19 +++++ .../text_truncate/text_truncate.tsx | 22 ++++- 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap b/src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap index 31be78ada0c..ec4f25f2d0c 100644 --- a/src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap +++ b/src/components/text_truncate/__snapshots__/text_truncate.test.tsx.snap @@ -5,8 +5,11 @@ exports[`EuiTextTruncate renders 1`] = ` aria-label="aria-label" class="testClass1 testClass2 emotion-euiTextTruncate-euiTestCss" data-test-subj="test subject string" - title="Hello world" > - Hello world + + Hello world +
`; diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx index ce044df63b6..fa4cf70466d 100644 --- a/src/components/text_truncate/text_truncate.spec.tsx +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -21,9 +21,24 @@ describe('EuiTextTruncate', () => { width: 200, }; - it('returns the text in full if no truncation is needed', () => { + const getTruncatedText = (selector = '#text') => + cy.get(`${selector} [data-test-subj="truncatedText"]`); + + it('does not render truncated text if no truncation is needed', () => { cy.mount(); - cy.get('#text').should('have.text', 'Hello world'); + cy.get('#text').should('not.have.attr', 'title'); + cy.get('#text [data-test-subj="fullText"]').should( + 'have.text', + 'Hello world' + ); + getTruncatedText().should('not.exist'); + }); + + it('renders truncated text and a title when truncation is needed', () => { + cy.mount(); + cy.get('#text').should('have.attr', 'title', props.text); + cy.get('#text [data-test-subj="fullText"]').should('have.text', props.text); + getTruncatedText().should('exist'); }); describe('truncation', () => { @@ -35,7 +50,7 @@ describe('EuiTextTruncate', () => { describe('middle', () => { it('truncations and inserts ellispes in the middle of the text', () => { cy.mount(); - cy.get('#text').should('have.text', expectedMiddleOutput); + getTruncatedText().should('have.text', expectedMiddleOutput); }); it('ignores `truncationOffset` and `truncationPosition`', () => { @@ -47,14 +62,14 @@ describe('EuiTextTruncate', () => { truncationPosition={20} /> ); - cy.get('#text').should('have.text', expectedMiddleOutput); + getTruncatedText().should('have.text', expectedMiddleOutput); }); }); describe('start', () => { it('truncates and inserts ellispis at the start of the text', () => { cy.mount(); - cy.get('#text').should('have.text', expectedStartOutput); + getTruncatedText().should('have.text', expectedStartOutput); }); describe('truncationOffset', () => { @@ -66,7 +81,10 @@ describe('EuiTextTruncate', () => { truncationOffset={5} /> ); - cy.get('#text').should('have.text', 'Lorem…ectetur adipiscing elit'); + getTruncatedText().should( + 'have.text', + 'Lorem…ectetur adipiscing elit' + ); }); it('falls back to middle truncation if truncationOffset is too large for the text', () => { @@ -77,7 +95,7 @@ describe('EuiTextTruncate', () => { truncationOffset={100} /> ); - cy.get('#text').should('have.text', expectedMiddleOutput); + getTruncatedText().should('have.text', expectedMiddleOutput); }); it('logs an error if the truncationOffset is too large for the container', () => { @@ -103,7 +121,7 @@ describe('EuiTextTruncate', () => { describe('end', () => { it('truncates and inserts ellispis at the end of the text', () => { cy.mount(); - cy.get('#text').should('have.text', expectedEndOutput); + getTruncatedText().should('have.text', expectedEndOutput); }); describe('truncationOffset', () => { @@ -115,7 +133,10 @@ describe('EuiTextTruncate', () => { truncationOffset={10} /> ); - cy.get('#text').should('have.text', 'Lorem ipsum dolor …scing elit'); + getTruncatedText().should( + 'have.text', + 'Lorem ipsum dolor …scing elit' + ); }); it('falls back to middle truncation if truncationOffset is too large for the text', () => { @@ -126,7 +147,7 @@ describe('EuiTextTruncate', () => { truncationOffset={30} /> ); - cy.get('#text').should('have.text', expectedMiddleOutput); + getTruncatedText().should('have.text', expectedMiddleOutput); }); it('logs an error if the truncationOffset is too large for the container', () => { @@ -152,7 +173,7 @@ describe('EuiTextTruncate', () => { describe('startEnd', () => { it('truncates and inserts ellipses at both the start and end of the text', () => { cy.mount(); - cy.get('#text').should('have.text', expectedStartEndOutput); + getTruncatedText().should('have.text', expectedStartEndOutput); }); it('ignores `truncationOffset`', () => { @@ -163,7 +184,7 @@ describe('EuiTextTruncate', () => { truncationOffset={10} /> ); - cy.get('#text').should('have.text', expectedStartEndOutput); + getTruncatedText().should('have.text', expectedStartEndOutput); }); describe('truncationPosition', () => { @@ -184,8 +205,14 @@ describe('EuiTextTruncate', () => { /> ); - cy.get('#text1').should('have.text', '…rem ipsum dolor sit amet, …'); - cy.get('#text2').should('have.text', '…amet, consectetur adipisci…'); + getTruncatedText('#text1').should( + 'have.text', + '…rem ipsum dolor sit amet, …' + ); + getTruncatedText('#text2').should( + 'have.text', + '…amet, consectetur adipisci…' + ); }); it('does not display the leading ellipsis if the anchor is close enough to the start', () => { @@ -196,7 +223,7 @@ describe('EuiTextTruncate', () => { truncationPosition={5} /> ); - cy.get('#text').should('have.text', expectedEndOutput); + getTruncatedText().should('have.text', expectedEndOutput); }); it('does not display the leading ellipsis if the anchor position is <= 0', () => { @@ -216,8 +243,8 @@ describe('EuiTextTruncate', () => { /> ); - cy.get('#text1').should('have.text', expectedEndOutput); - cy.get('#text2').should('have.text', expectedEndOutput); + getTruncatedText('#text1').should('have.text', expectedEndOutput); + getTruncatedText('#text2').should('have.text', expectedEndOutput); }); it('does not display the trailing ellipsis if the anchor is close enough to the end', () => { @@ -228,7 +255,7 @@ describe('EuiTextTruncate', () => { truncationPosition={42} /> ); - cy.get('#text').should('have.text', expectedStartOutput); + getTruncatedText().should('have.text', expectedStartOutput); }); it('does not display the trailing ellipsis if the anchor position is >= the text length', () => { @@ -248,8 +275,8 @@ describe('EuiTextTruncate', () => { /> ); - cy.get('#text1').should('have.text', expectedStartOutput); - cy.get('#text2').should('have.text', expectedStartOutput); + getTruncatedText('#text1').should('have.text', expectedStartOutput); + getTruncatedText('#text2').should('have.text', expectedStartOutput); }); }); }); @@ -273,8 +300,14 @@ describe('EuiTextTruncate', () => { /> ); - cy.get('#text1').should('have.text', 'Lorem ipsum [...]dipiscing elit'); - cy.get('#text2').should('have.text', '--lor sit amet, consectetur a--'); + getTruncatedText('#text1').should( + 'have.text', + 'Lorem ipsum [...]dipiscing elit' + ); + getTruncatedText('#text2').should( + 'have.text', + '--lor sit amet, consectetur a--' + ); }); it("does not render if the container isn't wide enough for the ellipsis", () => { @@ -296,8 +329,8 @@ describe('EuiTextTruncate', () => { /> ); - cy.get('#text1').should('have.text', ''); - cy.get('#text2').should('have.text', ''); + getTruncatedText('#text1').should('have.text', ''); + getTruncatedText('#text2').should('have.text', ''); cy.get('@spyConsoleError') .should( diff --git a/src/components/text_truncate/text_truncate.styles.ts b/src/components/text_truncate/text_truncate.styles.ts index 1563dcf339b..bf792b0b813 100644 --- a/src/components/text_truncate/text_truncate.styles.ts +++ b/src/components/text_truncate/text_truncate.styles.ts @@ -10,7 +10,26 @@ import { css } from '@emotion/react'; export const euiTextTruncateStyles = { euiTextTruncate: css` + position: relative; overflow: hidden; white-space: nowrap; `, + truncatedText: css` + user-select: none; + pointer-events: none; + `, + fullText: css` + position: absolute; + inset: 0; + overflow: hidden; + color: rgba(0, 0, 0, 0); + + /* Safari-only CSS hack + Adding text-overflow: ellipsis makes VoiceOver's screen reader outline obey the container width, + but Chrome+FF don't need it, and it interferes with their text selection highlights + */ + @supports (-webkit-hyphens: none) { + text-overflow: ellipsis; + } + `, }; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index ba0334a8bf5..da2e2b49498 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -309,15 +309,31 @@ const EuiTextTruncateWithWidth: FunctionComponent< containerEl, ]); + const isTruncating = truncatedText !== text; + return (
- {children ? children(truncatedText) : truncatedText} + {isTruncating ? ( + <> + + {children ? children(truncatedText) : truncatedText} + + + {text} + + + ) : ( + {text} + )}
); }; From f46b348f4a1a4c51b1e65869e0a0e66f3155e32a Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 00:22:00 -0700 Subject: [PATCH 22/44] Revert EuiComboBox changes to main --- src-docs/src/views/combo_box/combo_box.js | 18 +- .../__snapshots__/combo_box.test.tsx.snap | 39 ++--- src/components/combo_box/combo_box.tsx | 27 --- .../_combo_box_option.scss | 7 +- .../combo_box_options_list.tsx | 16 +- .../truncated_label.tsx | 165 ------------------ src/components/combo_box/types.ts | 2 - 7 files changed, 20 insertions(+), 254 deletions(-) delete mode 100644 src/components/combo_box/combo_box_options_list/truncated_label.tsx diff --git a/src-docs/src/views/combo_box/combo_box.js b/src-docs/src/views/combo_box/combo_box.js index 1956647cfb8..3801819e0f6 100644 --- a/src-docs/src/views/combo_box/combo_box.js +++ b/src-docs/src/views/combo_box/combo_box.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { EuiComboBox, EuiSwitch } from '../../../../src/components'; +import { EuiComboBox } from '../../../../src/components'; import { DisplayToggles } from '../form_controls/display_toggles'; const optionsStatic = [ @@ -40,7 +40,6 @@ const optionsStatic = [ ]; export default () => { const [options, setOptions] = useState(optionsStatic); - const [canTruncate, setCanTruncate] = useState(false); const [selectedOptions, setSelected] = useState([options[2], options[4]]); const onChange = (selectedOptions) => { @@ -73,19 +72,7 @@ export default () => { return ( /* DisplayToggles wrapper for Docs only */ - setCanTruncate(e.target.checked)} - />, - ]} - > + { isClearable={true} data-test-subj="demoComboBox" autoFocus - truncation={canTruncate ? 'middle' : undefined} /> ); diff --git a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap index 5a2ac492090..d3d455f538a 100644 --- a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap +++ b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap @@ -4,7 +4,6 @@ exports[`EuiComboBox is rendered 1`] = `
1 @@ -595,7 +584,7 @@ exports[`props option.prepend & option.append renders in the options dropdown 1` class="euiComboBoxOption__contentWrapper" > 2 @@ -622,7 +611,6 @@ exports[`props options list is rendered 1`] = `
Titan @@ -742,7 +729,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Enceladus @@ -769,7 +756,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Mimas @@ -796,7 +783,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Dione @@ -823,7 +810,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Iapetus @@ -850,7 +837,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Phoebe @@ -877,7 +864,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Rhea @@ -904,7 +891,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Pandora is one of Saturn's moons, named for a Titaness of Greek mythology @@ -931,7 +918,7 @@ exports[`props options list is rendered 1`] = ` class="euiComboBoxOption__contentWrapper" > Tethys @@ -951,7 +938,6 @@ exports[`props selectedOptions are rendered 1`] = ` className="euiComboBox" onBlur={[Function]} onKeyDown={[Function]} - truncation="end" > * supplied by `aria-label` or from [EuiFormRow](/#/forms/form-layouts). */ 'aria-labelledby'?: string; - /** - * Controls the truncation of the label text. - */ - truncation: EuiComboBoxTruncation; } /** @@ -194,7 +189,6 @@ interface EuiComboBoxState { matchingOptions: Array>; searchValue: string; width: number; - font: string; } const initialSearchValue = ''; @@ -214,7 +208,6 @@ export class EuiComboBox extends Component< prepend: undefined, append: undefined, sortMatchesBy: 'none' as const, - truncation: 'end', }; state: EuiComboBoxState = { @@ -235,7 +228,6 @@ export class EuiComboBox extends Component< }), searchValue: initialSearchValue, width: 0, - font: '', }; _isMounted = false; @@ -250,7 +242,6 @@ export class EuiComboBox extends Component< const comboBoxBounds = this.comboBoxRefInstance.getBoundingClientRect(); this.setState({ width: comboBoxBounds.width, - font: this.getFont(), }); } }; @@ -829,22 +820,6 @@ export class EuiComboBox extends Component< return stateUpdate; } - getFont = () => { - if (this.comboBoxRefInstance) { - const css = window.getComputedStyle(this.comboBoxRefInstance); - return [ - 'font-style', - 'font-variant', - 'font-weight', - 'font-size', - 'font-family', - ] - .map((prop) => css.getPropertyValue(prop)) - .join(' '); - } - return ''; - }; - updateMatchingOptionsIfDifferent = ( newMatchingOptions: Array> ) => { @@ -1032,8 +1007,6 @@ export class EuiComboBox extends Component< getSelectedOptionForSearchValue } listboxAriaLabel={listboxAriaLabel} - font={this.state.font} - truncation={this.props.truncation} /> )} diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss index a1d2d0f7d48..cc0e118af85 100644 --- a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss +++ b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss @@ -33,14 +33,11 @@ } &__content { + text-overflow: ellipsis; overflow: hidden; white-space: nowrap; flex: 1; text-align: left; - - &.euiComboBoxOption-truncationEnd { - text-overflow: ellipsis; - } } &__emptyStateText { @@ -72,4 +69,4 @@ display: block; } } -} \ No newline at end of file +} diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx index 65723ca0b22..de519ad32b4 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx @@ -20,6 +20,7 @@ import { } from 'react-window'; import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { EuiHighlight } from '../../highlight'; import { EuiText } from '../../text'; import { EuiLoadingSpinner } from '../../loading'; import { EuiComboBoxTitle } from './combo_box_title'; @@ -34,7 +35,6 @@ import { EuiComboBoxOptionOption, EuiComboBoxOptionsListPosition, EuiComboBoxSingleSelectionShape, - EuiComboBoxTruncation, OptionHandler, RefInstance, UpdatePositionHandler, @@ -42,7 +42,6 @@ import { import { CommonProps } from '../../common'; import { EuiBadge } from '../../badge'; import { EuiPopoverPanel } from '../../popover/popover_panel'; -import { TruncatedLabel } from './truncated_label'; const OPTION_CONTENT_CLASSNAME = 'euiComboBoxOption__content'; @@ -99,8 +98,6 @@ export type EuiComboBoxOptionsListProps = CommonProps & singleSelection?: boolean | EuiComboBoxSingleSelectionShape; delimiter?: string; zIndex?: number; - font: string; - truncation: EuiComboBoxTruncation; }; const hitEnterBadge = ( @@ -279,15 +276,13 @@ export class EuiComboBoxOptionsList extends Component< )} ) : ( - + > + {label} + )} {append && ( {append} @@ -331,7 +326,6 @@ export class EuiComboBoxOptionsList extends Component< zIndex, style, listboxAriaLabel, - font, ...rest } = this.props; diff --git a/src/components/combo_box/combo_box_options_list/truncated_label.tsx b/src/components/combo_box/combo_box_options_list/truncated_label.tsx deleted file mode 100644 index 3ec846906f9..00000000000 --- a/src/components/combo_box/combo_box_options_list/truncated_label.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useMemo } from 'react'; -import { EuiHighlight } from '../../highlight'; -import { EuiComboBoxTruncation } from '../types'; -import classNames from 'classnames'; - -interface TruncatedLabelProps { - label: string; - search: string; - font: string; - defaultComboboxWidth: number; - strict: boolean | undefined; - className: string | undefined; - truncation: EuiComboBoxTruncation; -} - -const separator = `…`; -const LABEL_VISIBLE_LENGTH = 8; // empirically found? -const COMBOBOX_PADDINGS = 40; // empirically found? - -let divEl: HTMLDivElement | null = null; - -const createDivEl = (font: string) => { - const el = document.createElement('div'); - el.style.position = 'absolute'; - el.style.float = 'left'; - el.style.whiteSpace = 'no-wrap'; - el.style.visibility = 'hidden'; - el.style.left = '-1000px'; - el.style.top = '-1000px'; - el.style.font = font; - return el; -}; - -const ensureDivEl = (font: string) => { - if (!divEl) { - divEl = createDivEl(font); - } - document.body.appendChild(divEl); -}; - -const cleanup = () => { - if (divEl && document.body.contains(divEl)) { - document.body.removeChild(divEl); - } -}; - -const getTextWidth = (text: string) => { - if (!divEl) { - return 0; - } - divEl.textContent = text; - return divEl.clientWidth; -}; - -const truncateLabel = ( - width: number, - font: string, - label: string, - approximateLength: number, - labelFn: (label: string, length: number) => string -) => { - let output = labelFn(label, approximateLength); - - ensureDivEl(font); - - while (getTextWidth(output) > width) { - approximateLength = approximateLength - 1; - const newOutput = labelFn(label, approximateLength); - if (newOutput === output) { - break; - } - output = newOutput; - } - cleanup(); - return output; -}; - -function getLabelFn( - label: string, - search: string, - searchPosition: number, - approximateLen: number -) { - if (!search || searchPosition === -1) { - return (text: string, length: number) => - `${text.substring(0, LABEL_VISIBLE_LENGTH)}${separator}${text.substring( - text.length - (length - LABEL_VISIBLE_LENGTH) - )}`; - } - if (searchPosition === 0) { - // search phrase at the beginning - return (text: string, length: number) => - `${text.substring(0, length)}${separator}`; - } - if (approximateLen > label.length - searchPosition) { - // search phrase close to the end or at the end - return (text: string, length: number) => - `${separator}${text.substring(text.length - length)}`; - } - // search phrase is in the middle - return (text: string, length: number) => - `${separator}${text.substring(searchPosition, length)}${separator}`; -} - -export const TruncatedLabel = function ({ - label, - strict, - className, - search, - font, - defaultComboboxWidth, - truncation, -}: TruncatedLabelProps) { - // avoid measure if truncation is at the end, CSS will take care of it - const textWidth = useMemo(() => { - if (truncation === 'end') { - return 0; - } - ensureDivEl(font); - const size = getTextWidth(label); - cleanup(); - return size; - }, [truncation, label, font]); - - const usableWidth = defaultComboboxWidth - COMBOBOX_PADDINGS; - - // Use CSS truncation when available - if (textWidth < usableWidth || truncation === 'end') { - return ( - - {label} - - ); - } - - const searchPosition = label.indexOf(search); - const approximateLen = Math.round((usableWidth * label.length) / textWidth); - const labelFn = getLabelFn(label, search, searchPosition, approximateLen); - - const outputLabel = truncateLabel( - usableWidth, - font, - label, - approximateLen, - labelFn - ); - - return ( - - {outputLabel} - - ); -}; diff --git a/src/components/combo_box/types.ts b/src/components/combo_box/types.ts index 46f3854e252..8510f659809 100644 --- a/src/components/combo_box/types.ts +++ b/src/components/combo_box/types.ts @@ -35,5 +35,3 @@ export type EuiComboBoxOptionsListPosition = 'top' | 'bottom'; export interface EuiComboBoxSingleSelectionShape { asPlainText?: boolean; } - -export type EuiComboBoxTruncation = 'end' | 'middle'; From 849122407f6326a6d25ddfb8c7190f2987539100 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 00:29:50 -0700 Subject: [PATCH 23/44] Add new `truncationProps` and `renderTruncatedOption` logic - that dogfoods `EuiTextTruncate` + add support for configuring truncation for all options and individual options + remove `title` attr, should alreaedy be handled by EuiTextTruncate + opinionated reordering of imports + clean up unnecessary `data-test-subj` prop (already inherited from CommonProps) + remove `OPTION_CONTENT_CLASSNAME` const by always rendering the `__content` wrapper regardless of render method --- .../__snapshots__/combo_box.test.tsx.snap | 198 ++++++++++++++++-- src/components/combo_box/combo_box.tsx | 25 ++- .../combo_box_input/combo_box_input.tsx | 12 +- .../combo_box_options_list.tsx | 119 ++++++++--- src/components/combo_box/types.ts | 3 + 5 files changed, 301 insertions(+), 56 deletions(-) diff --git a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap index d3d455f538a..0f3649cd923 100644 --- a/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap +++ b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap @@ -535,7 +535,6 @@ exports[`props option.prepend & option.append renders in the options dropdown 1` id="generated-id__option-0" role="option" style="position: absolute; left: 0px; top: 0px; height: 29px; width: 100%;" - title="1" type="button" > - 1 +
+
@@ -571,7 +585,6 @@ exports[`props option.prepend & option.append renders in the options dropdown 1` id="generated-id__option-1" role="option" style="position: absolute; left: 0px; top: 29px; height: 29px; width: 100%;" - title="2" type="button" > - 2 +
+
- Titan +
+
@@ -716,7 +758,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-1" role="option" style="position: absolute; left: 0px; top: 29px; height: 29px; width: 100%;" - title="Enceladus" type="button" > - Enceladus +
+
@@ -743,7 +799,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-2" role="option" style="position: absolute; left: 0px; top: 58px; height: 29px; width: 100%;" - title="Mimas" type="button" > - Mimas +
+
@@ -770,7 +840,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-3" role="option" style="position: absolute; left: 0px; top: 87px; height: 29px; width: 100%;" - title="Dione" type="button" > - Dione +
+
@@ -797,7 +881,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-4" role="option" style="position: absolute; left: 0px; top: 116px; height: 29px; width: 100%;" - title="Iapetus" type="button" > - Iapetus +
+
@@ -824,7 +922,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-5" role="option" style="position: absolute; left: 0px; top: 145px; height: 29px; width: 100%;" - title="Phoebe" type="button" > - Phoebe +
+
@@ -851,7 +963,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-6" role="option" style="position: absolute; left: 0px; top: 174px; height: 29px; width: 100%;" - title="Rhea" type="button" > - Rhea +
+
@@ -878,7 +1004,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-7" role="option" style="position: absolute; left: 0px; top: 203px; height: 29px; width: 100%;" - title="Pandora is one of Saturn's moons, named for a Titaness of Greek mythology" type="button" > - Pandora is one of Saturn's moons, named for a Titaness of Greek mythology +
+
@@ -905,7 +1045,6 @@ exports[`props options list is rendered 1`] = ` id="generated-id__option-8" role="option" style="position: absolute; left: 0px; top: 232px; height: 29px; width: 100%;" - title="Tethys" type="button" > - Tethys +
+
diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index 2858aa20e1c..9e967f97600 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -18,11 +18,16 @@ import React, { RefCallback, } from 'react'; import classNames from 'classnames'; +import AutosizeInput from 'react-input-autosize'; import { findPopoverPosition, htmlIdGenerator, keys } from '../../services'; +import { getElementZIndex } from '../../services/popover'; +import { CommonProps } from '../common'; import { EuiPortal } from '../portal'; import { EuiI18n } from '../i18n'; -import { EuiComboBoxOptionsList } from './combo_box_options_list'; +import { EuiFormControlLayoutProps } from '../form'; +import { EuiFilterSelectItemClass } from '../filter_group/filter_select_item'; +import type { EuiTextTruncateProps } from '../text_truncate'; import { getMatchingOptions, @@ -44,11 +49,7 @@ import { EuiComboBoxOptionsListPosition, EuiComboBoxSingleSelectionShape, } from './types'; -import { EuiFilterSelectItemClass } from '../filter_group/filter_select_item'; -import AutosizeInput from 'react-input-autosize'; -import { CommonProps } from '../common'; -import { EuiFormControlLayoutProps } from '../form'; -import { getElementZIndex } from '../../services/popover'; +import { EuiComboBoxOptionsList } from './combo_box_options_list'; type DrillProps = Pick< EuiComboBoxOptionsListProps, @@ -156,6 +157,16 @@ export interface _EuiComboBoxProps * supplied by `aria-label` or from [EuiFormRow](/#/forms/form-layouts). */ 'aria-labelledby'?: string; + /** + * By default, EuiComboBox will truncate option labels at the end of + * the string. You can use pass in a custom truncation configuration that + * accepts any prop that [EuiTextTruncate](/#/utilities/text-truncate) prop + * except for `text` and `children`. + * + * Note: when searching, custom truncation props are ignored. The highlighted search + * text will always take precedence. + */ + truncationProps?: Partial>; } /** @@ -921,6 +932,7 @@ export class EuiComboBox extends Component< delimiter, append, autoFocus, + truncationProps, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest @@ -1007,6 +1019,7 @@ export class EuiComboBox extends Component< getSelectedOptionForSearchValue } listboxAriaLabel={listboxAriaLabel} + truncationProps={truncationProps} /> )} diff --git a/src/components/combo_box/combo_box_input/combo_box_input.tsx b/src/components/combo_box/combo_box_input/combo_box_input.tsx index 744375462bc..a9f8e1ddde4 100644 --- a/src/components/combo_box/combo_box_input/combo_box_input.tsx +++ b/src/components/combo_box/combo_box_input/combo_box_input.tsx @@ -193,8 +193,16 @@ export class EuiComboBoxInput extends Component< const pills = selectedOptions ? selectedOptions.map((option) => { - const { key, label, color, onClick, append, prepend, ...rest } = - option; + const { + key, + label, + color, + onClick, + append, + prepend, + truncationProps, + ...rest + } = option; const pillOnClose = isDisabled || singleSelection || onClick ? undefined diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx index de519ad32b4..c554226e268 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx @@ -21,6 +21,7 @@ import { import { EuiFlexGroup, EuiFlexItem } from '../../flex'; import { EuiHighlight } from '../../highlight'; +import { EuiMark } from '../../mark'; import { EuiText } from '../../text'; import { EuiLoadingSpinner } from '../../loading'; import { EuiComboBoxTitle } from './combo_box_title'; @@ -31,6 +32,12 @@ import { FilterChecked, } from '../../filter_group/filter_select_item'; import { htmlIdGenerator } from '../../../services'; +import { CommonProps } from '../../common'; +import { EuiBadge } from '../../badge'; +import { EuiPopoverPanel } from '../../popover/popover_panel'; +import { EuiTextTruncate } from '../../text_truncate'; + +import type { _EuiComboBoxProps } from '../combo_box'; import { EuiComboBoxOptionOption, EuiComboBoxOptionsListPosition, @@ -39,15 +46,9 @@ import { RefInstance, UpdatePositionHandler, } from '../types'; -import { CommonProps } from '../../common'; -import { EuiBadge } from '../../badge'; -import { EuiPopoverPanel } from '../../popover/popover_panel'; - -const OPTION_CONTENT_CLASSNAME = 'euiComboBoxOption__content'; export type EuiComboBoxOptionsListProps = CommonProps & ComponentProps & { - 'data-test-subj': string; activeOptionIndex?: number; areAllOptionsSelected?: boolean; listboxAriaLabel: string; @@ -98,6 +99,7 @@ export type EuiComboBoxOptionsListProps = CommonProps & singleSelection?: boolean | EuiComboBoxSingleSelectionShape; delimiter?: string; zIndex?: number; + truncationProps?: _EuiComboBoxProps['truncationProps']; }; const hitEnterBadge = ( @@ -211,8 +213,16 @@ export class EuiComboBoxOptionsList extends Component< ListRow = ({ data, index, style }: ListChildComponentProps) => { const option = data[index]; - const { key, isGroupLabelOption, label, value, prepend, append, ...rest } = - option; + const { + key, + isGroupLabelOption, + label, + value, + prepend, + append, + truncationProps: _truncationProps, + ...rest + } = option; const { singleSelection, selectedOptions, @@ -223,6 +233,11 @@ export class EuiComboBoxOptionsList extends Component< searchValue, rootId, } = this.props; + // Individual truncation settings should override component prop + const truncationProps = { + ...this.props.truncationProps, + ..._truncationProps, + }; if (isGroupLabelOption) { return ( @@ -260,30 +275,21 @@ export class EuiComboBoxOptionsList extends Component< checked={checked} showIcons={singleSelection ? true : false} id={rootId(`_option-${index}`)} - title={label} {...rest} > {prepend && ( {prepend} )} - {renderOption ? ( - - {renderOption( - option, - searchValue, - 'euiComboBoxOption__renderOption' - )} - - ) : ( - - {label} - - )} + + {renderOption + ? renderOption( + option, + searchValue, + 'euiComboBoxOption__renderOption' + ) + : this.renderTruncatedOption(label, truncationProps)} + {append && ( {append} )} @@ -293,6 +299,66 @@ export class EuiComboBoxOptionsList extends Component< ); }; + optionWidth: number | undefined; + setOptionWidth = (width: number) => { + this.optionWidth = width; + }; + + renderTruncatedOption = ( + text: string, + truncationProps: EuiComboBoxOptionsListProps['truncationProps'] + ) => { + if (!this.props.searchValue) { + return ( + + {(text) => text} + + ); + } + + const searchValue = this.props.searchValue.trim(); + const searchPositionStart = this.props.isCaseSensitive + ? text.indexOf(searchValue) + : text.toLowerCase().indexOf(searchValue.toLowerCase()); + const searchPositionCenter = + searchPositionStart + Math.floor(searchValue.length / 2); + + return ( + + {(text) => ( + <> + {text.length >= searchValue.length ? ( + + {text} + + ) : ( + // If the available truncated text is shorter than the full search string, + // just highlight the entire truncated text + {text} + )} + + )} + + ); + }; + render() { const { 'data-test-subj': dataTestSubj, @@ -325,6 +391,7 @@ export class EuiComboBoxOptionsList extends Component< delimiter, zIndex, style, + truncationProps, listboxAriaLabel, ...rest } = this.props; diff --git a/src/components/combo_box/types.ts b/src/components/combo_box/types.ts index 8510f659809..0049587d0e7 100644 --- a/src/components/combo_box/types.ts +++ b/src/components/combo_box/types.ts @@ -9,6 +9,8 @@ import { ButtonHTMLAttributes, ReactNode } from 'react'; import { CommonProps } from '../common'; +import type { _EuiComboBoxProps } from './combo_box'; + // note similarity to `Option` in `components/selectable/types.tsx` export interface EuiComboBoxOptionOption< T = string | number | string[] | undefined @@ -21,6 +23,7 @@ export interface EuiComboBoxOptionOption< value?: T; prepend?: ReactNode; append?: ReactNode; + truncationProps?: _EuiComboBoxProps['truncationProps']; } export type UpdatePositionHandler = ( From c69441aaedaaa15b931290621a177b492865ac49 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 00:30:25 -0700 Subject: [PATCH 24/44] Add docs section for combobox truncation --- .../src/views/combo_box/combo_box_example.js | 41 +++++++ src-docs/src/views/combo_box/truncation.tsx | 110 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src-docs/src/views/combo_box/truncation.tsx diff --git a/src-docs/src/views/combo_box/combo_box_example.js b/src-docs/src/views/combo_box/combo_box_example.js index ea232ba2ded..62c78c8d76a 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -9,6 +9,7 @@ import { EuiCode, EuiComboBox, EuiText, + EuiTextTruncate, EuiCallOut, } from '../../../../src/components'; @@ -82,6 +83,21 @@ const renderOptionSnippet = ``; +import Truncation from './truncation'; +const truncationSource = require('!!raw-loader!./truncation'); +const truncationSnippet = ``; + import Groups from './groups'; const groupsSource = require('!!raw-loader!./groups'); const groupsSnippet = ` + By default, EuiComboBox truncates long option text at + the end of the string. You can use truncationProps{' '} + and almost any prop that{' '} + + EuiTextTruncate + {' '} + accepts to configure this behavior. This can be configured at the{' '} + EuiComboBox level, as well as by each individual + option. +

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

Truncation type

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

Truncation offset

+
+ + setTruncationOffset(Number(e.target.value))} + compressed + /> +
+ )} +
+ + + + ); +}; From 10dc0e2539f9c67edd458bb5d800d7177463bedd Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 19:46:47 -0700 Subject: [PATCH 25/44] Attempt to fix Cypress CI failures caused by fonts --- .../text_truncate/text_truncate.spec.tsx | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx index fa4cf70466d..5b9d4f8f2b6 100644 --- a/src/components/text_truncate/text_truncate.spec.tsx +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -10,7 +10,7 @@ /// /// -import React from 'react'; +import React, { ReactNode } from 'react'; import { EuiTextTruncate } from './text_truncate'; @@ -21,11 +21,32 @@ describe('EuiTextTruncate', () => { width: 200, }; + // CI doesn't have access to the Inter font, so we need to manually include it + // for font calculations to work correctly + const createFontStylesheet = () => { + const linkElem = document.createElement('link'); + linkElem.setAttribute('rel', 'stylesheet'); + linkElem.setAttribute( + 'href', + 'https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=Roboto+Mono:ital,wght@0,400..700;1,400..700&display=swap' + ); + linkElem.id = 'font-loaded'; + document.head.appendChild(linkElem); + cy.wait(1000); // Wait a tick to give the time to load/swap in + }; + + const mount = (children: ReactNode) => { + if (!document.getElementById('font-loaded')) { + createFontStylesheet(); + } + cy.mount(children); + }; + const getTruncatedText = (selector = '#text') => cy.get(`${selector} [data-test-subj="truncatedText"]`); it('does not render truncated text if no truncation is needed', () => { - cy.mount(); + mount(); cy.get('#text').should('not.have.attr', 'title'); cy.get('#text [data-test-subj="fullText"]').should( 'have.text', @@ -35,7 +56,7 @@ describe('EuiTextTruncate', () => { }); it('renders truncated text and a title when truncation is needed', () => { - cy.mount(); + mount(); cy.get('#text').should('have.attr', 'title', props.text); cy.get('#text [data-test-subj="fullText"]').should('have.text', props.text); getTruncatedText().should('exist'); @@ -49,12 +70,12 @@ describe('EuiTextTruncate', () => { describe('middle', () => { it('truncations and inserts ellispes in the middle of the text', () => { - cy.mount(); + mount(); getTruncatedText().should('have.text', expectedMiddleOutput); }); it('ignores `truncationOffset` and `truncationPosition`', () => { - cy.mount( + mount( { describe('start', () => { it('truncates and inserts ellispis at the start of the text', () => { - cy.mount(); + mount(); getTruncatedText().should('have.text', expectedStartOutput); }); describe('truncationOffset', () => { it('preserves starting characters with `truncationOffset`', () => { - cy.mount( + mount( { }); it('falls back to middle truncation if truncationOffset is too large for the text', () => { - cy.mount( + mount( { cy.window().then((win) => { cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); }); - cy.mount( + mount( { describe('end', () => { it('truncates and inserts ellispis at the end of the text', () => { - cy.mount(); + mount(); getTruncatedText().should('have.text', expectedEndOutput); }); describe('truncationOffset', () => { it('preserves ending characters with `truncationOffset`', () => { - cy.mount( + mount( { }); it('falls back to middle truncation if truncationOffset is too large for the text', () => { - cy.mount( + mount( { cy.window().then((win) => { cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); }); - cy.mount( + mount( { describe('startEnd', () => { it('truncates and inserts ellipses at both the start and end of the text', () => { - cy.mount(); + mount(); getTruncatedText().should('have.text', expectedStartEndOutput); }); it('ignores `truncationOffset`', () => { - cy.mount( + mount( { describe('truncationPosition', () => { it('allows customizing the anchor at which the truncation is positioned from', () => { - cy.mount( + mount( <> { }); it('does not display the leading ellipsis if the anchor is close enough to the start', () => { - cy.mount( + mount( { }); it('does not display the leading ellipsis if the anchor position is <= 0', () => { - cy.mount( + mount( <> { }); it('does not display the trailing ellipsis if the anchor is close enough to the end', () => { - cy.mount( + mount( { }); it('does not display the trailing ellipsis if the anchor position is >= the text length', () => { - cy.mount( + mount( <> { describe('ellipsis', () => { it('allows customizing the symbols used to represent an ellipsis', () => { - cy.mount( + mount( <> { cy.window().then((win) => { cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); }); - cy.mount( + mount( <> { describe('children', () => { it('allows customizing the rendered text via a render prop', () => { - cy.mount( + mount( {(text) => {text}} From f219119cb9c548830d741bb2bb4e84dac1c225d1 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 21:07:09 -0700 Subject: [PATCH 26/44] [REFACTOR] Move truncation string logic to a separate vanilla JS class --- src/components/text_truncate/index.ts | 2 + .../text_truncate/text_truncate.tsx | 178 +++----------- src/components/text_truncate/utils.test.ts | 177 +++++++++++++ src/components/text_truncate/utils.ts | 232 ++++++++++++++++++ 4 files changed, 443 insertions(+), 146 deletions(-) create mode 100644 src/components/text_truncate/utils.test.ts create mode 100644 src/components/text_truncate/utils.ts diff --git a/src/components/text_truncate/index.ts b/src/components/text_truncate/index.ts index cd8f9fd3749..a2785e5a6c7 100644 --- a/src/components/text_truncate/index.ts +++ b/src/components/text_truncate/index.ts @@ -11,3 +11,5 @@ export type { EuiTextTruncationTypes, } from './text_truncate'; export { EuiTextTruncate } from './text_truncate'; + +export { TruncationUtils } from './utils'; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index da2e2b49498..e42ddeae937 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -23,6 +23,7 @@ import { } from '../observer/resize_observer'; import type { CommonProps } from '../common'; +import { TruncationUtils } from './utils'; import { euiTextTruncateStyles } from './text_truncate.styles'; const TRUNCATION_TYPES = ['end', 'start', 'startEnd', 'middle'] as const; @@ -146,158 +147,43 @@ const EuiTextTruncateWithWidth: FunctionComponent< }, [_truncation, _truncationOffset, truncationPosition, text.length]); const truncatedText = useMemo(() => { - if (!containerEl || !width) return ''; - - // Create a temporary DOM element for manipulating text and determining text width - const span = document.createElement('span'); - containerEl.appendChild(span); - - // Check to make sure the container width can even fit the ellipsis, let alone text - span.textContent = - truncation === 'startEnd' // startEnd needs a little more space - ? `${ellipsis} ${ellipsis}` - : ellipsis; - if (span.offsetWidth >= width * 0.9) { - console.error( - 'The truncation ellipsis is larger than the available width. No text can be rendered.' - ); - containerEl.removeChild(span); - return ''; - } - - span.textContent = text; - - // Check if we need to truncate at all - if (width > span.offsetWidth) { - containerEl.removeChild(span); - return text; - } - - const substringOffset = truncationOffset + ellipsis.length + 1; - - switch (truncation) { - case 'end': - const endPosition = text.length - truncationOffset; - const endOffset = span.textContent.substring(endPosition); - const endRemaining = span.textContent.substring(0, endPosition); - - // Make sure that the offset alone isn't larger than the actual available width - span.textContent = `${ellipsis}${endOffset}`; - if (span.offsetWidth > width) { - // There isn't a super great way to handle this visually. - // The best we can do is simply render the broken truncation - console.error( - `The passed truncationOffset of ${truncationOffset} is too large for available width.` - ); + let truncatedText = ''; + if (!containerEl || !width) return truncatedText; + + const utils = new TruncationUtils({ + fullText: text, + ellipsis, + container: containerEl, + availableWidth: width, + }); + + if (utils.checkIfTruncationIsNeeded() === false) { + truncatedText = text; + } else if (utils.checkSufficientEllipsisWidth(truncation) === false) { + truncatedText = ''; + } else { + switch (truncation) { + case 'end': + truncatedText = utils.truncateEnd(truncationOffset); break; - } - span.textContent = `${endRemaining}${ellipsis}${endOffset}`; - - while (span.offsetWidth > width) { - const offset = span.textContent.length - substringOffset; - const trimmedText = span.textContent.substring(0, offset); - span.textContent = `${trimmedText}${ellipsis}${endOffset}`; - } - break; - - case 'start': - const startOffset = span.textContent.substring(0, truncationOffset); - const startRemaining = span.textContent.substring(truncationOffset); - - // Make sure that the offset alone isn't larger than the actual available width - span.textContent = `${startOffset}${ellipsis}`; - if (span.offsetWidth > width) { - // There isn't a super great way to handle this visually. - // The best we can do is simply render the broken truncation - console.error( - `The passed truncationOffset of ${truncationOffset} is too large for available width.` - ); + case 'start': + truncatedText = utils.truncateStart(truncationOffset); break; - } - span.textContent = `${startOffset}${ellipsis}${startRemaining}`; - - while (span.offsetWidth > width) { - const trimmedText = span.textContent.substring(substringOffset); - span.textContent = `${startOffset}${ellipsis}${trimmedText}`; - } - break; - - case 'startEnd': - if (truncationPosition == null) { - while (span.offsetWidth > width) { - const trimmedMiddle = span.textContent.substring( - substringOffset, - span.textContent.length - substringOffset - ); - span.textContent = `${ellipsis}${trimmedMiddle}${ellipsis}`; - } - } else { - // If using a non-centered startEnd anchor position, we need to *build* - // the string from scratch instead of *removing* from the full text string, - // to make sure we don't go past the beginning or end of the text - let builtText = ''; - span.textContent = builtText; - - // Ellipses are conditional - if the anchor is towards the beginning or end, - // it's possible they shouldn't render - let startingEllipsis = ellipsis; - let endingEllipsis = ellipsis; - - // Split the text into two at the anchor position - let firstPart = text.substring(0, truncationPosition); - let secondPart = text.substring(truncationPosition); - - while (span.offsetWidth <= width) { - // Because this logic builds text outwards vs. removes inwards, the final text - // width ends up a little larger than the container if we don't add this catch - const previousText = span.textContent; - span.textContent = `${startingEllipsis}${builtText}${endingEllipsis}`; - if (span.offsetWidth > width) { - span.textContent = previousText; - break; - } - - if (firstPart.length > 0) { - // Split off and prepend the last character of the first part - const lastChar = firstPart.length - 1; - builtText = `${firstPart.substring(lastChar)}${builtText}`; - firstPart = firstPart.substring(0, lastChar); - } else { - startingEllipsis = ''; - } - - if (secondPart.length > 0) { - // Split off and append first character of the second part - builtText = `${builtText}${secondPart.substring(0, 1)}`; - secondPart = secondPart.substring(1); - } else { - endingEllipsis = ''; - } - } - } - break; - - case 'middle': - const middlePosition = Math.floor(text.length / 2); - let firstHalf = text.substring(0, middlePosition); - let secondHalf = text.substring(middlePosition); - let trimfirstHalf = true; - - while (span.offsetWidth > width) { - if (trimfirstHalf) { - firstHalf = firstHalf.substring(0, firstHalf.length - 1); + case 'startEnd': + if (truncationPosition == null) { + truncatedText = utils.truncateStartEndAtMiddle(); } else { - secondHalf = secondHalf.substring(1); + truncatedText = + utils.truncateStartEndAtPosition(truncationPosition); } - span.textContent = `${firstHalf}${ellipsis}${secondHalf}`; - trimfirstHalf = !trimfirstHalf; - } - break; + break; + case 'middle': + truncatedText = utils.truncateMiddle(); + break; + } } - const truncatedText = span.textContent; - containerEl.removeChild(span); - + utils.cleanup(); return truncatedText; }, [ width, diff --git a/src/components/text_truncate/utils.test.ts b/src/components/text_truncate/utils.test.ts new file mode 100644 index 00000000000..d71d770ce5a --- /dev/null +++ b/src/components/text_truncate/utils.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { TruncationUtils } from './utils'; + +describe('TruncationUtils', () => { + const params = { + fullText: 'Lorem ipsum dolor sit amet', + ellipsis: '...', + availableWidth: 200, + container: document.createElement('div'), + }; + + // JSDOM doesn't have box model widths, so we need to mock it + const setSpanWidth = (width: number) => { + Object.defineProperty(HTMLSpanElement.prototype, 'offsetWidth', { + configurable: true, + value: width, + }); + }; + + // A few utilities log errors - silence them and capture the messages + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + beforeEach(() => consoleErrorSpy.mockClear()); + afterAll(() => consoleErrorSpy.mockRestore()); + + describe('span utils', () => { + const utils = new TruncationUtils(params); + + describe('textWidth', () => { + it('returns the offsetWidth of the internal span element', () => { + setSpanWidth(500); + expect(utils.textWidth).toEqual(500); + }); + }); + + describe('setTextToCheck', () => { + it('sets the text content of the internal span element', () => { + utils.setTextToCheck('hello world'); + expect(utils.span.textContent).toEqual('hello world'); + }); + }); + + describe('cleanup', () => { + it('removes the internal span element from the DOM', () => { + expect(params.container.contains(utils.span)).toBeTruthy(); + utils.cleanup(); + expect(params.container.contains(utils.span)).toBeFalsy(); + }); + }); + }); + + describe('early return checks', () => { + const utils = new TruncationUtils(params); + afterAll(() => utils.cleanup()); + + describe('checkIfTruncationIsNeeded', () => { + it('returns false if truncation is not needed', () => { + setSpanWidth(100); + expect(utils.checkIfTruncationIsNeeded()).toEqual(false); + + setSpanWidth(400); + expect(utils.checkIfTruncationIsNeeded()).toBeUndefined(); + }); + }); + + describe('checkSufficientEllipsisWidth', () => { + it('returns false and errors if the container is not wide enough for the ellipsis', () => { + setSpanWidth(201); + expect(utils.checkSufficientEllipsisWidth('startEnd')).toEqual(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'The truncation ellipsis is larger than the available width. No text can be rendered.' + ); + + setSpanWidth(10); + expect(utils.checkSufficientEllipsisWidth('start')).toBeUndefined(); + }); + }); + }); + + describe('truncation types', () => { + // Again, JSDOM doesn't have the concept of widths, so this is a bit + // of a mockery, but it at least gives us some confidence in our substring logic + let mockSpanWidth: number; + beforeEach(() => { + mockSpanWidth = params.availableWidth + 5; // 5 while loop iterations + }); + + // Simulate the width going down with each removed character + let mockTextWidth: jest.SpyInstance; + beforeAll(() => { + mockTextWidth = jest + .spyOn(TruncationUtils.prototype, 'textWidth', 'get') + .mockImplementation(() => mockSpanWidth--); + }); + const utils = new TruncationUtils(params); + + afterAll(() => { + utils.cleanup(); + mockTextWidth.mockRestore(); + }); + + describe('start', () => { + it('inserts ellipsis at the start of the text', () => { + expect(utils.truncateStart(0)).toEqual('... ipsum dolor sit amet'); + }); + + describe('truncationOffset', () => { + it('preserves the specified number of characters at the start of the text', () => { + mockTextWidth.mockImplementationOnce(() => 30); // Mocks the `checkTruncationOffsetWidth` check + expect(utils.truncateStart(3)).toEqual('Lor...sum dolor sit amet'); + }); + }); + }); + + describe('end', () => { + it('inserts ellipsis at the end of the text', () => { + expect(utils.truncateEnd(0)).toEqual('Lorem ipsum dolor sit...'); + }); + + describe('truncationOffset', () => { + it('preserves the specified number of characters at the end of the text', () => { + mockTextWidth.mockImplementationOnce(() => 30); // Mocks the `checkTruncationOffsetWidth` check + expect(utils.truncateEnd(3)).toEqual('Lorem ipsum dolor ...met'); + }); + }); + }); + + describe('startEnd', () => { + describe('with no truncationPosition', () => { + it('inserts ellipsis at the start and end of the text', () => { + expect(utils.truncateStartEndAtMiddle()).toEqual( + '... ipsum dolor sit...' + ); + }); + }); + + describe('with truncationPosition', () => { + // Because this approach builds up text instead of removing, we need to re-mock widths + beforeEach(() => { + mockSpanWidth = params.availableWidth - 8; + mockTextWidth.mockImplementation(() => mockSpanWidth++); + }); + afterAll(() => { + mockTextWidth.mockImplementation(() => mockSpanWidth--); // restore mock + }); + + it('allows moving the anchor point of the displayed text', () => { + expect(utils.truncateStartEndAtPosition(10)).toEqual( + '...rem ipsum dolor ...' + ); + }); + + it('does not display the leading ellipsis if the index is close to the start', () => { + expect(utils.truncateStartEndAtPosition(2)).toEqual('Lorem ipsu...'); + }); + + it('does not display the trailing ellipsis if the index is close to the end', () => { + expect(utils.truncateStartEndAtPosition(20)).toEqual( + '...dolor sit amet' + ); + }); + }); + }); + + describe('middle', () => { + it('inserts ellipsis in the middle of the text', () => { + expect(utils.truncateMiddle()).toEqual('Lorem ipsu...or sit amet'); + }); + }); + }); +}); diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts new file mode 100644 index 00000000000..d40e88a1ce4 --- /dev/null +++ b/src/components/text_truncate/utils.ts @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +type Params = { + fullText: string; + ellipsis: string; + availableWidth: number; + container: HTMLElement; +}; + +export class TruncationUtils { + fullText: Params['fullText']; + ellipsis: Params['ellipsis']; + availableWidth: Params['availableWidth']; + container: Params['container']; + span: HTMLSpanElement; + + constructor({ fullText, ellipsis, availableWidth, container }: Params) { + this.fullText = fullText; + this.ellipsis = ellipsis; + this.availableWidth = availableWidth; + this.container = container; + + // Create a temporary DOM element for manipulating text and determining text width + this.span = document.createElement('span'); + this.container.appendChild(this.span); + } + + /** + * Span utils + */ + + get textWidth() { + return this.span.offsetWidth; + } + + setTextToCheck = (text: string) => { + this.span.textContent = text; + }; + + cleanup = () => { + this.container.removeChild(this.span); + }; + + /** + * Early return checks + */ + + checkIfTruncationIsNeeded = () => { + this.setTextToCheck(this.fullText); + + if (this.availableWidth > this.textWidth) { + return false; + } + }; + + checkSufficientEllipsisWidth = (truncation: string) => { + const textToCheck = + truncation === 'startEnd' + ? `${this.ellipsis} ${this.ellipsis}` // startEnd needs a little more space + : this.ellipsis; + this.setTextToCheck(textToCheck); + + if (this.textWidth >= this.availableWidth * 0.9) { + console.error( + 'The truncation ellipsis is larger than the available width. No text can be rendered.' + ); + return false; + } + }; + + /** + * Truncation enums + */ + + truncateStart = (truncationOffset: number) => { + let truncatedText = this.fullText; + let leadingText = ''; + const combinedText = () => `${leadingText}${truncatedText}`; + + if (truncationOffset) { + [leadingText, truncatedText] = splitText(this.fullText).at( + truncationOffset + ); + // TODO: offset width check + } + + leadingText += this.ellipsis; + this.setTextToCheck(combinedText()); + + while (this.textWidth > this.availableWidth) { + truncatedText = removeFirstCharacter(truncatedText); + this.setTextToCheck(combinedText()); + } + + return combinedText(); + }; + + truncateEnd = (truncationOffset?: number) => { + let truncatedText = this.fullText; + let trailingText = ''; + const combinedText = () => `${truncatedText}${trailingText}`; + + if (truncationOffset) { + const index = this.fullText.length - truncationOffset; + [truncatedText, trailingText] = splitText(this.fullText).at(index); + // TODO: offset width check + } + + trailingText = this.ellipsis + trailingText; + this.setTextToCheck(combinedText()); + + while (this.textWidth > this.availableWidth) { + truncatedText = removeLastCharacter(truncatedText); + this.setTextToCheck(combinedText()); + } + + return combinedText(); + }; + + truncateStartEndAtPosition = (truncationPosition: number) => { + // If using a non-centered startEnd anchor position, we need to *build* + // the string from scratch instead of *removing* from the full text string, + // to make sure we don't go past the beginning or end of the text + let truncatedText = ''; + this.setTextToCheck(truncatedText); + + // Ellipses are conditional - if the anchor is towards the beginning or end, + // it's possible they shouldn't render + let startingEllipsis = this.ellipsis; + let endingEllipsis = this.ellipsis; + + // Split the text into two at the anchor position + let [firstPart, secondPart] = splitText(this.fullText).at( + truncationPosition + ); + + const combinedText = () => + `${startingEllipsis}${truncatedText}${endingEllipsis}`; + + while (this.textWidth <= this.availableWidth) { + if (firstPart.length > 0) { + truncatedText = `${getLastCharacter(firstPart)}${truncatedText}`; + firstPart = removeLastCharacter(firstPart); + } else { + startingEllipsis = ''; + } + + if (secondPart.length > 0) { + truncatedText = `${truncatedText}${getFirstCharacter(secondPart)}`; + secondPart = removeFirstCharacter(secondPart); + } else { + endingEllipsis = ''; + } + + this.setTextToCheck(combinedText()); + } + + // Because this logic builds text outwards vs. removes inwards, the final text + // width ends up a little larger than the container, and we need to remove + // the last added character(s) + if (!startingEllipsis) { + truncatedText = removeLastCharacter(truncatedText); + } else if (!endingEllipsis) { + truncatedText = removeFirstCharacter(truncatedText); + } else { + truncatedText = removeFirstAndLastCharacters(truncatedText); + } + + return combinedText(); + }; + + truncateStartEndAtMiddle = () => { + let truncatedText = this.fullText; + this.setTextToCheck(truncatedText); + + const combinedText = () => + `${this.ellipsis}${truncatedText}${this.ellipsis}`; + + while (this.textWidth > this.availableWidth) { + truncatedText = removeFirstAndLastCharacters(truncatedText); + this.setTextToCheck(combinedText()); + } + + return combinedText(); + }; + + truncateMiddle = () => { + const middlePosition = Math.floor(this.fullText.length / 2); + let [firstHalf, secondHalf] = splitText(this.fullText).at(middlePosition); + let trimfirstHalf; + + const combinedText = () => `${firstHalf}${this.ellipsis}${secondHalf}`; + this.setTextToCheck(combinedText()); + + while (this.textWidth > this.availableWidth) { + trimfirstHalf = !trimfirstHalf; + if (trimfirstHalf) { + firstHalf = removeLastCharacter(firstHalf); + } else { + secondHalf = removeFirstCharacter(secondHalf); + } + this.setTextToCheck(combinedText()); + } + + return combinedText(); + }; +} + +/** + * DRY character utils + */ +const removeLastCharacter = (text: string) => + text.substring(0, text.length - 1); + +const getLastCharacter = (text: string) => text.substring(text.length - 1); + +const removeFirstCharacter = (text: string) => text.substring(1); + +const getFirstCharacter = (text: string) => text.substring(0, 1); + +const removeFirstAndLastCharacters = (text: string) => + text.substring(1, text.length - 1); + +const splitText = (text: string) => ({ + at: (index: number) => [text.substring(0, index), text.substring(index)], +}); From 64aaebd55a850bc34283af439371d09a5d9cf9fa Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 21:10:24 -0700 Subject: [PATCH 27/44] Clean up `truncationOffset` edge case behavior - the new utils allowed me to see how to attempt to continue truncating the logic rather than just metaphorically throwing hands in the air! --- .../text_truncate/text_truncate.spec.tsx | 14 +++++------ .../text_truncate/text_truncate.tsx | 11 +++----- src/components/text_truncate/utils.test.ts | 25 +++++++++++++++++++ src/components/text_truncate/utils.ts | 25 +++++++++++++++++-- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx index 5b9d4f8f2b6..17857586507 100644 --- a/src/components/text_truncate/text_truncate.spec.tsx +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -108,7 +108,7 @@ describe('EuiTextTruncate', () => { ); }); - it('falls back to middle truncation if truncationOffset is too large for the text', () => { + it('ignores truncationOffset if larger than the text length', () => { mount( { truncationOffset={100} /> ); - getTruncatedText().should('have.text', expectedMiddleOutput); + getTruncatedText().should('have.text', expectedStartOutput); }); it('logs an error if the truncationOffset is too large for the container', () => { @@ -133,7 +133,7 @@ describe('EuiTextTruncate', () => { ); cy.get('@spyConsoleError').should( 'be.calledWith', - 'The passed truncationOffset of 5 is too large for available width.' + 'The passed truncationOffset is too large for the available width. Truncating the offset instead.' ); }); }); @@ -160,15 +160,15 @@ describe('EuiTextTruncate', () => { ); }); - it('falls back to middle truncation if truncationOffset is too large for the text', () => { + it('ignores truncationOffset if larger than the text length', () => { mount( ); - getTruncatedText().should('have.text', expectedMiddleOutput); + getTruncatedText().should('have.text', expectedEndOutput); }); it('logs an error if the truncationOffset is too large for the container', () => { @@ -185,7 +185,7 @@ describe('EuiTextTruncate', () => { ); cy.get('@spyConsoleError').should( 'be.calledWith', - 'The passed truncationOffset of 10 is too large for available width.' + 'The passed truncationOffset is too large for the available width. Truncating the offset instead.' ); }); }); diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index e42ddeae937..31feb638cc4 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -47,8 +47,8 @@ export type EuiTextTruncateProps = Omit< * It allows preserving a certain number of characters of either the * starting or ending text. * - * If the passed offset is greater than half of the total text length, - * the truncation will simply default to `middle` instead. + * If the passed offset is greater than the total text length, + * the offset will be ignored. */ truncationOffset?: number; /** @@ -130,11 +130,8 @@ const EuiTextTruncateWithWidth: FunctionComponent< let truncationOffset = 0; if (_truncation === 'end' || _truncation === 'start') { - const offsetIsTooLarge = _truncationOffset >= Math.floor(text.length / 2); - if (offsetIsTooLarge) { - truncation = 'middle'; - } else { - truncationOffset = _truncationOffset > 0 ? _truncationOffset : 0; // Negative offsets cause infinite loops + if (0 < _truncationOffset && _truncationOffset < text.length) { + truncationOffset = _truncationOffset; } } else if (_truncation === 'startEnd' && truncationPosition != null) { if (truncationPosition <= 0) { diff --git a/src/components/text_truncate/utils.test.ts b/src/components/text_truncate/utils.test.ts index d71d770ce5a..01d060c1108 100644 --- a/src/components/text_truncate/utils.test.ts +++ b/src/components/text_truncate/utils.test.ts @@ -81,6 +81,19 @@ describe('TruncationUtils', () => { expect(utils.checkSufficientEllipsisWidth('start')).toBeUndefined(); }); }); + + describe('checkTruncationOffsetWidth', () => { + it('returns false and errors if the container is not wide enough for the offset text', () => { + setSpanWidth(201); + expect(utils.checkTruncationOffsetWidth('hello')).toEqual(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'The passed truncationOffset is too large for the available width. Truncating the offset instead.' + ); + + setSpanWidth(200); + expect(utils.checkTruncationOffsetWidth('world')).toBeUndefined(); + }); + }); }); describe('truncation types', () => { @@ -115,6 +128,12 @@ describe('TruncationUtils', () => { mockTextWidth.mockImplementationOnce(() => 30); // Mocks the `checkTruncationOffsetWidth` check expect(utils.truncateStart(3)).toEqual('Lor...sum dolor sit amet'); }); + + it('truncates the offset if the truncationOffset is too large', () => { + mockTextWidth.mockImplementationOnce(() => 201); + expect(utils.truncateStart(20)).toEqual('... ipsum dolor si'); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); }); }); @@ -128,6 +147,12 @@ describe('TruncationUtils', () => { mockTextWidth.mockImplementationOnce(() => 30); // Mocks the `checkTruncationOffsetWidth` check expect(utils.truncateEnd(3)).toEqual('Lorem ipsum dolor ...met'); }); + + it('truncates the offset if the truncationOffset is too large', () => { + mockTextWidth.mockImplementationOnce(() => 201); + expect(utils.truncateEnd(18)).toEqual('sum dolor sit...'); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts index d40e88a1ce4..d69d55cad01 100644 --- a/src/components/text_truncate/utils.ts +++ b/src/components/text_truncate/utils.ts @@ -74,6 +74,17 @@ export class TruncationUtils { } }; + checkTruncationOffsetWidth = (text: string) => { + this.setTextToCheck(text); + + if (this.textWidth > this.availableWidth) { + console.error( + `The passed truncationOffset is too large for the available width. Truncating the offset instead.` + ); + return false; + } + }; + /** * Truncation enums */ @@ -87,7 +98,12 @@ export class TruncationUtils { [leadingText, truncatedText] = splitText(this.fullText).at( truncationOffset ); - // TODO: offset width check + + const widthCheck = `${leadingText}${this.ellipsis}`; + if (this.checkTruncationOffsetWidth(widthCheck) === false) { + truncatedText = leadingText; + leadingText = ''; + } } leadingText += this.ellipsis; @@ -109,7 +125,12 @@ export class TruncationUtils { if (truncationOffset) { const index = this.fullText.length - truncationOffset; [truncatedText, trailingText] = splitText(this.fullText).at(index); - // TODO: offset width check + + const widthCheck = `${this.ellipsis}${trailingText}`; + if (this.checkTruncationOffsetWidth(widthCheck) === false) { + truncatedText = trailingText; + trailingText = ''; + } } trailingText = this.ellipsis + trailingText; From 7d5ce5a910f41281291845fd5fcd709853848374 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 21:10:50 -0700 Subject: [PATCH 28/44] Fix storybook controls - `truncationPosition` can't be set back to undefined if it starts as a number --- src/components/text_truncate/text_truncate.stories.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/text_truncate/text_truncate.stories.tsx b/src/components/text_truncate/text_truncate.stories.tsx index cf6aea58397..82efe8a7755 100644 --- a/src/components/text_truncate/text_truncate.stories.tsx +++ b/src/components/text_truncate/text_truncate.stories.tsx @@ -40,8 +40,6 @@ export const Playground: Story = { ), args: { ...componentDefaults, - truncationOffset: 0, - truncationPosition: 0, width: 200, }, }; From 5fd309ec331394d74e2cd1efa61419234a8b938d Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 22 Aug 2023 21:42:28 -0700 Subject: [PATCH 29/44] it's hard to word good --- .../text_truncate/text_truncate_example.js | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src-docs/src/views/text_truncate/text_truncate_example.js b/src-docs/src/views/text_truncate/text_truncate_example.js index 651546622a3..a9799afc6de 100644 --- a/src-docs/src/views/text_truncate/text_truncate_example.js +++ b/src-docs/src/views/text_truncate/text_truncate_example.js @@ -38,22 +38,14 @@ export const TextTruncateExample = { text: ( <>

- EuiTextTruncate attempts to provide customizable - and size-aware truncation logic (until the happy day that browsers - add more{' '} - - customizable truncation out-of-the-box via CSS - - ). + EuiTextTruncate provides customizable and + size-aware single line text truncation.

The four truncation styles supported are start,{' '} end, startEnd, and{' '} - middle. The below demo can also be dynamically - resized to see how different truncation styles respond. + middle. Resize the below demo to see how + different truncation styles respond to dynamic width changes.

), @@ -72,7 +64,14 @@ export const TextTruncateExample = { EuiTextTruncate attempts to mimic the behavior of{' '} text-overflow: ellipsis as closely as possible, although there may be edge cases and cross-browser issues, as this - is essentially a browser implementation we are trying to polyfill. + is essentially a{' '} + + browser implementation + {' '} + we are trying to polyfill.

  • @@ -103,7 +102,7 @@ export const TextTruncateExample = { ], text: (

    - By default, EuiTextTruncate uses with the unicode + By default, EuiTextTruncate uses the unicode character for horizontal ellipis. It can be customized via the{' '} ellipsis prop as necessary (e.g. for specific languages, extra punctuation, etc). @@ -157,10 +156,9 @@ export const TextTruncateExample = { prop allows.

    - This behavior will intelligently detect when positions are close to - the start or end of the text, and omit leading or trailing ellipses - when necessary. If the passed position is greater than the total - text length, the truncation will simply default to `start` instead. + This behavior will intelligently detect when positions are near + enough to the start or end of the text to omit leading or trailing + ellipses when necessary.

    Increase or decrease the number control below to see the prop in @@ -194,17 +192,15 @@ export const TextTruncateExample = { rendering.

    - The primary type of use case in mind for this functionality would be - using{' '} + The below example demonstrates a primary use case for the render + prop and the truncationPosition prop. If a user + is searching for a specific word in truncated text, you can use{' '} EuiHighlight or EuiMark {' '} - to highlight certain portions of the text. This example also - demonstrates the primary use case for the{' '} - truncationPosition prop. If a user is searching - for a specific word in truncated text, you should pass the index of - that found word to ensure it is always visible, for the best user - experience. + to highlight the search term, and passing the index of the found + word to truncationPosition ensures the search + term is always visible to the user.

    Date: Wed, 23 Aug 2023 12:11:15 -0700 Subject: [PATCH 30/44] [PR feedback] Expand utilities for to specify DOM vs. canvas render methods + write more detailed jsdoc comments/descriptions + add a Cypress spec to confirm canvas util works correctly --- src/components/text_truncate/index.ts | 2 +- .../text_truncate/text_truncate.tsx | 4 +- src/components/text_truncate/utils.spec.tsx | 93 ++++++++++++ src/components/text_truncate/utils.test.ts | 58 ++++++-- src/components/text_truncate/utils.ts | 136 ++++++++++++++---- 5 files changed, 252 insertions(+), 41 deletions(-) create mode 100644 src/components/text_truncate/utils.spec.tsx diff --git a/src/components/text_truncate/index.ts b/src/components/text_truncate/index.ts index a2785e5a6c7..f329eedcf8a 100644 --- a/src/components/text_truncate/index.ts +++ b/src/components/text_truncate/index.ts @@ -12,4 +12,4 @@ export type { } from './text_truncate'; export { EuiTextTruncate } from './text_truncate'; -export { TruncationUtils } from './utils'; +export { TruncationUtilsForDOM, TruncationUtilsForCanvas } from './utils'; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 31feb638cc4..7f9a832333e 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -23,7 +23,7 @@ import { } from '../observer/resize_observer'; import type { CommonProps } from '../common'; -import { TruncationUtils } from './utils'; +import { TruncationUtilsForDOM } from './utils'; import { euiTextTruncateStyles } from './text_truncate.styles'; const TRUNCATION_TYPES = ['end', 'start', 'startEnd', 'middle'] as const; @@ -147,7 +147,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< let truncatedText = ''; if (!containerEl || !width) return truncatedText; - const utils = new TruncationUtils({ + const utils = new TruncationUtilsForDOM({ fullText: text, ellipsis, container: containerEl, diff --git a/src/components/text_truncate/utils.spec.tsx b/src/components/text_truncate/utils.spec.tsx new file mode 100644 index 00000000000..754f734c047 --- /dev/null +++ b/src/components/text_truncate/utils.spec.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/// +/// +/// + +import React, { + ReactNode, + FunctionComponent, + useState, + useEffect, +} from 'react'; + +import { TruncationUtilsForDOM, TruncationUtilsForCanvas } from './utils'; + +const sharedProps = { + fullText: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', + availableWidth: 200, + ellipsis: '...', +}; +const font = '14px Verdana'; // We need to use a OS-safe font that CI machines are likely to have + +/** + * Test utility for outputting the returned strings from each truncation utility + * in React. Given the same shared props and fonts, both render methods should + * arrive at the same truncated strings + */ +const TestSetup: FunctionComponent<{ + getUtils: () => TruncationUtilsForDOM | TruncationUtilsForCanvas; +}> = ({ getUtils }) => { + const [rendered, setRendered] = useState(null); + + useEffect(() => { + const utils = getUtils(); + setRendered( +
    +
    {utils.truncateStart(0)}
    +
    {utils.truncateEnd(0)}
    +
    {utils.truncateMiddle()}
    +
    {utils.truncateStartEndAtMiddle()}
    +
    {utils.truncateStartEndAtPosition(15)}
    +
    + ); + // @ts-ignore - the `?.` handles canvas which doesn't require a cleanup + utils.cleanup?.(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return <>{rendered}; +}; + +const assertExpectedOutput = () => { + cy.get('#start').should('have.text', '...consectetur adipiscing elit'); + cy.get('#end').should('have.text', 'Lorem ipsum dolor sit am...'); + cy.get('#middle').should('have.text', 'Lorem ipsum ...dipiscing elit'); + cy.get('#startEnd').should('have.text', '...r sit amet, consectetur...'); + cy.get('#startEndAt').should('have.text', '...m ipsum dolor sit amet...'); +}; + +describe('TruncationUtilsForDOM', () => { + const container = document.createElement('div'); + container.style.font = font; + const props = { ...sharedProps, container }; + + it('truncates text as expected', () => { + cy.mount( + { + document.body.appendChild(container); + const utils = new TruncationUtilsForDOM(props); + return utils; + }} + /> + ); + assertExpectedOutput(); + }); +}); + +describe('TruncationUtilsForCanvas', () => { + const props = { ...sharedProps, font }; + + it('truncates text as expected', () => { + cy.mount( + new TruncationUtilsForCanvas(props)} /> + ); + assertExpectedOutput(); + }); +}); diff --git a/src/components/text_truncate/utils.test.ts b/src/components/text_truncate/utils.test.ts index 01d060c1108..7b78daf4443 100644 --- a/src/components/text_truncate/utils.test.ts +++ b/src/components/text_truncate/utils.test.ts @@ -6,13 +6,17 @@ * Side Public License, v 1. */ -import { TruncationUtils } from './utils'; +import { TruncationUtilsForDOM, TruncationUtilsForCanvas } from './utils'; -describe('TruncationUtils', () => { +const sharedParams = { + fullText: 'Lorem ipsum dolor sit amet', + ellipsis: '...', + availableWidth: 200, +}; + +describe('TruncationUtilsForDOM', () => { const params = { - fullText: 'Lorem ipsum dolor sit amet', - ellipsis: '...', - availableWidth: 200, + ...sharedParams, container: document.createElement('div'), }; @@ -29,8 +33,8 @@ describe('TruncationUtils', () => { beforeEach(() => consoleErrorSpy.mockClear()); afterAll(() => consoleErrorSpy.mockRestore()); - describe('span utils', () => { - const utils = new TruncationUtils(params); + describe('DOM utils', () => { + const utils = new TruncationUtilsForDOM(params); describe('textWidth', () => { it('returns the offsetWidth of the internal span element', () => { @@ -56,7 +60,7 @@ describe('TruncationUtils', () => { }); describe('early return checks', () => { - const utils = new TruncationUtils(params); + const utils = new TruncationUtilsForDOM(params); afterAll(() => utils.cleanup()); describe('checkIfTruncationIsNeeded', () => { @@ -108,10 +112,10 @@ describe('TruncationUtils', () => { let mockTextWidth: jest.SpyInstance; beforeAll(() => { mockTextWidth = jest - .spyOn(TruncationUtils.prototype, 'textWidth', 'get') + .spyOn(TruncationUtilsForDOM.prototype, 'textWidth', 'get') .mockImplementation(() => mockSpanWidth--); }); - const utils = new TruncationUtils(params); + const utils = new TruncationUtilsForDOM(params); afterAll(() => { utils.cleanup(); @@ -200,3 +204,37 @@ describe('TruncationUtils', () => { }); }); }); + +describe('TruncationUtilsForCanvas', () => { + // Jest absolutely does not have canvas so I honestly have no idea why I'm even doing this + // Except that like a Pavlovian dog conditioned for treats, so I am conditioned + // for that sweet sweet green 100% code coverage + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: () => ({ measureText: () => ({ width: 200 }), font: '' }), + }); + + it('allows customizing the font', () => { + const utils = new TruncationUtilsForCanvas({ + ...sharedParams, + font: 'Inter', + }); + expect(utils.context.font).toEqual('Inter'); + }); + + describe('canvas utils', () => { + const utils = new TruncationUtilsForCanvas(sharedParams); + + describe('textWidth', () => { + it('returns the measured text width from the canvas', () => { + expect(utils.textWidth).toEqual(200); + }); + }); + + describe('setTextToCheck', () => { + it('sets the internal currentText variable', () => { + utils.setTextToCheck('hello world'); + expect(utils.currentText).toEqual('hello world'); + }); + }); + }); +}); diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts index d69d55cad01..178a2b4ac8a 100644 --- a/src/components/text_truncate/utils.ts +++ b/src/components/text_truncate/utils.ts @@ -6,45 +6,62 @@ * Side Public License, v 1. */ -type Params = { +interface SharedParams { fullText: string; ellipsis: string; availableWidth: number; +} +interface DOMParams extends SharedParams { container: HTMLElement; -}; +} +interface CanvasParams extends SharedParams { + font?: CanvasTextDrawingStyles['font']; +} -export class TruncationUtils { - fullText: Params['fullText']; - ellipsis: Params['ellipsis']; - availableWidth: Params['availableWidth']; - container: Params['container']; - span: HTMLSpanElement; +/** + * This internal shared/base class contains the actual logic for truncating text + * (as well as a few handy utilities for checking whether truncation is possible + * or even necessary). + * + * How the underlying mechanism works: the full text is rendered, and then + * characters are removed one by one until the width of the text fits within + * the specified available width. + * + * Side note: The exception to this is the `truncateStartEndAtPosition` method, + * which works by building up from an empty string / by adding characters + * instead of removing them. + */ +class _TruncationUtils { + fullText: SharedParams['fullText']; + ellipsis: SharedParams['ellipsis']; + availableWidth: SharedParams['availableWidth']; - constructor({ fullText, ellipsis, availableWidth, container }: Params) { + constructor({ fullText, ellipsis, availableWidth }: SharedParams) { this.fullText = fullText; this.ellipsis = ellipsis; this.availableWidth = availableWidth; - this.container = container; - - // Create a temporary DOM element for manipulating text and determining text width - this.span = document.createElement('span'); - this.container.appendChild(this.span); } /** - * Span utils + * Internal measurement utils which will be overridden depending on the + * rendering approach used (e.g. DOM vs Canvas). + * + * The thrown errors are there to ensure the base instance utils do not + * get called standalone in the future, if more extended classes are added + * someday (e.g. new shadow DOM tech, or Flash makes a surprise comeback). + * + * The istanbul code coverage ignores are there because this base class + * is not exported and in theory the code should never be reachable. */ - get textWidth() { - return this.span.offsetWidth; + /* istanbul ignore next */ + get textWidth(): number { + throw new Error('This function must be superseded by a DOM or Canvas util'); } - setTextToCheck = (text: string) => { - this.span.textContent = text; - }; - - cleanup = () => { - this.container.removeChild(this.span); + /* istanbul ignore next */ + setTextToCheck = (_: string): void => { + throw new Error('This function must be superseded by a DOM or Canvas util'); }; /** @@ -86,7 +103,7 @@ export class TruncationUtils { }; /** - * Truncation enums + * Truncation types logic. This is where the magic happens */ truncateStart = (truncationOffset: number) => { @@ -182,9 +199,9 @@ export class TruncationUtils { this.setTextToCheck(combinedText()); } - // Because this logic builds text outwards vs. removes inwards, the final text - // width ends up a little larger than the container, and we need to remove - // the last added character(s) + // Because this logic builds text outwards vs. removing inwards, the final + // text width ends up a little larger than the container, and we need to + // remove the last added character(s) if (!startingEllipsis) { truncatedText = removeLastCharacter(truncatedText); } else if (!endingEllipsis) { @@ -234,8 +251,71 @@ export class TruncationUtils { } /** - * DRY character utils + * Creates a temporary vanilla JS DOM element for manipulating text and + * determining text width. + * + * Requires passing in a container element to which the temporary element + * will be appended. Any CSS/font styles that need to be accounted for should + * be automatically inherited from the container. + * + * NOTE: The consumer is responsible for calling the `cleanup()` method manually + * to remove the temporary DOM node once their usage of this utility is complete. */ +export class TruncationUtilsForDOM extends _TruncationUtils { + container: DOMParams['container']; + span: HTMLSpanElement; + + constructor({ container, ...rest }: DOMParams) { + super(rest); + this.container = container; + + this.span = document.createElement('span'); + this.container.appendChild(this.span); + } + + get textWidth() { + return this.span.offsetWidth; + } + + setTextToCheck = (text: string) => { + this.span.textContent = text; + }; + + cleanup = () => { + this.container.removeChild(this.span); + }; +} + +/** + * Creates a temporary Canvas element for manipulating text & determining text width. + * This method is compatible with charts or other canvas-rendered frameworks, + * and requires no cleanup method. It will typically require passing font + * information to accurately measure text width. + */ +export class TruncationUtilsForCanvas extends _TruncationUtils { + context: CanvasRenderingContext2D; + currentText = ''; + + constructor({ font, ...rest }: CanvasParams) { + super(rest); + + this.context = document.createElement('canvas').getContext('2d')!; + if (font) this.context.font = font; + } + + get textWidth() { + return this.context.measureText(this.currentText).width; + } + + setTextToCheck = (text: string) => { + this.currentText = text; + }; +} + +/** + * DRY character/substring utils + */ + const removeLastCharacter = (text: string) => text.substring(0, text.length - 1); From 767a44ebdcc3715788728ba4f3486b7a26a14164 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Wed, 23 Aug 2023 12:11:51 -0700 Subject: [PATCH 31/44] [misc cleanup] fix `truncateStart` typing - param should be optional --- src/components/text_truncate/utils.spec.tsx | 4 ++-- src/components/text_truncate/utils.test.ts | 4 ++-- src/components/text_truncate/utils.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/text_truncate/utils.spec.tsx b/src/components/text_truncate/utils.spec.tsx index 754f734c047..adbd5e7c3e6 100644 --- a/src/components/text_truncate/utils.spec.tsx +++ b/src/components/text_truncate/utils.spec.tsx @@ -40,8 +40,8 @@ const TestSetup: FunctionComponent<{ const utils = getUtils(); setRendered(
    -
    {utils.truncateStart(0)}
    -
    {utils.truncateEnd(0)}
    +
    {utils.truncateStart()}
    +
    {utils.truncateEnd()}
    {utils.truncateMiddle()}
    {utils.truncateStartEndAtMiddle()}
    {utils.truncateStartEndAtPosition(15)}
    diff --git a/src/components/text_truncate/utils.test.ts b/src/components/text_truncate/utils.test.ts index 7b78daf4443..fc22fec8374 100644 --- a/src/components/text_truncate/utils.test.ts +++ b/src/components/text_truncate/utils.test.ts @@ -124,7 +124,7 @@ describe('TruncationUtilsForDOM', () => { describe('start', () => { it('inserts ellipsis at the start of the text', () => { - expect(utils.truncateStart(0)).toEqual('... ipsum dolor sit amet'); + expect(utils.truncateStart()).toEqual('... ipsum dolor sit amet'); }); describe('truncationOffset', () => { @@ -143,7 +143,7 @@ describe('TruncationUtilsForDOM', () => { describe('end', () => { it('inserts ellipsis at the end of the text', () => { - expect(utils.truncateEnd(0)).toEqual('Lorem ipsum dolor sit...'); + expect(utils.truncateEnd()).toEqual('Lorem ipsum dolor sit...'); }); describe('truncationOffset', () => { diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts index 178a2b4ac8a..80049b02071 100644 --- a/src/components/text_truncate/utils.ts +++ b/src/components/text_truncate/utils.ts @@ -106,7 +106,7 @@ class _TruncationUtils { * Truncation types logic. This is where the magic happens */ - truncateStart = (truncationOffset: number) => { + truncateStart = (truncationOffset?: number) => { let truncatedText = this.fullText; let leadingText = ''; const combinedText = () => `${leadingText}${truncatedText}`; From 4cd6ad582035c9a4d5946ae10aa016348fa17f1e Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Wed, 23 Aug 2023 12:16:14 -0700 Subject: [PATCH 32/44] Naming things is hard --- src/components/text_truncate/index.ts | 2 +- src/components/text_truncate/text_truncate.tsx | 4 ++-- src/components/text_truncate/utils.spec.tsx | 12 ++++++------ src/components/text_truncate/utils.test.ts | 18 +++++++++--------- src/components/text_truncate/utils.ts | 4 ++-- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/components/text_truncate/index.ts b/src/components/text_truncate/index.ts index f329eedcf8a..0cbfe6949ae 100644 --- a/src/components/text_truncate/index.ts +++ b/src/components/text_truncate/index.ts @@ -12,4 +12,4 @@ export type { } from './text_truncate'; export { EuiTextTruncate } from './text_truncate'; -export { TruncationUtilsForDOM, TruncationUtilsForCanvas } from './utils'; +export { TruncationUtilsWithDOM, TruncationUtilsWithCanvas } from './utils'; diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index 7f9a832333e..c6209e50d95 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -23,7 +23,7 @@ import { } from '../observer/resize_observer'; import type { CommonProps } from '../common'; -import { TruncationUtilsForDOM } from './utils'; +import { TruncationUtilsWithDOM } from './utils'; import { euiTextTruncateStyles } from './text_truncate.styles'; const TRUNCATION_TYPES = ['end', 'start', 'startEnd', 'middle'] as const; @@ -147,7 +147,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< let truncatedText = ''; if (!containerEl || !width) return truncatedText; - const utils = new TruncationUtilsForDOM({ + const utils = new TruncationUtilsWithDOM({ fullText: text, ellipsis, container: containerEl, diff --git a/src/components/text_truncate/utils.spec.tsx b/src/components/text_truncate/utils.spec.tsx index adbd5e7c3e6..cf7d3b2c452 100644 --- a/src/components/text_truncate/utils.spec.tsx +++ b/src/components/text_truncate/utils.spec.tsx @@ -17,7 +17,7 @@ import React, { useEffect, } from 'react'; -import { TruncationUtilsForDOM, TruncationUtilsForCanvas } from './utils'; +import { TruncationUtilsWithDOM, TruncationUtilsWithCanvas } from './utils'; const sharedProps = { fullText: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', @@ -32,7 +32,7 @@ const font = '14px Verdana'; // We need to use a OS-safe font that CI machines a * arrive at the same truncated strings */ const TestSetup: FunctionComponent<{ - getUtils: () => TruncationUtilsForDOM | TruncationUtilsForCanvas; + getUtils: () => TruncationUtilsWithDOM | TruncationUtilsWithCanvas; }> = ({ getUtils }) => { const [rendered, setRendered] = useState(null); @@ -62,7 +62,7 @@ const assertExpectedOutput = () => { cy.get('#startEndAt').should('have.text', '...m ipsum dolor sit amet...'); }; -describe('TruncationUtilsForDOM', () => { +describe('TruncationUtilsWithDOM', () => { const container = document.createElement('div'); container.style.font = font; const props = { ...sharedProps, container }; @@ -72,7 +72,7 @@ describe('TruncationUtilsForDOM', () => { { document.body.appendChild(container); - const utils = new TruncationUtilsForDOM(props); + const utils = new TruncationUtilsWithDOM(props); return utils; }} /> @@ -81,12 +81,12 @@ describe('TruncationUtilsForDOM', () => { }); }); -describe('TruncationUtilsForCanvas', () => { +describe('TruncationUtilsWithCanvas', () => { const props = { ...sharedProps, font }; it('truncates text as expected', () => { cy.mount( - new TruncationUtilsForCanvas(props)} /> + new TruncationUtilsWithCanvas(props)} /> ); assertExpectedOutput(); }); diff --git a/src/components/text_truncate/utils.test.ts b/src/components/text_truncate/utils.test.ts index fc22fec8374..deb3bcb779d 100644 --- a/src/components/text_truncate/utils.test.ts +++ b/src/components/text_truncate/utils.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { TruncationUtilsForDOM, TruncationUtilsForCanvas } from './utils'; +import { TruncationUtilsWithDOM, TruncationUtilsWithCanvas } from './utils'; const sharedParams = { fullText: 'Lorem ipsum dolor sit amet', @@ -14,7 +14,7 @@ const sharedParams = { availableWidth: 200, }; -describe('TruncationUtilsForDOM', () => { +describe('TruncationUtilsWithDOM', () => { const params = { ...sharedParams, container: document.createElement('div'), @@ -34,7 +34,7 @@ describe('TruncationUtilsForDOM', () => { afterAll(() => consoleErrorSpy.mockRestore()); describe('DOM utils', () => { - const utils = new TruncationUtilsForDOM(params); + const utils = new TruncationUtilsWithDOM(params); describe('textWidth', () => { it('returns the offsetWidth of the internal span element', () => { @@ -60,7 +60,7 @@ describe('TruncationUtilsForDOM', () => { }); describe('early return checks', () => { - const utils = new TruncationUtilsForDOM(params); + const utils = new TruncationUtilsWithDOM(params); afterAll(() => utils.cleanup()); describe('checkIfTruncationIsNeeded', () => { @@ -112,10 +112,10 @@ describe('TruncationUtilsForDOM', () => { let mockTextWidth: jest.SpyInstance; beforeAll(() => { mockTextWidth = jest - .spyOn(TruncationUtilsForDOM.prototype, 'textWidth', 'get') + .spyOn(TruncationUtilsWithDOM.prototype, 'textWidth', 'get') .mockImplementation(() => mockSpanWidth--); }); - const utils = new TruncationUtilsForDOM(params); + const utils = new TruncationUtilsWithDOM(params); afterAll(() => { utils.cleanup(); @@ -205,7 +205,7 @@ describe('TruncationUtilsForDOM', () => { }); }); -describe('TruncationUtilsForCanvas', () => { +describe('TruncationUtilsWithCanvas', () => { // Jest absolutely does not have canvas so I honestly have no idea why I'm even doing this // Except that like a Pavlovian dog conditioned for treats, so I am conditioned // for that sweet sweet green 100% code coverage @@ -214,7 +214,7 @@ describe('TruncationUtilsForCanvas', () => { }); it('allows customizing the font', () => { - const utils = new TruncationUtilsForCanvas({ + const utils = new TruncationUtilsWithCanvas({ ...sharedParams, font: 'Inter', }); @@ -222,7 +222,7 @@ describe('TruncationUtilsForCanvas', () => { }); describe('canvas utils', () => { - const utils = new TruncationUtilsForCanvas(sharedParams); + const utils = new TruncationUtilsWithCanvas(sharedParams); describe('textWidth', () => { it('returns the measured text width from the canvas', () => { diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts index 80049b02071..4ce49a621ed 100644 --- a/src/components/text_truncate/utils.ts +++ b/src/components/text_truncate/utils.ts @@ -261,7 +261,7 @@ class _TruncationUtils { * NOTE: The consumer is responsible for calling the `cleanup()` method manually * to remove the temporary DOM node once their usage of this utility is complete. */ -export class TruncationUtilsForDOM extends _TruncationUtils { +export class TruncationUtilsWithDOM extends _TruncationUtils { container: DOMParams['container']; span: HTMLSpanElement; @@ -292,7 +292,7 @@ export class TruncationUtilsForDOM extends _TruncationUtils { * and requires no cleanup method. It will typically require passing font * information to accurately measure text width. */ -export class TruncationUtilsForCanvas extends _TruncationUtils { +export class TruncationUtilsWithCanvas extends _TruncationUtils { context: CanvasRenderingContext2D; currentText = ''; From 85ccc1c4e4310632f62311cea9bb5991d6dbdb4e Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Wed, 23 Aug 2023 17:09:21 -0700 Subject: [PATCH 33/44] Fix CI font issues again :| - apparently has zero fonts in common with my machine, so google fonts it is + clean up previous font loading code - we only need 1 weight, and we can use `before()` instead of a mount util --- .../text_truncate/text_truncate.spec.tsx | 62 ++++++++----------- src/components/text_truncate/utils.spec.tsx | 33 ++++++---- 2 files changed, 49 insertions(+), 46 deletions(-) diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx index 17857586507..5b663557c28 100644 --- a/src/components/text_truncate/text_truncate.spec.tsx +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -10,7 +10,7 @@ /// /// -import React, { ReactNode } from 'react'; +import React from 'react'; import { EuiTextTruncate } from './text_truncate'; @@ -23,30 +23,22 @@ describe('EuiTextTruncate', () => { // CI doesn't have access to the Inter font, so we need to manually include it // for font calculations to work correctly - const createFontStylesheet = () => { + before(() => { const linkElem = document.createElement('link'); linkElem.setAttribute('rel', 'stylesheet'); linkElem.setAttribute( 'href', - 'https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=Roboto+Mono:ital,wght@0,400..700;1,400..700&display=swap' + 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap' ); - linkElem.id = 'font-loaded'; document.head.appendChild(linkElem); - cy.wait(1000); // Wait a tick to give the time to load/swap in - }; - - const mount = (children: ReactNode) => { - if (!document.getElementById('font-loaded')) { - createFontStylesheet(); - } - cy.mount(children); - }; + cy.wait(1000); // Wait a tick to give the font time to load/swap in + }); const getTruncatedText = (selector = '#text') => cy.get(`${selector} [data-test-subj="truncatedText"]`); it('does not render truncated text if no truncation is needed', () => { - mount(); + cy.mount(); cy.get('#text').should('not.have.attr', 'title'); cy.get('#text [data-test-subj="fullText"]').should( 'have.text', @@ -56,7 +48,7 @@ describe('EuiTextTruncate', () => { }); it('renders truncated text and a title when truncation is needed', () => { - mount(); + cy.mount(); cy.get('#text').should('have.attr', 'title', props.text); cy.get('#text [data-test-subj="fullText"]').should('have.text', props.text); getTruncatedText().should('exist'); @@ -70,12 +62,12 @@ describe('EuiTextTruncate', () => { describe('middle', () => { it('truncations and inserts ellispes in the middle of the text', () => { - mount(); + cy.mount(); getTruncatedText().should('have.text', expectedMiddleOutput); }); it('ignores `truncationOffset` and `truncationPosition`', () => { - mount( + cy.mount( { describe('start', () => { it('truncates and inserts ellispis at the start of the text', () => { - mount(); + cy.mount(); getTruncatedText().should('have.text', expectedStartOutput); }); describe('truncationOffset', () => { it('preserves starting characters with `truncationOffset`', () => { - mount( + cy.mount( { }); it('ignores truncationOffset if larger than the text length', () => { - mount( + cy.mount( { cy.window().then((win) => { cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); }); - mount( + cy.mount( { describe('end', () => { it('truncates and inserts ellispis at the end of the text', () => { - mount(); + cy.mount(); getTruncatedText().should('have.text', expectedEndOutput); }); describe('truncationOffset', () => { it('preserves ending characters with `truncationOffset`', () => { - mount( + cy.mount( { }); it('ignores truncationOffset if larger than the text length', () => { - mount( + cy.mount( { cy.window().then((win) => { cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); }); - mount( + cy.mount( { describe('startEnd', () => { it('truncates and inserts ellipses at both the start and end of the text', () => { - mount(); + cy.mount(); getTruncatedText().should('have.text', expectedStartEndOutput); }); it('ignores `truncationOffset`', () => { - mount( + cy.mount( { describe('truncationPosition', () => { it('allows customizing the anchor at which the truncation is positioned from', () => { - mount( + cy.mount( <> { }); it('does not display the leading ellipsis if the anchor is close enough to the start', () => { - mount( + cy.mount( { }); it('does not display the leading ellipsis if the anchor position is <= 0', () => { - mount( + cy.mount( <> { }); it('does not display the trailing ellipsis if the anchor is close enough to the end', () => { - mount( + cy.mount( { }); it('does not display the trailing ellipsis if the anchor position is >= the text length', () => { - mount( + cy.mount( <> { describe('ellipsis', () => { it('allows customizing the symbols used to represent an ellipsis', () => { - mount( + cy.mount( <> { cy.window().then((win) => { cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); }); - mount( + cy.mount( <> { describe('children', () => { it('allows customizing the rendered text via a render prop', () => { - mount( + cy.mount( {(text) => {text}} diff --git a/src/components/text_truncate/utils.spec.tsx b/src/components/text_truncate/utils.spec.tsx index cf7d3b2c452..621e9457534 100644 --- a/src/components/text_truncate/utils.spec.tsx +++ b/src/components/text_truncate/utils.spec.tsx @@ -24,13 +24,24 @@ const sharedProps = { availableWidth: 200, ellipsis: '...', }; -const font = '14px Verdana'; // We need to use a OS-safe font that CI machines are likely to have -/** - * Test utility for outputting the returned strings from each truncation utility - * in React. Given the same shared props and fonts, both render methods should - * arrive at the same truncated strings - */ +// CI doesn't have access to the Inter font, so we need to manually include it +// for font calculations to work correctly +const font = '14px Inter'; +before(() => { + const linkElem = document.createElement('link'); + linkElem.setAttribute('rel', 'stylesheet'); + linkElem.setAttribute( + 'href', + 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap' + ); + document.head.appendChild(linkElem); + cy.wait(1000); // Wait a tick to give the font time to load/swap in +}); + +// Test utility for outputting the returned strings from each truncation utility +// in React. Given the same shared props and fonts, both render methods should +// arrive at the same truncated strings const TestSetup: FunctionComponent<{ getUtils: () => TruncationUtilsWithDOM | TruncationUtilsWithCanvas; }> = ({ getUtils }) => { @@ -55,11 +66,11 @@ const TestSetup: FunctionComponent<{ }; const assertExpectedOutput = () => { - cy.get('#start').should('have.text', '...consectetur adipiscing elit'); - cy.get('#end').should('have.text', 'Lorem ipsum dolor sit am...'); - cy.get('#middle').should('have.text', 'Lorem ipsum ...dipiscing elit'); - cy.get('#startEnd').should('have.text', '...r sit amet, consectetur...'); - cy.get('#startEndAt').should('have.text', '...m ipsum dolor sit amet...'); + cy.get('#start').should('have.text', '...t, consectetur adipiscing elit'); + cy.get('#end').should('have.text', 'Lorem ipsum dolor sit amet, ...'); + cy.get('#middle').should('have.text', 'Lorem ipsum d...adipiscing elit'); + cy.get('#startEnd').should('have.text', '...lor sit amet, consectetur a...'); + cy.get('#startEndAt').should('have.text', '...rem ipsum dolor sit amet, ...'); }; describe('TruncationUtilsWithDOM', () => { From a1c487d8a9934061b654e0538c4a5f9d1b6b39ad Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 24 Aug 2023 11:28:58 -0700 Subject: [PATCH 34/44] [PR feedback] Improve perf of DOM measurement + harden standalone behavior --- src/components/text_truncate/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts index 4ce49a621ed..dd5b6b7d474 100644 --- a/src/components/text_truncate/utils.ts +++ b/src/components/text_truncate/utils.ts @@ -270,6 +270,8 @@ export class TruncationUtilsWithDOM extends _TruncationUtils { this.container = container; this.span = document.createElement('span'); + this.span.style.position = 'absolute'; // Prevent page reflow/repaint for performance + this.span.style.whiteSpace = 'nowrap'; // EuiTextTruncate already sets this on the parent, but we'll set it here as well for consumers who use this util standalone this.container.appendChild(this.span); } From b6b67b0ca1bf45a1fa08cc2a205fd3e883b31077 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 24 Aug 2023 14:24:25 -0700 Subject: [PATCH 35/44] Update `EuiTextTruncate` to support using `TruncationUtilsForCanvas` + re-add the font computation method Marco originally had in the canvas class, and require passing in either a font string or a container to compute fonts from --- .../text_truncate/text_truncate.tsx | 26 ++++++++-- src/components/text_truncate/utils.spec.tsx | 35 ++++++++++--- src/components/text_truncate/utils.test.ts | 25 +++++++--- src/components/text_truncate/utils.ts | 50 +++++++++++++++---- 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx index c6209e50d95..f0a0df6e685 100644 --- a/src/components/text_truncate/text_truncate.tsx +++ b/src/components/text_truncate/text_truncate.tsx @@ -23,7 +23,7 @@ import { } from '../observer/resize_observer'; import type { CommonProps } from '../common'; -import { TruncationUtilsWithDOM } from './utils'; +import { TruncationUtilsWithDOM, TruncationUtilsWithCanvas } from './utils'; import { euiTextTruncateStyles } from './text_truncate.styles'; const TRUNCATION_TYPES = ['end', 'start', 'startEnd', 'middle'] as const; @@ -84,6 +84,16 @@ export type EuiTextTruncateProps = Omit< * registers a size change. This callback will **not** fire if `width` is passed. */ onResize?: (width: number) => void; + /** + * By default, EuiTextTruncate will calculate its truncation via DOM manipulation + * and measurement, which has the benefit of automatically inheriting font styles. + * However, if this approach proves to have a significant performance impact for your + * usage, consider using the `canvas` API instead, which is more performant. + * + * Please note that there are minute pixel to subpixel differences between the + * two options due to different rendering engines. + */ + measurementRenderAPI?: 'dom' | 'canvas'; /** * By default, EuiTextTruncate will render the truncated string directly. * You can optionally pass a render prop function to the component, which @@ -118,6 +128,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< truncationPosition, ellipsis = '…', containerRef, + measurementRenderAPI = 'dom', ...rest }) => { // Note: This needs to be a state and not a ref to trigger a rerender on mount @@ -147,12 +158,16 @@ const EuiTextTruncateWithWidth: FunctionComponent< let truncatedText = ''; if (!containerEl || !width) return truncatedText; - const utils = new TruncationUtilsWithDOM({ + const params = { fullText: text, ellipsis, container: containerEl, availableWidth: width, - }); + }; + const utils = + measurementRenderAPI === 'canvas' + ? new TruncationUtilsWithCanvas(params) + : new TruncationUtilsWithDOM(params); if (utils.checkIfTruncationIsNeeded() === false) { truncatedText = text; @@ -180,7 +195,9 @@ const EuiTextTruncateWithWidth: FunctionComponent< } } - utils.cleanup(); + if (measurementRenderAPI === 'dom') { + (utils as TruncationUtilsWithDOM).cleanup(); + } return truncatedText; }, [ width, @@ -190,6 +207,7 @@ const EuiTextTruncateWithWidth: FunctionComponent< truncationPosition, ellipsis, containerEl, + measurementRenderAPI, ]); const isTruncating = truncatedText !== text; diff --git a/src/components/text_truncate/utils.spec.tsx b/src/components/text_truncate/utils.spec.tsx index 621e9457534..1c7ae254815 100644 --- a/src/components/text_truncate/utils.spec.tsx +++ b/src/components/text_truncate/utils.spec.tsx @@ -83,8 +83,7 @@ describe('TruncationUtilsWithDOM', () => { { document.body.appendChild(container); - const utils = new TruncationUtilsWithDOM(props); - return utils; + return new TruncationUtilsWithDOM(props); }} /> ); @@ -93,12 +92,32 @@ describe('TruncationUtilsWithDOM', () => { }); describe('TruncationUtilsWithCanvas', () => { - const props = { ...sharedProps, font }; + describe('container', () => { + const container = document.createElement('div'); + container.style.font = font; + const props = { ...sharedProps, container }; - it('truncates text as expected', () => { - cy.mount( - new TruncationUtilsWithCanvas(props)} /> - ); - assertExpectedOutput(); + it('truncates text as expected', () => { + cy.mount( + { + document.body.appendChild(container); + return new TruncationUtilsWithCanvas(props); + }} + /> + ); + assertExpectedOutput(); + }); + }); + + describe('font', () => { + const props = { ...sharedProps, font }; + + it('truncates text as expected', () => { + cy.mount( + new TruncationUtilsWithCanvas(props)} /> + ); + assertExpectedOutput(); + }); }); }); diff --git a/src/components/text_truncate/utils.test.ts b/src/components/text_truncate/utils.test.ts index deb3bcb779d..183ed862274 100644 --- a/src/components/text_truncate/utils.test.ts +++ b/src/components/text_truncate/utils.test.ts @@ -213,16 +213,29 @@ describe('TruncationUtilsWithCanvas', () => { value: () => ({ measureText: () => ({ width: 200 }), font: '' }), }); - it('allows customizing the font', () => { - const utils = new TruncationUtilsWithCanvas({ - ...sharedParams, - font: 'Inter', + describe('font calculations', () => { + it('computes the set font if passed a container element', () => { + const container = document.createElement('div'); + container.style.font = '14px Inter'; + + const utils = new TruncationUtilsWithCanvas({ + ...sharedParams, + container, + }); + expect(utils.context.font).toEqual('14px Inter'); + }); + + it('accepts a static font string', () => { + const utils = new TruncationUtilsWithCanvas({ + ...sharedParams, + font: '14px Inter', + }); + expect(utils.context.font).toEqual('14px Inter'); }); - expect(utils.context.font).toEqual('Inter'); }); describe('canvas utils', () => { - const utils = new TruncationUtilsWithCanvas(sharedParams); + const utils = new TruncationUtilsWithCanvas({ ...sharedParams, font: '' }); describe('textWidth', () => { it('returns the measured text width from the canvas', () => { diff --git a/src/components/text_truncate/utils.ts b/src/components/text_truncate/utils.ts index dd5b6b7d474..16403275e5c 100644 --- a/src/components/text_truncate/utils.ts +++ b/src/components/text_truncate/utils.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { ExclusiveUnion } from '../common'; + interface SharedParams { fullText: string; ellipsis: string; @@ -14,9 +16,11 @@ interface SharedParams { interface DOMParams extends SharedParams { container: HTMLElement; } -interface CanvasParams extends SharedParams { - font?: CanvasTextDrawingStyles['font']; -} +type CanvasParams = SharedParams & + ExclusiveUnion< + { container: HTMLElement }, + { font: CanvasTextDrawingStyles['font'] } + >; /** * This internal shared/base class contains the actual logic for truncating text @@ -289,22 +293,50 @@ export class TruncationUtilsWithDOM extends _TruncationUtils { } /** - * Creates a temporary Canvas element for manipulating text & determining text width. - * This method is compatible with charts or other canvas-rendered frameworks, - * and requires no cleanup method. It will typically require passing font - * information to accurately measure text width. + * Creates a temporary Canvas element for manipulating text & determining + * text width. This method is compatible with charts or other canvas-rendered + * frameworks, and requires no cleanup method. + * + * To accurately measure text, canvas rendering requires either a container to + * compute/derive font styles from, or a static font string (useful for usage + * outside the DOM). Particular care should be applied when fallback fonts are + * used, as more fallback fonts can lead to less precision. + * + * Please note that while canvas is more performant than DOM measurement, there + * are subpixel to single digit pixel differences between DOM and canvas + * measurement due to the different rendering engines used. */ export class TruncationUtilsWithCanvas extends _TruncationUtils { context: CanvasRenderingContext2D; currentText = ''; - constructor({ font, ...rest }: CanvasParams) { + constructor({ font, container, ...rest }: CanvasParams) { super(rest); this.context = document.createElement('canvas').getContext('2d')!; - if (font) this.context.font = font; + + // Set the canvas font to ensure text width calculations are correct + if (font) { + this.context.font = font; + } else if (container) { + this.context.font = this.computeFontFromElement(container); + } } + computeFontFromElement = (element: HTMLElement) => { + const computedStyles = window.getComputedStyle(element); + return [ + 'font-style', + 'font-variant', + 'font-weight', + 'font-size', + 'font-family', + ] + .map((prop) => computedStyles.getPropertyValue(prop)) + .join(' ') + .trim(); + }; + get textWidth() { return this.context.measureText(this.currentText).width; } From f4a9af4428c4e67b0e18d18f5eb93b773225bb74 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 24 Aug 2023 14:26:34 -0700 Subject: [PATCH 36/44] Add documentation section and warnings around performance + example where the perf differences between various recommended mitigations can be tested --- .../src/views/text_truncate/performance.tsx | 117 ++++++++++++++++++ .../text_truncate/text_truncate_example.js | 78 ++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src-docs/src/views/text_truncate/performance.tsx diff --git a/src-docs/src/views/text_truncate/performance.tsx b/src-docs/src/views/text_truncate/performance.tsx new file mode 100644 index 00000000000..0b1d5163a77 --- /dev/null +++ b/src-docs/src/views/text_truncate/performance.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { css } from '@emotion/react'; +import { throttle } from 'lodash'; +import { faker } from '@faker-js/faker'; +import { FixedSizeList } from 'react-window'; + +import { + EuiFlexGroup, + EuiPanel, + EuiText, + EuiFormRow, + EuiFieldNumber, + EuiSwitch, + EuiSpacer, + EuiTextTruncate, +} from '../../../../src'; + +const text = Array.from({ length: 100 }, () => faker.lorem.lines(5)); + +export default () => { + // Testing toggles + const [canvasRendering, setCanvasRendering] = useState(true); + const measurementRenderAPI = canvasRendering ? 'canvas' : 'dom'; + const [virtualization, setVirtualization] = useState(false); + const [throttleMs, setThrottleMs] = useState(100); + + // Width resize observer + const widthRef = useRef(null); + const [width, setWidth] = useState(200); + + useEffect(() => { + if (!widthRef.current) return; + + const onObserve = throttle((entries) => { + // Skipping a forEach as we're only observing one element + setWidth(entries[0].contentRect.width); + }, throttleMs); + + const resizeObserver = new ResizeObserver(onObserve); + resizeObserver.observe(widthRef.current); + + () => resizeObserver.disconnect(); + }, [throttleMs]); + + return ( + + + setCanvasRendering(!canvasRendering)} + /> + setVirtualization(!virtualization)} + /> + + setThrottleMs(Number(e.target.value))} + style={{ width: 100 }} + compressed + /> + + + + + + {virtualization ? ( + + {({ index, style }) => ( + + )} + + ) : ( + text.map((text, i) => ( + + )) + )} + + +

    + Drag the panel resize handle to test performance. Use the controls above + to compare the performance of different approaches. +

    +
    + ); +}; diff --git a/src-docs/src/views/text_truncate/text_truncate_example.js b/src-docs/src/views/text_truncate/text_truncate_example.js index a9799afc6de..6fb50844981 100644 --- a/src-docs/src/views/text_truncate/text_truncate_example.js +++ b/src-docs/src/views/text_truncate/text_truncate_example.js @@ -1,5 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; +import { css } from '@emotion/react'; import { GuideSectionTypes } from '../../components'; @@ -25,6 +26,9 @@ const truncationPositionSource = require('!!raw-loader!./truncation_position'); import RenderProp from './render_prop'; const renderPropSource = require('!!raw-loader!./render_prop'); +import Performance from './performance'; +const performanceSource = require('!!raw-loader!./performance'); + export const TextTruncateExample = { title: 'Text truncation', sections: [ @@ -215,5 +219,79 @@ export const TextTruncateExample = { {(text) => {text}}
    `, }, + { + title: 'Performance', + text: ( + <> +

    + EuiTextTruncate uses an extra DOM element under the + hood to manipulate text and calculate whether the text width fits + within the available width. Additionally, by default, the component + will include its own resize observer in order to react to width + changes. +

    +

    + These functionalities can cause performance issues if the component + is rendered many times per page, and we would strongly recommend + using caution when doing so. Several escape hatches are available + for performance improvements: +

    +
      + css` + li:not(:last-child) { + margin-block-end: ${euiTheme.size.m}; + } + ` + } + > +
    1. + Pass a width prop to skip initializing a resize + observer for each component instance. For text within a container + of the same width, we would strongly recommend applying a single + resize observer to the parent container and passing down that + width to all child EuiTextTruncates. +
    2. +
    3. + Use the measurementRenderAPI="canvas" prop to + utilize the Canvas API for text measurement. While this can be + significantly more performant at higher iterations, please do note + that there are minute pixel to subpixel differences in this + rendering method. +
    4. +
    5. + Strongly consider using{' '} + + virtualization + {' '} + to reduce the number of rendered elements visible at any given + time, or{' '} + + throttling + {' '} + any resize observers or width-based logic. +
    6. +
    7. + If necessary, consider pulling out the underlying{' '} + TruncationUtilsForDOM and{' '} + TruncationUtilsForCanvas truncation utils and + re-using the same canvas context or DOM node, as opposed to + repeatedly creating new ones. +
    8. +
    + + ), + demo: , + source: [{ type: GuideSectionTypes.TSX, code: performanceSource }], + props: { EuiTextTruncate }, + snippet: ``, + }, ], }; From 4a96de881f2a47b71299e1640646a970eb78b56c Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 24 Aug 2023 14:27:43 -0700 Subject: [PATCH 37/44] [misc cleanup] improve tests - add basic unit test for confirming that the right utils areb eing called - add missing E2E test for resize observer behavior --- .../text_truncate/text_truncate.spec.tsx | 11 ++++ .../text_truncate/text_truncate.test.tsx | 60 +++++++++++++++---- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx index 5b663557c28..0dbefe2b2cd 100644 --- a/src/components/text_truncate/text_truncate.spec.tsx +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -364,4 +364,15 @@ describe('EuiTextTruncate', () => { cy.get('[data-test-subj="test"]').should('exist'); }); }); + + describe('resize observer behavior', () => { + it('renders a resize observer if `width` is not passed', () => { + cy.viewport(200, 50); + cy.mount(); + getTruncatedText().should('have.text', 'Lorem ipsum dolor sit amet, …'); + + cy.viewport(100, 50); + getTruncatedText().should('have.text', 'Lorem ipsum …'); + }); + }); }); diff --git a/src/components/text_truncate/text_truncate.test.tsx b/src/components/text_truncate/text_truncate.test.tsx index 46b3f103ce9..7622fc047e5 100644 --- a/src/components/text_truncate/text_truncate.test.tsx +++ b/src/components/text_truncate/text_truncate.test.tsx @@ -11,9 +11,25 @@ import { render } from '../../test/rtl'; import { shouldRenderCustomStyles } from '../../test/internal'; import { requiredProps } from '../../test'; +// Util mocks +const mockEarlyReturn = { checkIfTruncationIsNeeded: () => false }; +const mockCleanup = jest.fn(); +jest.mock('./utils', () => { + return { + TruncationUtilsWithDOM: jest.fn(() => ({ + ...mockEarlyReturn, + cleanup: mockCleanup, + })), + TruncationUtilsWithCanvas: jest.fn(() => mockEarlyReturn), + }; +}); +import { TruncationUtilsWithCanvas } from './utils'; + import { EuiTextTruncate } from './text_truncate'; describe('EuiTextTruncate', () => { + beforeEach(() => jest.clearAllMocks()); + const props = { text: 'Hello world', width: 50, @@ -29,20 +45,40 @@ describe('EuiTextTruncate', () => { expect(container.firstChild).toMatchSnapshot(); }); - it('does not render a resize observer if a width is passed', () => { - const onResize = jest.fn(); - render(); - expect(onResize).not.toHaveBeenCalled(); + describe('resize observer', () => { + it('does not render a resize observer if a width is passed', () => { + const onResize = jest.fn(); + render(); + expect(onResize).not.toHaveBeenCalled(); + }); + + it('renders a resize observer when no width is passed', () => { + const onResize = jest.fn(); + render( + + ); + expect(onResize).toHaveBeenCalledWith(0); + }); }); - it('renders a resize observer when no width is passed', () => { - const onResize = jest.fn(); - render( - - ); - expect(onResize).toHaveBeenCalledWith(0); + describe('render API', () => { + it('calls the DOM cleanup method after each render', () => { + render(); + expect(mockCleanup).toHaveBeenCalledTimes(1); + }); + + it('allows switching to canvas rendering via `measurementRenderAPI`', () => { + render( + + ); + expect(TruncationUtilsWithCanvas).toHaveBeenCalledTimes(1); + expect(mockCleanup).not.toHaveBeenCalled(); + }); }); - // We can't unit test the actual truncation logic in JSDOM, because - // JSDOM doesn't have `offsetWidth`. See the Cypress spec tests instead + // We can't unit test the actual truncation logic in JSDOM - see Cypress spec tests instead }); From 879b38e91c36c75c834b73eb3c41fcf44077e9c4 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 25 Aug 2023 08:54:39 -0700 Subject: [PATCH 38/44] Add CSS comments/dev documentation --- .../text_truncate/text_truncate.styles.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/text_truncate/text_truncate.styles.ts b/src/components/text_truncate/text_truncate.styles.ts index bf792b0b813..c79482aa0f2 100644 --- a/src/components/text_truncate/text_truncate.styles.ts +++ b/src/components/text_truncate/text_truncate.styles.ts @@ -14,10 +14,23 @@ export const euiTextTruncateStyles = { overflow: hidden; white-space: nowrap; `, + /** + * The below CSS is a hack to get double clicking and selecting the *full* text + * instead of the truncated text (useful for copying/pasting, and mimics how + * `text-overflow: ellipsis` works). + * + * Real talk: I'm lowkey amazed it works and it wouldn't surprise me if we ran into + * cross-browser issues with this at some point. Hopefully CSS natively implements + * custom text truncation some day (https://github.com/w3c/csswg-drafts/issues/3937) + * and there'll be no need for the entire component at that point 🙏 + */ + // Makes the truncated text unselectable/un-clickable truncatedText: css` user-select: none; pointer-events: none; `, + // Positions the full text on top of the truncated text (so that clicking targets it) + // and gives it a color opacity of 0 so that it's not actually visible fullText: css` position: absolute; inset: 0; From 30196af4ffa7292604afb1d7d981ed6c540c2e7b Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 25 Aug 2023 08:55:47 -0700 Subject: [PATCH 39/44] Minor test cleanup - remove truncation error testing from the main component - we're already testing that in the utils unit tests - reorganize utils E2E tests slightly for readability - misc wording fixes --- .../text_truncate/text_truncate.spec.tsx | 40 +----- src/components/text_truncate/utils.spec.tsx | 134 ++++++++++-------- 2 files changed, 73 insertions(+), 101 deletions(-) diff --git a/src/components/text_truncate/text_truncate.spec.tsx b/src/components/text_truncate/text_truncate.spec.tsx index 0dbefe2b2cd..2e4c2558f56 100644 --- a/src/components/text_truncate/text_truncate.spec.tsx +++ b/src/components/text_truncate/text_truncate.spec.tsx @@ -31,7 +31,7 @@ describe('EuiTextTruncate', () => { 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap' ); document.head.appendChild(linkElem); - cy.wait(1000); // Wait a tick to give the font time to load/swap in + cy.wait(1000); // Wait a second to give the font time to load/swap in }); const getTruncatedText = (selector = '#text') => @@ -61,7 +61,7 @@ describe('EuiTextTruncate', () => { const expectedStartEndOutput = '…lor sit amet, consectetur a…'; describe('middle', () => { - it('truncations and inserts ellispes in the middle of the text', () => { + it('truncates and inserts ellispes in the middle of the text', () => { cy.mount(); getTruncatedText().should('have.text', expectedMiddleOutput); }); @@ -110,24 +110,6 @@ describe('EuiTextTruncate', () => { ); getTruncatedText().should('have.text', expectedStartOutput); }); - - it('logs an error if the truncationOffset is too large for the container', () => { - cy.window().then((win) => { - cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); - }); - cy.mount( - - ); - cy.get('@spyConsoleError').should( - 'be.calledWith', - 'The passed truncationOffset is too large for the available width. Truncating the offset instead.' - ); - }); }); }); @@ -162,24 +144,6 @@ describe('EuiTextTruncate', () => { ); getTruncatedText().should('have.text', expectedEndOutput); }); - - it('logs an error if the truncationOffset is too large for the container', () => { - cy.window().then((win) => { - cy.wrap(cy.spy(win.console, 'error')).as('spyConsoleError'); - }); - cy.mount( - - ); - cy.get('@spyConsoleError').should( - 'be.calledWith', - 'The passed truncationOffset is too large for the available width. Truncating the offset instead.' - ); - }); }); }); diff --git a/src/components/text_truncate/utils.spec.tsx b/src/components/text_truncate/utils.spec.tsx index 1c7ae254815..913806e11c5 100644 --- a/src/components/text_truncate/utils.spec.tsx +++ b/src/components/text_truncate/utils.spec.tsx @@ -19,12 +19,6 @@ import React, { import { TruncationUtilsWithDOM, TruncationUtilsWithCanvas } from './utils'; -const sharedProps = { - fullText: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', - availableWidth: 200, - ellipsis: '...', -}; - // CI doesn't have access to the Inter font, so we need to manually include it // for font calculations to work correctly const font = '14px Inter'; @@ -36,63 +30,57 @@ before(() => { 'https://fonts.googleapis.com/css2?family=Inter:wght@400&display=swap' ); document.head.appendChild(linkElem); - cy.wait(1000); // Wait a tick to give the font time to load/swap in + cy.wait(1000); // Wait a second to give the font time to load/swap in }); -// Test utility for outputting the returned strings from each truncation utility -// in React. Given the same shared props and fonts, both render methods should -// arrive at the same truncated strings -const TestSetup: FunctionComponent<{ - getUtils: () => TruncationUtilsWithDOM | TruncationUtilsWithCanvas; -}> = ({ getUtils }) => { - const [rendered, setRendered] = useState(null); - - useEffect(() => { - const utils = getUtils(); - setRendered( -
    -
    {utils.truncateStart()}
    -
    {utils.truncateEnd()}
    -
    {utils.truncateMiddle()}
    -
    {utils.truncateStartEndAtMiddle()}
    -
    {utils.truncateStartEndAtPosition(15)}
    -
    - ); - // @ts-ignore - the `?.` handles canvas which doesn't require a cleanup - utils.cleanup?.(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps +describe('Truncation utils', () => { + const sharedProps = { + fullText: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', + availableWidth: 200, + ellipsis: '...', + }; - return <>{rendered}; -}; + // Test utility for outputting the returned strings from each truncation utility + // in React. Given the same shared props and fonts, both render methods should + // arrive at the same truncated strings + const TestSetup: FunctionComponent<{ + getUtils: () => TruncationUtilsWithDOM | TruncationUtilsWithCanvas; + }> = ({ getUtils }) => { + const [rendered, setRendered] = useState(null); -const assertExpectedOutput = () => { - cy.get('#start').should('have.text', '...t, consectetur adipiscing elit'); - cy.get('#end').should('have.text', 'Lorem ipsum dolor sit amet, ...'); - cy.get('#middle').should('have.text', 'Lorem ipsum d...adipiscing elit'); - cy.get('#startEnd').should('have.text', '...lor sit amet, consectetur a...'); - cy.get('#startEndAt').should('have.text', '...rem ipsum dolor sit amet, ...'); -}; + useEffect(() => { + const utils = getUtils(); + setRendered( +
    +
    {utils.truncateStart()}
    +
    {utils.truncateEnd()}
    +
    {utils.truncateMiddle()}
    +
    {utils.truncateStartEndAtMiddle()}
    +
    {utils.truncateStartEndAtPosition(15)}
    +
    + ); + // @ts-ignore - the `?.` handles canvas which doesn't require a cleanup + utils.cleanup?.(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps -describe('TruncationUtilsWithDOM', () => { - const container = document.createElement('div'); - container.style.font = font; - const props = { ...sharedProps, container }; + return <>{rendered}; + }; - it('truncates text as expected', () => { - cy.mount( - { - document.body.appendChild(container); - return new TruncationUtilsWithDOM(props); - }} - /> + const assertExpectedOutput = () => { + cy.get('#start').should('have.text', '...t, consectetur adipiscing elit'); + cy.get('#end').should('have.text', 'Lorem ipsum dolor sit amet, ...'); + cy.get('#middle').should('have.text', 'Lorem ipsum d...adipiscing elit'); + cy.get('#startEnd').should( + 'have.text', + '...lor sit amet, consectetur a...' ); - assertExpectedOutput(); - }); -}); + cy.get('#startEndAt').should( + 'have.text', + '...rem ipsum dolor sit amet, ...' + ); + }; -describe('TruncationUtilsWithCanvas', () => { - describe('container', () => { + describe('TruncationUtilsWithDOM', () => { const container = document.createElement('div'); container.style.font = font; const props = { ...sharedProps, container }; @@ -102,7 +90,7 @@ describe('TruncationUtilsWithCanvas', () => { { document.body.appendChild(container); - return new TruncationUtilsWithCanvas(props); + return new TruncationUtilsWithDOM(props); }} /> ); @@ -110,14 +98,34 @@ describe('TruncationUtilsWithCanvas', () => { }); }); - describe('font', () => { - const props = { ...sharedProps, font }; + describe('TruncationUtilsWithCanvas', () => { + describe('container', () => { + const container = document.createElement('div'); + container.style.font = font; + const props = { ...sharedProps, container }; - it('truncates text as expected', () => { - cy.mount( - new TruncationUtilsWithCanvas(props)} /> - ); - assertExpectedOutput(); + it('truncates text as expected', () => { + cy.mount( + { + document.body.appendChild(container); + return new TruncationUtilsWithCanvas(props); + }} + /> + ); + assertExpectedOutput(); + }); + }); + + describe('font', () => { + const props = { ...sharedProps, font }; + + it('truncates text as expected', () => { + cy.mount( + new TruncationUtilsWithCanvas(props)} /> + ); + assertExpectedOutput(); + }); }); }); }); From 5098c4caf4881804de64660c1f7fbe43863711b6 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 1 Sep 2023 10:34:01 -0700 Subject: [PATCH 40/44] changelog --- upcoming_changelogs/7028.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 upcoming_changelogs/7028.md diff --git a/upcoming_changelogs/7028.md b/upcoming_changelogs/7028.md new file mode 100644 index 00000000000..66b98468613 --- /dev/null +++ b/upcoming_changelogs/7028.md @@ -0,0 +1,6 @@ +- Updated `EuiComboBox` to allow configuring text truncation behavior via `truncationProps`. These props can be set on the entire combobox as well as on on individual dropdown options. + +**Bug fixes** + +- `EuiComboBox` now always shows the highlighted search text, even on truncated text + From 3cb8c0d76b9b0f74f1e963487d6af98c5fa5ea01 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 1 Sep 2023 10:37:58 -0700 Subject: [PATCH 41/44] Make docs example less confusing --- src-docs/src/views/combo_box/truncation.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src-docs/src/views/combo_box/truncation.tsx b/src-docs/src/views/combo_box/truncation.tsx index 976ab622803..2719051ff4d 100644 --- a/src-docs/src/views/combo_box/truncation.tsx +++ b/src-docs/src/views/combo_box/truncation.tsx @@ -17,11 +17,6 @@ const options: EuiComboBoxOptionOption[] = [ { label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, lorem ispum', - // Option `truncationProps` will override EuiComboBox `truncationProps` - truncationProps: { - truncation: 'start', - truncationOffset: 5, - }, }, { label: @@ -40,8 +35,12 @@ const options: EuiComboBoxOptionOption[] = [ label: 'Vestibulum lobortis ipsum sit amet tellus scelerisque vestibulum', }, { - label: - 'Sed commodo sapien ut purus mattis, at condimentum mauris porttitor', + label: 'This combobox option has an individual `truncationProps` override', + // Option `truncationProps` will override EuiComboBox `truncationProps` + truncationProps: { + truncation: 'start', + truncationOffset: 5, + }, }, ]; From 998ecb1e20192e99acd4e2cb1ded0a27a8cc18ee Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 18 Sep 2023 12:27:08 -0700 Subject: [PATCH 42/44] Add tests --- src/components/combo_box/combo_box.spec.tsx | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/components/combo_box/combo_box.spec.tsx b/src/components/combo_box/combo_box.spec.tsx index 3266c16d207..54ce0f22617 100644 --- a/src/components/combo_box/combo_box.spec.tsx +++ b/src/components/combo_box/combo_box.spec.tsx @@ -37,6 +37,97 @@ describe('EuiComboBox', () => { }); }); + describe('truncation', () => { + const sharedProps = { + style: { width: 200 }, + options: [ + { label: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }, + ], + }; + + it('defaults to end truncation', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum dolor sit a…' + ); + }); + + it('allows customizing truncationProps', () => { + cy.realMount( + + ); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum …piscing elit.' + ); + }); + + it('allows individual option truncationProps to override parent truncationProps', () => { + cy.realMount( + + ); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem…tur adipiscing elit.' + ); + }); + + describe('when searching', () => { + it('uses start & end truncation position', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('sit'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + '…sum dolor sit amet, co…' + ); + }); + + it('does not truncate the start when the found search is near the start', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('ipsum'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + 'Lorem ipsum dolor sit a…' + ); + }); + + it('does not truncate the end when the found search is near the end', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('eli'); + cy.get('[data-test-subj="truncatedText"]').should( + 'have.text', + '…nsectetur adipiscing elit.' + ); + }); + + it('marks the full available text if the search input is longer than the truncated text', () => { + cy.realMount(); + cy.get('[data-test-subj="comboBoxInput"]').realClick(); + cy.realType('Lorem ipsum dolor sit amet'); + cy.get('.euiMark').should('have.text', '…rem ipsum dolor sit am…'); + }); + }); + }); + describe('Backspace to delete last pill', () => { const options = [ { label: 'Item 1' }, From d188bb53a0170f82e866e6e2a016486745d0efbf Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 18 Sep 2023 13:09:15 -0700 Subject: [PATCH 43/44] [Cypress] Fix font calculation --- src/components/combo_box/combo_box.spec.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/combo_box/combo_box.spec.tsx b/src/components/combo_box/combo_box.spec.tsx index 54ce0f22617..6fe737d7003 100644 --- a/src/components/combo_box/combo_box.spec.tsx +++ b/src/components/combo_box/combo_box.spec.tsx @@ -13,6 +13,19 @@ import React, { useState } from 'react'; import { EuiComboBox } from './index'; +// CI doesn't have access to the Inter font, so we need to manually include it +// for truncation font calculations to work correctly +before(() => { + const linkElem = document.createElement('link'); + linkElem.setAttribute('rel', 'stylesheet'); + linkElem.setAttribute( + 'href', + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap' + ); + document.head.appendChild(linkElem); + cy.wait(1000); // Wait a second to give the font time to load/swap in +}); + describe('EuiComboBox', () => { describe('Focus management', () => { it('keeps focus on the input box when clicking a disabled item', () => { From b97129752ee35c32b5b93118f5acbc4c7b7aca2c Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 18 Sep 2023 13:19:52 -0700 Subject: [PATCH 44/44] Add `shouldRenderCustomStyles` Jest tests --- src/components/combo_box/combo_box.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index eb2bf1624a2..e2151d9ed57 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -10,6 +10,7 @@ import React, { ReactNode } from 'react'; import { act, fireEvent } from '@testing-library/react'; import { shallow, mount } from 'enzyme'; import { render } from '../../test/rtl'; +import { shouldRenderCustomStyles } from '../../test/internal'; import { requiredProps, findTestSubject, @@ -64,6 +65,17 @@ const options: TitanOption[] = [ ]; describe('EuiComboBox', () => { + shouldRenderCustomStyles(); + + shouldRenderCustomStyles(, { + skip: { parentTest: true }, + childProps: ['truncationProps', 'options[0]'], + renderCallback: async ({ getByTestSubject, findAllByTestSubject }) => { + fireEvent.click(getByTestSubject('comboBoxToggleListButton')); + await findAllByTestSubject('truncatedText'); + }, + }); + test('is rendered', () => { const { container } = render();