From a9e46afbb5509e4eaf735529d6200dfde198b52b Mon Sep 17 00:00:00 2001 From: Saurabh Daware Date: Wed, 15 Jan 2025 14:21:21 +0530 Subject: [PATCH] feat(ActionList): add Virtualization (#2471) * feat: add virtulization library * feat: initial virtulization code * fix: get autocomplete working with virtulization * fix: actionlist rerenders * fix: ts * Create tidy-lies-confess.md * Update tidy-lies-confess.md * feat: resolve comments --- .changeset/tidy-lies-confess.md | 14 ++ packages/blade/package.json | 4 +- .../src/components/ActionList/ActionList.tsx | 14 +- .../ActionList/ActionListBox.native.tsx | 2 +- .../ActionList/ActionListBox.web.tsx | 132 +++++++++++++++++- .../components/ActionList/ActionListItem.tsx | 5 +- .../components/ActionList/docs/propsTable.tsx | 5 + .../styles/getBaseListBoxWrapperStyles.ts | 12 +- .../BaseMenu/BaseMenuItem/BaseMenuItem.tsx | 9 +- .../StyledMenuItemContainer.web.tsx | 5 +- .../BaseMenuItem/getBaseMenuItemStyles.ts | 5 +- .../BaseMenu/BaseMenuItem/tokens.ts | 38 +++++ .../docs/DropdownWithSelect.stories.tsx | 9 +- yarn.lock | 15 ++ 14 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 .changeset/tidy-lies-confess.md create mode 100644 packages/blade/src/components/BaseMenu/BaseMenuItem/tokens.ts diff --git a/.changeset/tidy-lies-confess.md b/.changeset/tidy-lies-confess.md new file mode 100644 index 00000000000..5c392c38001 --- /dev/null +++ b/.changeset/tidy-lies-confess.md @@ -0,0 +1,14 @@ +--- +"@razorpay/blade": minor +--- + +feat(ActionList): add Virtualization in ActionList + +```jsx + + +``` + +> [!NOTE] +> +> Current version only supports virtulization of fixed height list where items do not have descriptions. We'll be adding support for dynamic height lists in future versions diff --git a/packages/blade/package.json b/packages/blade/package.json index a855cc54d9a..6377a41e10e 100644 --- a/packages/blade/package.json +++ b/packages/blade/package.json @@ -148,7 +148,8 @@ "@mantine/core": "6.0.21", "@mantine/dates": "6.0.21", "@mantine/hooks": "6.0.21", - "dayjs": "1.11.10" + "dayjs": "1.11.10", + "react-window": "1.8.11" }, "devDependencies": { "http-server": "14.1.1", @@ -222,6 +223,7 @@ "@types/styled-components-react-native": "5.1.3", "@types/tinycolor2": "1.4.3", "@types/react-router-dom": "5.3.3", + "@types/react-window": "1.8.8", "@types/storybook-react-router": "1.0.5", "any-leaf": "1.2.2", "args-parser": "1.3.0", diff --git a/packages/blade/src/components/ActionList/ActionList.tsx b/packages/blade/src/components/ActionList/ActionList.tsx index 9c1ca3a1bf0..05ee740e642 100644 --- a/packages/blade/src/components/ActionList/ActionList.tsx +++ b/packages/blade/src/components/ActionList/ActionList.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { getActionListContainerRole, getActionListItemWrapperRole } from './getA11yRoles'; import { getActionListProperties } from './actionListUtils'; -import { ActionListBox } from './ActionListBox'; +import { ActionListBox as ActionListNormalBox, ActionListVirtualizedBox } from './ActionListBox'; import { componentIds } from './componentIds'; import { ActionListNoResults } from './ActionListNoResults'; import { useDropdown } from '~components/Dropdown/useDropdown'; @@ -17,10 +17,16 @@ import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; type ActionListProps = { children: React.ReactNode[]; + isVirtualized?: boolean; } & TestID & DataAnalyticsAttribute; -const _ActionList = ({ children, testID, ...rest }: ActionListProps): React.ReactElement => { +const _ActionList = ({ + children, + testID, + isVirtualized, + ...rest +}: ActionListProps): React.ReactElement => { const { setOptions, actionListItemRef, @@ -31,6 +37,8 @@ const _ActionList = ({ children, testID, ...rest }: ActionListProps): React.Reac filteredValues, } = useDropdown(); + const ActionListBox = isVirtualized ? ActionListVirtualizedBox : ActionListNormalBox; + const { isInBottomSheet } = useBottomSheetContext(); const { sectionData, childrenWithId, actionListOptions } = React.useMemo( @@ -38,8 +46,6 @@ const _ActionList = ({ children, testID, ...rest }: ActionListProps): React.Reac [children], ); - console.log({ actionListOptions }); - React.useEffect(() => { setOptions(actionListOptions); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/blade/src/components/ActionList/ActionListBox.native.tsx b/packages/blade/src/components/ActionList/ActionListBox.native.tsx index 8b6782268ab..22660e3037a 100644 --- a/packages/blade/src/components/ActionList/ActionListBox.native.tsx +++ b/packages/blade/src/components/ActionList/ActionListBox.native.tsx @@ -74,4 +74,4 @@ const _ActionListBox = React.forwardRef( const ActionListBox = assignWithoutSideEffects(_ActionListBox, { displayName: 'ActionListBox' }); -export { ActionListBox }; +export { ActionListBox, ActionListBox as ActionListVirtualizedBox }; diff --git a/packages/blade/src/components/ActionList/ActionListBox.web.tsx b/packages/blade/src/components/ActionList/ActionListBox.web.tsx index bb3c46ae3c9..31bec4c3d2f 100644 --- a/packages/blade/src/components/ActionList/ActionListBox.web.tsx +++ b/packages/blade/src/components/ActionList/ActionListBox.web.tsx @@ -1,12 +1,20 @@ /* eslint-disable react/display-name */ import React from 'react'; +import { FixedSizeList as VirtualizedList } from 'react-window'; import { StyledListBoxWrapper } from './styles/StyledListBoxWrapper'; import type { SectionData } from './actionListUtils'; +import { actionListMaxHeight, getActionListPadding } from './styles/getBaseListBoxWrapperStyles'; import { useBottomSheetContext } from '~components/BottomSheet/BottomSheetContext'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { makeAccessible } from '~utils/makeAccessible'; import type { DataAnalyticsAttribute } from '~utils/types'; import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; +import { useIsMobile } from '~utils/useIsMobile'; +import { getItemHeight } from '~components/BaseMenu/BaseMenuItem/tokens'; +import { useTheme } from '~utils'; +import type { Theme } from '~components/BladeProvider'; +import { useDropdown } from '~components/Dropdown/useDropdown'; +import { dropdownComponentIds } from '~components/Dropdown/dropdownComponentIds'; type ActionListBoxProps = { childrenWithId?: React.ReactNode[] | null; @@ -36,6 +44,126 @@ const _ActionListBox = React.forwardRef( }, ); -const ActionListBox = assignWithoutSideEffects(_ActionListBox, { displayName: 'ActionListBox' }); +const ActionListBox = assignWithoutSideEffects(React.memo(_ActionListBox), { + displayName: 'ActionListBox', +}); -export { ActionListBox }; +/** + * Returns the height of item and height of container based on theme and device + */ +const getVirtualItemParams = ({ + theme, + isMobile, +}: { + theme: Theme; + isMobile: boolean; +}): { + itemHeight: number; + actionListBoxHeight: number; +} => { + const itemHeightResponsive = getItemHeight(theme); + const actionListPadding = getActionListPadding(theme); + const actionListBoxHeight = actionListMaxHeight - actionListPadding * 2; + + return { + itemHeight: isMobile + ? itemHeightResponsive.itemHeightMobile + : itemHeightResponsive.itemHeightDesktop, + actionListBoxHeight, + }; +}; + +/** + * Takes the children (ActionListItem) and returns the filtered items based on `filteredValues` state + */ +const useFilteredItems = ( + children: React.ReactNode[], +): { + itemData: React.ReactNode[]; + itemCount: number; +} => { + const childrenArray = React.Children.toArray(children); // Convert children to an array + + const { filteredValues, hasAutoCompleteInBottomSheetHeader, dropdownTriggerer } = useDropdown(); + + const items = React.useMemo(() => { + const hasAutoComplete = + hasAutoCompleteInBottomSheetHeader || + dropdownTriggerer === dropdownComponentIds.triggers.AutoComplete; + + if (!hasAutoComplete) { + return childrenArray; + } + + // @ts-expect-error: props does exist + const filteredItems = childrenArray.filter((item) => filteredValues.includes(item.props.value)); + return filteredItems; + }, [filteredValues, hasAutoCompleteInBottomSheetHeader, dropdownTriggerer, childrenArray]); + + return { + itemData: items, + itemCount: items.length, + }; +}; + +const VirtualListItem = ({ + index, + style, + data, +}: { + index: number; + style: React.CSSProperties; + data: React.ReactNode[]; +}): React.ReactElement => { + return
{data[index]}
; +}; + +const _ActionListVirtualizedBox = React.forwardRef( + ({ childrenWithId, actionListItemWrapperRole, isMultiSelectable, ...rest }, ref) => { + const items = React.Children.toArray(childrenWithId); // Convert children to an array + const { isInBottomSheet } = useBottomSheetContext(); + const { itemData, itemCount } = useFilteredItems(items); + + const isMobile = useIsMobile(); + const { theme } = useTheme(); + const { itemHeight, actionListBoxHeight } = React.useMemo( + () => getVirtualItemParams({ theme, isMobile }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [theme.name, isMobile], + ); + + return ( + + {itemCount < 10 ? ( + childrenWithId + ) : ( + itemData[index]?.props.value} + > + {VirtualListItem} + + )} + + ); + }, +); + +const ActionListVirtualizedBox = assignWithoutSideEffects(React.memo(_ActionListVirtualizedBox), { + displayName: 'ActionListVirtualizedBox', +}); + +export { ActionListBox, ActionListVirtualizedBox }; diff --git a/packages/blade/src/components/ActionList/ActionListItem.tsx b/packages/blade/src/components/ActionList/ActionListItem.tsx index 7e6ccb4f743..1c34c504b0a 100644 --- a/packages/blade/src/components/ActionList/ActionListItem.tsx +++ b/packages/blade/src/components/ActionList/ActionListItem.tsx @@ -348,10 +348,11 @@ const _ActionListItem = (props: ActionListItemProps): React.ReactElement => { } }, [props.intent, dropdownTriggerer]); + const isVisible = hasAutoComplete && filteredValues ? filteredValues.includes(props.value) : true; + return ( - // We use this context to change the color of subcomponents like ActionListItemIcon, ActionListItemText, etc <ActionListSection[] /> ), + isVirtualized: { + note: + 'Currently only works in ActionList with static height items (items without description) and when ActionList has more than 10 items', + type: 'boolean', + }, }, ActionListItem: { title: 'string', diff --git a/packages/blade/src/components/ActionList/styles/getBaseListBoxWrapperStyles.ts b/packages/blade/src/components/ActionList/styles/getBaseListBoxWrapperStyles.ts index da547f32660..0eeec3f01f1 100644 --- a/packages/blade/src/components/ActionList/styles/getBaseListBoxWrapperStyles.ts +++ b/packages/blade/src/components/ActionList/styles/getBaseListBoxWrapperStyles.ts @@ -3,14 +3,20 @@ import type { Theme } from '~components/BladeProvider'; import { makeSize } from '~utils/makeSize'; import { size } from '~tokens/global'; +const actionListMaxHeight = size[300]; + +const getActionListPadding = (theme: Theme): number => { + return theme.spacing[3]; +}; + const getBaseListBoxWrapperStyles = (props: { theme: Theme; isInBottomSheet: boolean; }): CSSObject => { return { - maxHeight: props.isInBottomSheet ? undefined : makeSize(size[300]), - padding: props.isInBottomSheet ? undefined : makeSize(props.theme.spacing[3]), + maxHeight: props.isInBottomSheet ? undefined : makeSize(actionListMaxHeight), + padding: props.isInBottomSheet ? undefined : makeSize(getActionListPadding(props.theme)), }; }; -export { getBaseListBoxWrapperStyles }; +export { getBaseListBoxWrapperStyles, actionListMaxHeight, getActionListPadding }; diff --git a/packages/blade/src/components/BaseMenu/BaseMenuItem/BaseMenuItem.tsx b/packages/blade/src/components/BaseMenu/BaseMenuItem/BaseMenuItem.tsx index 2dc7daee43f..d598c2dfd65 100644 --- a/packages/blade/src/components/BaseMenu/BaseMenuItem/BaseMenuItem.tsx +++ b/packages/blade/src/components/BaseMenu/BaseMenuItem/BaseMenuItem.tsx @@ -2,14 +2,14 @@ import React from 'react'; import type { BaseMenuItemProps } from '../types'; import { BaseMenuItemContext } from '../BaseMenuContext'; import { StyledMenuItemContainer } from './StyledMenuItemContainer'; +import { itemFirstRowHeight } from './tokens'; import { Box } from '~components/Box'; import { getTextProps, Text } from '~components/Typography'; -import { size } from '~tokens/global'; -import { makeSize } from '~utils'; import { makeAccessible } from '~utils/makeAccessible'; import type { BladeElementRef } from '~utils/types'; import { BaseText } from '~components/Typography/BaseText'; import { useTruncationTitle } from '~utils/useTruncationTitle'; +import { makeSize } from '~utils'; const menuItemTitleColor = { negative: { @@ -25,7 +25,6 @@ const menuItemDescriptionColor = { } as const; // This is the height of item excluding the description to make sure description comes at the bottom and other first row items are center aligned -const itemFirstRowHeight = makeSize(size[20]); const _BaseMenuItem: React.ForwardRefRenderFunction = ( { @@ -75,7 +74,7 @@ const _BaseMenuItem: React.ForwardRefRenderFunction {leading} @@ -89,7 +88,7 @@ const _BaseMenuItem: React.ForwardRefRenderFunction ((props) => { return { ...getBaseMenuItemStyles({ theme: props.theme }), - padding: makeSize(props.theme.spacing[2]), + padding: makeSize(getItemPadding(props.theme).itemPaddingMobile), display: props.isVisible ? 'flex' : 'none', [`@media ${getMediaQuery({ min: props.theme.breakpoints.m })}`]: { - padding: makeSize(props.theme.spacing[3]), + padding: makeSize(getItemPadding(props.theme).itemPaddingDesktop), }, '&:hover:not([aria-disabled=true]), &[aria-expanded="true"]': { backgroundColor: diff --git a/packages/blade/src/components/BaseMenu/BaseMenuItem/getBaseMenuItemStyles.ts b/packages/blade/src/components/BaseMenu/BaseMenuItem/getBaseMenuItemStyles.ts index edef57bd3b4..8ce094cbd54 100644 --- a/packages/blade/src/components/BaseMenu/BaseMenuItem/getBaseMenuItemStyles.ts +++ b/packages/blade/src/components/BaseMenu/BaseMenuItem/getBaseMenuItemStyles.ts @@ -1,4 +1,5 @@ import type { CSSObject } from 'styled-components'; +import { getItemMargin } from './tokens'; import type { Theme } from '~components/BladeProvider'; import { isReactNative, makeBorderSize } from '~utils'; import { makeSize } from '~utils/makeSize'; @@ -11,8 +12,8 @@ const getBaseMenuItemStyles = (props: { theme: Theme }): CSSObject => { textAlign: isReactNative() ? undefined : 'left', backgroundColor: 'transparent', borderRadius: makeSize(props.theme.border.radius.medium), - marginTop: makeSize(props.theme.spacing[1]), - marginBottom: makeSize(props.theme.spacing[1]), + marginTop: makeSize(getItemMargin(props.theme)), + marginBottom: makeSize(getItemMargin(props.theme)), textDecoration: 'none', cursor: 'pointer', width: '100%', diff --git a/packages/blade/src/components/BaseMenu/BaseMenuItem/tokens.ts b/packages/blade/src/components/BaseMenu/BaseMenuItem/tokens.ts new file mode 100644 index 00000000000..b5f25d3444e --- /dev/null +++ b/packages/blade/src/components/BaseMenu/BaseMenuItem/tokens.ts @@ -0,0 +1,38 @@ +import type { Theme } from '~components/BladeProvider'; +import { size } from '~tokens/global'; + +const itemFirstRowHeight = size[20]; + +const getItemPadding = ( + theme: Theme, +): { + itemPaddingMobile: 4; + itemPaddingDesktop: 8; +} => { + return { + itemPaddingMobile: theme.spacing[2], + itemPaddingDesktop: theme.spacing[3], + }; +}; + +const getItemMargin = (theme: Theme): number => { + return theme.spacing[1]; +}; + +const getItemHeight = ( + theme: Theme, +): { + itemHeightMobile: number; + itemHeightDesktop: number; +} => { + return { + itemHeightMobile: + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + itemFirstRowHeight + getItemPadding(theme).itemPaddingMobile * 2 + getItemMargin(theme) * 2, + itemHeightDesktop: + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + itemFirstRowHeight + getItemPadding(theme).itemPaddingDesktop * 2 + getItemMargin(theme) * 2, + }; +}; + +export { itemFirstRowHeight, getItemPadding, getItemMargin, getItemHeight }; diff --git a/packages/blade/src/components/Dropdown/docs/DropdownWithSelect.stories.tsx b/packages/blade/src/components/Dropdown/docs/DropdownWithSelect.stories.tsx index 0e269758ebd..eaaf242c3c6 100644 --- a/packages/blade/src/components/Dropdown/docs/DropdownWithSelect.stories.tsx +++ b/packages/blade/src/components/Dropdown/docs/DropdownWithSelect.stories.tsx @@ -16,7 +16,7 @@ import { } from './stories'; import { Sandbox } from '~utils/storybook/Sandbox'; -import { SelectInput } from '~components/Input/DropdownInputTriggers'; +import { AutoComplete, SelectInput } from '~components/Input/DropdownInputTriggers'; import { ActionList, ActionListItem, @@ -381,7 +381,7 @@ export const InternalSectionListPerformance = (): React.ReactElement => { - + @@ -464,9 +464,9 @@ export const InternalDropdownPerformance = (): React.ReactElement => { return ( - + - + {fruits.map((fruit) => { if (typeof fruit === 'string') { return ; @@ -476,7 +476,6 @@ export const InternalDropdownPerformance = (): React.ReactElement => { ⌘ + S} leading={} - description={fruit.description} key={fruit.name} title={fruit.name} value={fruit.name} diff --git a/yarn.lock b/yarn.lock index ce0d5d458e6..a160488d788 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7618,6 +7618,13 @@ dependencies: "@types/react" "*" +"@types/react-window@1.8.8": + version "1.8.8" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" + integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@18.2.24", "@types/react@>=16", "@types/react@>=16.0.0", "@types/react@^17": version "18.2.24" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.24.tgz#3c7d68c02e0205a472f04abe4a0c1df35d995c05" @@ -24189,6 +24196,14 @@ react-virtualized-auto-sizer@1.0.7: resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz#bfb8414698ad1597912473de3e2e5f82180c1195" integrity sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA== +react-window@1.8.11: + version "1.8.11" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.11.tgz#a857b48fa85bd77042d59cc460964ff2e0648525" + integrity sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react-window@1.8.7: version "1.8.7" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.7.tgz#5e9fd0d23f48f432d7022cdb327219353a15f0d4"