Skip to content

Commit 4a1c9a5

Browse files
kendallgassnerCopilotprimer[bot]
authored
Added callback onActiveDescendantChanged prop to FilteredActionList (#7277)
Co-authored-by: Copilot <[email protected]> Co-authored-by: primer[bot] <119360173+primer[bot]@users.noreply.github.com>
1 parent 90f5751 commit 4a1c9a5

File tree

5 files changed

+88
-48
lines changed

5 files changed

+88
-48
lines changed

.changeset/fruity-groups-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Added callback prop onActiveDescendantChanged to FilteredActionList

packages/react/src/FilteredActionList/FilteredActionList.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ export interface FilteredActionListProps extends Partial<Omit<GroupedListProps,
6060
* @default 'wrap'
6161
*/
6262
focusOutBehavior?: 'stop' | 'wrap'
63+
/**
64+
* Callback function that is called when the active descendant changes.
65+
*
66+
* @param newActiveDescendant - The new active descendant element.
67+
* @param previousActiveDescendant - The previous active descendant element.
68+
* @param directlyActivated - Whether the active descendant was directly activated (e.g., by a keyboard event).
69+
*/
70+
onActiveDescendantChanged?: (
71+
newActiveDescendant: HTMLElement | undefined,
72+
previousActiveDescendant: HTMLElement | undefined,
73+
directlyActivated: boolean,
74+
) => void
6375
/**
6476
* Private API for use internally only. Adds the ability to switch between
6577
* `active-descendant` and roving tabindex.
@@ -115,6 +127,7 @@ export function FilteredActionList({
115127
actionListProps,
116128
focusOutBehavior = 'wrap',
117129
_PrivateFocusManagement = 'active-descendant',
130+
onActiveDescendantChanged,
118131
disableSelectOnHover = false,
119132
setInitialFocus = false,
120133
...listProps
@@ -239,16 +252,17 @@ export function FilteredActionList({
239252
activeDescendantFocus: inputRef,
240253
onActiveDescendantChanged: (current, previous, directlyActivated) => {
241254
activeDescendantRef.current = current
242-
243255
if (current && scrollContainerRef.current && directlyActivated) {
244256
scrollIntoView(current, scrollContainerRef.current, menuScrollMargins)
245257
}
258+
259+
onActiveDescendantChanged?.(current, previous, directlyActivated)
246260
},
247261
focusInStrategy: setInitialFocus ? 'initial' : 'previous',
248262
ignoreHoverEvents: disableSelectOnHover,
249263
}
250264
: undefined,
251-
[listContainerElement, usingRovingTabindex],
265+
[listContainerElement, usingRovingTabindex, onActiveDescendantChanged],
252266
)
253267

254268
useEffect(() => {

packages/react/src/SelectPanel/SelectPanel.docs.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,15 @@
222222
},
223223
{
224224
"name": "focusOutBehavior",
225-
"type": "'start' | 'wrap'",
225+
"type": "'start' | 'wrap'",
226226
"defaultValue": "'wrap'",
227227
"description": "Determines how keyboard focus behaves when navigating beyond the first or last item in the list."
228+
},
229+
{
230+
"name": "onActiveDescendantChanged",
231+
"type": "(newActiveDescendant: HTMLElement | undefined, previousActiveDescendant: HTMLElement | undefined, directlyActivated: boolean) => void | undefined",
232+
"defaultValue": "undefined",
233+
"description": "Callback function that is called when the active descendant changes."
228234
}
229235
],
230236
"subcomponents": []

packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {useState, useMemo, useRef, useEffect} from 'react'
1+
import React, {useState, useMemo, useRef, useEffect, useCallback} from 'react'
22
import type {Meta} from '@storybook/react-vite'
33
import {Button} from '../Button'
44
import type {ItemInput} from '../FilteredActionList'
@@ -627,54 +627,14 @@ export const Virtualized = () => {
627627
count: filteredItems.length,
628628
getScrollElement: () => scrollContainer ?? null,
629629
estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT,
630-
overscan: 10,
630+
overscan: 5,
631631
enabled: renderSubset,
632+
getItemKey: index => filteredItems[index].id,
632633
measureElement: el => {
633634
return (el as HTMLElement).scrollHeight
634635
},
635636
})
636637

637-
const virtualizedContainerStyle = useMemo(
638-
() =>
639-
renderSubset
640-
? {
641-
height: virtualizer.getTotalSize(),
642-
width: '100%',
643-
position: 'relative' as const,
644-
}
645-
: undefined,
646-
[renderSubset, virtualizer],
647-
)
648-
649-
const virtualizedItems = useMemo(
650-
() =>
651-
renderSubset
652-
? virtualizer.getVirtualItems().map((virtualItem: VirtualItem) => {
653-
const item = filteredItems[virtualItem.index]
654-
655-
return {
656-
...item,
657-
key: virtualItem.index,
658-
'data-index': virtualItem.index,
659-
ref: (node: Element | null) => {
660-
if (node && node.getAttribute('data-index')) {
661-
virtualizer.measureElement(node)
662-
}
663-
},
664-
style: {
665-
position: 'absolute',
666-
top: 0,
667-
left: 0,
668-
width: '100%',
669-
height: `${virtualItem.size}px`,
670-
transform: `translateY(${virtualItem.start}px)`,
671-
},
672-
}
673-
})
674-
: filteredItems,
675-
[renderSubset, virtualizer, filteredItems],
676-
)
677-
678638
return (
679639
<form>
680640
<FormControl>
@@ -709,7 +669,32 @@ export const Virtualized = () => {
709669
)}
710670
open={open}
711671
onOpenChange={onOpenChange}
712-
items={virtualizedItems}
672+
items={
673+
renderSubset
674+
? virtualizer.getVirtualItems().map((virtualItem: VirtualItem) => {
675+
const item = filteredItems[virtualItem.index]
676+
677+
return {
678+
...item,
679+
key: item.id,
680+
'data-index': virtualItem.index,
681+
ref: (node: Element | null) => {
682+
if (node && node.getAttribute('data-index')) {
683+
virtualizer.measureElement(node)
684+
}
685+
},
686+
style: {
687+
position: 'absolute',
688+
top: 0,
689+
left: 0,
690+
width: '100%',
691+
height: `${virtualItem.size}px`,
692+
transform: `translateY(${virtualItem.start}px)`,
693+
},
694+
}
695+
})
696+
: filteredItems
697+
}
713698
selected={selected}
714699
onSelectedChange={setSelected}
715700
onFilterChange={setFilter}
@@ -719,10 +704,27 @@ export const Virtualized = () => {
719704
overlayProps={{
720705
id: 'select-labels-panel-dialog',
721706
}}
707+
onActiveDescendantChanged={useCallback(
708+
(newActivedescendant: HTMLElement | undefined) => {
709+
const index = newActivedescendant?.getAttribute('data-index')
710+
const range = virtualizer.range
711+
if (newActivedescendant === undefined) return
712+
if (index && range && (Number(index) < range.startIndex || Number(index) >= range.endIndex)) {
713+
virtualizer.scrollToIndex(Number(newActivedescendant.getAttribute('data-index')), {align: 'auto'})
714+
}
715+
},
716+
[virtualizer],
717+
)}
722718
focusOutBehavior="stop"
723719
scrollContainerRef={node => setScrollContainer(node)}
724720
actionListProps={{
725-
style: virtualizedContainerStyle,
721+
style: renderSubset
722+
? {
723+
height: virtualizer.getTotalSize(),
724+
width: '100%',
725+
position: 'relative' as const,
726+
}
727+
: undefined,
726728
}}
727729
/>
728730
</FormControl>

packages/react/src/SelectPanel/SelectPanel.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ for (const usingRemoveActiveDescendant of [false, true]) {
8585
expect(trigger).toHaveAttribute('aria-expanded', 'false')
8686
})
8787

88+
it('should call onActiveDescendantChanged when using keyboard while focusing on an item', async () => {
89+
const user = userEvent.setup()
90+
// jest function
91+
const onActiveDescendantChanged = vi.fn()
92+
93+
render(<BasicSelectPanel onActiveDescendantChanged={onActiveDescendantChanged} />)
94+
95+
await user.click(screen.getByText('Select items'))
96+
97+
await user.type(document.activeElement!, '{ArrowDown}')
98+
expect(onActiveDescendantChanged).toHaveBeenCalled()
99+
})
100+
88101
it('should open the select panel when activating the trigger', async () => {
89102
const user = userEvent.setup()
90103

0 commit comments

Comments
 (0)