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"