Skip to content
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Currently, this repo is in Prerelease. When it is released, this project will ad

## Prerelease

### Adds

- Adds the `lazy-load` option to the `listOverflow` prop of the `MultiSelect` component.

## 4.4.0 (July 8, 2026)

### Adds
Expand Down
13 changes: 12 additions & 1 deletion src/components/MultiSelect/MultiSelect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { changelogData } from "./multiSelectChangelogData";
- [Width](#width)
- [Default Open State](#default-open-state)
- [Close on Blur State](#close-on-blur-state)
- [Lazy Loading Items](#lazy-loading-items)
- [MultiSelect in a Group](#multiselect-in-a-group)
- [Controlling state using selectedItems and onChange props](#controlling-state-using-selecteditems-and-onchange-props)
- [MultiSelect NextJS routing implementation](#multiselect-nextjs-routing-implementation)
Expand Down Expand Up @@ -211,7 +212,8 @@ counting the items to display.
content={
<>
<strong>IMPORTANT:</strong> This prop can only be used with{" "}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: update "with" to "when"?

<code>listOverflow="expand"</code>.
<code>listOverflow</code> is set to <code>"expand"</code> or{" "}
<code>"lazy-load"</code>.
</>
}
variant="informative"
Expand Down Expand Up @@ -751,6 +753,15 @@ focus leaves the component (the user clicks outside or uses the keyboard to tab
language="tsx"
/>

## Lazy loading items

If a large number of items is hindering performance, `listOverflow` can be set
to `lazy-load`. The component renders the first `defaultItemsVisible` items on load.
Scrolling down in the panel triggers more items to be rendered. The number of
items loaded scales with how far has been already scrolled.

<Canvas of={MultiSelectStories.lazyLoadingItems} />

## MultiSelect in a Group

When using the `MultiSelect` component in a group, it is recommended to use the
Expand Down
14 changes: 14 additions & 0 deletions src/components/MultiSelect/MultiSelect.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import MultiSelect, {
import Text from "../Text/Text";
import useMultiSelect from "../../hooks/useMultiSelect";
import Button from "../Button/Button";
import withLazyLoadItems from "./MultiSelectWithLazyLoadItems";

const withItems = [
{
Expand Down Expand Up @@ -617,6 +618,19 @@ export const closeOnBlurState: Story = {
),
};

export const lazyLoadingItems: Story = {
render: () => (
<MultiSelectStory
id="multi-select-id-18"
isBlockElement
isDefaultOpen={false}
isSearchable={true}
items={withLazyLoadItems}
listOverflow="lazy-load"
/>
),
};

export const InAGroup: Story = {
render: () => <MultiSelectGroupStory items={withItems} />,
};
Expand Down
78 changes: 77 additions & 1 deletion src/components/MultiSelect/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,56 @@
import { axe } from "jest-axe";
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import renderer from "react-test-renderer";
import userEvent from "@testing-library/user-event";
import { useEffect } from "react";
import MultiSelect from "./MultiSelect";
import useMultiSelect from "../../hooks/useMultiSelect";
import withLazyLoadItems from "./MultiSelectWithLazyLoadItems";

jest.mock("../../hooks/useSafeId", () => ({
...jest.requireActual("../../hooks/useSafeId"),
useSafeId: jest.fn((id) => id || "test-id"),
}));

let intersectionCallback: IntersectionObserverCallback | null = null;

const observeMock = jest.fn();
const disconnectMock = jest.fn();

beforeEach(() => {
intersectionCallback = null;
observeMock.mockClear();
disconnectMock.mockClear();

window.IntersectionObserver = jest.fn().mockImplementation((callback) => {
intersectionCallback = callback;
return {
observe: observeMock,
disconnect: disconnectMock,
unobserve: jest.fn(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't also need to be cleared?

};
});
});

const triggerIntersection = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice update. How did you catch that the mock scroll behavior was not actually working as expected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was failing because no more items were being loaded on scroll - seems like the scroll handler was taking over. I also didn't realize earlier that the IntersectionObserver doesn't work in the test environment because the environment doesn't have the browser layout engine.

if (!intersectionCallback) return;

intersectionCallback(
[
{
isIntersecting: true,
target: document.createElement("div"),
intersectionRatio: 1,
time: 0,
boundingClientRect: {} as DOMRectReadOnly,
intersectionRect: {} as DOMRectReadOnly,
rootBounds: null,
},
],
{} as IntersectionObserver
);
};

const items = [
{ id: "dogs", name: "Dogs", isDisabled: false },
{ id: "cats", name: "Cats", isDisabled: false },
Expand Down Expand Up @@ -470,6 +510,42 @@ describe("MultiSelect", () => {
);
});

it("should lazily load more list items while scrolling in lazy load mode", async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever test!! also wondering: did you try a more involved mock of the intersection observer and it got too messy ? It seems like it would be difficult to mock

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not 🤔 I could try it though if that would be preferred

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries just wondering

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the good test and good mock.

render(
<MultiSelect
id="multiselect-lazy-load-id"
buttonText="Multiselect button text"
items={withLazyLoadItems}
isDefaultOpen={true}
isSearchable={false}
isBlockElement={false}
listOverflow="lazy-load"
selectedItems={selectedTestItems}
onChange={() => null}
onClear={() => null}
/>
);

expect(screen.queryAllByRole("checkbox")).toHaveLength(25);

await waitFor(() => expect(observeMock).toHaveBeenCalled());

act(() => triggerIntersection());
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(50)
);

act(() => triggerIntersection());
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(80)
);

act(() => triggerIntersection());
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(116)
);
});

it("should call onChange when an item without child items or a child item is selected/unselected", () => {
const onChangeMock = jest.fn();
const onMixedStateChangeMock = jest.fn();
Expand Down
91 changes: 84 additions & 7 deletions src/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import {
ChakraComponent,
useMultiStyleConfig,
} from "@chakra-ui/react";
import React, { forwardRef, useEffect, useRef, useState } from "react";
import React, {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from "react";

import Accordion from "./../Accordion/Accordion";
import Button from "./../Button/Button";
Expand All @@ -24,7 +30,11 @@ export interface MultiSelectItem {
}
export const multiSelectWidthsArray = ["fitContent", "full"] as const;
export type MultiSelectWidths = typeof multiSelectWidthsArray[number];
export const multiSelectListOverflowArray = ["scroll", "expand"] as const;
export const multiSelectListOverflowArray = [
"scroll",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interested in marty/edwin thoughts but maybe lazy-load should be a flag you can set if you've picked scroll, not a standalone type

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that does make sense

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me review before you make this update. Without having looked too closely at the PR, Emma's comment sounds reasonable. Perhaps it can be an independent flag that works for both scroll and expand?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shouldn't be possible on expand right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be wrong to always have lazy load be enabled for the scroll option? It works well and it doesn't seem like it has a big overhead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agreed. lazy-load should not be an option for listOverflow. It should be a boolean flag.

Here's another idea. Although, I am not sure if the timing of this is possible. Can lazy load be automatically enabled if the total options is greater than x? Otherwise, scrolling functions as is always has?

Do we know the threshold when performance takes a hit?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the threshold. I'm open to logging performance/render times compared to the number of items, but I imagine that performance could also vary based on how it's used in the consuming app?

Right now 20 + defaultItemsVisible are loaded initially per @bigfishdesign13 's suggestion (although defaultItemsVisible is only used for expand since the default appears to be ~7 for scrolling, but that might be a separate concern). Maybe this could be the threshold for when lazy loading is enabled?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, let's keep it at 20 + defaultItemVisible as the threshold for now and we can gauge its performance once it's live.

"expand",
"lazy-load",
] as const;
export type MultiSelectListOverflowTypes =
typeof multiSelectListOverflowArray[number];
export interface SelectedItems {
Expand All @@ -51,7 +61,7 @@ export interface MultiSelectProps extends BoxProps {
/** The items to be rendered in the Multiselect as checkbox options. */
items: MultiSelectItem[];
/** listOverflow is a property indicating how the list should handle overflow,
* with options limited to either "scroll" or "expand." */
* with options limited to "scroll", "expand", or "lazy-load". */
listOverflow?: MultiSelectListOverflowTypes;
/** The action to perform for the clear/reset button of individual MultiSelects. */
onClear?: () => void;
Expand Down Expand Up @@ -111,6 +121,13 @@ export const MultiSelect: ChakraComponent<
const expandToggleButtonRef: React.RefObject<HTMLButtonElement> =
useRef<HTMLButtonElement>();

// Used for Intersection Observer for lazy loading
const itemsListRef: React.RefObject<HTMLDivElement> =
useRef<HTMLDivElement>();
// Observation target for Intersection Observer
const lazyLoadTargetRef: React.RefObject<HTMLDivElement> =
useRef<HTMLDivElement>();

// Tells `Accordion` to close if open when user clicks outside of the container
const handleClickOutside = (e) => {
if (e.type === "mousedown") {
Expand Down Expand Up @@ -162,12 +179,26 @@ export const MultiSelect: ChakraComponent<

const isOverflowExpand =
items.length > defaultItemsVisible && listOverflow === "expand";
const lazyLoadIncrementNum = 20;
const isOverflowLazy =
items.length > defaultItemsVisible + lazyLoadIncrementNum &&
listOverflow === "lazy-load";
const defaultItemsList = React.useMemo(
() => (isOverflowExpand ? items.slice(0, defaultItemsVisible) : items),
[isOverflowExpand, items, defaultItemsVisible]
);
const [itemsList, setItemsList] = useState(defaultItemsList);
const [isExpandable, setIsExpandable] = useState(true);
const [lazyItemsVisible, setLazyItemsVisible] = useState(
defaultItemsVisible + lazyLoadIncrementNum
);

const hasScrollablePanel =
listOverflow === "scroll" || listOverflow === "lazy-load";

const visibleItemsList = isOverflowLazy
? itemsList.slice(0, lazyItemsVisible)
: itemsList;

const selectedItemsCount: number =
selectedItems[mainId]?.items.length || 0;
Expand Down Expand Up @@ -245,6 +276,19 @@ export const MultiSelect: ChakraComponent<
return <Box>No options found</Box>;
};

const loadMoreLazyItems = useCallback(() => {
if (!isOverflowLazy) {
return;
}

setLazyItemsVisible((previousVisibleItems) =>
Math.min(
previousVisibleItems * 1.2 + lazyLoadIncrementNum,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you create a CONSTANT_VARIABLE for 1.2? This is to reduce magic numbers and better understand when reading this why it's set to 1.2.

itemsList.length
)
);
}, [isOverflowLazy, itemsList]);

const onChangeSearch = (event) => {
const value = event.target.value.trim().toLowerCase();
if (!value) {
Expand Down Expand Up @@ -291,6 +335,33 @@ export const MultiSelect: ChakraComponent<
setItemsList(isExpandable ? defaultItemsList : items);
}, [isExpandable, defaultItemsList, items]);

React.useEffect(() => {
if (
!isOverflowLazy ||
!itemsListRef.current ||
!lazyLoadTargetRef.current
) {
return;
}

const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadMoreLazyItems();
}
},
{
root: itemsListRef.current,
threshold: 0,
rootMargin: `${12 * lazyItemsVisible}px`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also create another constant var for 12?

}
);

observer.observe(lazyLoadTargetRef.current);

return () => observer.disconnect();
}, [isOverflowLazy, loadMoreLazyItems, lazyItemsVisible]);

const ExpandToggleButton = (): JSX.Element => {
return (
<Button
Expand Down Expand Up @@ -402,22 +473,24 @@ export const MultiSelect: ChakraComponent<

const accordionPanel = (
<Box position="relative">
{isSearchable && !isOverflowExpand ? (
{isSearchable && hasScrollablePanel ? (
<Box position="sticky" top="0" marginBottom="12px" zIndex="1">
{searchInput}
</Box>
) : isSearchable && isOverflowExpand ? (
) : isSearchable && (isOverflowExpand || isOverflowLazy) ? (
searchInput
) : null}

<Box
data-testid={isOverflowLazy ? `${mainId}-items-list` : undefined}
ref={itemsListRef}
maxHeight={listHeight}
overflowY="auto"
paddingTop="xxs"
paddingLeft="xs"
paddingBottom="xxs"
>
{itemsList.length === 0 ? (
{visibleItemsList.length === 0 ? (
<NoSearchResults />
) : (
<>
Expand All @@ -430,13 +503,17 @@ export const MultiSelect: ChakraComponent<
showLabel={false}
name="multi-select-checkbox-group"
>
{itemsList.map((item: MultiSelectItem) => (
{visibleItemsList.map((item: MultiSelectItem) => (
<React.Fragment key={item.id}>
{getMultiSelectCheckboxItem(item)}
</React.Fragment>
))}
</CheckboxGroup>
{isOverflowExpand && <ExpandToggleButton />}
{isOverflowLazy &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense why we need this, but can you add a short comment describing why? When we revisit this component in the future, it'll be clear why this is needed in the DOM.

visibleItemsList.length < itemsList.length && (
<Box ref={lazyLoadTargetRef} height="1px" />
)}
</>
)}
</Box>
Expand Down
Loading
Loading