Skip to content

Commit 62fc3bb

Browse files
Expose virtualization Props for SelectPanel (#7220)
1 parent 64257e2 commit 62fc3bb

File tree

6 files changed

+214
-8
lines changed

6 files changed

+214
-8
lines changed

.changeset/salty-geese-say.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Expose props to allow virtualization in the SelectPanel

package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"@figma/code-connect": "1.3.2",
108108
"@primer/css": "^21.5.1",
109109
"@primer/doc-gen": "^0.0.1",
110+
"@tanstack/react-virtual": "^3.13.12",
110111
"@rollup/plugin-babel": "6.1.0",
111112
"@rollup/plugin-commonjs": "29.0.0",
112113
"@rollup/plugin-json": "6.1.0",

packages/react/src/FilteredActionList/FilteredActionList.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type React from 'react'
55
import {useCallback, useEffect, useRef, useState} from 'react'
66
import type {TextInputProps} from '../TextInput'
77
import TextInput from '../TextInput'
8-
import {ActionList} from '../ActionList'
8+
import {ActionList, type ActionListProps} from '../ActionList'
99
import type {GroupedListProps, ListPropsBase, ItemInput, RenderItemFn} from './'
1010
import {useFocusZone} from '../hooks/useFocusZone'
1111
import {useId} from '../hooks/useId'
@@ -17,7 +17,6 @@ import type {FilteredActionListLoadingType} from './FilteredActionListLoaders'
1717
import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './FilteredActionListLoaders'
1818
import classes from './FilteredActionList.module.css'
1919
import Checkbox from '../Checkbox'
20-
2120
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
2221
import {isValidElementType} from 'react-is'
2322
import {useAnnouncements} from './useAnnouncements'
@@ -33,6 +32,10 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
3332
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement> | null) => void
3433
onListContainerRefChanged?: (ref: HTMLElement | null) => void
3534
onInputRefChanged?: (ref: React.RefObject<HTMLInputElement>) => void
35+
/**
36+
* A ref assigned to the scrollable container wrapping the ActionList
37+
*/
38+
scrollContainerRef?: React.Ref<HTMLDivElement | null>
3639
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
3740
inputRef?: React.RefObject<HTMLInputElement>
3841
message?: React.ReactNode
@@ -44,6 +47,19 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
4447
announcementsEnabled?: boolean
4548
fullScreenOnNarrow?: boolean
4649
onSelectAllChange?: (checked: boolean) => void
50+
/**
51+
* Additional props to pass to the underlying ActionList component.
52+
*/
53+
actionListProps?: Partial<ActionListProps>
54+
/**
55+
* Determines how keyboard focus behaves when navigating beyond the first or last item in the list.
56+
*
57+
* - `'stop'`: Focus will stop at the first or last item; further navigation in that direction will not move focus.
58+
* - `'wrap'`: Focus will wrap around to the opposite end of the list when navigating past the boundaries (e.g., pressing Down on the last item moves focus to the first).
59+
*
60+
* @default 'wrap'
61+
*/
62+
focusOutBehavior?: 'stop' | 'wrap'
4763
/**
4864
* Private API for use internally only. Adds the ability to switch between
4965
* `active-descendant` and roving tabindex.
@@ -77,6 +93,7 @@ export function FilteredActionList({
7793
items,
7894
textInputProps,
7995
inputRef: providedInputRef,
96+
scrollContainerRef: providedScrollContainerRef,
8097
groupMetadata,
8198
showItemDividers,
8299
message,
@@ -86,6 +103,8 @@ export function FilteredActionList({
86103
announcementsEnabled = true,
87104
fullScreenOnNarrow,
88105
onSelectAllChange,
106+
actionListProps,
107+
focusOutBehavior = 'wrap',
89108
_PrivateFocusManagement = 'active-descendant',
90109
...listProps
91110
}: FilteredActionListProps): JSX.Element {
@@ -102,14 +121,16 @@ export function FilteredActionList({
102121
const inputAndListContainerRef = useRef<HTMLDivElement>(null)
103122
const listRef = useRef<HTMLUListElement>(null)
104123

105-
const scrollContainerRef = useRef<HTMLDivElement>(null)
124+
const scrollContainerRef = useProvidedRefOrCreate<HTMLDivElement>(
125+
providedScrollContainerRef as React.RefObject<HTMLDivElement>,
126+
)
106127
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
107128

108129
const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex'
109130
const [listContainerElement, setListContainerElement] = useState<HTMLUListElement | null>(null)
110131
const activeDescendantRef = useRef<HTMLElement>()
111132

112-
const listId = useId()
133+
const listId = useId(actionListProps?.id)
113134
const inputDescriptionTextId = useId()
114135
const [isInputFocused, setIsInputFocused] = useState(false)
115136

@@ -200,7 +221,7 @@ export function FilteredActionList({
200221
? {
201222
containerRef: {current: listContainerElement},
202223
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
203-
focusOutBehavior: 'wrap',
224+
focusOutBehavior,
204225
focusableElementFilter: element => {
205226
return !(element instanceof HTMLInputElement)
206227
},
@@ -224,7 +245,7 @@ export function FilteredActionList({
224245
behavior: 'auto',
225246
})
226247
}
227-
}, [items, inputRef])
248+
}, [items, inputRef, scrollContainerRef])
228249

229250
useEffect(() => {
230251
if (usingRovingTabindex) {
@@ -288,9 +309,10 @@ export function FilteredActionList({
288309
showDividers={showItemDividers}
289310
selectionVariant={selectionVariant}
290311
{...listProps}
312+
{...actionListProps}
291313
role="listbox"
292314
id={listId}
293-
className={classes.ActionList}
315+
className={clsx(classes.ActionList, actionListProps?.className)}
294316
>
295317
{groupMetadata?.length
296318
? groupMetadata.map((group, index) => {

packages/react/src/SelectPanel/SelectPanel.docs.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,24 @@
207207
"type": "'start' | 'end' | 'center'",
208208
"defaultValue": "'start'",
209209
"description": "Determines the alignment of the panel relative to the anchor. Defaults to 'start' which aligns the left edge of the panel with the left edge of the anchor."
210+
},
211+
{
212+
"name": "scrollContainerRef",
213+
"type": "React.Ref<HTMLDivElement | null>",
214+
"defaultValue": "undefined",
215+
"description": "A ref assigned to the scrollable container wrapping the ActionList"
216+
},
217+
{
218+
"name": "actionListProps",
219+
"type": "Partial<ActionListProps>",
220+
"defaultValue": "undefined",
221+
"description": "See [ActionList props](/react/ActionList#props)."
222+
},
223+
{
224+
"name": "focusOutBehavior",
225+
"type": "'start' | 'wrap'",
226+
"defaultValue": "'wrap'",
227+
"description": "Determines how keyboard focus behaves when navigating beyond the first or last item in the list."
210228
}
211229
],
212230
"subcomponents": []

packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import FormControl from '../FormControl'
1010
import {Stack} from '../Stack'
1111
import {Dialog} from '../experimental'
1212
import styles from './SelectPanel.examples.stories.module.css'
13+
import {useVirtualizer} from '@tanstack/react-virtual'
1314
import Checkbox from '../Checkbox'
1415
import Label from '../Label'
1516

@@ -472,7 +473,7 @@ export const WithDefaultMessage = () => {
472473
)
473474
}
474475

475-
const NUMBER_OF_ITEMS = 500
476+
const NUMBER_OF_ITEMS = 1800
476477
const lotsOfItems = Array.from({length: NUMBER_OF_ITEMS}, (_, index) => {
477478
return {
478479
id: index,
@@ -583,3 +584,132 @@ export const RenderMoreOnScroll = () => {
583584
</form>
584585
)
585586
}
587+
588+
const DEFAULT_VIRTUAL_ITEM_HEIGHT = 35
589+
590+
export const Virtualized = () => {
591+
const [selected, setSelected] = useState<ItemInput[]>([])
592+
const [open, setOpen] = useState(false)
593+
const [renderSubset, setRenderSubset] = useState(true)
594+
595+
const [filter, setFilter] = useState('')
596+
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null)
597+
const filteredItems = lotsOfItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
598+
599+
/* perf measurement logic start */
600+
const timeBeforeOpen = useRef<number>()
601+
const timeAfterOpen = useRef<number>()
602+
const [timeTakenToOpen, setTimeTakenToOpen] = useState<number>()
603+
604+
const onOpenChange = () => {
605+
if (!open) timeBeforeOpen.current = performance.now()
606+
setOpen(!open)
607+
}
608+
useEffect(
609+
function measureTimeAfterOpen() {
610+
if (open) {
611+
timeAfterOpen.current = performance.now()
612+
if (timeBeforeOpen.current) setTimeTakenToOpen(timeAfterOpen.current - timeBeforeOpen.current)
613+
}
614+
},
615+
[open],
616+
)
617+
618+
const virtualizer = useVirtualizer({
619+
count: filteredItems.length,
620+
getScrollElement: () => scrollContainer ?? null,
621+
estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT,
622+
overscan: 10,
623+
debug: true,
624+
enabled: renderSubset,
625+
})
626+
627+
const virtualizedContainerStyle = useMemo(
628+
() =>
629+
renderSubset
630+
? {
631+
height: virtualizer.getTotalSize(),
632+
width: '100%',
633+
position: 'relative' as const,
634+
}
635+
: undefined,
636+
[renderSubset, virtualizer],
637+
)
638+
639+
const virtualizedItems = useMemo(
640+
() =>
641+
renderSubset
642+
? virtualizer.getVirtualItems().map(virtualItem => {
643+
const item = filteredItems[virtualItem.index]
644+
645+
return {
646+
...item,
647+
key: virtualItem.index,
648+
style: {
649+
position: 'absolute',
650+
top: 0,
651+
left: 0,
652+
width: '100%',
653+
height: `${virtualItem.size}px`,
654+
transform: `translateY(${virtualItem.start}px)`,
655+
},
656+
}
657+
})
658+
: filteredItems,
659+
[renderSubset, virtualizer, filteredItems],
660+
)
661+
662+
return (
663+
<form>
664+
<FormControl>
665+
<FormControl.Label>Render subset of items on initial open</FormControl.Label>
666+
<FormControl.Caption>
667+
{renderSubset
668+
? 'Uses virtualization to render visible items efficiently'
669+
: `Loads all ${NUMBER_OF_ITEMS} items at once without virtualization`}
670+
</FormControl.Caption>
671+
<Checkbox
672+
checked={renderSubset}
673+
onChange={() => {
674+
setRenderSubset(!renderSubset)
675+
setTimeTakenToOpen(undefined)
676+
}}
677+
/>
678+
</FormControl>
679+
<p>
680+
Time taken (ms) to render initial {renderSubset ? 50 : NUMBER_OF_ITEMS} items:{' '}
681+
{timeTakenToOpen ? <Label>{timeTakenToOpen.toFixed(2)} ms</Label> : '(click "Select Labels" to open)'}
682+
</p>
683+
<FormControl>
684+
<FormControl.Label>Labels</FormControl.Label>
685+
<SelectPanel
686+
title="Select labels"
687+
placeholder="Select labels"
688+
subtitle="Use labels to organize issues and pull requests"
689+
renderAnchor={({children, ...anchorProps}) => (
690+
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
691+
{children}
692+
</Button>
693+
)}
694+
open={open}
695+
onOpenChange={onOpenChange}
696+
items={virtualizedItems}
697+
selected={selected}
698+
onSelectedChange={setSelected}
699+
onFilterChange={setFilter}
700+
width="medium"
701+
height="large"
702+
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
703+
overlayProps={{
704+
id: 'select-labels-panel-dialog',
705+
}}
706+
focusOutBehavior="stop"
707+
scrollContainerRef={node => setScrollContainer(node)}
708+
actionListProps={{
709+
style: virtualizedContainerStyle,
710+
}}
711+
/>
712+
</FormControl>
713+
</form>
714+
)
715+
}

0 commit comments

Comments
 (0)