Skip to content

Commit

Permalink
Add virtual scrolling to combobox
Browse files Browse the repository at this point in the history
  • Loading branch information
paustint committed Mar 5, 2023
1 parent 7480d1c commit c3d9b91
Show file tree
Hide file tree
Showing 9 changed files with 79 additions and 134 deletions.
67 changes: 3 additions & 64 deletions libs/ui/src/lib/expression-group/ExpressionConditionRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { css } from '@emotion/react';
import { useDebounce } from '@jetstream/shared/ui-utils';
import { multiWordObjectFilter, getFlattenedListItems } from '@jetstream/shared/utils';
import { getFlattenedListItems } from '@jetstream/shared/utils';
import {
AndOr,
ExpressionConditionHelpText,
Expand All @@ -18,18 +18,15 @@ import isNumber from 'lodash/isNumber';
import isString from 'lodash/isString';
import React, { FunctionComponent, useEffect, useState } from 'react';
import { useDrag } from 'react-dnd';
import { Icon } from '../widgets/Icon';
import FormRowButton from '../form/button/FormRowButton';
import Combobox from '../form/combobox/Combobox';
import { ComboboxListItem } from '../form/combobox/ComboboxListItem';
import { ComboboxListItemGroup } from '../form/combobox/ComboboxListItemGroup';
import ComboboxWithItems from '../form/combobox/ComboboxWithItems';
import { ComboboxWithItemsVirtual } from '../form/combobox/ComboboxWithItemsVirtual';
import DatePicker from '../form/date/DatePicker';
import Input from '../form/input/Input';
import Picklist from '../form/picklist/Picklist';
import Textarea from '../form/textarea/Textarea';
import { Icon } from '../widgets/Icon';
import { DraggableRow } from './expression-types';
import ComboboxWithItems from '../form/combobox/ComboboxWithItems';

export interface ExpressionConditionRowProps {
rowKey: number;
Expand Down Expand Up @@ -88,22 +85,9 @@ export const ExpressionConditionRow: FunctionComponent<ExpressionConditionRowPro
onDelete,
}) => {
const [disableValueInput, setDisableValueInput] = useState(false);
// const [visibleResources, setVisibleResources] = useState<ListItemGroup[]>(resources); // TOD: can we remove?
const [flattenedResources, setFlattenedResources] = useState<ListItem[]>(() => getFlattenedListItems(resources));
const [selectedResourceType, setSelectedResourceType] = useState<ListItem<ExpressionRowValueType>[]>();
// const, s] = useState<string | null>(null);
const [selectedValue, setSelectValue] = useState(selected.value);
// const [selectedResourceComboboxLabel, setSelectedResourceComboboxLabel] = useState<string | null>(() => {
// if (selected.resource) {
// const group = resources.find((currResource) => currResource.id === selected.resourceGroup);
// if (group) {
// const item = group.items.find((item) => item.id === selected.resource);
// return item ? getSelectionLabel(group.label, item) : null;
// }
// }
// return null;
// });
// const [selectedResourceTitle] = useState<string | null>(null);
// used to force re-render and re-init for picklist values - since array turns to string and takes multiple renders
// the default picklist value does not get picked up in time - so this forces the picklist to re-render
const [picklistKey, setPicklistKey] = useState<string>(`${new Date().getTime()}`);
Expand Down Expand Up @@ -168,10 +152,6 @@ export const ExpressionConditionRow: FunctionComponent<ExpressionConditionRowPro
setFlattenedResources(getFlattenedListItems(resources));
}, [resources]);

// useEffect(() => {
// setFlattenedResources(getFlattenedListItems(visibleResources));
// }, [visibleResources]);

function handleSelectedResourceType(type: ListItem<ExpressionRowValueType>[]) {
setSelectedResourceType(type);
if (type && type[0] && selected.resourceType !== type[0].value) {
Expand Down Expand Up @@ -225,9 +205,6 @@ export const ExpressionConditionRow: FunctionComponent<ExpressionConditionRowPro
comboboxProps={{
label: resourceLabel,
labelHelp: resourceHelpText,
// onInputChange: (filter) => s(filter),
// selectedItemLabel: selectedResourceComboboxLabel,
// selectedItemTitle: selectedResourceTitle,
itemLength: 10,
}}
selectedItemLabelFn={getSelectionLabel}
Expand All @@ -237,44 +214,6 @@ export const ExpressionConditionRow: FunctionComponent<ExpressionConditionRowPro
onChange({ ...selected, resource: item.id, resourceGroup: item.group?.id || '', resourceMeta: item.meta })
}
/>
{/* <Combobox
label={resourceLabel}
labelHelp={resourceHelpText}
onInputChange={(filter) => s(filter)}
selectedItemLabel={selectedResourceComboboxLabel}
selectedItemTitle={selectedResourceTitle}
itemLength={10}
// Select first item
onInputEnter={() => {
const groupWithItems = visibleResources.findIndex((group) => group.items.length > 0);
if (groupWithItems >= 0) {
const group = visibleResources[groupWithItems];
const item = group.items[0];
setSelectedResourceComboboxLabel(getSelectionLabel(group.label, item));
onChange({ ...selected, resource: item.id, resourceGroup: group.id, resourceMeta: item.meta });
}
}}
>
{visibleResources
.filter((group) => group.items.length > 0)
.map((group) => (
<ComboboxListItemGroup key={group.id} label={group.label}>
{group.items.map((item) => (
<ComboboxListItem
key={item.id}
id={item.id}
label={item.label}
secondaryLabel={item.secondaryLabel}
selected={item.id === selected.resource}
onSelection={(id) => {
setSelectedResourceComboboxLabel(getSelectionLabel(group.label, item));
onChange({ ...selected, resource: id, resourceGroup: group.id, resourceMeta: item.meta });
}}
/>
))}
</ComboboxListItemGroup>
))}
</Combobox> */}
</div>
{/* Operator */}
<div className="slds-col slds-grow-none">
Expand Down
5 changes: 2 additions & 3 deletions libs/ui/src/lib/form/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const iconNotLoading = (
// <ComboboxElement ref={ref} {...props} icon={props.loading ? iconLoading : iconNotLoading} />
// ));

export const Combobox = forwardRef(
export const Combobox = forwardRef<ComboboxPropsRef, ComboboxProps>(
(
{
className,
Expand Down Expand Up @@ -165,7 +165,6 @@ export const Combobox = forwardRef(
ref,
() => ({
getPopoverRef: () => {
console.log('popoverRef.current', popoverRef.current);
return popoverRef.current;
},
close: () => {
Expand Down Expand Up @@ -534,7 +533,7 @@ export const Combobox = forwardRef(
<div ref={divContainerEl}>
{Children.count(children) === 0 && (
<ul className="slds-listbox slds-listbox_vertical" role="presentation">
<ComboboxListItem id="placeholder" label={noItemsPlaceholder} selected={false} onSelection={NOOP} />
<ComboboxListItem id="placeholder" placeholder label={noItemsPlaceholder} selected={false} onSelection={NOOP} />
</ul>
)}
{hasGroups && childrenWithRef}
Expand Down
38 changes: 24 additions & 14 deletions libs/ui/src/lib/form/combobox/ComboboxListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ComboboxListItemProps {
selected: boolean;
disabled?: boolean;
hasError?: boolean;
placeholder?: boolean;
onSelection: (id: string) => void;
children?: React.ReactNode; // required because forwardRef
}
Expand All @@ -40,6 +41,7 @@ export const ComboboxListItem = forwardRef<HTMLLIElement, ComboboxListItemProps>
selected,
disabled,
hasError,
placeholder,
onSelection,
children,
},
Expand All @@ -60,29 +62,37 @@ export const ComboboxListItem = forwardRef<HTMLLIElement, ComboboxListItemProps>
id={id}
aria-disabled={disabled}
className={classNames(
'slds-media slds-listbox__option slds-listbox__option_plain slds-media_small',
'slds-media slds-listbox__option slds-listbox__option_plain',
{
'slds-is-selected': selected,
'slds-text-color_error': hasError,
'slds-media_small': !placeholder,
},
textContainerClassName
)}
role="option"
aria-selected={selected}
>
<span className="slds-media__figure slds-listbox__option-icon">
{selected && (
<Icon
type="utility"
icon="check"
className="slds-icon slds-icon_x-small"
containerClassname={classNames('slds-icon_container slds-icon-utility-check slds-current-color', {
'slds-icon_disabled': disabled,
})}
/>
)}
</span>
<span className="slds-media__body" css={textBodyCss}>
{!placeholder && (
<span className="slds-media__figure slds-listbox__option-icon">
{selected && (
<Icon
type="utility"
icon="check"
className="slds-icon slds-icon_x-small"
containerClassname={classNames('slds-icon_container slds-icon-utility-check slds-current-color', {
'slds-icon_disabled': disabled,
})}
/>
)}
</span>
)}
<span
className={classNames({
'slds-media__body': !placeholder,
})}
css={textBodyCss}
>
{label && (!secondaryLabel || !secondaryLabelOnNewLine) && (
<span className={classNames('slds-truncate', textClassName)} title={title} css={textCss}>
{label}
Expand Down
34 changes: 22 additions & 12 deletions libs/ui/src/lib/form/combobox/ComboboxListVirtual.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { css } from '@emotion/react';
import { NOOP } from '@jetstream/shared/utils';
import { ListItem, Maybe } from '@jetstream/types';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ComboboxListItem } from './ComboboxListItem';
Expand All @@ -8,22 +9,12 @@ export interface ComboboxListVirtualProps {
parentRef: HTMLDivElement | null;
selectedItem: Maybe<ListItem<string, any>>;
onSelected: (item: ListItem) => void;
// getScrollElement: () => Maybe<HTMLDivElement>;
}

export const ComboboxListVirtual = ({ items, selectedItem, parentRef, onSelected }: ComboboxListVirtualProps) => {
const rowVirtualizer = useVirtualizer({
count: items.length,
observeElementRect: (instance, cb) => {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
cb(entry.contentRect);
});
observer.observe(instance);
return () => observer.disconnect();
},
getScrollElement: () => parentRef,
// getScrollElement: getScrollElement as any, // TS definition docs say "can return undefined", but TS definition does not allow
estimateSize: (index: number) => {
const item = items[index];
if (item.isGroup) {
Expand All @@ -34,17 +25,35 @@ export const ComboboxListVirtual = ({ items, selectedItem, parentRef, onSelected
},
});

const virtualItems = rowVirtualizer.getVirtualItems();

return (
<ul
className="slds-listbox slds-listbox_vertical"
role="group"
css={css`
height: ${rowVirtualizer.getTotalSize()}px;
height: ${rowVirtualizer.getTotalSize() || 36}px;
width: 100%;
position: relative;
`}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
{virtualItems.length === 0 && (
<ComboboxListItem
containerCss={css`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 36px;
`}
id="placeholder"
placeholder
label="There are no items for selection"
selected={false}
onSelection={NOOP}
/>
)}
{virtualItems.map((virtualItem) => {
const item = items[virtualItem.index];

const styles = css`
Expand All @@ -66,6 +75,7 @@ export const ComboboxListVirtual = ({ items, selectedItem, parentRef, onSelected
</li>
) : (
<ComboboxListItem
key={item.id}
id={item.id}
containerCss={styles}
label={item.label}
Expand Down
2 changes: 1 addition & 1 deletion libs/ui/src/lib/form/combobox/ComboboxWithItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const ComboboxWithItems: FunctionComponent<ComboboxWithItemsProps> = ({
selectedItemTitleFn = defaultSelectedItemTitleFn,
onSelected,
}) => {
const comboboxRef = useRef<ComboboxPropsRef>();
const comboboxRef = useRef<ComboboxPropsRef>(null);
const [filterTextNonDebounced, setFilterText] = useState<string>('');
const filterText = useDebounce(filterTextNonDebounced, 300);
const [selectedItem, setSelectedItem] = useState<Maybe<ListItem>>(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const ComboboxWithItemsTypeAhead: FunctionComponent<ComboboxWithItemsType
onSelected,
}) => {
const [loading, setLoading] = useState(false);
const comboboxRef = useRef<ComboboxPropsRef>();
const comboboxRef = useRef<ComboboxPropsRef>(null);
const [filterTextNonDebounced, setFilterText] = useState<string>('');
const filterText = useDebounce(filterTextNonDebounced, 300);
const [selectedItem, setSelectedItem] = useState<Maybe<ListItem>>(() =>
Expand Down
46 changes: 16 additions & 30 deletions libs/ui/src/lib/form/combobox/ComboboxWithItemsVirtual.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@ export interface ComboboxWithItemsVirtualProps {
}

/**
* Combobox wrapper to simplify the creation of a combobox
* This allow text filtering/search with a simple picklist like interaction
*
* Does not support groups
* TODO: create ComboboxWithGroups component
* Combobox with virtualized list of items
* Items must be a flat list. If you need groups, set isGroup: true on an item
* You can use getFlattenedListItems to handle this
*/
export const ComboboxWithItemsVirtual: FunctionComponent<ComboboxWithItemsVirtualProps> = ({
comboboxProps,
Expand All @@ -38,8 +36,7 @@ export const ComboboxWithItemsVirtual: FunctionComponent<ComboboxWithItemsVirtua
selectedItemTitleFn = defaultSelectedItemTitleFn,
onSelected,
}) => {
const comboboxRef = useRef<ComboboxPropsRef>();
const [popoverContainerRef, setPopoverContainerRef] = useState<HTMLDivElement | null>(null);
const comboboxRef = useRef<ComboboxPropsRef>(null);
const [filterTextNonDebounced, setFilterText] = useState<string>('');
const filterText = useDebounce(filterTextNonDebounced, 300);
const [selectedItem, setSelectedItem] = useState<Maybe<ListItem>>(() =>
Expand Down Expand Up @@ -84,13 +81,20 @@ export const ComboboxWithItemsVirtual: FunctionComponent<ComboboxWithItemsVirtua
setVisibleItems(items);
} else {
const filter = filterText.toLowerCase().trim();
setVisibleItems(items.filter(filterFn(filter)));

// Since data coming in is flat, ensure that groups with items stay in the list
let visibleItemsAndGroups = items.filter((item, index, array) => item.isGroup || filterFn(filter)(item, index, array));
const visibleGroups = new Set(visibleItemsAndGroups.map((item) => item.group?.id).filter(Boolean));
visibleItemsAndGroups = visibleItemsAndGroups.filter((item) => !item.isGroup || (item.isGroup && visibleGroups.has(item.id)));

setVisibleItems(visibleItemsAndGroups);
}
}, [items, filterText, filterFn]);

const onInputEnter = useCallback(() => {
if (visibleItems.length > 0) {
onSelected(visibleItems[0]);
const firstItem = visibleItems.find((item) => !item.isGroup);
if (firstItem) {
onSelected(firstItem);
}
}, [onSelected, visibleItems]);

Expand All @@ -103,30 +107,12 @@ export const ComboboxWithItemsVirtual: FunctionComponent<ComboboxWithItemsVirtua
onInputChange={setFilterText}
onInputEnter={onInputEnter}
>
{/* <ComboboxListVirtual
<ComboboxListVirtual
items={visibleItems}
parentRef={comboboxRef.current?.getPopoverRef() || null}
// getScrollElement={() => {
// console.log('comboboxRef.current', comboboxRef.current?.getPopoverRef());
// return comboboxRef.current?.getPopoverRef();
// }}
selectedItem={selectedItem}
onSelected={onSelected}
/> */}
{comboboxRef.current?.getPopoverRef() ? (
<ComboboxListVirtual
items={visibleItems}
parentRef={comboboxRef.current?.getPopoverRef() || null}
// getScrollElement={() => {
// console.log('comboboxRef.current', comboboxRef.current?.getPopoverRef());
// return comboboxRef.current?.getPopoverRef();
// }}
selectedItem={selectedItem}
onSelected={onSelected}
/>
) : (
<></>
)}
/>
</Combobox>
);
};
Expand Down
Loading

0 comments on commit c3d9b91

Please sign in to comment.