Skip to content

Commit a9e46af

Browse files
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
1 parent 73c9c24 commit a9e46af

14 files changed

+242
-27
lines changed

.changeset/tidy-lies-confess.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@razorpay/blade": minor
3+
---
4+
5+
feat(ActionList): add Virtualization in ActionList
6+
7+
```jsx
8+
<ActionList isVirtualized>
9+
</ActionList>
10+
```
11+
12+
> [!NOTE]
13+
>
14+
> 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

packages/blade/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@
148148
"@mantine/core": "6.0.21",
149149
"@mantine/dates": "6.0.21",
150150
"@mantine/hooks": "6.0.21",
151-
"dayjs": "1.11.10"
151+
"dayjs": "1.11.10",
152+
"react-window": "1.8.11"
152153
},
153154
"devDependencies": {
154155
"http-server": "14.1.1",
@@ -222,6 +223,7 @@
222223
"@types/styled-components-react-native": "5.1.3",
223224
"@types/tinycolor2": "1.4.3",
224225
"@types/react-router-dom": "5.3.3",
226+
"@types/react-window": "1.8.8",
225227
"@types/storybook-react-router": "1.0.5",
226228
"any-leaf": "1.2.2",
227229
"args-parser": "1.3.0",

packages/blade/src/components/ActionList/ActionList.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import React from 'react';
33
import { getActionListContainerRole, getActionListItemWrapperRole } from './getA11yRoles';
44
import { getActionListProperties } from './actionListUtils';
5-
import { ActionListBox } from './ActionListBox';
5+
import { ActionListBox as ActionListNormalBox, ActionListVirtualizedBox } from './ActionListBox';
66
import { componentIds } from './componentIds';
77
import { ActionListNoResults } from './ActionListNoResults';
88
import { useDropdown } from '~components/Dropdown/useDropdown';
@@ -17,10 +17,16 @@ import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute';
1717

1818
type ActionListProps = {
1919
children: React.ReactNode[];
20+
isVirtualized?: boolean;
2021
} & TestID &
2122
DataAnalyticsAttribute;
2223

23-
const _ActionList = ({ children, testID, ...rest }: ActionListProps): React.ReactElement => {
24+
const _ActionList = ({
25+
children,
26+
testID,
27+
isVirtualized,
28+
...rest
29+
}: ActionListProps): React.ReactElement => {
2430
const {
2531
setOptions,
2632
actionListItemRef,
@@ -31,15 +37,15 @@ const _ActionList = ({ children, testID, ...rest }: ActionListProps): React.Reac
3137
filteredValues,
3238
} = useDropdown();
3339

40+
const ActionListBox = isVirtualized ? ActionListVirtualizedBox : ActionListNormalBox;
41+
3442
const { isInBottomSheet } = useBottomSheetContext();
3543

3644
const { sectionData, childrenWithId, actionListOptions } = React.useMemo(
3745
() => getActionListProperties(children),
3846
[children],
3947
);
4048

41-
console.log({ actionListOptions });
42-
4349
React.useEffect(() => {
4450
setOptions(actionListOptions);
4551
// eslint-disable-next-line react-hooks/exhaustive-deps

packages/blade/src/components/ActionList/ActionListBox.native.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,4 @@ const _ActionListBox = React.forwardRef<SectionList, ActionListBoxProps>(
7474

7575
const ActionListBox = assignWithoutSideEffects(_ActionListBox, { displayName: 'ActionListBox' });
7676

77-
export { ActionListBox };
77+
export { ActionListBox, ActionListBox as ActionListVirtualizedBox };

packages/blade/src/components/ActionList/ActionListBox.web.tsx

+130-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
/* eslint-disable react/display-name */
22
import React from 'react';
3+
import { FixedSizeList as VirtualizedList } from 'react-window';
34
import { StyledListBoxWrapper } from './styles/StyledListBoxWrapper';
45
import type { SectionData } from './actionListUtils';
6+
import { actionListMaxHeight, getActionListPadding } from './styles/getBaseListBoxWrapperStyles';
57
import { useBottomSheetContext } from '~components/BottomSheet/BottomSheetContext';
68
import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects';
79
import { makeAccessible } from '~utils/makeAccessible';
810
import type { DataAnalyticsAttribute } from '~utils/types';
911
import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute';
12+
import { useIsMobile } from '~utils/useIsMobile';
13+
import { getItemHeight } from '~components/BaseMenu/BaseMenuItem/tokens';
14+
import { useTheme } from '~utils';
15+
import type { Theme } from '~components/BladeProvider';
16+
import { useDropdown } from '~components/Dropdown/useDropdown';
17+
import { dropdownComponentIds } from '~components/Dropdown/dropdownComponentIds';
1018

1119
type ActionListBoxProps = {
1220
childrenWithId?: React.ReactNode[] | null;
@@ -36,6 +44,126 @@ const _ActionListBox = React.forwardRef<HTMLDivElement, ActionListBoxProps>(
3644
},
3745
);
3846

39-
const ActionListBox = assignWithoutSideEffects(_ActionListBox, { displayName: 'ActionListBox' });
47+
const ActionListBox = assignWithoutSideEffects(React.memo(_ActionListBox), {
48+
displayName: 'ActionListBox',
49+
});
4050

41-
export { ActionListBox };
51+
/**
52+
* Returns the height of item and height of container based on theme and device
53+
*/
54+
const getVirtualItemParams = ({
55+
theme,
56+
isMobile,
57+
}: {
58+
theme: Theme;
59+
isMobile: boolean;
60+
}): {
61+
itemHeight: number;
62+
actionListBoxHeight: number;
63+
} => {
64+
const itemHeightResponsive = getItemHeight(theme);
65+
const actionListPadding = getActionListPadding(theme);
66+
const actionListBoxHeight = actionListMaxHeight - actionListPadding * 2;
67+
68+
return {
69+
itemHeight: isMobile
70+
? itemHeightResponsive.itemHeightMobile
71+
: itemHeightResponsive.itemHeightDesktop,
72+
actionListBoxHeight,
73+
};
74+
};
75+
76+
/**
77+
* Takes the children (ActionListItem) and returns the filtered items based on `filteredValues` state
78+
*/
79+
const useFilteredItems = (
80+
children: React.ReactNode[],
81+
): {
82+
itemData: React.ReactNode[];
83+
itemCount: number;
84+
} => {
85+
const childrenArray = React.Children.toArray(children); // Convert children to an array
86+
87+
const { filteredValues, hasAutoCompleteInBottomSheetHeader, dropdownTriggerer } = useDropdown();
88+
89+
const items = React.useMemo(() => {
90+
const hasAutoComplete =
91+
hasAutoCompleteInBottomSheetHeader ||
92+
dropdownTriggerer === dropdownComponentIds.triggers.AutoComplete;
93+
94+
if (!hasAutoComplete) {
95+
return childrenArray;
96+
}
97+
98+
// @ts-expect-error: props does exist
99+
const filteredItems = childrenArray.filter((item) => filteredValues.includes(item.props.value));
100+
return filteredItems;
101+
}, [filteredValues, hasAutoCompleteInBottomSheetHeader, dropdownTriggerer, childrenArray]);
102+
103+
return {
104+
itemData: items,
105+
itemCount: items.length,
106+
};
107+
};
108+
109+
const VirtualListItem = ({
110+
index,
111+
style,
112+
data,
113+
}: {
114+
index: number;
115+
style: React.CSSProperties;
116+
data: React.ReactNode[];
117+
}): React.ReactElement => {
118+
return <div style={style}>{data[index]}</div>;
119+
};
120+
121+
const _ActionListVirtualizedBox = React.forwardRef<HTMLDivElement, ActionListBoxProps>(
122+
({ childrenWithId, actionListItemWrapperRole, isMultiSelectable, ...rest }, ref) => {
123+
const items = React.Children.toArray(childrenWithId); // Convert children to an array
124+
const { isInBottomSheet } = useBottomSheetContext();
125+
const { itemData, itemCount } = useFilteredItems(items);
126+
127+
const isMobile = useIsMobile();
128+
const { theme } = useTheme();
129+
const { itemHeight, actionListBoxHeight } = React.useMemo(
130+
() => getVirtualItemParams({ theme, isMobile }),
131+
// eslint-disable-next-line react-hooks/exhaustive-deps
132+
[theme.name, isMobile],
133+
);
134+
135+
return (
136+
<StyledListBoxWrapper
137+
isInBottomSheet={isInBottomSheet}
138+
ref={ref}
139+
{...makeAccessible({
140+
role: actionListItemWrapperRole,
141+
multiSelectable: actionListItemWrapperRole === 'listbox' ? isMultiSelectable : undefined,
142+
})}
143+
{...makeAnalyticsAttribute(rest)}
144+
>
145+
{itemCount < 10 ? (
146+
childrenWithId
147+
) : (
148+
<VirtualizedList
149+
height={actionListBoxHeight}
150+
width="100%"
151+
itemSize={itemHeight}
152+
itemCount={itemCount}
153+
itemData={itemData}
154+
// @ts-expect-error: props does exist
155+
itemKey={(index) => itemData[index]?.props.value}
156+
>
157+
{VirtualListItem}
158+
</VirtualizedList>
159+
)}
160+
</StyledListBoxWrapper>
161+
);
162+
},
163+
);
164+
165+
const ActionListVirtualizedBox = assignWithoutSideEffects(React.memo(_ActionListVirtualizedBox), {
166+
displayName: 'ActionListVirtualizedBox',
167+
});
168+
169+
export { ActionListBox, ActionListVirtualizedBox };

packages/blade/src/components/ActionList/ActionListItem.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,11 @@ const _ActionListItem = (props: ActionListItemProps): React.ReactElement => {
348348
}
349349
}, [props.intent, dropdownTriggerer]);
350350

351+
const isVisible = hasAutoComplete && filteredValues ? filteredValues.includes(props.value) : true;
352+
351353
return (
352-
// We use this context to change the color of subcomponents like ActionListItemIcon, ActionListItemText, etc
353354
<BaseMenuItem
354-
isVisible={hasAutoComplete && filteredValues ? filteredValues.includes(props.value) : true}
355+
isVisible={isVisible}
355356
as={!isReactNative() ? renderOnWebAs : undefined}
356357
id={`${dropdownBaseId}-${props._index}`}
357358
tabIndex={-1}

packages/blade/src/components/ActionList/docs/propsTable.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const actionListPropsTables: {
1919
<ScrollLink href="#actionlistsection">&lt;ActionListSection[] /&gt;</ScrollLink>
2020
</>
2121
),
22+
isVirtualized: {
23+
note:
24+
'Currently only works in ActionList with static height items (items without description) and when ActionList has more than 10 items',
25+
type: 'boolean',
26+
},
2227
},
2328
ActionListItem: {
2429
title: 'string',

packages/blade/src/components/ActionList/styles/getBaseListBoxWrapperStyles.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ import type { Theme } from '~components/BladeProvider';
33
import { makeSize } from '~utils/makeSize';
44
import { size } from '~tokens/global';
55

6+
const actionListMaxHeight = size[300];
7+
8+
const getActionListPadding = (theme: Theme): number => {
9+
return theme.spacing[3];
10+
};
11+
612
const getBaseListBoxWrapperStyles = (props: {
713
theme: Theme;
814
isInBottomSheet: boolean;
915
}): CSSObject => {
1016
return {
11-
maxHeight: props.isInBottomSheet ? undefined : makeSize(size[300]),
12-
padding: props.isInBottomSheet ? undefined : makeSize(props.theme.spacing[3]),
17+
maxHeight: props.isInBottomSheet ? undefined : makeSize(actionListMaxHeight),
18+
padding: props.isInBottomSheet ? undefined : makeSize(getActionListPadding(props.theme)),
1319
};
1420
};
1521

16-
export { getBaseListBoxWrapperStyles };
22+
export { getBaseListBoxWrapperStyles, actionListMaxHeight, getActionListPadding };

packages/blade/src/components/BaseMenu/BaseMenuItem/BaseMenuItem.tsx

+4-5
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import React from 'react';
22
import type { BaseMenuItemProps } from '../types';
33
import { BaseMenuItemContext } from '../BaseMenuContext';
44
import { StyledMenuItemContainer } from './StyledMenuItemContainer';
5+
import { itemFirstRowHeight } from './tokens';
56
import { Box } from '~components/Box';
67
import { getTextProps, Text } from '~components/Typography';
7-
import { size } from '~tokens/global';
8-
import { makeSize } from '~utils';
98
import { makeAccessible } from '~utils/makeAccessible';
109
import type { BladeElementRef } from '~utils/types';
1110
import { BaseText } from '~components/Typography/BaseText';
1211
import { useTruncationTitle } from '~utils/useTruncationTitle';
12+
import { makeSize } from '~utils';
1313

1414
const menuItemTitleColor = {
1515
negative: {
@@ -25,7 +25,6 @@ const menuItemDescriptionColor = {
2525
} as const;
2626

2727
// 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
28-
const itemFirstRowHeight = makeSize(size[20]);
2928

3029
const _BaseMenuItem: React.ForwardRefRenderFunction<BladeElementRef, BaseMenuItemProps> = (
3130
{
@@ -75,7 +74,7 @@ const _BaseMenuItem: React.ForwardRefRenderFunction<BladeElementRef, BaseMenuIte
7574
display="flex"
7675
justifyContent="center"
7776
alignItems="center"
78-
height={itemFirstRowHeight}
77+
height={makeSize(itemFirstRowHeight)}
7978
>
8079
{leading}
8180
</Box>
@@ -89,7 +88,7 @@ const _BaseMenuItem: React.ForwardRefRenderFunction<BladeElementRef, BaseMenuIte
8988
display="flex"
9089
alignItems="center"
9190
flexDirection="row"
92-
height={itemFirstRowHeight}
91+
height={makeSize(itemFirstRowHeight)}
9392
ref={containerRef as never}
9493
>
9594
<BaseText

packages/blade/src/components/BaseMenu/BaseMenuItem/StyledMenuItemContainer.web.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import styled from 'styled-components';
22
import type { StyledBaseMenuItemContainerProps } from '../types';
33
import { getBaseMenuItemStyles } from './getBaseMenuItemStyles';
4+
import { getItemPadding } from './tokens';
45
import { getMediaQuery, makeSize } from '~utils';
56
import { getFocusRingStyles } from '~utils/getFocusRingStyles';
67
import BaseBox from '~components/Box/BaseBox';
78

89
const StyledMenuItemContainer = styled(BaseBox)<StyledBaseMenuItemContainerProps>((props) => {
910
return {
1011
...getBaseMenuItemStyles({ theme: props.theme }),
11-
padding: makeSize(props.theme.spacing[2]),
12+
padding: makeSize(getItemPadding(props.theme).itemPaddingMobile),
1213
display: props.isVisible ? 'flex' : 'none',
1314
[`@media ${getMediaQuery({ min: props.theme.breakpoints.m })}`]: {
14-
padding: makeSize(props.theme.spacing[3]),
15+
padding: makeSize(getItemPadding(props.theme).itemPaddingDesktop),
1516
},
1617
'&:hover:not([aria-disabled=true]), &[aria-expanded="true"]': {
1718
backgroundColor:

packages/blade/src/components/BaseMenu/BaseMenuItem/getBaseMenuItemStyles.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CSSObject } from 'styled-components';
2+
import { getItemMargin } from './tokens';
23
import type { Theme } from '~components/BladeProvider';
34
import { isReactNative, makeBorderSize } from '~utils';
45
import { makeSize } from '~utils/makeSize';
@@ -11,8 +12,8 @@ const getBaseMenuItemStyles = (props: { theme: Theme }): CSSObject => {
1112
textAlign: isReactNative() ? undefined : 'left',
1213
backgroundColor: 'transparent',
1314
borderRadius: makeSize(props.theme.border.radius.medium),
14-
marginTop: makeSize(props.theme.spacing[1]),
15-
marginBottom: makeSize(props.theme.spacing[1]),
15+
marginTop: makeSize(getItemMargin(props.theme)),
16+
marginBottom: makeSize(getItemMargin(props.theme)),
1617
textDecoration: 'none',
1718
cursor: 'pointer',
1819
width: '100%',

0 commit comments

Comments
 (0)