diff --git a/src-docs/src/views/selectable/selectable_truncation.tsx b/src-docs/src/views/selectable/selectable_truncation.tsx index 7d51e1bc9562..81863d3f9547 100644 --- a/src-docs/src/views/selectable/selectable_truncation.tsx +++ b/src-docs/src/views/selectable/selectable_truncation.tsx @@ -123,7 +123,10 @@ export default () => { )} - + { const childrenParams = { height: defaultHeight ?? 600, width: defaultWidth ?? 600, }; + + useEffect(() => { + onResize?.(childrenParams); + }, [onResize, defaultHeight, defaultWidth]); // eslint-disable-line react-hooks/exhaustive-deps + return
{children(childrenParams)}
; }; diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx index 778d987a3906..140fa0ce4547 100644 --- a/src/components/selectable/selectable_list/selectable_list.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list.test.tsx @@ -345,22 +345,6 @@ describe('EuiSelectableListItem', () => { expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); }); - it('defaults to CSS truncation if truncationProps is not passed', () => { - const { container } = render( - - ); - - expect( - container.querySelector('.euiTextTruncate') - ).not.toBeInTheDocument(); - expect( - container.querySelector('.euiSelectableListItem__text--truncate') - ).toBeInTheDocument(); - }); - it('allows setting `truncationProps` per-option', () => { const { container } = render( { }); }); + describe('truncation performance optimization', () => { + it('does not render EuiTextTruncate if not virtualized and text is wrapping', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.euiTextTruncate') + ).not.toBeInTheDocument(); + }); + + it('does not render EuiTextTruncate, and defaults to CSS truncation, if no truncationProps have been passed', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.euiTextTruncate') + ).not.toBeInTheDocument(); + expect( + container.querySelector('.euiSelectableListItem__text--truncate') + ).toBeInTheDocument(); + }); + + it('attempts to use a default optimized option width calculated from the wrapping EuiAutoSizer', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiTextTruncate')).toBeInTheDocument(); + expect( + container.querySelector('[data-resize-observer]') + ).not.toBeInTheDocument(); + }); + + it('falls back to individual resize observers if options have append/prepend nodes', () => { + const { container } = render( + + ); + + expect(container.querySelectorAll('.euiTextTruncate')).toHaveLength(3); + expect(container.querySelectorAll('[data-resize-observer]')).toHaveLength( + 2 + ); + }); + + it('falls back to individual resize observers if individual options are truncated', () => { + const { container } = render( + + ); + + expect(container.querySelectorAll('.euiTextTruncate')).toHaveLength(2); + expect(container.querySelectorAll('[data-resize-observer]')).toHaveLength( + 2 + ); + }); + }); + describe('group labels', () => { const optionsWithGroupLabels: EuiSelectableOption[] = [ { label: 'Spaaaace' }, diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index 21d5071f78b0..35e67a418589 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -158,13 +158,37 @@ export type EuiSelectableListProps = EuiSelectableOptionsListProps & { setActiveOptionIndex: (index: number, cb?: () => void) => void; }; -export class EuiSelectableList extends Component> { +type State = { + defaultOptionWidth: number; + optionArray: Array>; + itemData: Record>; + ariaPosInSetMap: Record; + ariaSetSize: number; +}; + +export class EuiSelectableList extends Component< + EuiSelectableListProps, + State +> { static defaultProps = { rowHeight: 32, searchValue: '', isVirtualized: true, }; + constructor(props: EuiSelectableListProps) { + super(props); + + const optionArray = props.visibleOptions || props.options; + + this.state = { + defaultOptionWidth: 0, + optionArray: optionArray, + itemData: { ...optionArray }, + ...this.calculateAriaSetAttrs(optionArray), + }; + } + listRef: FixedSizeList | null = null; listBoxRef: HTMLUListElement | null = null; @@ -219,8 +243,8 @@ export class EuiSelectableList extends Component> { } }; - componentDidUpdate() { - const { activeOptionIndex } = this.props; + componentDidUpdate(prevProps: EuiSelectableListProps) { + const { activeOptionIndex, visibleOptions, options } = this.props; if (this.listBoxRef && this.props.searchable !== true) { this.listBoxRef.setAttribute( @@ -229,30 +253,36 @@ export class EuiSelectableList extends Component> { ); } - if (this.listRef && typeof this.props.activeOptionIndex !== 'undefined') { - this.listRef.scrollToItem(this.props.activeOptionIndex, 'auto'); + if (this.listRef && typeof activeOptionIndex !== 'undefined') { + this.listRef.scrollToItem(activeOptionIndex, 'auto'); } - } - constructor(props: EuiSelectableListProps) { - super(props); + if ( + prevProps.visibleOptions !== visibleOptions || + prevProps.options !== options + ) { + const optionArray = visibleOptions || options; + this.setState({ + optionArray, + itemData: { ...optionArray }, + ...this.calculateAriaSetAttrs(optionArray), + }); + } } - ariaSetSize = 0; - ariaPosInSetMap: Record = {}; - - calculateAriaSetAttrs = (optionArray: Array>) => { - this.ariaPosInSetMap = {}; + // This utility is necessary to exclude group labels from the aria set count + calculateAriaSetAttrs = (optionArray: State['optionArray']) => { + const ariaPosInSetMap: State['ariaPosInSetMap'] = {}; let latestAriaPosIndex = 0; optionArray.forEach((option, index) => { if (!option.isGroupLabel) { latestAriaPosIndex++; - this.ariaPosInSetMap[index] = latestAriaPosIndex; + ariaPosInSetMap[index] = latestAriaPosIndex; } }); - this.ariaSetSize = latestAriaPosIndex; + return { ariaPosInSetMap, ariaSetSize: latestAriaPosIndex }; }; ListRow = memo(({ data, index, style }: ListChildComponentProps) => { @@ -304,6 +334,7 @@ export class EuiSelectableList extends Component> { } const id = makeOptionId(index); + const isFocused = activeOptionIndex === index; // Text wrapping const canWrap = !isVirtualized; @@ -312,7 +343,9 @@ export class EuiSelectableList extends Component> { // Truncation config (if any). If none, CSS truncation is used const truncationProps = - textWrap === 'truncate' ? this.getTruncationProps(option) : undefined; + textWrap === 'truncate' + ? this.getTruncationProps(option, isFocused) + : undefined; return ( extends Component> { this.onAddOrRemoveOption(option, event); }} ref={ref ? ref.bind(null, index) : undefined} - isFocused={activeOptionIndex === index} + isFocused={isFocused} title={searchableLabel || label} checked={checked} disabled={disabled} prepend={prepend} append={append} - aria-posinset={this.ariaPosInSetMap[index]} - aria-setsize={this.ariaSetSize} + aria-posinset={this.state.ariaPosInSetMap[index]} + aria-setsize={this.state.ariaSetSize} onFocusBadge={onFocusBadge} allowExclusions={allowExclusions} showIcons={showIcons} @@ -358,13 +391,12 @@ export class EuiSelectableList extends Component> { ); }, areEqual); - renderVirtualizedList = ( - heightIsFull: boolean, - optionArray: EuiSelectableOption[] - ) => { + renderVirtualizedList = () => { if (!this.props.isVirtualized) return null; + const { optionArray, itemData } = this.state; const { windowProps, height: forcedHeight, rowHeight } = this.props; + const heightIsFull = forcedHeight === 'full'; const virtualizationProps = { className: 'euiSelectableList__list', @@ -373,7 +405,7 @@ export class EuiSelectableList extends Component> { innerRef: this.setListBoxRef, innerElementType: 'ul', itemCount: optionArray.length, - itemData: optionArray, + itemData: itemData, itemSize: rowHeight, 'data-skip-axe': 'scrollable-region-focusable', ...windowProps, @@ -397,7 +429,7 @@ export class EuiSelectableList extends Component> { } return heightIsFull ? ( - + {({ width, height }: EuiAutoSize) => ( {this.ListRow} @@ -405,7 +437,10 @@ export class EuiSelectableList extends Component> { )} ) : ( - + {({ width }: EuiAutoSizeHorizontal) => ( extends Component> { ); }; - getTruncationProps = (option: EuiSelectableOption) => { + forceVirtualizedListRowRerender = () => { + this.setState({ itemData: { ...this.state.optionArray } }); + }; + + // EuiTextTruncate is expensive perf-wise - we use several utilities here to + // offset its performance cost + + // and creates a resize observer for + // each individual item. This logic tries to offset this performance hit by + // guesstimating a default width for each option + focusBadgeOffset = 0; + + calculateDefaultOptionWidth = ({ + width: containerWidth, + }: EuiAutoSizeHorizontal) => { + const { truncationProps, searchable, searchValue } = this.props; + + // If it's not likely we'll need to use EuiTextTruncate, don't set state/rerender on every panel resize + const mayTruncate = searchable || truncationProps; + if (!mayTruncate) return; + + const paddingOffset = this.props.paddingSize === 'none' ? 0 : 24; // Defaults to 's' + const checkedIconOffset = this.props.showIcons === false ? 0 : 28; // Defaults to true + this.focusBadgeOffset = this.props.onFocusBadge === false ? 0 : 46; + + this.setState({ + defaultOptionWidth: containerWidth - paddingOffset - checkedIconOffset, + }); + + // Potentially force list rows to rerender on dynamic resize as well, + // but try to do it as lightly as possible + if (truncationProps) { + this.forceVirtualizedListRowRerender(); + } else if (searchable && searchValue) { + this.forceVirtualizedListRowRerender(); + } + }; + + getTruncationProps = (option: EuiSelectableOption, isFocused: boolean) => { // Individual truncation settings should override component-wide settings const truncationProps = { ...this.props.truncationProps, @@ -431,10 +504,17 @@ export class EuiSelectableList extends Component> { this.props.searchValue || Object.keys(truncationProps).length > 0; if (!hasComplexTruncation) return undefined; - // TODO: Performantly calculate a default option width, so that - // each list item doesn't have to generate its own resize observer - - return truncationProps; + // Determine whether we can use the optimized default option width + const { defaultOptionWidth } = this.state; + const useDefaultWidth = !option.append && !option.prepend; + const defaultWidth = + useDefaultWidth && defaultOptionWidth + ? isFocused + ? defaultOptionWidth - this.focusBadgeOffset + : defaultOptionWidth + : undefined; + + return { width: defaultWidth, ...truncationProps }; }; renderSearchedText = ( @@ -523,15 +603,10 @@ export class EuiSelectableList extends Component> { ...rest } = this.props; - const optionArray = visibleOptions || options; - this.calculateAriaSetAttrs(optionArray); - - const heightIsFull = forcedHeight === 'full'; - const classes = classNames( 'euiSelectableList', { - 'euiSelectableList-fullHeight': heightIsFull, + 'euiSelectableList-fullHeight': forcedHeight === 'full', 'euiSelectableList-bordered': bordered, }, className @@ -540,19 +615,19 @@ export class EuiSelectableList extends Component> { return (
{isVirtualized ? ( - this.renderVirtualizedList(heightIsFull, optionArray) + this.renderVirtualizedList() ) : (
    - {optionArray.map((_, index) => + {this.state.optionArray.map((_, index) => React.createElement( this.ListRow, { key: index, - data: optionArray, + data: this.state.optionArray, index, }, null