Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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/salty-geese-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Expose props to allow virtualization in the SelectPanel
30 changes: 30 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"@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
18 changes: 13 additions & 5 deletions packages/react/src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type React from 'react'
import {useCallback, useEffect, useRef, useState} from 'react'
import type {TextInputProps} from '../TextInput'
import TextInput from '../TextInput'
import {ActionList} from '../ActionList'
import {ActionList, type ActionListProps} from '../ActionList'
import type {GroupedListProps, ListPropsBase, ItemInput, RenderItemFn} from './'
import {useFocusZone} from '../hooks/useFocusZone'
import {useId} from '../hooks/useId'
Expand All @@ -17,7 +17,6 @@ import type {FilteredActionListLoadingType} from './FilteredActionListLoaders'
import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './FilteredActionListLoaders'
import classes from './FilteredActionList.module.css'
import Checkbox from '../Checkbox'

import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
import {isValidElementType} from 'react-is'
import {useAnnouncements} from './useAnnouncements'
Expand All @@ -33,6 +32,7 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement> | null) => void
onListContainerRefChanged?: (ref: HTMLElement | null) => void
onInputRefChanged?: (ref: React.RefObject<HTMLInputElement>) => void
Comment thread
kendallgassner marked this conversation as resolved.
scrollContainerRef?: React.Ref<HTMLDivElement | null>
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
inputRef?: React.RefObject<HTMLInputElement>
message?: React.ReactNode
Expand All @@ -44,6 +44,8 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
announcementsEnabled?: boolean
fullScreenOnNarrow?: boolean
onSelectAllChange?: (checked: boolean) => void
Comment thread
kendallgassner marked this conversation as resolved.
actionListProps?: Partial<ActionListProps>
Comment thread
kendallgassner marked this conversation as resolved.
focusOutBehavior?: 'stop' | 'wrap'
Comment thread
kendallgassner marked this conversation as resolved.
/**
* Private API for use internally only. Adds the ability to switch between
* `active-descendant` and roving tabindex.
Expand Down Expand Up @@ -77,6 +79,7 @@ export function FilteredActionList({
items,
textInputProps,
inputRef: providedInputRef,
scrollContainerRef: providedScrollContainerRef,
groupMetadata,
showItemDividers,
message,
Expand All @@ -86,6 +89,8 @@ export function FilteredActionList({
announcementsEnabled = true,
fullScreenOnNarrow,
onSelectAllChange,
actionListProps,
focusOutBehavior,
_PrivateFocusManagement = 'active-descendant',
...listProps
}: FilteredActionListProps): JSX.Element {
Expand All @@ -102,7 +107,9 @@ export function FilteredActionList({
const inputAndListContainerRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(null)

const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useProvidedRefOrCreate<HTMLDivElement>(
providedScrollContainerRef as React.RefObject<HTMLDivElement>,
Comment thread
kendallgassner marked this conversation as resolved.
)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)

const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex'
Expand Down Expand Up @@ -200,7 +207,7 @@ export function FilteredActionList({
? {
containerRef: {current: listContainerElement},
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
focusOutBehavior: 'wrap',
focusOutBehavior: focusOutBehavior ?? 'wrap',
focusableElementFilter: element => {
return !(element instanceof HTMLInputElement)
},
Expand All @@ -224,7 +231,7 @@ export function FilteredActionList({
behavior: 'auto',
})
}
}, [items, inputRef])
}, [items, inputRef, scrollContainerRef])
Comment thread
kendallgassner marked this conversation as resolved.

useEffect(() => {
if (usingRovingTabindex) {
Expand Down Expand Up @@ -291,6 +298,7 @@ export function FilteredActionList({
role="listbox"
id={listId}
className={classes.ActionList}
{...actionListProps}
Comment thread
kendallgassner marked this conversation as resolved.
Outdated
>
{groupMetadata?.length
? groupMetadata.map((group, index) => {
Expand Down
132 changes: 131 additions & 1 deletion packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import FormControl from '../FormControl'
import {Stack} from '../Stack'
import {Dialog} from '../experimental'
import styles from './SelectPanel.examples.stories.module.css'
import {useVirtualizer} from '@tanstack/react-virtual'
import Checkbox from '../Checkbox'
import Label from '../Label'

Expand Down Expand Up @@ -472,7 +473,7 @@ export const WithDefaultMessage = () => {
)
}

const NUMBER_OF_ITEMS = 500
const NUMBER_OF_ITEMS = 1800
const lotsOfItems = Array.from({length: NUMBER_OF_ITEMS}, (_, index) => {
return {
id: index,
Expand Down Expand Up @@ -583,3 +584,132 @@ export const RenderMoreOnScroll = () => {
</form>
)
}

const DEFAULT_VIRTUAL_ITEM_HEIGHT = 35

export const Virtualized = () => {
const [selected, setSelected] = useState<ItemInput[]>([])
const [open, setOpen] = useState(false)
const [renderSubset, setRenderSubset] = useState(true)

const [filter, setFilter] = useState('')
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null)
const filteredItems = lotsOfItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))

/* perf measurement logic start */
const timeBeforeOpen = useRef<number>()
const timeAfterOpen = useRef<number>()
const [timeTakenToOpen, setTimeTakenToOpen] = useState<number>()

const onOpenChange = () => {
if (!open) timeBeforeOpen.current = performance.now()
setOpen(!open)
}
useEffect(
function measureTimeAfterOpen() {
if (open) {
timeAfterOpen.current = performance.now()
if (timeBeforeOpen.current) setTimeTakenToOpen(timeAfterOpen.current - timeBeforeOpen.current)
}
},
[open],
)

const virtualizer = useVirtualizer({
count: filteredItems.length,
getScrollElement: () => scrollContainer ?? null,
estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT,
overscan: 10,
debug: true,
enabled: renderSubset,
})

const virtualizedContainerStyle = useMemo(
() =>
renderSubset
? {
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative' as const,
}
: undefined,
[renderSubset, virtualizer],
)

const virtualizedItems = useMemo(
() =>
renderSubset
? virtualizer.getVirtualItems().map(virtualItem => {
const item = filteredItems[virtualItem.index]

return {
...item,
key: virtualItem.index,
style: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
},
}
})
: filteredItems,
[renderSubset, virtualizer, filteredItems],
)

return (
<form>
<FormControl>
<FormControl.Label>Render subset of items on initial open</FormControl.Label>
<FormControl.Caption>
{renderSubset
? 'Uses virtualization to render visible items efficiently'
: `Loads all ${NUMBER_OF_ITEMS} items at once without virtualization`}
</FormControl.Caption>
<Checkbox
checked={renderSubset}
onChange={() => {
setRenderSubset(!renderSubset)
setTimeTakenToOpen(undefined)
}}
/>
</FormControl>
<p>
Time taken (ms) to render initial {renderSubset ? 50 : NUMBER_OF_ITEMS} items:{' '}
{timeTakenToOpen ? <Label>{timeTakenToOpen.toFixed(2)} ms</Label> : '(click "Select Labels" to open)'}
</p>
<FormControl>
<FormControl.Label>Labels</FormControl.Label>
<SelectPanel
title="Select labels"
placeholder="Select labels"
subtitle="Use labels to organize issues and pull requests"
renderAnchor={({children, ...anchorProps}) => (
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
{children}
</Button>
)}
open={open}
onOpenChange={onOpenChange}
items={virtualizedItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
width="medium"
height="large"
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
overlayProps={{
id: 'select-labels-panel-dialog',
}}
focusOutBehavior="stop"
scrollContainerRef={node => setScrollContainer(node)}
actionListProps={{
style: virtualizedContainerStyle,
}}
Comment thread
kendallgassner marked this conversation as resolved.
/>
</FormControl>
</form>
)
}
Loading