Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selectpanel-built-in-virtualization.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 12 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
178 changes: 136 additions & 42 deletions packages/react/src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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}

Expand Down Expand Up @@ -112,6 +113,18 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
* @default 'auto'
*/
scrollBehavior?: ScrollBehavior
/**
* 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.
*
* This is a purely client-side optimization — it does not require server-side pagination.
* The consumer can still pass all items at once; the component will only render what is visible.
*
* Recommended for lists with more than 100 items.
*
* @default false
*/
virtualized?: boolean
Copy link
Member

Choose a reason for hiding this comment

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

TODO:

  • add to SelectPanel.docs.json

}

export function FilteredActionList({
Expand Down Expand Up @@ -143,6 +156,7 @@ export function FilteredActionList({
setInitialFocus = false,
focusPrependedElements,
scrollBehavior,
virtualized = false,
...listProps
}: FilteredActionListProps): JSX.Element {
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '')
Expand Down Expand Up @@ -258,13 +272,22 @@ export function FilteredActionList({
? {
containerRef: {current: listContainerElement},
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
focusOutBehavior,
focusOutBehavior: virtualized ? 'stop' : focusOutBehavior,
Copy link
Member

Choose a reason for hiding this comment

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

curious why this is needed 👀

focusableElementFilter: element => {
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,
Expand All @@ -279,7 +302,7 @@ export function FilteredActionList({
focusPrependedElements,
}
: undefined,
[listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements],
[listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements, virtualized],
)

useEffect(() => {
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
if (onSelectAllChange) {
Expand All @@ -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 (
<ActionList.Group key={index}>
<ActionList.GroupHeading variant={group.header?.variant ? group.header.variant : undefined}>
{group.header?.title ? group.header.title : `Group ${group.groupId}`}
</ActionList.GroupHeading>
{getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, itemIndex) => {
const key = itemKey ?? item.id?.toString() ?? itemIndex.toString()
return (
<MappedActionListItem
Comment on lines +400 to +413
Copy link
Member

Choose a reason for hiding this comment

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

does this mean the virtualized option won't work with groups? if so we should call that out in the prop info

key={key}
className={clsx(classes.ActionListItem, 'className' in item ? item.className : undefined)}
data-input-focused={isInputFocused ? '' : undefined}
data-first-child={index === firstGroupIndex && itemIndex === 0 ? '' : undefined}
{...item}
renderItem={listProps.renderItem}
/>
)
})}
</ActionList.Group>
)
})
}

if (virtualized && virtualizedItemEntries) {
return virtualizedItemEntries.map(({virtualItem, item: {key: itemKey, ...item}, index}) => {
const key = itemKey ?? item.id?.toString() ?? index.toString()
return (
<MappedActionListItem
key={key}
className={clsx(classes.ActionListItem, 'className' in item ? item.className : undefined)}
data-input-focused={isInputFocused ? '' : undefined}
data-first-child={index === 0 ? '' : undefined}
data-index={virtualItem.index}
ref={(node: HTMLLIElement | null) => {
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 (
<MappedActionListItem
key={key}
className={clsx(classes.ActionListItem, 'className' in item ? item.className : undefined)}
data-input-focused={isInputFocused ? '' : undefined}
data-first-child={index === 0 ? '' : undefined}
{...item}
renderItem={listProps.renderItem}
/>
)
})
}

const actionListContent = (
<ActionList
ref={usingRovingTabindex ? listRef : listContainerRefCallback}
Expand All @@ -357,46 +479,18 @@ export function FilteredActionList({
role="listbox"
id={listId}
className={clsx(classes.ActionList, actionListProps?.className)}
>
{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,
Copy link
Member

Choose a reason for hiding this comment

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

do the 'height' and 'width' SelectPanel props still work alongside these?

...actionListProps?.style,
}
return (
<ActionList.Group key={index}>
<ActionList.GroupHeading variant={group.header?.variant ? group.header.variant : undefined}>
{group.header?.title ? group.header.title : `Group ${group.groupId}`}
</ActionList.GroupHeading>
{getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, itemIndex) => {
const key = itemKey ?? item.id?.toString() ?? itemIndex.toString()
return (
<MappedActionListItem
key={key}
className={clsx(classes.ActionListItem, 'className' in item ? item.className : undefined)}
data-input-focused={isInputFocused ? '' : undefined}
data-first-child={index === firstGroupIndex && itemIndex === 0 ? '' : undefined}
{...item}
renderItem={listProps.renderItem}
/>
)
})}
</ActionList.Group>
)
})
: items.map(({key: itemKey, ...item}, index) => {
const key = itemKey ?? item.id?.toString() ?? index.toString()
return (
<MappedActionListItem
key={key}
className={clsx(classes.ActionListItem, 'className' in item ? item.className : undefined)}
data-input-focused={isInputFocused ? '' : undefined}
data-first-child={index === 0 ? '' : undefined}
{...item}
renderItem={listProps.renderItem}
/>
)
})}
: actionListProps?.style
}
>
{renderListItems()}
</ActionList>
)

Expand Down
Loading
Loading