`;
-exports[`EuiSelectableListItem props singleSelection can be forced so that at least one must be selected 1`] = `
+exports[`EuiSelectableListItem props showIcons can be turned off 1`] = `
`;
-exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = `
+exports[`EuiSelectableListItem props singleSelection can be forced so that at least one must be selected 1`] = `
`;
-exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = `
+exports[`EuiSelectableListItem props singleSelection can be turned on 1`] = `
-
- Titan
-
+ Titan
@@ -3718,11 +2960,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = `
data-euiicon-type="empty"
/>
-
- Enceladus
-
+ Enceladus
@@ -3745,11 +2985,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = `
data-euiicon-type="empty"
/>
-
- Mimas
-
+ Mimas
@@ -3772,11 +3010,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = `
data-euiicon-type="empty"
/>
-
- Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
-
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
@@ -3799,11 +3035,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = `
data-euiicon-type="empty"
/>
-
- Tethys
-
+ Tethys
@@ -3826,11 +3060,9 @@ exports[`EuiSelectableListItem props textWrap can be "wrap" 1`] = `
data-euiicon-type="empty"
/>
-
- Hyperion
-
+ Hyperion
@@ -3882,9 +3114,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = `
-
- Mimas
-
+ Mimas
@@ -3909,9 +3139,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = `
-
- Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
-
+ Pandora is one of Saturn's moons, named for a Titaness of Greek mythology
@@ -3936,9 +3164,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = `
-
- Tethys
-
+ Tethys
@@ -3963,9 +3189,7 @@ exports[`EuiSelectableListItem props visibleOptions 1`] = `
-
- Hyperion
-
+ Hyperion
diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx
index 6d671212cba..140fa0ce454 100644
--- a/src/components/selectable/selectable_list/selectable_list.test.tsx
+++ b/src/components/selectable/selectable_list/selectable_list.test.tsx
@@ -67,28 +67,38 @@ describe('EuiSelectableListItem', () => {
expect(container.firstChild).toMatchSnapshot();
});
- test('searchValue', () => {
- const { container } = render(
-
- );
+ describe('searchValue', () => {
+ it('renders just a EuiHighlight component when wrapping text', () => {
+ const { container } = render(
+
+ );
- expect(container.firstChild).toMatchSnapshot();
- });
+ expect(container.querySelector('.euiMark')).toHaveTextContent('Mi');
+ expect(
+ container.querySelector('.euiTextTruncate')
+ ).not.toBeInTheDocument();
+ });
- test('searchValue', () => {
- const { container } = render(
-
- );
+ it('renders an EuiTextTruncate component when truncating text', () => {
+ const { container, getByTestSubject } = render(
+
+ );
- expect(container.firstChild).toMatchSnapshot();
+ expect(getByTestSubject('titanOption')).toContainElement(
+ container.querySelector('.euiTextTruncate')
+ );
+ });
});
test('renderOption', () => {
@@ -258,20 +268,187 @@ describe('EuiSelectableListItem', () => {
});
describe('textWrap', () => {
- test('can be "wrap"', () => {
+ test('wrap', () => {
+ const { container } = render(
+
+ );
+
+ expect(
+ container.querySelector('.euiSelectableListItem__text--truncate')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not allow wrapping text if virtualization is on', () => {
const { container } = render(
+ );
+
+ expect(
+ container.querySelector('.euiSelectableListItem__text--truncate')
+ ).toBeInTheDocument();
+ });
+
+ it('allows setting `textWrap` per-option', () => {
+ const { container } = render(
+
+ );
+
+ expect(
+ container.querySelectorAll('.euiSelectableListItem__text--truncate')
+ ).toHaveLength(1);
+ });
+
+ test('truncate', () => {
+ const { container } = render(
+
+ );
+
+ expect(
+ container.querySelector('.euiSelectableListItem__text--truncate')
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('truncationProps', () => {
+ it('renders EuiTextTruncate', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument();
+ });
+
+ it('allows setting `truncationProps` per-option', () => {
+ const { container } = render(
+
);
- expect(container.firstChild).toMatchSnapshot();
+ expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument();
});
});
});
+ describe('truncation performance optimization', () => {
+ it('does not render EuiTextTruncate if not virtualized and text is wrapping', () => {
+ const { container } = render(
+
+ );
+
+ expect(
+ container.querySelector('.euiTextTruncate')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not render EuiTextTruncate, and defaults to CSS truncation, if no truncationProps have been passed', () => {
+ const { container } = render(
+
+ );
+
+ expect(
+ container.querySelector('.euiTextTruncate')
+ ).not.toBeInTheDocument();
+ expect(
+ container.querySelector('.euiSelectableListItem__text--truncate')
+ ).toBeInTheDocument();
+ });
+
+ it('attempts to use a default optimized option width calculated from the wrapping EuiAutoSizer', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument();
+ expect(
+ container.querySelector('[data-resize-observer]')
+ ).not.toBeInTheDocument();
+ });
+
+ it('falls back to individual resize observers if options have append/prepend nodes', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelectorAll('.euiTextTruncate')).toHaveLength(3);
+ expect(container.querySelectorAll('[data-resize-observer]')).toHaveLength(
+ 2
+ );
+ });
+
+ it('falls back to individual resize observers if individual options are truncated', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelectorAll('.euiTextTruncate')).toHaveLength(2);
+ expect(container.querySelectorAll('[data-resize-observer]')).toHaveLength(
+ 2
+ );
+ });
+ });
+
describe('group labels', () => {
const optionsWithGroupLabels: EuiSelectableOption[] = [
{ label: 'Spaaaace' },
diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx
index 1f29762d580..7e907e050e9 100644
--- a/src/components/selectable/selectable_list/selectable_list.tsx
+++ b/src/components/selectable/selectable_list/selectable_list.tsx
@@ -20,6 +20,7 @@ import {
ListChildComponentProps as ReactWindowListChildComponentProps,
areEqual,
} from 'react-window';
+
import { CommonProps, ExclusiveUnion } from '../../common';
import {
EuiAutoSizer,
@@ -27,6 +28,9 @@ import {
EuiAutoSizeHorizontal,
} from '../../auto_sizer';
import { EuiHighlight } from '../../highlight';
+import { EuiMark } from '../../mark';
+import { EuiTextTruncate } from '../../text_truncate';
+
import { EuiSelectableOption } from '../selectable_option';
import { EuiSelectableOnChangeEvent } from '../selectable';
import {
@@ -96,6 +100,15 @@ export type EuiSelectableOptionsListProps = CommonProps &
* Wrapping only works if virtualization is off.
*/
textWrap?: EuiSelectableListItemProps['textWrap'];
+ /**
+ * If textWrap is set to `truncate`, you can pass a custom truncation configuration
+ * that accepts any [EuiTextTruncate](/#/utilities/text-truncation) prop except for
+ * `text` and `children`.
+ *
+ * Note: when searching, custom truncation props are ignored. The highlighted search
+ * text will always take precedence.
+ */
+ truncationProps?: EuiSelectableOption['truncationProps'];
} & EuiSelectableOptionsListVirtualizedProps;
export type EuiSelectableListProps
= EuiSelectableOptionsListProps & {
@@ -145,13 +158,37 @@ export type EuiSelectableListProps = EuiSelectableOptionsListProps & {
setActiveOptionIndex: (index: number, cb?: () => void) => void;
};
-export class EuiSelectableList extends Component> {
+type State = {
+ defaultOptionWidth: number;
+ optionArray: Array>;
+ itemData: Record>;
+ ariaPosInSetMap: Record;
+ ariaSetSize: number;
+};
+
+export class EuiSelectableList extends Component<
+ EuiSelectableListProps,
+ State
+> {
static defaultProps = {
rowHeight: 32,
searchValue: '',
isVirtualized: true,
};
+ constructor(props: EuiSelectableListProps) {
+ super(props);
+
+ const optionArray = props.visibleOptions || props.options;
+
+ this.state = {
+ defaultOptionWidth: 0,
+ optionArray: optionArray,
+ itemData: { ...optionArray },
+ ...this.calculateAriaSetAttrs(optionArray),
+ };
+ }
+
listRef: FixedSizeList | null = null;
listBoxRef: HTMLUListElement | null = null;
@@ -206,8 +243,8 @@ export class EuiSelectableList extends Component> {
}
};
- componentDidUpdate() {
- const { activeOptionIndex } = this.props;
+ componentDidUpdate(prevProps: EuiSelectableListProps) {
+ const { activeOptionIndex, visibleOptions, options } = this.props;
if (this.listBoxRef && this.props.searchable !== true) {
this.listBoxRef.setAttribute(
@@ -216,30 +253,36 @@ export class EuiSelectableList extends Component> {
);
}
- if (this.listRef && typeof this.props.activeOptionIndex !== 'undefined') {
- this.listRef.scrollToItem(this.props.activeOptionIndex, 'auto');
+ if (this.listRef && typeof activeOptionIndex !== 'undefined') {
+ this.listRef.scrollToItem(activeOptionIndex, 'auto');
}
- }
- constructor(props: EuiSelectableListProps) {
- super(props);
+ if (
+ prevProps.visibleOptions !== visibleOptions ||
+ prevProps.options !== options
+ ) {
+ const optionArray = visibleOptions || options;
+ this.setState({
+ optionArray,
+ itemData: { ...optionArray },
+ ...this.calculateAriaSetAttrs(optionArray),
+ });
+ }
}
- ariaSetSize = 0;
- ariaPosInSetMap: Record = {};
-
- calculateAriaSetAttrs = (optionArray: Array>) => {
- this.ariaPosInSetMap = {};
+ // This utility is necessary to exclude group labels from the aria set count
+ calculateAriaSetAttrs = (optionArray: State['optionArray']) => {
+ const ariaPosInSetMap: State['ariaPosInSetMap'] = {};
let latestAriaPosIndex = 0;
optionArray.forEach((option, index) => {
if (!option.isGroupLabel) {
latestAriaPosIndex++;
- this.ariaPosInSetMap[index] = latestAriaPosIndex;
+ ariaPosInSetMap[index] = latestAriaPosIndex;
}
});
- this.ariaSetSize = latestAriaPosIndex;
+ return { ariaPosInSetMap, ariaSetSize: latestAriaPosIndex };
};
ListRow = memo(({ data, index, style }: ListChildComponentProps) => {
@@ -256,6 +299,7 @@ export class EuiSelectableList extends Component> {
key,
searchableLabel,
data: _data,
+ truncationProps: _truncationProps,
...optionRest
} = option;
@@ -264,13 +308,13 @@ export class EuiSelectableList extends Component> {
allowExclusions,
onFocusBadge,
paddingSize,
- searchValue,
showIcons,
makeOptionId,
renderOption,
setActiveOptionIndex,
searchable,
- textWrap,
+ searchValue,
+ isVirtualized,
} = this.props;
if (isGroupLabel) {
@@ -290,6 +334,18 @@ export class EuiSelectableList extends Component> {
}
const id = makeOptionId(index);
+ const isFocused = activeOptionIndex === index;
+
+ // Text wrapping
+ const canWrap = !isVirtualized;
+ const _textWrap = option.textWrap ?? this.props.textWrap;
+ const textWrap = canWrap ? _textWrap : 'truncate';
+
+ // Truncation config (if any). If none, CSS truncation is used
+ const truncationProps =
+ textWrap === 'truncate'
+ ? this.getTruncationProps(option, isFocused)
+ : undefined;
return (
extends Component> {
this.onAddOrRemoveOption(option, event);
}}
ref={ref ? ref.bind(null, index) : undefined}
- isFocused={activeOptionIndex === index}
+ isFocused={isFocused}
title={searchableLabel || label}
checked={checked}
disabled={disabled}
prepend={prepend}
append={append}
- aria-posinset={this.ariaPosInSetMap[index]}
- aria-setsize={this.ariaSetSize}
+ aria-posinset={this.state.ariaPosInSetMap[index]}
+ aria-setsize={this.state.ariaSetSize}
onFocusBadge={onFocusBadge}
allowExclusions={allowExclusions}
showIcons={showIcons}
@@ -320,26 +376,27 @@ export class EuiSelectableList extends Component> {
textWrap={textWrap}
{...(optionRest as EuiSelectableListItemProps)}
>
- {renderOption ? (
- renderOption(
- // @ts-ignore complex
- { ..._option, ...optionData },
- this.props.searchValue
- )
- ) : (
- {label}
- )}
+ {renderOption
+ ? renderOption(
+ // @ts-ignore complex
+ { ..._option, ...optionData },
+ searchValue
+ )
+ : searchValue
+ ? this.renderSearchedText(label, truncationProps)
+ : truncationProps
+ ? this.renderTruncatedText(label, truncationProps)
+ : label}
);
}, areEqual);
- renderVirtualizedList = (
- heightIsFull: boolean,
- optionArray: EuiSelectableOption[]
- ) => {
+ renderVirtualizedList = () => {
if (!this.props.isVirtualized) return null;
+ const { optionArray, itemData } = this.state;
const { windowProps, height: forcedHeight, rowHeight } = this.props;
+ const heightIsFull = forcedHeight === 'full';
const virtualizationProps = {
className: 'euiSelectableList__list',
@@ -348,7 +405,7 @@ export class EuiSelectableList extends Component> {
innerRef: this.setListBoxRef,
innerElementType: 'ul',
itemCount: optionArray.length,
- itemData: optionArray,
+ itemData: itemData,
itemSize: rowHeight,
'data-skip-axe': 'scrollable-region-focusable',
...windowProps,
@@ -372,7 +429,7 @@ export class EuiSelectableList extends Component> {
}
return heightIsFull ? (
-
+
{({ width, height }: EuiAutoSize) => (
{this.ListRow}
@@ -380,7 +437,10 @@ export class EuiSelectableList extends Component> {
)}
) : (
-
+
{({ width }: EuiAutoSizeHorizontal) => (
extends Component> {
);
};
+ forceVirtualizedListRowRerender = () => {
+ this.setState({ itemData: { ...this.state.optionArray } });
+ };
+
+ // EuiTextTruncate is expensive perf-wise - we use several utilities here to
+ // offset its performance cost
+
+ // and creates a resize observer for
+ // each individual item. This logic tries to offset this performance hit by
+ // guesstimating a default width for each option
+ focusBadgeOffset = 0;
+
+ calculateDefaultOptionWidth = ({
+ width: containerWidth,
+ }: EuiAutoSizeHorizontal) => {
+ const { truncationProps, searchable, searchValue } = this.props;
+
+ // If it's not likely we'll need to use EuiTextTruncate, don't set state/rerender on every panel resize
+ const mayTruncate = searchable || truncationProps;
+ if (!mayTruncate) return;
+
+ const paddingOffset = this.props.paddingSize === 'none' ? 0 : 24; // Defaults to 's'
+ const checkedIconOffset = this.props.showIcons === false ? 0 : 28; // Defaults to true
+ this.focusBadgeOffset = this.props.onFocusBadge === false ? 0 : 46;
+
+ this.setState({
+ defaultOptionWidth: containerWidth - paddingOffset - checkedIconOffset,
+ });
+
+ // Potentially force list rows to rerender on dynamic resize as well,
+ // but try to do it as lightly as possible
+ if (truncationProps || (searchable && searchValue)) {
+ this.forceVirtualizedListRowRerender();
+ }
+ };
+
+ getTruncationProps = (option: EuiSelectableOption, isFocused: boolean) => {
+ // Individual truncation settings should override component-wide settings
+ const truncationProps = {
+ ...this.props.truncationProps,
+ ...option.truncationProps,
+ };
+
+ // If we're not actually using EuiTextTruncate, no need to continue
+ const hasComplexTruncation =
+ this.props.searchValue || Object.keys(truncationProps).length > 0;
+ if (!hasComplexTruncation) return undefined;
+
+ // Determine whether we can use the optimized default option width
+ const { defaultOptionWidth } = this.state;
+ const useDefaultWidth = !option.append && !option.prepend;
+ const defaultWidth =
+ useDefaultWidth && defaultOptionWidth
+ ? isFocused
+ ? defaultOptionWidth - this.focusBadgeOffset
+ : defaultOptionWidth
+ : undefined;
+
+ return { width: defaultWidth, ...truncationProps };
+ };
+
+ renderSearchedText = (
+ text: string,
+ truncationProps?: EuiSelectableOptionsListProps['truncationProps']
+ ) => {
+ const { searchValue } = this.props;
+
+ // If truncationProps is undefined, we're using non-virtualized text wrapping
+ if (!truncationProps) {
+ return {text};
+ }
+
+ const searchPositionStart = text
+ .toLowerCase()
+ .indexOf(searchValue.toLowerCase());
+ const searchPositionCenter =
+ searchPositionStart + Math.floor(searchValue.length / 2);
+
+ return (
+
+ {(text) => (
+ <>
+ {text.length >= searchValue.length ? (
+ {text}
+ ) : (
+ // If the available truncated text is shorter than the full search string,
+ // just highlight the entire truncated text
+ {text}
+ )}
+ >
+ )}
+
+ );
+ };
+
+ renderTruncatedText = (
+ text: string,
+ truncationProps?: EuiSelectableOptionsListProps['truncationProps']
+ ) => {
+ return (
+ // For some bizarre reason, truncation in EuiSelectable is off on initial mount
+ // (but not on rerender) for Safari and _some_ truncation types in Firefox :|
+ // Waiting a tick before calculating truncation seems to smooth over the issue
+
+ {(text) => text}
+
+ );
+ };
+
render() {
const {
className,
@@ -422,18 +597,14 @@ export class EuiSelectableList extends Component> {
role,
isVirtualized,
textWrap,
+ truncationProps,
...rest
} = this.props;
- const optionArray = visibleOptions || options;
- this.calculateAriaSetAttrs(optionArray);
-
- const heightIsFull = forcedHeight === 'full';
-
const classes = classNames(
'euiSelectableList',
{
- 'euiSelectableList-fullHeight': heightIsFull,
+ 'euiSelectableList-fullHeight': forcedHeight === 'full',
'euiSelectableList-bordered': bordered,
},
className
@@ -442,19 +613,19 @@ export class EuiSelectableList extends Component> {
return (
{isVirtualized ? (
- this.renderVirtualizedList(heightIsFull, optionArray)
+ this.renderVirtualizedList()
) : (
- {optionArray.map((_, index) =>
+ {this.state.optionArray.map((_, index) =>
React.createElement(
this.ListRow,
{
key: index,
- data: optionArray,
+ data: this.state.optionArray,
index,
},
null
diff --git a/src/components/selectable/selectable_list/selectable_list_item.tsx b/src/components/selectable/selectable_list/selectable_list_item.tsx
index ef97942ca42..a603c32bc75 100644
--- a/src/components/selectable/selectable_list/selectable_list_item.tsx
+++ b/src/components/selectable/selectable_list/selectable_list_item.tsx
@@ -8,13 +8,18 @@
import classNames from 'classnames';
import React, { Component, LiHTMLAttributes } from 'react';
+
import { CommonProps, keysOf } from '../../common';
import { EuiI18n } from '../../i18n';
import { EuiIcon, IconColor, IconType } from '../../icon';
-import { EuiSelectableOptionCheckedType } from '../selectable_option';
import { EuiScreenReaderOnly } from '../../accessibility';
import { EuiBadge, EuiBadgeProps } from '../../badge';
+import type {
+ EuiSelectableOption,
+ EuiSelectableOptionCheckedType,
+} from '../selectable_option';
+
function resolveIconAndColor(checked: EuiSelectableOptionCheckedType): {
icon: IconType;
color?: IconColor;
@@ -84,7 +89,7 @@ export type EuiSelectableListItemProps = LiHTMLAttributes &
* How to handle long text within the item.
* Wrapping only works if virtualization is off.
*/
- textWrap?: 'truncate' | 'wrap';
+ textWrap?: EuiSelectableOption['textWrap'];
};
export class EuiSelectableListItem extends Component {
diff --git a/src/components/selectable/selectable_option.tsx b/src/components/selectable/selectable_option.tsx
index e0cf9cdb265..1e1ff0e2093 100644
--- a/src/components/selectable/selectable_option.tsx
+++ b/src/components/selectable/selectable_option.tsx
@@ -8,6 +8,7 @@
import React, { HTMLAttributes } from 'react';
import { CommonProps, ExclusiveUnion } from '../common';
+import type { EuiTextTruncateProps } from '../text_truncate';
export type EuiSelectableOptionCheckedType = 'on' | 'off' | 'mixed' | undefined;
@@ -58,6 +59,21 @@ export type EuiSelectableOptionBase = CommonProps & {
* Bypass `EuiSelectableItem` and avoid DOM attribute warnings.
*/
data?: { [key: string]: any };
+ /**
+ * How to handle long text within the item.
+ * Wrapping only works if `isVirtualization` is false.
+ * @default 'truncate'
+ */
+ textWrap?: 'truncate' | 'wrap';
+ /**
+ * If textWrap is set to `truncate`, you can pass a custom truncation configuration
+ * that accepts any [EuiTextTruncate](/#/utilities/text-truncation) prop except for
+ * `text` and `children`.
+ *
+ * Note: when searching, custom truncation props are ignored. The highlighted search
+ * text will always take precedence.
+ */
+ truncationProps?: Partial>;
};
type _EuiSelectableGroupLabelOption = Omit<
diff --git a/src/components/text_truncate/text_truncate.test.tsx b/src/components/text_truncate/text_truncate.test.tsx
index ef90d44d885..640ab4afdbe 100644
--- a/src/components/text_truncate/text_truncate.test.tsx
+++ b/src/components/text_truncate/text_truncate.test.tsx
@@ -18,6 +18,7 @@ jest.mock('./utils', () => ({
}));
import { EuiTextTruncate } from './text_truncate';
+import { act } from '@testing-library/react';
describe('EuiTextTruncate', () => {
beforeEach(() => jest.clearAllMocks());
@@ -37,19 +38,40 @@ describe('EuiTextTruncate', () => {
expect(container.firstChild).toMatchSnapshot();
});
+ it('allows delaying truncation calculation by `calculationDelayMs`', () => {
+ jest.useFakeTimers();
+
+ const { queryByTestSubject } = render(
+
+ );
+ expect(queryByTestSubject('truncatedText')).not.toBeInTheDocument();
+
+ act(() => jest.advanceTimersByTime(50));
+ expect(queryByTestSubject('truncatedText')).toBeInTheDocument();
+
+ jest.useRealTimers();
+ });
+
describe('resize observer', () => {
it('does not render a resize observer if a width is passed', () => {
const onResize = jest.fn();
- render();
+ const { container } = render(
+
+ );
expect(onResize).not.toHaveBeenCalled();
+ expect(container.firstChild).not.toHaveAttribute('data-resize-observer');
});
it('renders a resize observer when no width is passed', () => {
const onResize = jest.fn();
- render(
+ const { container } = render(
);
expect(onResize).toHaveBeenCalledWith(0);
+ expect(container.firstChild).toHaveAttribute(
+ 'data-resize-observer',
+ 'true'
+ );
});
});
diff --git a/src/components/text_truncate/text_truncate.tsx b/src/components/text_truncate/text_truncate.tsx
index 4054b1dac1b..b2b38355e40 100644
--- a/src/components/text_truncate/text_truncate.tsx
+++ b/src/components/text_truncate/text_truncate.tsx
@@ -14,6 +14,7 @@ import React, {
useState,
useMemo,
useCallback,
+ useEffect,
} from 'react';
import classNames from 'classnames';
@@ -92,6 +93,12 @@ export type EuiTextTruncateProps = Omit<
* or highlighting
*/
children?: (truncatedString: string) => ReactNode;
+ /**
+ * For some edge case scenarios, EuiTextTruncate's calculations may be off until
+ * fonts are done loading or layout is done shifting or settling. Adding a delay
+ * may help resolve any rendering issues.
+ */
+ calculationDelayMs?: number;
};
export const EuiTextTruncate: FunctionComponent = ({
@@ -119,6 +126,7 @@ const EuiTextTruncateWithWidth: FunctionComponent<
truncationOffset: _truncationOffset = 0,
truncationPosition,
ellipsis = '…',
+ calculationDelayMs,
containerRef,
className,
...rest
@@ -127,6 +135,14 @@ const EuiTextTruncateWithWidth: FunctionComponent<
const [containerEl, setContainerEl] = useState(null);
const refs = useCombinedRefs([setContainerEl, containerRef]);
+ // If necessary, wait a tick on mount before truncating
+ const [ready, setReady] = useState(!calculationDelayMs);
+ useEffect(() => {
+ if (calculationDelayMs) {
+ setTimeout(() => setReady(true), calculationDelayMs);
+ }
+ }, [calculationDelayMs]);
+
// Handle exceptions where we need to override the passed props
const { truncation, truncationOffset } = useMemo(() => {
let truncation = _truncation;
@@ -148,7 +164,8 @@ const EuiTextTruncateWithWidth: FunctionComponent<
const truncatedText = useMemo(() => {
let truncatedText = '';
- if (!containerEl || !width) return truncatedText;
+ if (!ready || !containerEl) return text;
+ if (!width) return truncatedText;
const utils = new TruncationUtils({
fullText: text,
@@ -184,6 +201,7 @@ const EuiTextTruncateWithWidth: FunctionComponent<
}
return truncatedText;
}, [
+ ready,
width,
text,
truncation,
@@ -245,7 +263,12 @@ const EuiTextTruncateWithResizeObserver: FunctionComponent<
return (
{(ref) => (
-
+
)}
);