Skip to content

Commit

Permalink
feat: allow virtualization of autocomplete (#2122)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrispulsinelli-okta authored Mar 12, 2024
1 parent b19bc01 commit a8d8c3c
Show file tree
Hide file tree
Showing 25 changed files with 66,008 additions and 446 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3 changes: 0 additions & 3 deletions packages/babel-plugin-fully-specified/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,11 @@ export function plugin({
switch (path.node.type) {
case "ImportDeclaration":
path.replaceWith(
// @ts-expect-error TEMP: This type aren't working after the upgrade, but they need to be fixed.
t.importDeclaration(path.node.specifiers, fullySpecifiedLiteral),
);
return;
case "ExportNamedDeclaration":
path.replaceWith(
// @ts-expect-error TEMP: This type aren't working after the upgrade, but they need to be fixed.
t.exportNamedDeclaration(
path.node.declaration,
path.node.specifiers,
Expand All @@ -103,7 +101,6 @@ export function plugin({
);
return;
case "ExportAllDeclaration":
// @ts-expect-error TEMP: This type aren't working after the upgrade, but they need to be fixed.
path.replaceWith(t.exportAllDeclaration(fullySpecifiedLiteral));
return;
default:
Expand Down
4 changes: 4 additions & 0 deletions packages/odyssey-react-mui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
"i18next": "^23.8.2",
"material-react-table": "^2.11.3",
"react-i18next": "^14.0.5",
"react-virtualized-auto-sizer": "^1.0.22",
"react-window": "^1.8.10",
"tsx": "^4.7.1",
"word-wrap": "^1.2.5"
},
Expand All @@ -96,6 +98,8 @@
"@types/jest-axe": "^3.5.9",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"babel-plugin-import": "^1.13.5",
"eslint": "^8.56.0",
"jest": "^29.7.0",
Expand Down
123 changes: 122 additions & 1 deletion packages/odyssey-react-mui/src/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,29 @@ import {
AutocompleteValue,
AutocompleteRenderInputParams,
} from "@mui/material";
import { memo, useCallback, useMemo, useRef } from "react";
import {
createContext,
FC,
forwardRef,
HTMLAttributes,
memo,
ReactElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import styled from "@emotion/styled";
import { VariableSizeList, ListChildComponentProps } from "react-window";
import _AutoSizer, {
Props as AutoSizerProps,
Size as AutoSizerSize,
} from "react-virtualized-auto-sizer";

// This is required to get around a react-types issue for "AutoSizer is not a valid JSX element."
// @see https://github.com/bvaughn/react-virtualized/issues/1739#issuecomment-1291444246
const AutoSizer = _AutoSizer as unknown as FC<AutoSizerProps>;

import { Field } from "./Field";
import { FieldComponentProps } from "./FieldComponentProps";
Expand Down Expand Up @@ -160,6 +182,13 @@ export type AutocompleteProps<
* You will need to implement this function if your `option` items are objects.
*/
getIsOptionEqualToValue?: (option: OptionType, value: OptionType) => boolean;

/**
* If this component is required to display a virtualized list of options,
* then this value needs to be set to true.
* It is recommended if you're options are on the order of 10's of hundreds or more.
*/
isVirtualized?: boolean;
} & Pick<
FieldComponentProps,
| "errorMessage"
Expand All @@ -173,6 +202,11 @@ export type AutocompleteProps<
> &
Pick<HtmlProps, "ariaDescribedBy" | "testId" | "translate">;

const ListboxContainer = styled.div`
width: 100%;
height: 100%;
`;

const Autocomplete = <
OptionType,
HasMultipleChoices extends boolean | undefined,
Expand All @@ -191,6 +225,7 @@ const Autocomplete = <
isLoading,
isOptional = false,
isReadOnly,
isVirtualized: isVirtualizedProp = false,
hint,
HintLinkComponent,
label,
Expand All @@ -211,6 +246,8 @@ const Autocomplete = <
uncontrolledValue: defaultValue,
}),
);

const isVirtualized = useRef(Boolean(isVirtualizedProp));
const defaultValueProp = useMemo<
| AutocompleteValue<
OptionType,
Expand Down Expand Up @@ -300,6 +337,88 @@ const Autocomplete = <
testId,
],
);

const renderVirtualizedRow = ({
data,
index,
style,
}: ListChildComponentProps) => {
const baseOption = data[index];
/**
* react-window calculates the absolute positions of the list items, via an inline style, so
* we need to add it to each list item that is being rendered in the viewable list window.
* See here if you need to know more: https://github.com/bvaughn/react-window?tab=readme-ov-file#why-is-my-list-blank-when-i-scroll
*/
const optionItem = { ...baseOption, props: { ...baseOption.props, style } };
return optionItem;
};

const OuterListboxContext = createContext({});

const OuterListboxElementType = forwardRef<HTMLDivElement>((props, ref) => {
const outerProps = useContext(OuterListboxContext);
return <div ref={ref} {...props} {...outerProps} />;
});

function useResetCache(length: number) {
const ref = useRef<VariableSizeList>(null);
useEffect(() => {
if (ref.current) {
ref.current.resetAfterIndex(0, true);
}
}, [length]);
return ref;
}

const ListboxComponent = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLElement>
>(function (props, ref) {
const { children, ...other } = props;
const itemData: ReactElement[] = (children as ReactElement[]).flatMap(
(item: ReactElement & { children?: ReactElement[] }) =>
[item].concat(item.children || []),
);

// the height of an Odyssey autocomplete option item that is used to calculate height of window
const optionHeight = 45; //px

// The number of items (rows or columns) to render outside of the visible area for performance and scrolling reasons
const overscanRowCount = 8;

const itemSize = useCallback(() => optionHeight, []);

const gridRef = useResetCache(itemData.length);

const renderWindow = useCallback(
({ height, width }: AutoSizerSize) => (
<VariableSizeList
innerElementType="ul"
itemData={itemData}
itemCount={itemData.length}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
itemSize={itemSize}
height={height}
width={width}
ref={gridRef}
outerElementType={OuterListboxElementType}
overscanCount={overscanRowCount}
>
{renderVirtualizedRow}
</VariableSizeList>
),
[itemData, gridRef, itemSize],
);

return (
<ListboxContainer ref={ref}>
<OuterListboxContext.Provider value={other}>
<AutoSizer>{renderWindow}</AutoSizer>
</OuterListboxContext.Provider>
</ListboxContainer>
);
});

const onChange = useCallback<
NonNullable<
UseAutocompleteProps<
Expand Down Expand Up @@ -336,6 +455,8 @@ const Autocomplete = <
<MuiAutocomplete
{...valueProps}
{...inputValueProp}
// conditionally provide the ListboxComponent if this needs to be virtualized
{...(isVirtualized.current && { ListboxComponent })}
// AutoComplete is wrapped in a div within MUI which does not get the disabled attr. So this aria-disabled gets set in the div
aria-disabled={isDisabled}
disableCloseOnSelect={hasMultipleChoices}
Expand Down
Loading

0 comments on commit a8d8c3c

Please sign in to comment.