From 61ea8a07bd8a2032042d2a6c4cb423d1d1635c20 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 11:35:16 +0000 Subject: [PATCH 01/12] feat(SelectPanel): add built-in client-side list virtualization via `virtualized` prop Add a `virtualized` boolean prop to SelectPanel/FilteredActionList that enables client-side list virtualization using @tanstack/react-virtual (already a dependency). When enabled, only the visible items plus a small overscan buffer are rendered in the DOM, dramatically improving performance for large lists. - Add `virtualized` prop to FilteredActionListProps with JSDoc - Wire up useVirtualizer with scroll container, dynamic measurement, overscan=10 - Render virtual items with absolute positioning inside sized container - Handle focus zone scroll-to-index for keyboard navigation of virtual items - Thread `virtualized` prop through SelectPanel to FilteredActionList - Add VirtualizedBuiltIn comparison story (side-by-side with timing) - Rename existing consumer-side virtualization story for clarity --- .../FilteredActionList/FilteredActionList.tsx | 178 +++++++++++++----- .../SelectPanel.examples.stories.tsx | 112 ++++++++++- .../react/src/SelectPanel/SelectPanel.tsx | 2 + 3 files changed, 249 insertions(+), 43 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index b356e07f71b..9e83fbfda1c 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,18 @@ export interface FilteredActionListProps extends Partial { return !(element instanceof HTMLInputElement) }, activeDescendantFocus: inputRef, onActiveDescendantChanged: (current, previous, directlyActivated) => { activeDescendantRef.current = current + + if (virtualized && 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 +302,7 @@ export function FilteredActionList({ focusPrependedElements, } : undefined, - [listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements], + [listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements, virtualized], ) useEffect(() => { @@ -330,6 +353,31 @@ export function FilteredActionList({ ) useScrollFlash(scrollContainerRef) + const DEFAULT_VIRTUAL_ITEM_HEIGHT = 49 + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT, + overscan: 10, + enabled: virtualized, + getItemKey: index => { + const item = items[index] + return item.key ?? item.id?.toString() ?? index.toString() + }, + measureElement: el => (el as HTMLElement).scrollHeight, + }) + + const virtualItems = virtualized ? virtualizer.getVirtualItems() : undefined + + const virtualizedItemEntries = useMemo(() => { + if (!virtualized || !virtualItems) return undefined + return virtualItems.map(virtualItem => { + const item = items[virtualItem.index] + return {virtualItem, item, index: virtualItem.index} + }) + }, [virtualized, virtualItems, items]) + const handleSelectAllChange = useCallback( (e: React.ChangeEvent) => { if (onSelectAllChange) { @@ -347,6 +395,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 (virtualized && 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, + width: '100%', + 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 + style={ + virtualized + ? { + height: virtualizer.getTotalSize(), + width: '100%', + position: 'relative' as const, + ...actionListProps?.style, } - 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.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}
From ad6f91e2ed9048b3396a70b06508e8a289191afa Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 11:55:18 +0000 Subject: [PATCH 02/12] Move @tanstack/react-virtual from devDependencies to dependencies --- package-lock.json | 26 ++++++++++++-------------- packages/react/package.json | 2 +- 2 files changed, 13 insertions(+), 15 deletions(-) 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", From bf26e4bca1cbd0116538dafb1c9c409a00394d32 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 11:55:51 +0000 Subject: [PATCH 03/12] Add changeset for SelectPanel built-in virtualization --- .changeset/selectpanel-built-in-virtualization.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/selectpanel-built-in-virtualization.md 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. From 295a4891d624b9d724b0ecc25a92d5b57ea4a75c Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 15:57:27 +0000 Subject: [PATCH 04/12] docs: add comment clarifying virtualization styles vs SelectPanel height/width props --- packages/react/src/FilteredActionList/FilteredActionList.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 9e83fbfda1c..4367e665ba3 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -479,6 +479,11 @@ export function FilteredActionList({ role="listbox" id={listId} className={clsx(classes.ActionList, actionListProps?.className)} + // 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={ virtualized ? { From b319abc37eed6b1aa69361554f020ed10dfdf233 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 15:59:29 +0000 Subject: [PATCH 05/12] perf: set estimateSize to 32px to match actual ActionList.Item height The previous 49px estimate caused a 53% overestimate of total virtual height (~88k px vs actual ~57.6k px for 1800 items). Since items are dynamically measured via measureElement, this only affects the initial scroll thumb size and reduces layout shift when jumping to far indices. --- packages/react/src/FilteredActionList/FilteredActionList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 4367e665ba3..00b5f1f523f 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -353,7 +353,10 @@ export function FilteredActionList({ ) useScrollFlash(scrollContainerRef) - const DEFAULT_VIRTUAL_ITEM_HEIGHT = 49 + // 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, From c1d626084483bf89e051970702ddbcb3136c9667 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 16:01:51 +0000 Subject: [PATCH 06/12] docs: document that virtualized has no effect with groupMetadata - Update virtualized JSDoc to note the limitation - Add __DEV__ console.warn when both virtualized and groupMetadata are set --- .../src/FilteredActionList/FilteredActionList.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 00b5f1f523f..c7e6f92f7b8 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -122,6 +122,9 @@ export interface FilteredActionListProps extends Partial) => { From fac2583e2f0f55d03bbe42132d5c6d219c10ce88 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 16:04:33 +0000 Subject: [PATCH 07/12] docs: explain why focusOutBehavior is forced to 'stop' when virtualized --- packages/react/src/FilteredActionList/FilteredActionList.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index c7e6f92f7b8..2ae63e2fa93 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -285,6 +285,10 @@ export function FilteredActionList({ ? { containerRef: {current: listContainerElement}, bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, + // 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: virtualized ? 'stop' : focusOutBehavior, focusableElementFilter: element => { return !(element instanceof HTMLInputElement) From 99c789a9dc014b56fa8c536d0cd60b6d2eef3b63 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 16:06:23 +0000 Subject: [PATCH 08/12] docs: add virtualized prop to SelectPanel.docs.json --- packages/react/src/SelectPanel/SelectPanel.docs.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 +} From 0e37b9c4fc6d6b042fc11ec8b23f98a7e7136c21 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 16:08:08 +0000 Subject: [PATCH 09/12] refactor: move useVirtualizer above useFocusZone The useFocusZone callback references virtualizer.range and virtualizer.scrollToIndex. While this worked at runtime (closures capture bindings, not values), having the declaration after its reference was confusing and flagged by reviewers. Moving it above eliminates the apparent TDZ issue. --- .../FilteredActionList/FilteredActionList.tsx | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 2ae63e2fa93..2884709cc57 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -280,6 +280,34 @@ 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: virtualized, + getItemKey: index => { + const item = items[index] + return item.key ?? item.id?.toString() ?? index.toString() + }, + measureElement: el => (el as HTMLElement).scrollHeight, + }) + + const virtualItems = virtualized ? virtualizer.getVirtualItems() : undefined + + const virtualizedItemEntries = useMemo(() => { + if (!virtualized || !virtualItems) return undefined + return virtualItems.map(virtualItem => { + const item = items[virtualItem.index] + return {virtualItem, item, index: virtualItem.index} + }) + }, [virtualized, virtualItems, items]) + useFocusZone( !usingRovingTabindex ? { @@ -370,34 +398,6 @@ export function FilteredActionList({ ) useScrollFlash(scrollContainerRef) - // 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: virtualized, - getItemKey: index => { - const item = items[index] - return item.key ?? item.id?.toString() ?? index.toString() - }, - measureElement: el => (el as HTMLElement).scrollHeight, - }) - - const virtualItems = virtualized ? virtualizer.getVirtualItems() : undefined - - const virtualizedItemEntries = useMemo(() => { - if (!virtualized || !virtualItems) return undefined - return virtualItems.map(virtualItem => { - const item = items[virtualItem.index] - return {virtualItem, item, index: virtualItem.index} - }) - }, [virtualized, virtualItems, items]) - const handleSelectAllChange = useCallback( (e: React.ChangeEvent) => { if (onSelectAllChange) { From ac6508eb3521608ae17c65ee707f510399e78c8c Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 16:09:21 +0000 Subject: [PATCH 10/12] fix: derive isVirtualized to prevent broken layout with groupMetadata When virtualized=true and groupMetadata is provided, the grouped content renders normally but the ActionList wrapper was still receiving virtualization styles (position: relative, huge height) and focusOutBehavior was forced to 'stop'. This caused incorrect layout. Now all runtime virtualization logic uses: This ensures grouped lists are never treated as virtualized. --- .../FilteredActionList/FilteredActionList.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 2884709cc57..5f1534f55ff 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -172,6 +172,10 @@ export function FilteredActionList({ } } + // Virtualization is disabled when groups are present — grouped lists render + // normally regardless of the `virtualized` prop. + const isVirtualized = virtualized && !groupMetadata?.length + const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') const onInputChange = useCallback( (e: React.ChangeEvent) => { @@ -290,7 +294,7 @@ export function FilteredActionList({ getScrollElement: () => scrollContainerRef.current, estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT, overscan: 10, - enabled: virtualized, + enabled: isVirtualized, getItemKey: index => { const item = items[index] return item.key ?? item.id?.toString() ?? index.toString() @@ -298,15 +302,15 @@ export function FilteredActionList({ measureElement: el => (el as HTMLElement).scrollHeight, }) - const virtualItems = virtualized ? virtualizer.getVirtualItems() : undefined + const virtualItems = isVirtualized ? virtualizer.getVirtualItems() : undefined const virtualizedItemEntries = useMemo(() => { - if (!virtualized || !virtualItems) return undefined + if (!isVirtualized || !virtualItems) return undefined return virtualItems.map(virtualItem => { const item = items[virtualItem.index] return {virtualItem, item, index: virtualItem.index} }) - }, [virtualized, virtualItems, items]) + }, [isVirtualized, virtualItems, items]) useFocusZone( !usingRovingTabindex @@ -317,7 +321,7 @@ export function FilteredActionList({ // '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: virtualized ? 'stop' : focusOutBehavior, + focusOutBehavior: isVirtualized ? 'stop' : focusOutBehavior, focusableElementFilter: element => { return !(element instanceof HTMLInputElement) }, @@ -325,7 +329,7 @@ export function FilteredActionList({ onActiveDescendantChanged: (current, previous, directlyActivated) => { activeDescendantRef.current = current - if (virtualized && 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)) { @@ -347,7 +351,7 @@ export function FilteredActionList({ focusPrependedElements, } : undefined, - [listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements, virtualized], + [listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements, isVirtualized], ) useEffect(() => { @@ -445,7 +449,7 @@ export function FilteredActionList({ }) } - if (virtualized && virtualizedItemEntries) { + if (isVirtualized && virtualizedItemEntries) { return virtualizedItemEntries.map(({virtualItem, item: {key: itemKey, ...item}, index}) => { const key = itemKey ?? item.id?.toString() ?? index.toString() return ( @@ -505,7 +509,7 @@ export function FilteredActionList({ // These styles are independent of SelectPanel's `height`/`width` props, which control the // outer overlay dimensions, not the list content area. style={ - virtualized + isVirtualized ? { height: virtualizer.getTotalSize(), width: '100%', From 50eb41783353bd1ca7b0616037209a375bb9744a Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 16:12:53 +0000 Subject: [PATCH 11/12] fix: spread consumer styles before virtualization styles Virtualization-critical styles (height, position, width) must not be overridable by actionListProps.style. Spread consumer styles first so the required virtualization properties always win. --- packages/react/src/FilteredActionList/FilteredActionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 5f1534f55ff..1b99d36d8bf 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -511,10 +511,10 @@ export function FilteredActionList({ style={ isVirtualized ? { + ...actionListProps?.style, height: virtualizer.getTotalSize(), width: '100%', position: 'relative' as const, - ...actionListProps?.style, } : actionListProps?.style } From 7584e24c0d30bb63403455a8477988e405f9c37b Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 16:17:56 +0000 Subject: [PATCH 12/12] fix: remove width: 100% to eliminate horizontal scrollbar in virtualized list Absolutely positioned items with width: 100% resolved to the containing block (the position: relative ActionList), ignoring the vertical scrollbar's ~8px. This caused every item to overflow horizontally. Replace with left: 0 + right: 0 on items, and remove width: 100% from the ActionList container. Items now size to 304px (matching non-virtualized behavior) and the horizontal scrollbar is gone. --- packages/react/src/FilteredActionList/FilteredActionList.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 1b99d36d8bf..10afa57107c 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -468,7 +468,7 @@ export function FilteredActionList({ position: 'absolute' as const, top: 0, left: 0, - width: '100%', + right: 0, transform: `translateY(${virtualItem.start}px)`, }} {...item} @@ -513,7 +513,6 @@ export function FilteredActionList({ ? { ...actionListProps?.style, height: virtualizer.getTotalSize(), - width: '100%', position: 'relative' as const, } : actionListProps?.style