Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,22 @@ Props of Material UI Select are also available.

Props of Material UI Autocomplete are also available.

| Prop | Type | Default | Definition |
| ----------------- | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------- |
| name\* | string | | The name of the input |
| control\* | `Control` | | The React Hook Form object to register components into React Hook Form. |
| defaultValue\* | any | | The default value of the input that would be injected into React Hook Form Controller and the component |
| options | `{}[]` | | The option items that is available to the component. |
| optionValue | string | 'value' | Set property of options's value |
| optionLabel | string | 'label' | Set property of items’s text label |
| multiple | boolean | | If `true`, menu will support multiple selections. |
| onChange | `(event: SelectChangeEvent) => void` | | Callback fired when a menu item is selected. |
| disableClearable | boolean | | |
| textFieldProps | `TextFieldProps` | | The props that will be passed to TextField component in the `renderInput` of `AutoComplete`. |
| loading | boolean | false | Displays linear progress bar |
| customOptionLabel | `(option: any) => any` | | Display custom option label |
| Prop | Type | Default | Definition |
| ----------------------- | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------- |
| name\* | string | | The name of the input |
| control\* | `Control` | | The React Hook Form object to register components into React Hook Form. |
| defaultValue\* | any | | The default value of the input that would be injected into React Hook Form Controller and the component |
| options | `{}[]` | | The option items that is available to the component. |
| optionValue | string | 'value' | Set property of options's value |
| optionLabel | string | 'label' | Set property of items’s text label |
| multiple | boolean | | If `true`, menu will support multiple selections. |
| onChange | `(event: SelectChangeEvent) => void` | | Callback fired when a menu item is selected. |
| disableClearable | boolean | | |
| textFieldProps | `TextFieldProps` | | The props that will be passed to TextField component in the `renderInput` of `AutoComplete`. |
| loading | boolean | false | Displays linear progress bar |
| customOptionLabel | `(option: any) => any` | | Display custom option label |
| virtualizationThreshold | number | 100 | The limit of options to render before enabling virtualization. |
| virtualizationProps | `VirtualListBoxProps` | | The props that will be passed to `VirtualListBox` component, which is built on top of `react-window`. |

#### RadioGroup Controller

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@
"@storybook/react-webpack5": "7.6.5",
"@types/lodash.get": "^4.4.6",
"@types/react": "^17.0.39",
"@types/react-window": "^1",
"babel-loader": "^8.2.3",
"chromatic": "^6.5.4",
"lodash.get": "^4.4.2",
"moment": "^2.29.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.2",
"react-window": "^1.8.10",
"rollup": "^4.9.1",
"rollup-plugin-dts": "^6.1.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
Expand Down Expand Up @@ -79,4 +81,4 @@
"@storybook/react-docgen-typescript-plugin": "npm:react-docgen-typescript-plugin@1.0.2"
},
"packageManager": "yarn@4.0.2"
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { FormControl, LinearProgress as MuiLinearProgress, TextField } from '@mui/material';
import Autocomplete from '@mui/material/Autocomplete';
import { styled } from '@mui/material/styles';
import get from 'lodash.get';
import React from 'react';
import { Controller } from 'react-hook-form';
import Autocomplete from '@mui/material/Autocomplete';
import { TextField, LinearProgress as MuiLinearProgress, FormControl } from '@mui/material';
import { AutocompleteControllerProps } from '../../../fields/index';
import get from 'lodash.get';
import { styled } from '@mui/material/styles';
import { VirtualListBox } from '../../shared/list-box-component';

const LinearProgress = styled(MuiLinearProgress)(
() => `
Expand All @@ -28,8 +29,12 @@ export const AutocompleteController = ({
onChange,
customOptionLabel,
onBlur,
virtualizationThreshold = 100,
virtualizationProps,
...rest
}: AutocompleteControllerProps) => {
const isVirtualizing = options.length > virtualizationThreshold;

return loading ? (
<FormControl fullWidth={textFieldProps?.fullWidth}>
<LinearProgress />
Expand All @@ -55,13 +60,24 @@ export const AutocompleteController = ({
get(option, optionLabel, '') || (found && get(found, optionLabel, '')) || option || '';
return customOptionLabel ? customOptionLabel(found || option || '') : label?.toString();
}}
renderOption={(props: React.HTMLAttributes<HTMLLIElement>, option: any): React.ReactNode => {
renderOption={(...args): React.ReactNode | unknown[] => {
if (isVirtualizing) {
/*
* This is passing the props to `renderRow` in ListboxComponent,
* which is a custom component that renders the options for virtualized lists
*/
return args;
}

const [props, option] = args;

return (
<li {...props} key={props.id}>
{customOptionLabel ? customOptionLabel(option) : get(option, optionLabel, '')}
</li>
);
}}
renderGroup={isVirtualizing ? (...args) => args : undefined}
disableCloseOnSelect={multiple}
isOptionEqualToValue={(option: any, value: any) => {
return typeof value === 'string'
Expand All @@ -87,6 +103,9 @@ export const AutocompleteController = ({
onBlur?.(...args);
fieldOnBlur?.();
}}
disableListWrap={isVirtualizing}
ListboxComponent={isVirtualizing ? VirtualListBox : undefined}
ListboxProps={isVirtualizing ? ({ virtualizationProps } as any) : undefined}
{...rest}
renderInput={(params) => {
return (
Expand Down
132 changes: 132 additions & 0 deletions src/components/shared/list-box-component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { ListSubheader, Typography, useMediaQuery, useTheme } from '@mui/material';
import React, { FC, HTMLAttributes, ReactNode } from 'react';
import { ListChildComponentProps, VariableSizeList, VariableSizeListProps } from 'react-window';

// Constants
const LISTBOX_PADDING_PX = 8;

// Types
type GroupOption = {
group: string;
children: Array<any[]>;
key: string;
};

type Options = Array<Array<GroupOption>>;

export type VirtualListBoxProps = HTMLAttributes<HTMLElement> & {
virtualizationProps?: Partial<VariableSizeListProps>;
children?: ReactNode;
};

// Context
const OuterElementContext = React.createContext({});

// Component to render the outer element (ul)
const OuterElementType: FC<HTMLAttributes<HTMLUListElement>> = (props) => {
const outerProps = React.useContext(OuterElementContext);
return <ul {...props} {...outerProps} />;
};

// Custom hook for cache management
const useListCache = (data: any) => {
const listRef = React.useRef<VariableSizeList>(null);

React.useEffect(() => {
if (listRef.current) {
listRef.current.resetAfterIndex(0, true);
}
}, [data]);

return listRef;
};

// Row renderer function
const renderListRow = ({ data, index, style }: ListChildComponentProps) => {
const rowData = data[index];
const [componentProps, option, _, ownerState] = rowData ?? [];

const adjustedStyle = {
...style,
top: (style?.top as number) + LISTBOX_PADDING_PX
};

if (componentProps?.hasOwnProperty('group')) {
return (
<ListSubheader key={rowData.key} component="div" style={adjustedStyle}>
{componentProps.group}
</ListSubheader>
);
}

const { key, ...optionProps } = componentProps ?? {};
return (
<Typography key={key} component="li" {...optionProps} noWrap style={adjustedStyle}>
{ownerState?.getOptionLabel(option)}
</Typography>
);
};

// Helper function to flatten nested options
const flattenOptionsHierarchy = (options: Options): Options => {
const flattenedData: Options = [];

options.forEach((items: Options[0]) => {
const groupChildren =
items
?.map((item: any) => item?.children)
?.flat()
?.filter(Boolean) ?? [];

flattenedData?.push(items, ...groupChildren);
});

return flattenedData;
};

// Main component
export const VirtualListBox = React.forwardRef<HTMLDivElement, VirtualListBoxProps>(function VirtualListBox(
props,
ref
) {
const { children, virtualizationProps, ...otherProps } = props;

const flattenedItems = flattenOptionsHierarchy(children as Options);
const totalItems = flattenedItems.length;

const listRef = useListCache(totalItems);
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.up('sm'), { noSsr: true });

const itemHeight = isSmallScreen ? 36 : 48;
const groupHeight = 48;

const getItemHeight = (item: Options | any[]) => (item?.hasOwnProperty('group') ? groupHeight : itemHeight);

const calculateListHeight = () => {
if (totalItems > 8) {
return 8 * itemHeight;
}
return flattenedItems.map(getItemHeight).reduce((sum, height) => sum + height, 0);
};

return (
<div ref={ref}>
<OuterElementContext.Provider value={otherProps}>
<VariableSizeList
itemData={flattenedItems}
height={calculateListHeight() + 2 * LISTBOX_PADDING_PX}
width="100%"
ref={listRef}
outerElementType={OuterElementType}
itemSize={(index) => getItemHeight(flattenedItems[index])}
overscanCount={5}
itemCount={totalItems}
{...virtualizationProps}
>
{renderListRow}
</VariableSizeList>
</OuterElementContext.Provider>
</div>
);
});
10 changes: 7 additions & 3 deletions src/fields/typing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Control } from 'react-hook-form';
import { TextFieldProps, SelectProps, SelectChangeEvent, AutocompleteProps } from '@mui/material';
import React from 'react';
import { AutocompleteProps, SelectChangeEvent, SelectProps, TextFieldProps } from '@mui/material';
import { DatePickerProps } from '@mui/x-date-pickers';
import React from 'react';
import { Control } from 'react-hook-form';
import { VirtualListBoxProps } from '../components/shared/list-box-component';

// Common input field props
export type MuiRhfFieldProps = {
Expand Down Expand Up @@ -79,6 +80,9 @@ export type AutocompleteControllerProps = Omit<MuiRhfFieldProps, 'helperText'> &

onChange?: (event: SelectChangeEvent, e: React.SyntheticEvent<Element, Event>) => void;
customOptionLabel?: (option: any) => any;

virtualizationThreshold?: number;
virtualizationProps?: VirtualListBoxProps['virtualizationProps'];
};

export type CustomComponentControllerProps = MuiRhfFieldProps & {
Expand Down
45 changes: 41 additions & 4 deletions src/stories/Autocomplete.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { Meta, StoryFn } from '@storybook/react';
import { AutocompleteController } from '../components/InputController/AutocompleteController/AutocompleteController';
import React from 'react';
import { useForm } from 'react-hook-form';
import { AutocompleteControllerProps } from '../fields';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { AutocompleteController } from '../components/InputController/AutocompleteController/AutocompleteController';
import { AutocompleteControllerProps } from '../fields';

const meta: Meta = {
title: 'Autocomplete Controller',
Expand Down Expand Up @@ -79,3 +79,40 @@ AutocompleteMultiple.args = {
],
multiple: true
};

export const AutocompleteVirtualized = Template.bind({});

function random(length: number) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';

for (let i = 0; i < length; i += 1) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}

return result;
}

const OPTIONS = Array.from(new Array(1500))
.map(() => {
const value = random(10 + Math.ceil(Math.random() * 20));

return {
label: value,
entity: {
id: value
}
};
})
.sort((a, b) => a.label.toUpperCase().localeCompare(b.label.toUpperCase()));

AutocompleteVirtualized.args = {
name: 'autocompleteVirtualized',
textFieldProps: {
label: 'Autocomplete Virtualized Controller',
fullWidth: true
},
defaultValue: [],
optionValue: 'entity.id',
options: OPTIONS
};
Loading