From c085033cfb2be2b1929497ad7a5e48bf008bf703 Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 19 Nov 2025 21:43:59 +0000 Subject: [PATCH 1/8] WIP --- packages/react/package.json | 1 + .../FilteredActionList/FilteredActionList.tsx | 18 ++- .../SelectPanel.examples.stories.tsx | 138 +++++++++++++++++- 3 files changed, 150 insertions(+), 7 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 6686ebe2daf..d468a3b5d6a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -83,6 +83,7 @@ "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.13.0", "@primer/primitives": "10.x || 11.x", + "@tanstack/react-virtual": "^3.13.12", "clsx": "^2.1.1", "color2k": "^2.0.3", "deepmerge": "^4.3.1", diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 4c2b59b2cc2..b49627b2784 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -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,7 @@ export interface FilteredActionListProps extends Partial | null) => void onListContainerRefChanged?: (ref: HTMLElement | null) => void onInputRefChanged?: (ref: React.RefObject) => void + scrollContainerRef?: React.MutableRefObject textInputProps?: Partial> inputRef?: React.RefObject message?: React.ReactNode @@ -44,6 +44,8 @@ export interface FilteredActionListProps extends Partial void + actionListStyles?: React.CSSProperties + focusOutBehavior?: 'stop' | 'wrap' /** * Private API for use internally only. Adds the ability to switch between * `active-descendant` and roving tabindex. @@ -77,6 +79,7 @@ export function FilteredActionList({ items, textInputProps, inputRef: providedInputRef, + scrollContainerRef: providedScrollContainerRef, groupMetadata, showItemDividers, message, @@ -86,6 +89,8 @@ export function FilteredActionList({ announcementsEnabled = true, fullScreenOnNarrow, onSelectAllChange, + actionListStyles, + focusOutBehavior, _PrivateFocusManagement = 'active-descendant', ...listProps }: FilteredActionListProps): JSX.Element { @@ -102,7 +107,7 @@ export function FilteredActionList({ const inputAndListContainerRef = useRef(null) const listRef = useRef(null) - const scrollContainerRef = useRef(null) + const scrollContainerRef = useProvidedRefOrCreate(providedScrollContainerRef) const inputRef = useProvidedRefOrCreate(providedInputRef) const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex' @@ -167,7 +172,7 @@ export function FilteredActionList({ } } }, - [items, groupMetadata, getItemListForEachGroup], + [listRef, groupMetadata, items, getItemListForEachGroup], ) const onInputKeyPress: KeyboardEventHandler = useCallback( @@ -200,7 +205,7 @@ export function FilteredActionList({ ? { containerRef: {current: listContainerElement}, bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, - focusOutBehavior: 'wrap', + focusOutBehavior: focusOutBehavior ?? 'wrap', focusableElementFilter: element => { return !(element instanceof HTMLInputElement) }, @@ -224,7 +229,7 @@ export function FilteredActionList({ behavior: 'auto', }) } - }, [items, inputRef]) + }, [items, inputRef, scrollContainerRef]) useEffect(() => { if (usingRovingTabindex) { @@ -246,7 +251,7 @@ export function FilteredActionList({ inputAndListContainerElement.removeEventListener('focusin', handleFocusIn) } } - }, [items, inputRef, listContainerElement, usingRovingTabindex]) // Re-run when items change to update active indicators + }, [items, inputRef, listContainerElement, usingRovingTabindex, listRef]) // Re-run when items change to update active indicators useEffect(() => { if (usingRovingTabindex && !loading) { @@ -291,6 +296,7 @@ export function FilteredActionList({ role="listbox" id={listId} className={classes.ActionList} + style={actionListStyles} > {groupMetadata?.length ? groupMetadata.map((group, index) => { diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 3141591f221..d961c2d8cfb 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,138 @@ export const RenderMoreOnScroll = () => { ) } + +const DEFAULT_VIRTUAL_ITEM_HEIGHT = 35 + +export const Virtualized = () => { + const [selected, setSelected] = useState([]) + const [open, setOpen] = useState(false) + const [renderSubset, setRenderSubset] = React.useState(true) + + const [filter, setFilter] = useState('') + const scrollContainerRef = useRef(null) + const filteredItems = lotsOfItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + + const timeAfterOpen = useRef() + /* 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], + ) + + // useEffect(() => { + // return () => { + // setRenderSubset(true) + // } + // }, [scrollContainerRef.current]) + + const virtualizer = useVirtualizer({ + count: filteredItems.length, + getScrollElement: () => scrollContainerRef.current ?? 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, + style: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }, + } + }) + : filteredItems, + [renderSubset, virtualizer, filteredItems], + ) + + console.log('virtualizedItems', virtualizedItems.length) + + 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={scrollContainerRef} + actionListStyles={virtualizedContainerStyle} + /> + +
+ ) +} From 381b4b074f35d3baebbf0a47b2e72dd86df7acd0 Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 19 Nov 2025 22:34:16 +0000 Subject: [PATCH 2/8] fix child rendering issue --- package-lock.json | 28 +++++++++++++++++++ .../FilteredActionList/FilteredActionList.tsx | 14 ++++++---- .../SelectPanel.examples.stories.tsx | 19 ++++++------- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 80e38040423..07f9235aaa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8431,6 +8431,33 @@ "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==", + "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==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "dev": true, @@ -27139,6 +27166,7 @@ "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.13.0", "@primer/primitives": "10.x || 11.x", + "@tanstack/react-virtual": "^3.13.12", "clsx": "^2.1.1", "color2k": "^2.0.3", "deepmerge": "^4.3.1", diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index b49627b2784..eb5ff522106 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' @@ -32,7 +32,7 @@ export interface FilteredActionListProps extends Partial | null) => void onListContainerRefChanged?: (ref: HTMLElement | null) => void onInputRefChanged?: (ref: React.RefObject) => void - scrollContainerRef?: React.MutableRefObject + scrollContainerRef?: React.Ref textInputProps?: Partial> inputRef?: React.RefObject message?: React.ReactNode @@ -44,7 +44,7 @@ export interface FilteredActionListProps extends Partial void - actionListStyles?: React.CSSProperties + actionListProps?: Partial focusOutBehavior?: 'stop' | 'wrap' /** * Private API for use internally only. Adds the ability to switch between @@ -89,7 +89,7 @@ export function FilteredActionList({ announcementsEnabled = true, fullScreenOnNarrow, onSelectAllChange, - actionListStyles, + actionListProps, focusOutBehavior, _PrivateFocusManagement = 'active-descendant', ...listProps @@ -107,7 +107,9 @@ export function FilteredActionList({ const inputAndListContainerRef = useRef(null) const listRef = useRef(null) - const scrollContainerRef = useProvidedRefOrCreate(providedScrollContainerRef) + const scrollContainerRef = useProvidedRefOrCreate( + providedScrollContainerRef as React.RefObject, + ) const inputRef = useProvidedRefOrCreate(providedInputRef) const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex' @@ -296,7 +298,7 @@ export function FilteredActionList({ role="listbox" id={listId} className={classes.ActionList} - style={actionListStyles} + {...actionListProps} > {groupMetadata?.length ? groupMetadata.map((group, index) => { diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index d961c2d8cfb..ab474f40d7b 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -593,7 +593,7 @@ export const Virtualized = () => { const [renderSubset, setRenderSubset] = React.useState(true) const [filter, setFilter] = useState('') - const scrollContainerRef = useRef(null) + const [scrollContainer, setScrollContainer] = useState(null) const filteredItems = lotsOfItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) const timeAfterOpen = useRef() @@ -616,15 +616,9 @@ export const Virtualized = () => { [open], ) - // useEffect(() => { - // return () => { - // setRenderSubset(true) - // } - // }, [scrollContainerRef.current]) - const virtualizer = useVirtualizer({ count: filteredItems.length, - getScrollElement: () => scrollContainerRef.current ?? null, + getScrollElement: () => scrollContainer ?? null, estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT, overscan: 10, debug: true, @@ -651,6 +645,7 @@ export const Virtualized = () => { return { ...item, + key: virtualItem.index, style: { position: 'absolute', top: 0, @@ -665,7 +660,7 @@ export const Virtualized = () => { [renderSubset, virtualizer, filteredItems], ) - console.log('virtualizedItems', virtualizedItems.length) + // console.log('virtualizedItems', virtualizedItems.length) return (
@@ -712,8 +707,10 @@ export const Virtualized = () => { id: 'select-labels-panel-dialog', }} focusOutBehavior="stop" - scrollContainerRef={scrollContainerRef} - actionListStyles={virtualizedContainerStyle} + scrollContainerRef={node => setScrollContainer(node)} + actionListProps={{ + style: virtualizedContainerStyle, + }} />
From 3ebade48d31de0a94766e7b35943330b0b61d4ad Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 19 Nov 2025 22:35:56 +0000 Subject: [PATCH 3/8] changeset --- .changeset/salty-geese-say.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/salty-geese-say.md 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 From 3e423812d26c0cf1039ee3250acb468d82eefe7e Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 19 Nov 2025 22:47:56 +0000 Subject: [PATCH 4/8] remove unnecessary change --- package-lock.json | 4 +++- packages/react/package.json | 2 +- packages/react/src/FilteredActionList/FilteredActionList.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07f9235aaa4..626bf0fda64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8435,6 +8435,7 @@ "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" @@ -8452,6 +8453,7 @@ "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", @@ -27166,7 +27168,6 @@ "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.13.0", "@primer/primitives": "10.x || 11.x", - "@tanstack/react-virtual": "^3.13.12", "clsx": "^2.1.1", "color2k": "^2.0.3", "deepmerge": "^4.3.1", @@ -27204,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 d468a3b5d6a..d63ee809bcf 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -83,7 +83,6 @@ "@primer/live-region-element": "^0.7.1", "@primer/octicons-react": "^19.13.0", "@primer/primitives": "10.x || 11.x", - "@tanstack/react-virtual": "^3.13.12", "clsx": "^2.1.1", "color2k": "^2.0.3", "deepmerge": "^4.3.1", @@ -108,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 eb5ff522106..e24f18293e8 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -174,7 +174,7 @@ export function FilteredActionList({ } } }, - [listRef, groupMetadata, items, getItemListForEachGroup], + [items, groupMetadata, getItemListForEachGroup], ) const onInputKeyPress: KeyboardEventHandler = useCallback( From 92068924e4961e13e157caaba3d07a02df271e26 Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 19 Nov 2025 22:48:21 +0000 Subject: [PATCH 5/8] remove unnecessary change --- packages/react/src/FilteredActionList/FilteredActionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index e24f18293e8..f34824f8102 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -253,7 +253,7 @@ export function FilteredActionList({ inputAndListContainerElement.removeEventListener('focusin', handleFocusIn) } } - }, [items, inputRef, listContainerElement, usingRovingTabindex, listRef]) // Re-run when items change to update active indicators + }, [items, inputRef, listContainerElement, usingRovingTabindex]) // Re-run when items change to update active indicators useEffect(() => { if (usingRovingTabindex && !loading) { From 949358242643caee233fed26226dae383d183d79 Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 19 Nov 2025 23:13:39 +0000 Subject: [PATCH 6/8] clean up --- .../react/src/FilteredActionList/FilteredActionList.tsx | 2 +- .../react/src/SelectPanel/SelectPanel.examples.stories.tsx | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index f34824f8102..2ea5b8d3b1a 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -231,7 +231,7 @@ export function FilteredActionList({ behavior: 'auto', }) } - }, [items, inputRef, scrollContainerRef]) + }, [items, inputRef]) useEffect(() => { if (usingRovingTabindex) { diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index ab474f40d7b..7ba9103e6f8 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -590,16 +590,15 @@ const DEFAULT_VIRTUAL_ITEM_HEIGHT = 35 export const Virtualized = () => { const [selected, setSelected] = useState([]) const [open, setOpen] = useState(false) - const [renderSubset, setRenderSubset] = React.useState(true) + 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())) - const timeAfterOpen = useRef() /* perf measurement logic start */ const timeBeforeOpen = useRef() - // const timeAfterOpen = useRef() + const timeAfterOpen = useRef() const [timeTakenToOpen, setTimeTakenToOpen] = useState() const onOpenChange = () => { @@ -660,8 +659,6 @@ export const Virtualized = () => { [renderSubset, virtualizer, filteredItems], ) - // console.log('virtualizedItems', virtualizedItems.length) - return (
From b3a8af84e7dc7461ae30399be224fa1d44cbca0e Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Wed, 19 Nov 2025 23:36:53 +0000 Subject: [PATCH 7/8] add dep --- packages/react/src/FilteredActionList/FilteredActionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 2ea5b8d3b1a..f34824f8102 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -231,7 +231,7 @@ export function FilteredActionList({ behavior: 'auto', }) } - }, [items, inputRef]) + }, [items, inputRef, scrollContainerRef]) useEffect(() => { if (usingRovingTabindex) { From 8d689d30bf8b5fda5158eef667885c8e0cbc9401 Mon Sep 17 00:00:00 2001 From: Kendall Gassner Date: Thu, 20 Nov 2025 19:20:03 +0000 Subject: [PATCH 8/8] edits --- .../FilteredActionList/FilteredActionList.tsx | 24 +++++++++++++++---- .../src/SelectPanel/SelectPanel.docs.json | 18 ++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index f34824f8102..6cc5a08076d 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -32,6 +32,9 @@ 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 @@ -44,7 +47,18 @@ 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 @@ -90,7 +104,7 @@ export function FilteredActionList({ fullScreenOnNarrow, onSelectAllChange, actionListProps, - focusOutBehavior, + focusOutBehavior = 'wrap', _PrivateFocusManagement = 'active-descendant', ...listProps }: FilteredActionListProps): JSX.Element { @@ -116,7 +130,7 @@ export function FilteredActionList({ const [listContainerElement, setListContainerElement] = useState(null) const activeDescendantRef = useRef() - const listId = useId() + const listId = useId(actionListProps?.id) const inputDescriptionTextId = useId() const [isInputFocused, setIsInputFocused] = useState(false) @@ -207,7 +221,7 @@ export function FilteredActionList({ ? { containerRef: {current: listContainerElement}, bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, - focusOutBehavior: focusOutBehavior ?? 'wrap', + focusOutBehavior, focusableElementFilter: element => { return !(element instanceof HTMLInputElement) }, @@ -295,10 +309,10 @@ export function FilteredActionList({ showDividers={showItemDividers} selectionVariant={selectionVariant} {...listProps} + {...actionListProps} role="listbox" id={listId} - className={classes.ActionList} - {...actionListProps} + 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": []