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
15 changes: 15 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 "./MultiSelectStoriesData";

const withItems = [
{
Expand Down Expand Up @@ -617,6 +618,20 @@ 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"
defaultItemsVisible={5}
/>
),
};

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,5 +1,5 @@
import { axe } from "jest-axe";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
Comment thread
EdwinGuzman marked this conversation as resolved.
Outdated
import renderer from "react-test-renderer";
import userEvent from "@testing-library/user-event";
import { useEffect } from "react";
Expand All @@ -11,6 +11,14 @@ jest.mock("../../hooks/useSafeId", () => ({
useSafeId: jest.fn((id) => id || "test-id"),
}));

const intersectionObserverMock = () => ({
observe: () => null,
disconnect: () => null,
});
window.IntersectionObserver = jest

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.

Emma already asked if you tested the rc npm release in Research Catalog. Did you also run tests in RC?

I noticed that whenever we mock global APIs in the DS, unit tests in consuming apps need to be updated as well. It's not bad, but we would just have to let teams know about the dependency.

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.

Yes, the IntersectionObserver mock is needed when lazy-load is selected. Would this be a factor at all in deciding whether lazy loading should be always enabled for scroll?

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.

Not a big factor but just something to call out when devs import the release version. Did you have to add it in a global pre-test jest file in RC?

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.

Seems like it's only needed if the number of items in the MultiSelect > defaultItemsVisible + 20, when lazy loading is triggered. Otherwise putting this block of code

const intersectionObserverMock = () => ({
  observe: () => null,
  disconnect: () => null,
  unobserve: () => null,
})
window.IntersectionObserver = jest
  .fn()
  .mockImplementation(intersectionObserverMock)

either in the test files with the MultiSelect, or the global pre-test file is needed.

.fn()
.mockImplementation(intersectionObserverMock);

const items = [
{ id: "dogs", name: "Dogs", isDisabled: false },
{ id: "cats", name: "Cats", isDisabled: false },
Expand Down Expand Up @@ -65,6 +73,22 @@ const itemsWithCount = [

const defaultItemsVisible = 5;

const lazyLoadItems = [
{ id: "item-1", name: "Item 1" },
{ id: "item-2", name: "Item 2" },
{ id: "item-3", name: "Item 3" },
{ id: "item-4", name: "Item 4" },
{ id: "item-5", name: "Item 5" },
{ id: "item-6", name: "Item 6" },
{ id: "item-7", name: "Item 7" },
{ id: "item-8", name: "Item 8" },
{ id: "item-9", name: "Item 9" },
{ id: "item-10", name: "Item 10" },
{ id: "item-11", name: "Item 11" },
{ id: "item-12", name: "Item 12" },
{ id: "item-13", name: "Item 13" },
];

const MultiSelectTestComponent = ({
multiSelectId,
initialSelectedItems = {},
Expand Down Expand Up @@ -470,6 +494,58 @@ 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"
defaultItemsVisible={3}
items={lazyLoadItems}
isDefaultOpen={true}
isSearchable={false}
isBlockElement={false}
listOverflow="lazy-load"
selectedItems={selectedTestItems}
onChange={() => null}
onClear={() => null}
/>
);

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

const listContainer = screen.getByTestId(
"multiselect-lazy-load-id-items-list"
);
Object.defineProperty(listContainer, "clientHeight", {
configurable: true,
value: 500,
});
Object.defineProperty(listContainer, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(listContainer, "scrollTop", {
configurable: true,
value: 500,
writable: true,
});

fireEvent.scroll(listContainer);
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(7)
);

fireEvent.scroll(listContainer);
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(12)
);

fireEvent.scroll(listContainer);
await waitFor(() =>
expect(screen.queryAllByRole("checkbox")).toHaveLength(13)
);
});

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
120 changes: 106 additions & 14 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 @@ -153,21 +170,31 @@ export const MultiSelect: ChakraComponent<

const MINIMUM_ITEMS_LIST_HEIGHT = "215px";
const MAXIMUM_ITEMS_LIST_HEIGHT = "270px";
const listHeight =
listOverflow === "expand"
? "unset"
: isSearchable
? MAXIMUM_ITEMS_LIST_HEIGHT
: MINIMUM_ITEMS_LIST_HEIGHT;

const isOverflowExpand =
items.length > defaultItemsVisible && listOverflow === "expand";
const isOverflowLazy =
items.length > defaultItemsVisible && 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);

const hasScrollablePanel = listOverflow === "scroll" || isOverflowLazy;

const listHeight =
listOverflow === "expand"
? "unset"
: isSearchable
? MAXIMUM_ITEMS_LIST_HEIGHT
: MINIMUM_ITEMS_LIST_HEIGHT;

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

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

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

setLazyItemsVisible((previousVisibleItems) =>
Math.min(
previousVisibleItems +
defaultItemsVisible +
previousVisibleItems / defaultItemsVisible, // Scales with further scrolling

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.

What are the benefits to scaling the batch?

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.

When I was experimenting with scrolling quickly, the scrolling speed can increase as I scroll further down. The scaling was to prevent too many items from loading at the beginning, while preventing the stuttering that I mentioned above if the user is scrolling quickly to the bottom (which I saw more on research catalog than storybook). Maybe I could set a constant higher amount to load, though @bigfishdesign13 suggested continuing with the scaling, which I can try adapting to the IntersectionObserver.

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.

The current configuration works well. No matter how fast I try to scroll, I never hit the bottom. I would prefer to load a constant number of options during the scrolling, but I also don't want users to hit the bottom.

The data for all of the options is always available, right? No API call to get more data from the server. So any lag that happens as the options are added is due to rendering, which could be impacted by the power of a users computer or device. If there are concerns about the scaling, let's try a static number and try to find the sweet spot.

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 realized that a lot of the slowness issues I was having without scaling was because I was testing in research catalog dev mode earlier, my mistake. I removed the scaling and set the rootMargin to 300px which seems to work well enough, let me know if there are any issues with that

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

const onChangeSearch = (event) => {
const value = event.target.value.trim().toLowerCase();
if (!value) {
Expand Down Expand Up @@ -287,10 +329,53 @@ export const MultiSelect: ChakraComponent<
}, 1); // Ensure focus logic runs after state update
};

const onItemsListScroll = () => {

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.

I think this chunk can be replaced by setting rootMargin on the IntersectionObserver– i'm new to that API but it seems like if it's set (to 200px or something) it would trigger the same anticipatory load?

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.

This chunk allows for a more gradual and scaled loading - without it, scrolling down just a bit loads a lot of items (even with rootMargin at 0px). But that would work too, any thoughts on which is preferred?
I'm also quite new to the API so I could definitely be missing something else, and I can continue to explore it more

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 i see what you're saying here! I think it's worth the workaround to have a better scrolling experience

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, the scrolling-loading could use some polishing. I'd love to have a chat to learn more about the API and the config.

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.

Image

I put these console.logs right before the loadMoreLazyItems() call in the observer and right before loadMoreLazyItems() call in the onItemsListScroll function.

The scroll handler keeps moving the sentinel away before the observer can reach it. So the observer fires only once, while the scroll handler takes over and it's running a lot.

I tried removing onItemsListScroll and I personally did not observe much of a difference, so would love to see the difference you're seeing.

Also with regards to, "scrolling down just a bit loads a lot of items" -- isn't the number of items related to the setState in loadMoreLazyItems, rather than anything to do with the rootMargin?

setLazyItemsVisible((previousVisibleItems) =>
          Math.min(
            previousVisibleItems +
              defaultItemsVisible +
              previousVisibleItems / defaultItemsVisible, // Scales with further scrolling
            itemsList.length
          )
        );

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.

@oliviawongnyc I didn't realize that, thanks for pointing that out! When I was testing in research catalog using just the observer, scrolling fast would cause stuttering. After scrolling all the way to the bottom, it took some time to load more items, and by then the user would be scrolling past the panel entirely. So I think I overcorrected for that and messed up the observer in the process without realizing it.

As for the difference, I misinterpreted the comment and thought the chunk was referring to the scaling part of it. Removing the scroll handler changes the behavior slightly but doesn't make the difference I mentioned, sorry for the confusion.

I'll take a look at reducing stuttering while only using the IntersectionObserver.

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 removed the scroll handler and tweaked some of the scaling. I tried calculating the rootMargin using pixel heights in the scroll panel but that didn't work out great, so the numbers I have now are from trial and error. Any thoughts on that or the scrolling experience now @7emansell @oliviawongnyc ?

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.

Changes looking good to me!

As for the scaling, what happens with a fixed root margin? rootMargin: "0px 0px 100px 0px", for example? I would think we just want the observer to trigger loading before someone reaches the bottom of the visible list and not need to scale, but lmk your thoughts.

Same question about the batching. Does the increment size need to scale?

if (!isOverflowLazy || !itemsListRef.current) {
return;
}

const { scrollTop, clientHeight, scrollHeight } = itemsListRef.current;
const scrollThreshold = scrollHeight / 2; // Scales with further scrolling
const isAtBottom =
Comment thread
EdwinGuzman marked this conversation as resolved.
Outdated

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.

What do you think about isHalfwayThroughLoadedContent rather than isAtBottom? Then someone reading this can easily see that when isHalfwayThroughLoadedContent, loadMoreLazyItems() and I think it would act like a little bit of documentation.

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.

Scratch this. After looking at this longer, I don't think we should use two triggers for the loading, and think we should use the observer over this onScroll.

scrollTop + clientHeight >= scrollHeight - scrollThreshold;

if (!isAtBottom) {
return;
}

loadMoreLazyItems();
};

React.useEffect(() => {
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,
}
);

observer.observe(lazyLoadTargetRef.current);

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

const ExpandToggleButton = (): JSX.Element => {
return (
<Button
Expand Down Expand Up @@ -402,22 +487,25 @@ 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}
onScroll={isOverflowLazy ? onItemsListScroll : undefined}
maxHeight={listHeight}
overflowY="auto"
paddingTop="xxs"
paddingLeft="xs"
paddingBottom="xxs"
>
{itemsList.length === 0 ? (
{visibleItemsList.length === 0 ? (
<NoSearchResults />
) : (
<>
Expand All @@ -430,13 +518,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