diff --git a/CHANGELOG.md b/CHANGELOG.md index ecff29bfe..28f41ed53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ * Fix visual issue with `` where dropdown caret shifts downward when a validation message is present. Refs STCOM-1323. * Fix MCL Paging bug - back button being incorrectly disabled. This was due to an inaccurate rowcount/rowIndex value. Refs STCOM-1331. * `Datepicker` - add the `hideCalendarButton` property to hide the calendar icon button. Refs STCOM-1342. +* Optimize rendering of 2k+ option lists in `Selection`. Refs STCOM-1340. ## [12.1.0](https://github.com/folio-org/stripes-components/tree/v12.1.0) (2024-03-12) [Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.0.0...v12.1.0) diff --git a/lib/Selection/Selection.js b/lib/Selection/Selection.js index 0677026ab..532cf965d 100644 --- a/lib/Selection/Selection.js +++ b/lib/Selection/Selection.js @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl'; import { useCombobox } from 'downshift'; import classNames from 'classnames'; import isEqual from 'lodash/isEqual'; +import debounce from 'lodash/debounce'; import formField from '../FormField'; import parseMeta from '../FormField/parseMeta'; @@ -14,12 +15,13 @@ import { filterOptionList, getSelectedObject, flattenOptionList, - reconcileReducedIndex + reconcileReducedIndex, } from './utils'; import SelectionOverlay from './SelectionOverlay'; import Label from '../Label'; import TextFieldIcon from '../TextField/TextFieldIcon'; +import Icon from '../Icon'; import useProvidedRefOrCreate from '../../hooks/useProvidedRefOrCreate' import formStyles from '../sharedStyles/form.css'; import css from './Selection.css'; @@ -29,7 +31,7 @@ import useProvidedIdOrCreate from '../../hooks/useProvidedIdOrCreate'; // a rough way to discern if an option is grouped or not - if it finds an index at the top level // of dataOptions, it's not grouped... const optionIsGrouped = (item, dataOptions) => { - return dataOptions.findIndex((i) => isEqual(i, item)) === -1; + return dataOptions.findIndex((i) => i === item) === -1; }; const getControlWidth = (control) => { @@ -46,14 +48,13 @@ const getItemClass = (item, i, props) => { return; } + const cursored = i === highlightedIndex ? ' ' + css.cursor : ''; + const selected = value === selectedItem?.value ? ' ' + css.selected : ''; + const grouped = optionIsGrouped(item, dataOptions) ? ' ' + css.groupedOption : ''; + // eslint-disable-next-line consistent-return - return classNames( - css.option, - { [css.cursor]: i === highlightedIndex }, - { [`${css.selected}`]: value === selectedItem?.value }, - { [`${css.groupedOption}`]: optionIsGrouped(item, dataOptions) }, - ); -}; + return `${css.option}${cursored}${selected}${grouped}`; +} const getClass = ({ dirty, @@ -86,6 +87,8 @@ const getClass = ({ ); }; +/* eslint-disable prefer-arrow-callback */ + const Selection = ({ asyncFilter, autofocus, @@ -104,7 +107,7 @@ const Selection = ({ label, listMaxHeight = '174px', loading, - loadingMessage, + loadingMessage = , marginBottom0, name, onFilter = filterOptionList, @@ -122,19 +125,45 @@ const Selection = ({ }) => { const { formatMessage } = useIntl(); const [filterValue, updateFilterValue] = useState(''); + const [debouncedFilterValue, updateDebouncedFilter] = useState(''); const dataLength = useRef(dataOptions?.length || 0); const controlRef = useProvidedRefOrCreate(inputRef); const awaitingChange = useRef(false); - const options = useMemo( - () => (asyncFilter ? dataOptions : - filterValue ? onFilter(filterValue, dataOptions) : dataOptions), - [asyncFilter, filterValue, dataOptions, onFilter] - ); + const dbUpdateFilter = useRef(debounce((filter) => { + updateDebouncedFilter(filter) + }, 200)).current; + const filterUpdateFn = useRef(function filterUpdater(filter) { + updateFilterValue(filter); + // debounce updates to the filter for large data sets... + dbUpdateFilter(filter); + }).current; + + const filterFn = useCallback(function filter(data) { + return onFilter(debouncedFilterValue, data); + }, [debouncedFilterValue, onFilter]); + + const flattenRef = useRef(function flattener(data) { + return flattenOptionList(data); + }).current; + + const reduceOptionsRef = useRef(function dataReducer(data) { + return reduceOptions(data); + }).current; + + const options = useMemo(() => { + return (asyncFilter || !debouncedFilterValue) ? dataOptions : filterFn(dataOptions) + }, + [dataOptions, debouncedFilterValue]); const testId = useProvidedIdOrCreate(id, 'selection-'); + const hasGroups = dataOptions.some((item) => item.options); // we need to skip over group headings since those can neither be selectable or cursored over. - const reducedListItems = reduceOptions(options); + const reducedListItems = useMemo( + () => { return hasGroups ? reduceOptionsRef(options) : options }, + [options.length, hasGroups] + ) + const { isOpen, getToggleButtonProps, @@ -146,7 +175,7 @@ const Selection = ({ selectedItem, selectItem: updateSelectedItem, } = useCombobox({ - items: reducedListItems, + items: reducedListItems || [], itemToString: defaultItemToString, initialSelectedItem: value ? getSelectedObject(value, dataOptions) : null, onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { @@ -155,6 +184,7 @@ const Selection = ({ if (onChange && newSelectedItem?.value !== value) { onChange(newSelectedItem.value); } + updateDebouncedFilter(''); // so that we can unfilter the options for the next time the list opens. }, isItemDisabled(item) { return ((readOnly || readonly) && !isEqual(item, selectedItem)); @@ -208,14 +238,60 @@ const Selection = ({ * This memoized function is passed into the SelectionOverlay & SelectionList */ + // if the options are grouped, flatten them for rendering. + const flattenedOptions = useMemo( + () => { + return hasGroups && options.length > 0 ? flattenRef(options) : options; + }, + [options, hasGroups] + ) + + const renderedOptionsFn = useCallback(function optionRenderer(data) { + const rendered = []; + for (let i = 0; i < data.length; i++) { + const item = data[i] + if (item.value) { + const reducedIndex = reconcileReducedIndex(item, reducedListItems); + rendered.push( +
  • e.stopPropagation(), + })} + className={getItemClass(item, reducedIndex, { selectedItem, highlightedIndex, dataOptions })} + > + {formatter({ option: item, searchTerm: filterValue })} +
  • + ) + } else { + rendered.push( +
  • + {formatter({ option: item, searchTerm: filterValue })} +
  • + ); + } + } + return rendered; + }, [filterValue, flattenedOptions]); + + const optionsProcessing = false; + const renderedOptions = useMemo( + () => renderedOptionsFn(flattenedOptions), + [flattenedOptions.length, debouncedFilterValue] + ); + // It doesn't need to update if *all of the things it uses change... /* eslint-disable react-hooks/exhaustive-deps */ const renderOptions = useCallback(() => { + if (!isOpen) return null; // if options are delivered with groupings, we flatten the options for // a set of selectable indices. Group labels are not selectable. - const flattenedOptions = flattenOptionList(options); /* loading message */ - if (loading) { + if (loading || (filterValue !== debouncedFilterValue)) { return (
  • ); } - - return flattenedOptions.map((item, i) => { - if (item.value) { - const reducedIndex = reconcileReducedIndex(item, reducedListItems); - return ( -
  • e.stopPropagation(), - })} - className={getItemClass(item, reducedIndex, { selectedItem, highlightedIndex, dataOptions })} - > - {formatter({ option: item, searchTerm: filterValue })} -
  • - ) - } - return ( -
  • - {formatter({ option: item, searchTerm: filterValue })} -
  • - ); - }) + return renderedOptions; }, [ loading, - filterValue, selectedItem, highlightedIndex, value, options, + isOpen, + optionsProcessing, + renderedOptions, ]); const renderFilterInput = useCallback((filterRef) => ( @@ -301,7 +354,7 @@ const Selection = ({ onMouseUp: (e) => e.stopPropagation(), })} onClick={() => {}} - onChange={(e) => updateFilterValue(e.target.value)} + onChange={(e) => filterUpdateFn(e.target.value)} aria-label={formatMessage({ id: 'stripes-components.selection.filterOptionsLabel' }, { label })} className={css.selectionFilter} placeholder={formatMessage({ id: 'stripes-components.selection.filterOptionsPlaceholder' })} @@ -436,3 +489,5 @@ export default formField( warning: (meta.touched ? parseMeta(meta, 'warning') : ''), }) ); + +/* eslint-enable */ diff --git a/lib/Selection/SelectionList.js b/lib/Selection/SelectionList.js index 6fc7e9144..142f81dbf 100644 --- a/lib/Selection/SelectionList.js +++ b/lib/Selection/SelectionList.js @@ -7,6 +7,7 @@ const SelectionList = ({ labelId, listMaxHeight, renderOptions, + isOpen, }) => ( ); SelectionList.propTypes = { getMenuProps: PropTypes.func, + isOpen: PropTypes.bool, labelId: PropTypes.string, listMaxHeight: PropTypes.string, renderOptions: PropTypes.func, diff --git a/lib/Selection/SelectionOverlay.js b/lib/Selection/SelectionOverlay.js index fc860c430..24ab8a6bb 100644 --- a/lib/Selection/SelectionOverlay.js +++ b/lib/Selection/SelectionOverlay.js @@ -51,6 +51,7 @@ const SelectionOverlay = ({ listMaxHeight={listMaxHeight} optionAlignment={optionAlignment} getMenuProps={getMenuProps} + isOpen={isOpen} {...props} /> ); @@ -71,7 +72,7 @@ const SelectionOverlay = ({ id={`sl-container-${id}`} > {renderFilterInput(filterRef)} - {selectList} + {isOpen && selectList} diff --git a/lib/Selection/stories/BasicUsage.js b/lib/Selection/stories/BasicUsage.js index bb917ce3b..76c503e5e 100644 --- a/lib/Selection/stories/BasicUsage.js +++ b/lib/Selection/stories/BasicUsage.js @@ -2,8 +2,15 @@ * Selection basic usage */ -import React from 'react'; +import faker from 'faker'; import Selection from '../Selection'; +import { syncGenerate } from '../../MultiColumnList/stories/service'; + + +const hugeOptionsList = syncGenerate(3000, 0, () => { + const item = faker.address.city(); + return { value: item, label: item }; +}); // the dataOptions prop takes an array of objects with 'label' and 'value' keys const countriesOptions = [ @@ -17,58 +24,67 @@ const countriesOptions = [ // ...obviously there are more.... ]; -export default () => ( -
    - - - - -
    -
    - -

    My own label

    - -
    -); +export default () => { + return ( +
    + + + + + +
    +
    + +

    My own label

    + +
    + ) +}; diff --git a/lib/Selection/utils.js b/lib/Selection/utils.js index 0c7a7e6a0..cc50dc978 100644 --- a/lib/Selection/utils.js +++ b/lib/Selection/utils.js @@ -1,11 +1,9 @@ -import isEqual from 'lodash/isEqual'; - /* filterOptionList * conforms to shapes of options. * standard options are { value, label } * grouped options are { label, options } where options contains standard options. */ -export const filterOptionList = (value, dataOptions) => { +export const filterOptionList = (value, dataOptions = []) => { const valueRE = new RegExp(`^${value}`, 'i'); const baseFilter = (o) => valueRE.test(o.label); // if items have an 'options' field, filter those items and return the dataOptions group with the @@ -27,7 +25,14 @@ export const filterOptionList = (value, dataOptions) => { return options; }, []); } - return dataOptions.filter(baseFilter); + + const result = []; + for (let i = 0; i < dataOptions.length; i++) { + if (baseFilter(dataOptions[i])) { + result.push(dataOptions[i]); + } + } + return result; }; export const flattenOptionList = (dataOptions) => { @@ -73,13 +78,18 @@ export const ensureValuedOption = (index, dataOptions) => { export const defaultItemToString = (item) => item?.label; // removes any option group headers, leaving only selectable options. -export const reduceOptions = (dataOptions) => dataOptions?.reduce((options, op) => { - if (op.value) options.push(op); - if (op.options) return [...options, ...op.options]; - return options; -}, []); +export const reduceOptions = (dataOptions) => { + if (dataOptions) { + return dataOptions.reduce((options, op) => { + if (op.value) options.push(op); + if (op.options) return [...options, ...op.options]; + return options; + }, []); + } + return []; +} // reconcile index of rendered item to items that are only selectable. export const reconcileReducedIndex = (item, items) => { - return items.findIndex((i) => isEqual(i, item)); + return items.findIndex((i) => i === item); };