diff --git a/.changeset/fruity-groups-brush.md b/.changeset/fruity-groups-brush.md new file mode 100644 index 00000000000..74ee1e9d819 --- /dev/null +++ b/.changeset/fruity-groups-brush.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Added callback prop onActiveDescendantChanged to FilteredActionList diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 6589e9eb983..fa5164ecc57 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -60,6 +60,18 @@ export interface FilteredActionListProps extends Partial void /** * Private API for use internally only. Adds the ability to switch between * `active-descendant` and roving tabindex. @@ -115,6 +127,7 @@ export function FilteredActionList({ actionListProps, focusOutBehavior = 'wrap', _PrivateFocusManagement = 'active-descendant', + onActiveDescendantChanged, disableSelectOnHover = false, setInitialFocus = false, ...listProps @@ -239,16 +252,17 @@ export function FilteredActionList({ activeDescendantFocus: inputRef, onActiveDescendantChanged: (current, previous, directlyActivated) => { activeDescendantRef.current = current - if (current && scrollContainerRef.current && directlyActivated) { scrollIntoView(current, scrollContainerRef.current, menuScrollMargins) } + + onActiveDescendantChanged?.(current, previous, directlyActivated) }, focusInStrategy: setInitialFocus ? 'initial' : 'previous', ignoreHoverEvents: disableSelectOnHover, } : undefined, - [listContainerElement, usingRovingTabindex], + [listContainerElement, usingRovingTabindex, onActiveDescendantChanged], ) useEffect(() => { diff --git a/packages/react/src/SelectPanel/SelectPanel.docs.json b/packages/react/src/SelectPanel/SelectPanel.docs.json index 370af70ee9c..8980b64d1d6 100644 --- a/packages/react/src/SelectPanel/SelectPanel.docs.json +++ b/packages/react/src/SelectPanel/SelectPanel.docs.json @@ -222,9 +222,15 @@ }, { "name": "focusOutBehavior", - "type": "'start' | 'wrap'", + "type": "'start' | 'wrap'", "defaultValue": "'wrap'", "description": "Determines how keyboard focus behaves when navigating beyond the first or last item in the list." + }, + { + "name": "onActiveDescendantChanged", + "type": "(newActiveDescendant: HTMLElement | undefined, previousActiveDescendant: HTMLElement | undefined, directlyActivated: boolean) => void | undefined", + "defaultValue": "undefined", + "description": "Callback function that is called when the active descendant changes." } ], "subcomponents": [] diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 64f6c723f75..864d6e02b62 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -1,4 +1,4 @@ -import React, {useState, useMemo, useRef, useEffect} from 'react' +import React, {useState, useMemo, useRef, useEffect, useCallback} from 'react' import type {Meta} from '@storybook/react-vite' import {Button} from '../Button' import type {ItemInput} from '../FilteredActionList' @@ -627,54 +627,14 @@ export const Virtualized = () => { count: filteredItems.length, getScrollElement: () => scrollContainer ?? null, estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT, - overscan: 10, + overscan: 5, enabled: renderSubset, + getItemKey: index => filteredItems[index].id, measureElement: el => { return (el as HTMLElement).scrollHeight }, }) - const virtualizedContainerStyle = useMemo( - () => - renderSubset - ? { - height: virtualizer.getTotalSize(), - width: '100%', - position: 'relative' as const, - } - : undefined, - [renderSubset, virtualizer], - ) - - const virtualizedItems = useMemo( - () => - renderSubset - ? virtualizer.getVirtualItems().map((virtualItem: VirtualItem) => { - const item = filteredItems[virtualItem.index] - - return { - ...item, - key: virtualItem.index, - 'data-index': virtualItem.index, - ref: (node: Element | null) => { - if (node && node.getAttribute('data-index')) { - virtualizer.measureElement(node) - } - }, - style: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: `${virtualItem.size}px`, - transform: `translateY(${virtualItem.start}px)`, - }, - } - }) - : filteredItems, - [renderSubset, virtualizer, filteredItems], - ) - return (
@@ -709,7 +669,32 @@ export const Virtualized = () => { )} open={open} onOpenChange={onOpenChange} - items={virtualizedItems} + items={ + renderSubset + ? virtualizer.getVirtualItems().map((virtualItem: VirtualItem) => { + const item = filteredItems[virtualItem.index] + + return { + ...item, + key: item.id, + 'data-index': virtualItem.index, + ref: (node: Element | null) => { + if (node && node.getAttribute('data-index')) { + virtualizer.measureElement(node) + } + }, + style: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }, + } + }) + : filteredItems + } selected={selected} onSelectedChange={setSelected} onFilterChange={setFilter} @@ -719,10 +704,27 @@ export const Virtualized = () => { overlayProps={{ id: 'select-labels-panel-dialog', }} + onActiveDescendantChanged={useCallback( + (newActivedescendant: HTMLElement | undefined) => { + const index = newActivedescendant?.getAttribute('data-index') + const range = virtualizer.range + if (newActivedescendant === undefined) return + if (index && range && (Number(index) < range.startIndex || Number(index) >= range.endIndex)) { + virtualizer.scrollToIndex(Number(newActivedescendant.getAttribute('data-index')), {align: 'auto'}) + } + }, + [virtualizer], + )} focusOutBehavior="stop" scrollContainerRef={node => setScrollContainer(node)} actionListProps={{ - style: virtualizedContainerStyle, + style: renderSubset + ? { + height: virtualizer.getTotalSize(), + width: '100%', + position: 'relative' as const, + } + : undefined, }} /> diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 47ee19a457d..57c2b8cbb98 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -85,6 +85,19 @@ for (const usingRemoveActiveDescendant of [false, true]) { expect(trigger).toHaveAttribute('aria-expanded', 'false') }) + it('should call onActiveDescendantChanged when using keyboard while focusing on an item', async () => { + const user = userEvent.setup() + // jest function + const onActiveDescendantChanged = vi.fn() + + render() + + await user.click(screen.getByText('Select items')) + + await user.type(document.activeElement!, '{ArrowDown}') + expect(onActiveDescendantChanged).toHaveBeenCalled() + }) + it('should open the select panel when activating the trigger', async () => { const user = userEvent.setup()