From 2c0caf24391ca85bfe5c65031eb47d382345bdfc Mon Sep 17 00:00:00 2001 From: "Hedi M. Sharif" Date: Mon, 4 Nov 2024 14:31:17 +0300 Subject: [PATCH 1/6] chore(dependencies): installing react-window --- package.json | 4 +++- yarn.lock | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index fdaaac8..0642148 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@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", @@ -45,6 +46,7 @@ "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", @@ -79,4 +81,4 @@ "@storybook/react-docgen-typescript-plugin": "npm:react-docgen-typescript-plugin@1.0.2" }, "packageManager": "yarn@4.0.2" -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index e2a5bc7..c425eb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2363,6 +2363,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.0.0": + version: 7.26.0 + resolution: "@babel/runtime@npm:7.26.0" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 12c01357e0345f89f4f7e8c0e81921f2a3e3e101f06e8eaa18a382b517376520cd2fa8c237726eb094dab25532855df28a7baaf1c26342b52782f6936b07c287 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.23.1 resolution: "@babel/runtime@npm:7.23.1" @@ -5371,6 +5380,15 @@ __metadata: languageName: node linkType: hard +"@types/react-window@npm:^1": + version: 1.8.8 + resolution: "@types/react-window@npm:1.8.8" + dependencies: + "@types/react": "npm:*" + checksum: 2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f + languageName: node + linkType: hard + "@types/react@npm:*, @types/react@npm:>=16": version: 18.2.22 resolution: "@types/react@npm:18.2.22" @@ -9289,6 +9307,13 @@ __metadata: languageName: node linkType: hard +"memoize-one@npm:>=3.1.1 <6": + version: 5.2.1 + resolution: "memoize-one@npm:5.2.1" + checksum: fd22dbe9a978a2b4f30d6a491fc02fb90792432ad0dab840dc96c1734d2bd7c9cdeb6a26130ec60507eb43230559523615873168bcbe8fafab221c30b11d54c1 + languageName: node + linkType: hard + "memoizerific@npm:^1.11.3": version: 1.11.3 resolution: "memoizerific@npm:1.11.3" @@ -9595,6 +9620,7 @@ __metadata: "@storybook/react-webpack5": "npm:7.6.5" "@types/lodash.get": "npm:^4.4.6" "@types/react": "npm:^17.0.39" + "@types/react-window": "npm:^1" babel-loader: "npm:^8.2.3" chromatic: "npm:^6.5.4" lodash.get: "npm:^4.4.2" @@ -9602,6 +9628,7 @@ __metadata: react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-hook-form: "npm:^7.49.2" + react-window: "npm:^1.8.10" rollup: "npm:^4.9.1" rollup-plugin-dts: "npm:^6.1.0" rollup-plugin-peer-deps-external: "npm:^2.2.4" @@ -10674,6 +10701,19 @@ __metadata: languageName: node linkType: hard +"react-window@npm:^1.8.10": + version: 1.8.10 + resolution: "react-window@npm:1.8.10" + dependencies: + "@babel/runtime": "npm:^7.0.0" + memoize-one: "npm:>=3.1.1 <6" + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: eda9afb667d9784513dcc2755b65edf3a1412e7877975322993c1382908aaef0c0b948b7e3b2d705e353306556274d90f7ab19ac40aef2184fa39d4c1e2232ea + languageName: node + linkType: hard + "react@npm:^18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0" From de823befad0e01d8c48cead7410cf046f3d2c240 Mon Sep 17 00:00:00 2001 From: "Hedi M. Sharif" Date: Mon, 4 Nov 2024 14:34:41 +0300 Subject: [PATCH 2/6] add(components): adding list-box-component as a shared component this is a fine tuned version of the MUI's documentation code, ref: https://mui.com/material-ui/react-autocomplete/#virtualization --- src/components/shared/list-box-component.tsx | 132 +++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/components/shared/list-box-component.tsx diff --git a/src/components/shared/list-box-component.tsx b/src/components/shared/list-box-component.tsx new file mode 100644 index 0000000..268fa6a --- /dev/null +++ b/src/components/shared/list-box-component.tsx @@ -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; + key: string; +}; + +type Options = Array>; + +export type VirtualListBoxProps = HTMLAttributes & { + virtualizationProps?: Partial; + children?: ReactNode; +}; + +// Context +const OuterElementContext = React.createContext({}); + +// Component to render the outer element (ul) +const OuterElementType: FC> = (props) => { + const outerProps = React.useContext(OuterElementContext); + return
    ; +}; + +// Custom hook for cache management +const useListCache = (data: any) => { + const listRef = React.useRef(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 ( + + {componentProps.group} + + ); + } + + const { key, ...optionProps } = componentProps ?? {}; + return ( + + {ownerState?.getOptionLabel(option)} + + ); +}; + +// 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(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 ( +
    + + getItemHeight(flattenedItems[index])} + overscanCount={5} + itemCount={totalItems} + {...virtualizationProps} + > + {renderListRow} + + +
    + ); +}); From f7fb39ea12221de9d3531e878d49ab93e4055074 Mon Sep 17 00:00:00 2001 From: "Hedi M. Sharif" Date: Mon, 4 Nov 2024 14:35:39 +0300 Subject: [PATCH 3/6] feat(controllers): adding virtualization feature for autocomplete controller --- .../AutocompleteController.tsx | 29 +++++++++++++++---- src/fields/typing.ts | 10 +++++-- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/components/InputController/AutocompleteController/AutocompleteController.tsx b/src/components/InputController/AutocompleteController/AutocompleteController.tsx index d73fa45..ed33f6f 100644 --- a/src/components/InputController/AutocompleteController/AutocompleteController.tsx +++ b/src/components/InputController/AutocompleteController/AutocompleteController.tsx @@ -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)( () => ` @@ -28,8 +29,12 @@ export const AutocompleteController = ({ onChange, customOptionLabel, onBlur, + virtualizationThreshold = 100, + virtualizationProps, ...rest }: AutocompleteControllerProps) => { + const isVirtualizing = options.length > virtualizationThreshold; + return loading ? ( @@ -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, 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 (
  • {customOptionLabel ? customOptionLabel(option) : get(option, optionLabel, '')}
  • ); }} + renderGroup={isVirtualizing ? (...args) => args : undefined} disableCloseOnSelect={multiple} isOptionEqualToValue={(option: any, value: any) => { return typeof value === 'string' @@ -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 ( diff --git a/src/fields/typing.ts b/src/fields/typing.ts index 8c212f6..7495a1f 100644 --- a/src/fields/typing.ts +++ b/src/fields/typing.ts @@ -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 = { @@ -79,6 +80,9 @@ export type AutocompleteControllerProps = Omit & onChange?: (event: SelectChangeEvent, e: React.SyntheticEvent) => void; customOptionLabel?: (option: any) => any; + + virtualizationThreshold?: number; + virtualizationProps?: VirtualListBoxProps['virtualizationProps']; }; export type CustomComponentControllerProps = MuiRhfFieldProps & { From 637c8ca067254e54dbc9d7d8021388b2f7e7c535 Mon Sep 17 00:00:00 2001 From: "Hedi M. Sharif" Date: Mon, 4 Nov 2024 14:39:06 +0300 Subject: [PATCH 4/6] storybook(controllers): adding example of virtualized autocomplete --- src/stories/Autocomplete.stories.tsx | 46 +++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/stories/Autocomplete.stories.tsx b/src/stories/Autocomplete.stories.tsx index e58dfcd..3782789 100644 --- a/src/stories/Autocomplete.stories.tsx +++ b/src/stories/Autocomplete.stories.tsx @@ -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', @@ -79,3 +79,41 @@ 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', + open: true, + options: OPTIONS +}; From 203ef714b1c1bb2f4263e967fa25814823d3896e Mon Sep 17 00:00:00 2001 From: "Hedi M. Sharif" Date: Mon, 4 Nov 2024 14:44:54 +0300 Subject: [PATCH 5/6] docs(controllers/autocomplete): documenting virtualization feature --- README.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3454c0b..a654e01 100644 --- a/README.md +++ b/README.md @@ -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 From 521285c261be542d416b5ebf7f4ba3bb4927e0d7 Mon Sep 17 00:00:00 2001 From: "Hedi M. Sharif" Date: Mon, 4 Nov 2024 14:46:58 +0300 Subject: [PATCH 6/6] storybook(controllers): removing passed extra argument --- src/stories/Autocomplete.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stories/Autocomplete.stories.tsx b/src/stories/Autocomplete.stories.tsx index 3782789..ac4db99 100644 --- a/src/stories/Autocomplete.stories.tsx +++ b/src/stories/Autocomplete.stories.tsx @@ -114,6 +114,5 @@ AutocompleteVirtualized.args = { }, defaultValue: [], optionValue: 'entity.id', - open: true, options: OPTIONS };