diff --git a/.changeset/salty-geese-say.md b/.changeset/salty-geese-say.md new file mode 100644 index 00000000000..4ac8204b82a --- /dev/null +++ b/.changeset/salty-geese-say.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Expose props to allow virtualization in the SelectPanel diff --git a/package-lock.json b/package-lock.json index 80e38040423..626bf0fda64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8431,6 +8431,35 @@ "tslib": "^2.8.0" } }, + "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, + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "dev": true, @@ -27176,6 +27205,7 @@ "@storybook/addon-links": "^9.1.10", "@storybook/icons": "^1.6.0", "@storybook/react-vite": "^9.1.10", + "@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 6686ebe2daf..d63ee809bcf 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 4c2b59b2cc2..6cc5a08076d 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -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' @@ -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' @@ -33,6 +32,10 @@ export interface FilteredActionListProps extends Partial | null) => void onListContainerRefChanged?: (ref: HTMLElement | null) => void onInputRefChanged?: (ref: React.RefObject) => void + /** + * A ref assigned to the scrollable container wrapping the ActionList + */ + scrollContainerRef?: React.Ref textInputProps?: Partial> inputRef?: React.RefObject message?: React.ReactNode @@ -44,6 +47,19 @@ export interface FilteredActionListProps extends Partial void + /** + * Additional props to pass to the underlying ActionList component. + */ + actionListProps?: Partial + /** + * 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. @@ -77,6 +93,7 @@ export function FilteredActionList({ items, textInputProps, inputRef: providedInputRef, + scrollContainerRef: providedScrollContainerRef, groupMetadata, showItemDividers, message, @@ -86,6 +103,8 @@ export function FilteredActionList({ announcementsEnabled = true, fullScreenOnNarrow, onSelectAllChange, + actionListProps, + focusOutBehavior = 'wrap', _PrivateFocusManagement = 'active-descendant', ...listProps }: FilteredActionListProps): JSX.Element { @@ -102,14 +121,16 @@ export function FilteredActionList({ const inputAndListContainerRef = useRef(null) const listRef = useRef(null) - const scrollContainerRef = useRef(null) + const scrollContainerRef = useProvidedRefOrCreate( + providedScrollContainerRef as React.RefObject, + ) const inputRef = useProvidedRefOrCreate(providedInputRef) const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex' const [listContainerElement, setListContainerElement] = useState(null) const activeDescendantRef = useRef() - const listId = useId() + const listId = useId(actionListProps?.id) const inputDescriptionTextId = useId() const [isInputFocused, setIsInputFocused] = useState(false) @@ -200,7 +221,7 @@ export function FilteredActionList({ ? { containerRef: {current: listContainerElement}, bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, - focusOutBehavior: 'wrap', + focusOutBehavior, focusableElementFilter: element => { return !(element instanceof HTMLInputElement) }, @@ -224,7 +245,7 @@ export function FilteredActionList({ behavior: 'auto', }) } - }, [items, inputRef]) + }, [items, inputRef, scrollContainerRef]) useEffect(() => { if (usingRovingTabindex) { @@ -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) => { diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index aa55f1761c1..370af70ee9c 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -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", + "defaultValue": "undefined", + "description": "A ref assigned to the scrollable container wrapping the ActionList" + }, + { + "name": "actionListProps", + "type": "Partial", + "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": [] diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 3141591f221..7ba9103e6f8 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -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' @@ -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, @@ -583,3 +584,132 @@ export const RenderMoreOnScroll = () => { ) } + +const DEFAULT_VIRTUAL_ITEM_HEIGHT = 35 + +export const Virtualized = () => { + const [selected, setSelected] = useState([]) + const [open, setOpen] = useState(false) + const [renderSubset, setRenderSubset] = useState(true) + + const [filter, setFilter] = useState('') + const [scrollContainer, setScrollContainer] = useState(null) + const filteredItems = lotsOfItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + + /* perf measurement logic start */ + const timeBeforeOpen = useRef() + const timeAfterOpen = useRef() + const [timeTakenToOpen, setTimeTakenToOpen] = useState() + + 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 ( +
+ + Render subset of items on initial open + + {renderSubset + ? 'Uses virtualization to render visible items efficiently' + : `Loads all ${NUMBER_OF_ITEMS} items at once without virtualization`} + + { + setRenderSubset(!renderSubset) + setTimeTakenToOpen(undefined) + }} + /> + +

+ Time taken (ms) to render initial {renderSubset ? 50 : NUMBER_OF_ITEMS} items:{' '} + {timeTakenToOpen ? : '(click "Select Labels" to open)'} +

+ + Labels + ( + + )} + 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, + }} + /> + +
+ ) +}