diff --git a/.changeset/selectpanel-built-in-virtualization.md b/.changeset/selectpanel-built-in-virtualization.md new file mode 100644 index 00000000000..783ac5efe64 --- /dev/null +++ b/.changeset/selectpanel-built-in-virtualization.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Add built-in client-side list virtualization to `SelectPanel` via a new `virtualized` prop. When enabled, only the visible items plus a small overscan buffer are rendered in the DOM, dramatically improving performance for large lists. diff --git a/package-lock.json b/package-lock.json index a8504a0d680..514205777b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@primer/react": "38.10.0", + "@primer/react": "38.11.0", "@primer/styled-react": "1.0.3", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -95,7 +95,7 @@ "name": "example-nextjs", "version": "0.0.0", "dependencies": { - "@primer/react": "38.10.0", + "@primer/react": "38.11.0", "@primer/styled-react": "1.0.3", "next": "^16.1.5", "react": "^19.2.0", @@ -138,7 +138,7 @@ "version": "0.0.0", "dependencies": { "@primer/octicons-react": "^19.21.0", - "@primer/react": "38.10.0", + "@primer/react": "38.11.0", "@primer/styled-react": "1.0.3", "clsx": "^2.1.1", "next": "^16.1.5", @@ -8418,13 +8418,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", - "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", - "dev": true, + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", + "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.12" + "@tanstack/virtual-core": "3.13.18" }, "funding": { "type": "github", @@ -8436,10 +8435,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", - "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", - "dev": true, + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", "license": "MIT", "funding": { "type": "github", @@ -27004,7 +27002,7 @@ }, "packages/react": { "name": "@primer/react", - "version": "38.10.0", + "version": "38.11.0", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.1", @@ -27016,6 +27014,7 @@ "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.21.0", "@primer/primitives": "10.x || 11.x", + "@tanstack/react-virtual": "^3.13.18", "clsx": "^2.1.1", "color2k": "^2.0.3", "deepmerge": "^4.3.1", @@ -27051,7 +27050,6 @@ "@storybook/addon-links": "^10.1.11", "@storybook/icons": "^2.0.1", "@storybook/react-vite": "^10.1.11", - "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^16.3.0", diff --git a/packages/react/package.json b/packages/react/package.json index b22cb0bb110..389119257a1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -83,6 +83,7 @@ "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.21.0", "@primer/primitives": "10.x || 11.x", + "@tanstack/react-virtual": "^3.13.18", "clsx": "^2.1.1", "color2k": "^2.0.3", "deepmerge": "^4.3.1", @@ -107,7 +108,6 @@ "@figma/code-connect": "1.3.2", "@primer/css": "^21.5.1", "@primer/doc-gen": "^0.0.1", - "@tanstack/react-virtual": "^3.13.12", "@rollup/plugin-babel": "6.1.0", "@rollup/plugin-commonjs": "29.0.0", "@rollup/plugin-json": "6.1.0", diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index b356e07f71b..10afa57107c 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -2,7 +2,7 @@ import type {ScrollIntoViewOptions} from '@primer/behaviors' import {scrollIntoView, FocusKeys} from '@primer/behaviors' import type {KeyboardEventHandler, JSX} from 'react' import type React from 'react' -import {forwardRef, useCallback, useEffect, useRef, useState} from 'react' +import {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react' import type {TextInputProps} from '../TextInput' import TextInput from '../TextInput' import {ActionList, type ActionListProps} from '../ActionList' @@ -21,6 +21,7 @@ import {ActionListContainerContext} from '../ActionList/ActionListContainerConte import {isValidElementType} from 'react-is' import {useAnnouncements} from './useAnnouncements' import {clsx} from 'clsx' +import {useVirtualizer} from '@tanstack/react-virtual' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -112,6 +113,21 @@ export interface FilteredActionListProps extends Partial) => { @@ -253,18 +284,59 @@ export function FilteredActionList({ onInputRefChanged?.(inputRef) }, [inputRef, onInputRefChanged]) + // Matches the most common ActionList.Item height (single-line text + description). + // Items are measured dynamically via `measureElement`, so this only affects the + // initial total-height estimate before items scroll into view. + const DEFAULT_VIRTUAL_ITEM_HEIGHT = 32 + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT, + overscan: 10, + enabled: isVirtualized, + getItemKey: index => { + const item = items[index] + return item.key ?? item.id?.toString() ?? index.toString() + }, + measureElement: el => (el as HTMLElement).scrollHeight, + }) + + const virtualItems = isVirtualized ? virtualizer.getVirtualItems() : undefined + + const virtualizedItemEntries = useMemo(() => { + if (!isVirtualized || !virtualItems) return undefined + return virtualItems.map(virtualItem => { + const item = items[virtualItem.index] + return {virtualItem, item, index: virtualItem.index} + }) + }, [isVirtualized, virtualItems, items]) + useFocusZone( !usingRovingTabindex ? { containerRef: {current: listContainerElement}, bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, - focusOutBehavior, + // With virtualization, only a subset of items exists in the DOM at any time. + // 'wrap' would cycle focus within the visible window instead of reaching the + // true end of the list. 'stop' lets the virtualizer's scrollToIndex bring + // the correct items into view when navigating past the rendered boundaries. + focusOutBehavior: isVirtualized ? 'stop' : focusOutBehavior, focusableElementFilter: element => { return !(element instanceof HTMLInputElement) }, activeDescendantFocus: inputRef, onActiveDescendantChanged: (current, previous, directlyActivated) => { activeDescendantRef.current = current + + if (isVirtualized && current) { + const index = current.getAttribute('data-index') + const range = virtualizer.range + if (index !== null && range && (Number(index) < range.startIndex || Number(index) >= range.endIndex)) { + virtualizer.scrollToIndex(Number(index), {align: 'auto'}) + } + } + if (current && scrollContainerRef.current && (directlyActivated || focusPrependedElements)) { scrollIntoView(current, scrollContainerRef.current, { ...menuScrollMargins, @@ -279,7 +351,7 @@ export function FilteredActionList({ focusPrependedElements, } : undefined, - [listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements], + [listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements, isVirtualized], ) useEffect(() => { @@ -347,6 +419,80 @@ export function FilteredActionList({ return message } let firstGroupIndex = 0 + + const renderListItems = () => { + if (groupMetadata?.length) { + return groupMetadata.map((group, index) => { + if (index === firstGroupIndex && getItemListForEachGroup(group.groupId).length === 0) { + firstGroupIndex++ + } + return ( + + + {group.header?.title ? group.header.title : `Group ${group.groupId}`} + + {getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, itemIndex) => { + const key = itemKey ?? item.id?.toString() ?? itemIndex.toString() + return ( + + ) + })} + + ) + }) + } + + if (isVirtualized && virtualizedItemEntries) { + return virtualizedItemEntries.map(({virtualItem, item: {key: itemKey, ...item}, index}) => { + const key = itemKey ?? item.id?.toString() ?? index.toString() + return ( + { + if (node) { + virtualizer.measureElement(node) + } + }} + style={{ + position: 'absolute' as const, + top: 0, + left: 0, + right: 0, + transform: `translateY(${virtualItem.start}px)`, + }} + {...item} + renderItem={listProps.renderItem} + /> + ) + }) + } + + return items.map(({key: itemKey, ...item}, index) => { + const key = itemKey ?? item.id?.toString() ?? index.toString() + return ( + + ) + }) + } + const actionListContent = ( - {groupMetadata?.length - ? groupMetadata.map((group, index) => { - if (index === firstGroupIndex && getItemListForEachGroup(group.groupId).length === 0) { - firstGroupIndex++ // Increment firstGroupIndex if the first group has no items + // When virtualized, the ActionList needs `position: relative` so that absolutely-positioned + // virtual items are placed correctly, and its `height` must equal the total virtual content + // size so the scroll container produces the right scrollbar. + // These styles are independent of SelectPanel's `height`/`width` props, which control the + // outer overlay dimensions, not the list content area. + style={ + isVirtualized + ? { + ...actionListProps?.style, + height: virtualizer.getTotalSize(), + position: 'relative' as const, } - return ( - - - {group.header?.title ? group.header.title : `Group ${group.groupId}`} - - {getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, itemIndex) => { - const key = itemKey ?? item.id?.toString() ?? itemIndex.toString() - return ( - - ) - })} - - ) - }) - : items.map(({key: itemKey, ...item}, index) => { - const key = itemKey ?? item.id?.toString() ?? index.toString() - return ( - - ) - })} + : actionListProps?.style + } + > + {renderListItems()} ) diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index 8980b64d1d6..f55961d2614 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -219,7 +219,7 @@ "type": "Partial", "defaultValue": "undefined", "description": "See [ActionList props](/react/ActionList#props)." - }, + }, { "name": "focusOutBehavior", "type": "'start' | 'wrap'", @@ -231,7 +231,13 @@ "type": "(newActiveDescendant: HTMLElement | undefined, previousActiveDescendant: HTMLElement | undefined, directlyActivated: boolean) => void | undefined", "defaultValue": "undefined", "description": "Callback function that is called when the active descendant changes." + }, + { + "name": "virtualized", + "type": "boolean", + "defaultValue": "false", + "description": "If true, enables client-side list virtualization. Only the visible items plus a small overscan buffer are rendered in the DOM, dramatically improving performance for large lists. Recommended for lists with more than 100 items. Has no effect when `groupMetadata` is provided." } ], "subcomponents": [] -} \ No newline at end of file +} diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 864d6e02b62..e8d1ae888bc 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -595,7 +595,7 @@ export const RenderMoreOnScroll = () => { const DEFAULT_VIRTUAL_ITEM_HEIGHT = 35 -export const Virtualized = () => { +export const VirtualizedConsumerSide = () => { const [selected, setSelected] = useState([]) const [open, setOpen] = useState(false) const [renderSubset, setRenderSubset] = useState(true) @@ -731,3 +731,113 @@ export const Virtualized = () => { ) } + +export const VirtualizedBuiltIn = () => { + const [selectedA, setSelectedA] = useState([]) + const [selectedB, setSelectedB] = useState([]) + const [filterA, setFilterA] = useState('') + const [filterB, setFilterB] = useState('') + + const filteredItemsA = lotsOfItems.filter(item => item.text.toLowerCase().startsWith(filterA.toLowerCase())) + const filteredItemsB = lotsOfItems.filter(item => item.text.toLowerCase().startsWith(filterB.toLowerCase())) + + const [openA, setOpenA] = useState(false) + const [openB, setOpenB] = useState(false) + + /* perf measurement: non-virtualized */ + const timeBeforeOpenA = useRef() + const timeAfterOpenA = useRef() + const [timeTakenA, setTimeTakenA] = useState() + + const onOpenChangeA = () => { + if (!openA) timeBeforeOpenA.current = performance.now() + setOpenA(!openA) + } + useEffect( + function measureA() { + if (openA) { + timeAfterOpenA.current = performance.now() + if (timeBeforeOpenA.current) setTimeTakenA(timeAfterOpenA.current - timeBeforeOpenA.current) + } + }, + [openA], + ) + + /* perf measurement: virtualized */ + const timeBeforeOpenB = useRef() + const timeAfterOpenB = useRef() + const [timeTakenB, setTimeTakenB] = useState() + + const onOpenChangeB = () => { + if (!openB) timeBeforeOpenB.current = performance.now() + setOpenB(!openB) + } + useEffect( + function measureB() { + if (openB) { + timeAfterOpenB.current = performance.now() + if (timeBeforeOpenB.current) setTimeTakenB(timeAfterOpenB.current - timeBeforeOpenB.current) + } + }, + [openB], + ) + + return ( + +
+

Without virtualization

+

Time to open (ms): {timeTakenA ? : '(click to open)'}

+ + Labels ({NUMBER_OF_ITEMS} items) + ( + + )} + open={openA} + onOpenChange={onOpenChangeA} + items={filteredItemsA} + selected={selectedA} + onSelectedChange={setSelectedA} + onFilterChange={setFilterA} + width="medium" + height="large" + message={filteredItemsA.length === 0 ? NoResultsMessage(filterA) : undefined} + /> + +
+ +
+

With virtualization

+

Time to open (ms): {timeTakenB ? : '(click to open)'}

+ + Labels ({NUMBER_OF_ITEMS} items, virtualized) + ( + + )} + open={openB} + onOpenChange={onOpenChangeB} + items={filteredItemsB} + selected={selectedB} + onSelectedChange={setSelectedB} + onFilterChange={setFilterB} + virtualized + width="medium" + height="large" + message={filteredItemsB.length === 0 ? NoResultsMessage(filterB) : undefined} + /> + +
+
+ ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 112327b846c..d5c808f2cef 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -199,6 +199,7 @@ function Panel({ align, showSelectAll = false, focusPrependedElements, + virtualized, ...listProps }: SelectPanelProps): JSX.Element { const titleId = useId() @@ -891,6 +892,7 @@ function Panel({ fullScreenOnNarrow={usingFullScreenOnNarrow} className={clsx(className, classes.FilteredActionList)} focusPrependedElements={focusPrependedElements} + virtualized={virtualized} /> {footer ? (
{footer}