diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d55dd0fe8..5b089c8b4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [`main`](https://github.com/elastic/eui/tree/main) +- Added virtulized rendering option to `EuiSelectableList` with `isVirtualized` ([#5521](https://github.com/elastic/eui/pull/5521)) +- Added expanded option properties to `EuiSelectableOption` with `data` ([#5521](https://github.com/elastic/eui/pull/5521)) + **Breaking changes** - Changed `EuiSearchBar` to preserve phrases with leading and trailing spaces, instead of dropping surrounding whitespace ([#5514](https://github.com/elastic/eui/pull/5514)) diff --git a/src-docs/src/views/selectable/selectable_custom_render.js b/src-docs/src/views/selectable/selectable_custom_render.js index 75e4f2050e8..ef4c3dcea12 100644 --- a/src-docs/src/views/selectable/selectable_custom_render.js +++ b/src-docs/src/views/selectable/selectable_custom_render.js @@ -12,6 +12,7 @@ import { createDataStore } from '../tables/data_store'; export default () => { const [useCustomContent, setUseCustomContent] = useState(false); + const [isVirtualized, setIsVirtualized] = useState(true); const countries = createDataStore().countries.map((country) => { return { @@ -20,6 +21,9 @@ export default () => { prepend: country.flag, append: {country.code}, showIcons: false, + data: { + secondaryContent: 'I am secondary content, I am!', + }, }; }); @@ -38,15 +42,19 @@ export default () => { setUseCustomContent(e.currentTarget.checked); }; + const onVirtualized = (e) => { + setIsVirtualized(e.currentTarget.checked); + }; + const renderCountryOption = (option, searchValue) => { return ( <> {option.label} -
- + {/*
*/} + - I am secondary content, I am! + {option.secondaryContent} @@ -54,33 +62,42 @@ export default () => { ); }; + let listProps = { + isVirtualized, + }; + let customProps; if (useCustomContent) { customProps = { height: 240, renderOption: renderCountryOption, - listProps: { - rowHeight: 50, - showIcons: false, - }, + }; + listProps = { + rowHeight: 50, + isVirtualized, }; } return ( <> + {' '} +   - - {(list, search) => ( diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js index 9f36c676b1f..cfc07fc6491 100644 --- a/src-docs/src/views/selectable/selectable_example.js +++ b/src-docs/src/views/selectable/selectable_example.js @@ -347,6 +347,20 @@ export const SelectableExample = { similar to a title. Add one of these by setting the{' '} option.isGroupLabel to true.{' '}

+

Row height and virtualization

+

+ When virtualization is on,{' '} + every row must be the same height in order for the + list to know how to scroll to the selected or highlighted option. It + applies the listProps.rowHeight (in pixels) + directly to each option hiding any overflow. +

+

+ If listProps.isVirtualized is set to{' '} + false, each row will fit its contents and removes + all scrolling. Therefore, we recommend having a large enough + container to accomodate all optons. +

Custom content

While it is best to stick to the{' '} @@ -357,15 +371,17 @@ export const SelectableExample = { searchValue to use for highlighting.

- In order for the list to know how to scroll to the selected or - highlighted option, it must also know the height of the rows. It - applies this pixel height directly to options. If your custom - content is taller than the default of 32px tall, - you will need to recalculate this height and apply it via{' '} - listProps.rowHeight. + To provide data that can be used by the{' '} + renderOption function that does not match the + standard option API, use option.data which will + make custom data available in the option{' '} + parameter. See the secondaryContent configuration + in the following example.

- Every row must be the same height. + Also, if your custom content is taller than the default{' '} + listProps.rowHeight of 32px{' '} + tall, you will need to pass in a custom value to this prop.

), diff --git a/src/components/selectable/__snapshots__/selectable.test.tsx.snap b/src/components/selectable/__snapshots__/selectable.test.tsx.snap index 35ce39017b3..dfb568309cf 100644 --- a/src/components/selectable/__snapshots__/selectable.test.tsx.snap +++ b/src/components/selectable/__snapshots__/selectable.test.tsx.snap @@ -1,5 +1,107 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`EuiSelectable custom options with data 1`] = ` +
+
+
+
+
    +
  • + + + + + VI: Titan + + + +
  • +
  • + + + + + II: Enceladus + + + +
  • +
  • + + + + + XVII: Pandora is one of Saturn's moons, named for a Titaness of Greek mythology + + + +
  • +
+
+
+
+
+`; + exports[`EuiSelectable is rendered 1`] = `
{ (component.find('EuiSelectableList').props() as any).visibleOptions ).toEqual(options); }); + + test('with data', () => { + type WithData = { + numeral?: string; + }; + const options = [ + { + label: 'Titan', + data: { + numeral: 'VI', + }, + }, + { + label: 'Enceladus', + data: { + numeral: 'II', + }, + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + data: { + numeral: 'XVII', + }, + }, + ]; + const component = render( + + options={options} + renderOption={(option) => { + return ( + + {option.numeral}: {option.label} + + ); + }} + > + {(list) => list} + + ); + + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index b2cd6af939d..5d4a63e8550 100644 --- a/src/components/selectable/selectable.tsx +++ b/src/components/selectable/selectable.tsx @@ -18,7 +18,10 @@ import classNames from 'classnames'; import { CommonProps, ExclusiveUnion } from '../common'; import { EuiSelectableSearch } from './selectable_search'; import { EuiSelectableMessage } from './selectable_message'; -import { EuiSelectableList } from './selectable_list'; +import { + EuiSelectableList, + EuiSelectableOptionsListVirtualizedProps, +} from './selectable_list'; import { EuiLoadingSpinner } from '../loading'; import { EuiSpacer } from '../spacer'; import { getMatchingOptions } from './matching_options'; @@ -434,8 +437,23 @@ export class EuiSelectable extends Component< const { 'aria-label': listAriaLabel, 'aria-describedby': listAriaDescribedby, + isVirtualized, + rowHeight, ...cleanedListProps - } = listProps || unknownAccessibleName; + } = (listProps || unknownAccessibleName) as typeof listProps & + typeof unknownAccessibleName; + + let virtualizedProps: EuiSelectableOptionsListVirtualizedProps; + + if (isVirtualized === false) { + virtualizedProps = { + isVirtualized, + }; + } else if (rowHeight != null) { + virtualizedProps = { + rowHeight, + }; + } const classes = classNames( 'euiSelectable', @@ -629,6 +647,7 @@ export class EuiSelectable extends Component< ? listAccessibleName : searchable && { 'aria-label': placeholderName })} {...cleanedListProps} + {...virtualizedProps} /> )} diff --git a/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap index 0a69ca20b06..e695d3effb2 100644 --- a/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap +++ b/src/components/selectable/selectable_list/__snapshots__/selectable_list.test.tsx.snap @@ -1087,6 +1087,171 @@ exports[`EuiSelectableListItem props height is full 1`] = `
`; +exports[`EuiSelectableListItem props isVirtualized can be false 1`] = ` +
+
+
    +
  • + + + + + Titan + + + +
  • +
  • + + + + + Enceladus + + + +
  • +
  • + + + + + Mimas + + + +
  • +
  • + + + + + Pandora is one of Saturn's moons, named for a Titaness of Greek mythology + + + +
  • +
  • + + + + + Tethys + + + +
  • +
  • + + + + + Hyperion + + + +
  • +
+
+
+`; + exports[`EuiSelectableListItem props renderOption 1`] = `
{ expect(component).toMatchSnapshot(); }); + + test('isVirtualized can be false', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index a4cd93b029c..98085662232 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -6,7 +6,13 @@ * Side Public License, v 1. */ -import React, { Component, HTMLAttributes, ReactNode, memo } from 'react'; +import React, { + Component, + HTMLAttributes, + ReactNode, + memo, + CSSProperties, +} from 'react'; import classNames from 'classnames'; import { FixedSizeList, @@ -14,7 +20,7 @@ import { ListChildComponentProps as ReactWindowListChildComponentProps, areEqual, } from 'react-window'; -import { CommonProps } from '../../common'; +import { CommonProps, ExclusiveUnion } from '../../common'; import { EuiAutoSizer } from '../../auto_sizer'; import { EuiHighlight } from '../../highlight'; import { EuiSelectableOption } from '../selectable_option'; @@ -24,10 +30,29 @@ import { } from './selectable_list_item'; interface ListChildComponentProps - extends ReactWindowListChildComponentProps { + extends Omit { data: Array>; + style?: CSSProperties; } +export type EuiSelectableOptionsListVirtualizedProps = ExclusiveUnion< + { + /** + * Use virtualized rendering for list items with `react-window`. + * Sets each row's height to the value of `rowHeight`. + */ + isVirtualized?: true; + /** + * The height of each option in pixels. Defaults to `32`. + * Has no effect if `isVirtualized=false`. + */ + rowHeight: number; + }, + { + isVirtualized: false; + } +>; + // Consumer Configurable Props via `EuiSelectable.listProps` export type EuiSelectableOptionsListProps = CommonProps & HTMLAttributes & { @@ -37,10 +62,6 @@ export type EuiSelectableOptionsListProps = CommonProps & * directly to that option */ activeOptionIndex?: number; - /** - * The height of each option in pixels. Defaults to `32` - */ - rowHeight: number; /** * Show the check/cross selection indicator icons */ @@ -61,7 +82,7 @@ export type EuiSelectableOptionsListProps = CommonProps & * The default content when `true` is `↩ to select/deselect/include/exclude` */ onFocusBadge?: EuiSelectableListItemProps['onFocusBadge']; - }; + } & EuiSelectableOptionsListVirtualizedProps; export type EuiSelectableListProps = EuiSelectableOptionsListProps & { /** @@ -109,6 +130,7 @@ export class EuiSelectableList extends Component> { static defaultProps = { rowHeight: 32, searchValue: '', + isVirtualized: true, }; listRef: FixedSizeList | null = null; @@ -186,6 +208,7 @@ export class EuiSelectableList extends Component> { ListRow = memo(({ data, index, style }: ListChildComponentProps) => { const option = data[index]; + const { data: optionData, ..._option } = option; const { label, isGroupLabel, @@ -196,6 +219,7 @@ export class EuiSelectableList extends Component> { ref, key, searchableLabel, + data: _data, ...optionRest } = option; @@ -241,7 +265,11 @@ export class EuiSelectableList extends Component> { {...(optionRest as EuiSelectableListItemProps)} > {this.props.renderOption ? ( - this.props.renderOption(option, this.props.searchValue) + this.props.renderOption( + // @ts-ignore complex + { ..._option, ...optionData }, + this.props.searchValue + ) ) : ( {label} )} @@ -273,6 +301,7 @@ export class EuiSelectableList extends Component> { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, + isVirtualized, ...rest } = this.props; @@ -293,9 +322,9 @@ export class EuiSelectableList extends Component> { if (numVisibleMoreThanMax) { // Show only half of the last one to indicate there's more to scroll to - calculatedHeight = (maxVisibleOptions - 0.5) * rowHeight; + calculatedHeight = (maxVisibleOptions - 0.5) * rowHeight!; } else { - calculatedHeight = numVisibleOptions * rowHeight; + calculatedHeight = numVisibleOptions * rowHeight!; } } @@ -310,26 +339,47 @@ export class EuiSelectableList extends Component> { return (
- - {({ width, height }) => ( - - {this.ListRow} - - )} - + {isVirtualized ? ( + + {({ width, height }) => ( + + {this.ListRow} + + )} + + ) : ( +
+
    + {optionArray.map((_, index) => + React.createElement( + this.ListRow, + { + key: index, + data: optionArray, + index, + }, + null + ) + )} +
+
+ )}
); } diff --git a/src/components/selectable/selectable_option.tsx b/src/components/selectable/selectable_option.tsx index 7b4ca6bfbcb..17fdaeafb54 100644 --- a/src/components/selectable/selectable_option.tsx +++ b/src/components/selectable/selectable_option.tsx @@ -53,6 +53,11 @@ export type EuiSelectableOptionBase = CommonProps & { * Option item `id`s are coordinated at a higher level for a11y reasons. */ id?: never; + /** + * Option data to pass through to the `renderOptions` element. + * Bypass `EuiSelectableItem` and avoid DOM attribute warnings. + */ + data?: { [key: string]: any }; }; type _EuiSelectableGroupLabelOption = Omit<