Skip to content
Merged
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/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
36 changes: 29 additions & 7 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,10 @@ 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
/**
* A ref assigned to the scrollable container wrapping the ActionList
*/
scrollContainerRef?: React.Ref<HTMLDivElement | null>
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
inputRef?: React.RefObject<HTMLInputElement>
message?: React.ReactNode
Expand All @@ -44,6 +47,19 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
announcementsEnabled?: boolean
fullScreenOnNarrow?: boolean
onSelectAllChange?: (checked: boolean) => void
/**
* Additional props to pass to the underlying ActionList component.
*/
actionListProps?: Partial<ActionListProps>
/**
* Determines how keyboard focus behaves when navigating beyond the first or last item in the list.
*
* - `'stop'`: Focus will stop at the first or last item; further navigation in that direction will not move focus.
* - `'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).
*
* @default 'wrap'
*/
focusOutBehavior?: 'stop' | 'wrap'
/**
* Private API for use internally only. Adds the ability to switch between
* `active-descendant` and roving tabindex.
Expand Down Expand Up @@ -77,6 +93,7 @@ export function FilteredActionList({
items,
textInputProps,
inputRef: providedInputRef,
scrollContainerRef: providedScrollContainerRef,
groupMetadata,
showItemDividers,
message,
Expand All @@ -86,6 +103,8 @@ export function FilteredActionList({
announcementsEnabled = true,
fullScreenOnNarrow,
onSelectAllChange,
actionListProps,
focusOutBehavior = 'wrap',
_PrivateFocusManagement = 'active-descendant',
...listProps
}: FilteredActionListProps): JSX.Element {
Expand All @@ -102,14 +121,16 @@ 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>,
)
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)

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

const listId = useId()
const listId = useId(actionListProps?.id)
const inputDescriptionTextId = useId()
const [isInputFocused, setIsInputFocused] = useState(false)

Expand Down Expand Up @@ -200,7 +221,7 @@ export function FilteredActionList({
? {
containerRef: {current: listContainerElement},
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
focusOutBehavior: 'wrap',
focusOutBehavior,
focusableElementFilter: element => {
return !(element instanceof HTMLInputElement)
},
Expand All @@ -224,7 +245,7 @@ export function FilteredActionList({
behavior: 'auto',
})
}
}, [items, inputRef])
}, [items, inputRef, scrollContainerRef])

useEffect(() => {
if (usingRovingTabindex) {
Expand Down Expand Up @@ -288,9 +309,10 @@ export function FilteredActionList({
showDividers={showItemDividers}
selectionVariant={selectionVariant}
{...listProps}
{...actionListProps}
role="listbox"
id={listId}
className={classes.ActionList}
className={clsx(classes.ActionList, actionListProps?.className)}
>
{groupMetadata?.length
? groupMetadata.map((group, index) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,24 @@
"type": "'start' | 'end' | 'center'",
"defaultValue": "'start'",
"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."
},
{
"name": "scrollContainerRef",
"type": "React.Ref<HTMLDivElement | null>",
"defaultValue": "undefined",
"description": "A ref assigned to the scrollable container wrapping the ActionList"
},
{
"name": "actionListProps",
"type": "Partial<ActionListProps>",
"defaultValue": "undefined",
"description": "See [ActionList props](/react/ActionList#props)."
},
{
"name": "focusOutBehavior",
"type": "'start' | 'wrap'",
"defaultValue": "'wrap'",
"description": "Determines how keyboard focus behaves when navigating beyond the first or last item in the list."
}
],
"subcomponents": []
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,
}}
/>
</FormControl>
</form>
)
}
Loading