From 6b10163a6b378f987c9e476475c07a8cbb028c82 Mon Sep 17 00:00:00 2001 From: Kasey Wei Date: Thu, 9 Jul 2026 16:50:12 -0400 Subject: [PATCH 01/16] lazy loading --- src/components/MultiSelect/MultiSelect.mdx | 13 +- .../MultiSelect/MultiSelect.stories.tsx | 417 +++++++++++++++++- src/components/MultiSelect/MultiSelect.tsx | 124 +++++- 3 files changed, 537 insertions(+), 17 deletions(-) diff --git a/src/components/MultiSelect/MultiSelect.mdx b/src/components/MultiSelect/MultiSelect.mdx index 86a3d10ca..161f9ee65 100644 --- a/src/components/MultiSelect/MultiSelect.mdx +++ b/src/components/MultiSelect/MultiSelect.mdx @@ -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) @@ -211,7 +212,8 @@ counting the items to display. content={ <> IMPORTANT: This prop can only be used with{" "} - listOverflow="expand". + listOverflow is set to "expand" or{" "} + "lazy". } variant="informative" @@ -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`. 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. + + + ## MultiSelect in a Group When using the `MultiSelect` component in a group, it is recommended to use the diff --git a/src/components/MultiSelect/MultiSelect.stories.tsx b/src/components/MultiSelect/MultiSelect.stories.tsx index 6eb70fcf7..4136ac53c 100644 --- a/src/components/MultiSelect/MultiSelect.stories.tsx +++ b/src/components/MultiSelect/MultiSelect.stories.tsx @@ -270,6 +270,403 @@ const withItemCountItems = [ }, ]; +const withLazyItems = [ + "Admiral", + "AgentS", + "Agnes", + "Al", + "Alfonso", + "Alice", + "Alli", + "Amelia", + "Anabelle", + "Anchovy", + "Angus", + "Anicotti", + "Ankha", + "Annalisa", + "Annalise", + "Antonio", + "Apollo", + "Apple", + "Astrid", + "Audie", + "Aurora", + "Ava", + "Avery", + "Axel", + "Baabara", + "Bam", + "Bangle", + "Barold", + "Bea", + "Beardo", + "Beau", + "Becky", + "Bella", + "Benedict", + "Benjamin", + "Bertha", + "Bettina", + "Bianca", + "Biff", + "Big Top", + "Bill", + "Billy", + "Biskit", + "Bitty", + "Blaire", + "Blanche", + "Bluebear", + "Bob", + "Bonbon", + "Bones", + "Boomer", + "Boone", + "Boots", + "Boris", + "Boyd", + "Bree", + "Broccolo", + "Broffina", + "Bruce", + "Bubbles", + "Buck", + "Bud", + "Bunnie", + "Butch", + "Buzz", + "Cally", + "Camofrog", + "Canberra", + "Candi", + "Carmen", + "Caroline", + "Carrie", + "Cashmere", + "Celia", + "Cesar", + "Chadder", + "Charlise", + "Cheri", + "Cherry", + "Chester", + "Chevre", + "Chief", + "Chops", + "Chow", + "Chrissy", + "Claude", + "Claudia", + "Clay", + "Cleo", + "Clyde", + "Coach", + "Cobb", + "Coco", + "Cole", + "Colton", + "Cookie", + "Cousteau", + "Cranston", + "Croque", + "Cube", + "Curlos", + "Curly", + "Curt", + "Cyd", + "Cyrano", + "Daisy", + "Deena", + "Deirdre", + "Del", + "Deli", + "Derwin", + "Diana", + "Diva", + "Dizzy", + "Dobie", + "Doc", + "Dom", + "Dora", + "Dotty", + "Drago", + "Drake", + "Drift", + "Ed", + "Egbert", + "Elise", + "Ellie", + "Elmer", + "Eloise", + "Elvis", + "Erik", + "Eugene", + "Eunice", + "Fang", + "Fauna", + "Felicity", + "Filbert", + "Flip", + "Flo", + "Flora", + "Flurry", + "Francine", + "Frank", + "Freckles", + "Freya", + "Friga", + "Frita", + "Frobert", + "Fuchsia", + "Gabi", + "Gala", + "Gaston", + "Gayle", + "Genji", + "Gigi", + "Gladys", + "Gloria", + "Goldie", + "Gonzo", + "Goose", + "Graham", + "Greta", + "Grizzly", + "Groucho", + "Gruff", + "Gwen", + "Hamlet", + "Hamphrey", + "Hans", + "Harry", + "Hazel", + "Henry", + "Hippeux", + "Hopkins", + "Hopper", + "Hornsby", + "Huck", + "Hugh", + "Iggly", + "Ike", + "Jacob", + "Jacques", + "Jambette", + "Jay", + "Jeremiah", + "Jitters", + "Joey", + "Judy", + "Julia", + "Julian", + "June", + "Kabuki", + "Katt", + "Keaton", + "Ken", + "Ketchup", + "Kevin", + "Kid Cat", + "Kidd", + "Kiki", + "Kitt", + "Kitty", + "Klaus", + "Knox", + "Kody", + "Kyle", + "Leonardo", + "Leopold", + "Lily", + "Limberg", + "Lionel", + "Lobo", + "Lolly", + "Lopez", + "Louie", + "Lucha", + "Lucky", + "Lucy", + "Lyman", + "Mac", + "Maddie", + "Maelle", + "Maggie", + "Mallary", + "Maple", + "Marcel", + "Marcie", + "Margie", + "Marina", + "Marshal", + "Mathilda", + "Megan", + "Melba", + "Merengue", + "Merry", + "Midge", + "Mint", + "Mira", + "Miranda", + "Mitzi", + "Moe", + "Molly", + "Monique", + "Monty", + "Moose", + "Mott", + "Muffy", + "Murphy", + "Nan", + "Nana", + "Naomi", + "Nate", + "Nibbles", + "Norma", + "Octavian", + "O'Hare", + "Olaf", + "Olive", + "Olivia", + "Opal", + "Ozzie", + "Pancetti", + "Pango", + "Paolo", + "Papi", + "Pashmina", + "Pate", + "Patty", + "Paula", + "Peaches", + "Peanut", + "Pecan", + "Peck", + "Peewee", + "Peggy", + "Pekoe", + "Penelope", + "Phil", + "Phoebe", + "Pierce", + "Pietro", + "Pinky", + "Piper", + "Pippy", + "Plucky", + "Pompom", + "Poncho", + "Poppy", + "Portia", + "Prince", + "Puck", + "Puddles", + "Pudge", + "Punchy", + "Purrl", + "Queenie", + "Quillson", + "Raddle", + "Rasher", + "Raymond", + "Renée", + "Reneigh", + "Rex", + "Rhonda", + "Ribbot", + "Ricky", + "Rizzo", + "Roald", + "Robin", + "Rocco", + "Rocket", + "Rod", + "Rodeo", + "Rodney", + "Rolf", + "Rooney", + "Rory", + "Roscoe", + "Rosie", + "Rowan", + "Ruby", + "Rudy", + "Sally", + "Samson", + "Sandy", + "Savannah", + "Scoot", + "Shari", + "Sheldon", + "Shep", + "Sherb", + "Simon", + "Skye", + "Sly", + "Snake", + "Snooty", + "Soleil", + "Sparro", + "Spike", + "Spork", + "Sprinkle", + "Sprocket", + "Static", + "Stella", + "Sterling", + "Stinky", + "Stitches", + "Stu", + "Sydney", + "Sylvana", + "Sylvia", + "Tabby", + "Tad", + "Tammi", + "Tammy", + "Tangy", + "Tank", + "Tasha", + "T-Bone", + "Teddy", + "Tex", + "Tia", + "Tiffany", + "Timbra", + "Tipper", + "Tom", + "Truffles", + "Tucker", + "Tutu", + "Twiggy", + "Tybalt", + "Ursala", + "Velma", + "Vesta", + "Vic", + "Victoria", + "Violet", + "Vivian", + "Vladimir", + "Wade", + "Walker", + "Walt", + "Wart Jr.", + "Weber", + "Wendy", + "Whitney", + "Willow", + "Winnie", + "Wolfgang", + "Yuka", + "Zell", + "Zucker", +].map((item) => ({ + id: item, + name: item, +})); + const meta: Meta = { title: "Components/Form Elements/MultiSelect", component: MultiSelect, @@ -324,7 +721,7 @@ export const withControls: Story = { isDefaultOpen: false, isSearchable: true, items: withItems, - listOverflow: "scroll", + listOverflow: "lazy", onClear: undefined, onChange: undefined, onMixedStateChange: undefined, @@ -434,8 +831,10 @@ export const disabledListItems: Story = { id="multi-select-id-5" isBlockElement isDefaultOpen={false} - isSearchable={false} + isSearchable={true} items={withDisabledItems} + listOverflow="lazy" + defaultItemsVisible={3} /> ), }; @@ -617,6 +1016,20 @@ export const closeOnBlurState: Story = { ), }; +export const lazyLoadingItems: Story = { + render: () => ( + + ), +}; + export const InAGroup: Story = { render: () => , }; diff --git a/src/components/MultiSelect/MultiSelect.tsx b/src/components/MultiSelect/MultiSelect.tsx index 08854d919..5b4e9a0e6 100644 --- a/src/components/MultiSelect/MultiSelect.tsx +++ b/src/components/MultiSelect/MultiSelect.tsx @@ -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"; @@ -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", + "expand", + "lazy", +] as const; export type MultiSelectListOverflowTypes = typeof multiSelectListOverflowArray[number]; export interface SelectedItems { @@ -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." */ listOverflow?: MultiSelectListOverflowTypes; /** The action to perform for the clear/reset button of individual MultiSelects. */ onClear?: () => void; @@ -111,6 +121,13 @@ export const MultiSelect: ChakraComponent< const expandToggleButtonRef: React.RefObject = useRef(); + // Used for Intersection Observer for lazy loading + const itemsListRef: React.RefObject = + useRef(); + // Observation target for Intersection Observer + const lazyLoadTargetRef: React.RefObject = + useRef(); + // Tells `Accordion` to close if open when user clicks outside of the container const handleClickOutside = (e) => { if (e.type === "mousedown") { @@ -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"; 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; @@ -245,6 +272,21 @@ export const MultiSelect: ChakraComponent< return No options found; }; + const loadMoreLazyItems = useCallback(() => { + if (!isOverflowLazy) { + return; + } + + setLazyItemsVisible((previousVisibleItems) => + Math.min( + previousVisibleItems + + defaultItemsVisible + + previousVisibleItems / defaultItemsVisible, // Scales with further scrolling + itemsList.length + ) + ); + }, [defaultItemsVisible, isOverflowLazy, itemsList]); + const onChangeSearch = (event) => { const value = event.target.value.trim().toLowerCase(); if (!value) { @@ -287,10 +329,53 @@ export const MultiSelect: ChakraComponent< }, 1); // Ensure focus logic runs after state update }; + const onItemsListScroll = () => { + if (!isOverflowLazy || !itemsListRef.current) { + return; + } + + const { scrollTop, clientHeight, scrollHeight } = itemsListRef.current; + const scrollThreshold = scrollHeight / 2; // Scales with further scrolling + const isAtBottom = + 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 (