1
- import type { ScrollIntoViewOptions } from '@primer/behaviors'
2
- import { scrollIntoView , FocusKeys } from '@primer/behaviors'
3
- import type { KeyboardEventHandler } from 'react'
4
1
import React , { useCallback , useEffect , useRef , useState } from 'react'
5
2
import styled from 'styled-components'
6
3
import Box from '../Box'
@@ -9,7 +6,6 @@ import TextInput from '../TextInput'
9
6
import { get } from '../constants'
10
7
import { ActionList } from '../ActionList'
11
8
import type { GroupedListProps , ListPropsBase , ItemInput } from '../SelectPanel/types'
12
- import { useFocusZone } from '../hooks/useFocusZone'
13
9
import { useId } from '../hooks/useId'
14
10
import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate'
15
11
import { useProvidedStateOrCreate } from '../hooks/useProvidedStateOrCreate'
@@ -18,13 +14,12 @@ import {VisuallyHidden} from '../VisuallyHidden'
18
14
import type { SxProp } from '../sx'
19
15
import type { FilteredActionListLoadingType } from './FilteredActionListLoaders'
20
16
import { FilteredActionListLoadingTypes , FilteredActionListBodyLoader } from './FilteredActionListLoaders'
17
+ import { ActionListContainerContext } from '../ActionList/ActionListContainerContext'
21
18
22
19
import { isValidElementType } from 'react-is'
23
20
import type { RenderItemFn } from '../deprecated/ActionList/List'
24
21
import { useAnnouncements } from './useAnnouncements'
25
22
26
- const menuScrollMargins : ScrollIntoViewOptions = { startMargin : 0 , endMargin : 8 }
27
-
28
23
export interface FilteredActionListProps
29
24
extends Partial < Omit < GroupedListProps , keyof ListPropsBase > > ,
30
25
ListPropsBase ,
@@ -34,7 +29,6 @@ export interface FilteredActionListProps
34
29
placeholderText ?: string
35
30
filterValue ?: string
36
31
onFilterChange : ( value : string , e : React . ChangeEvent < HTMLInputElement > ) => void
37
- onListContainerRefChanged ?: ( ref : HTMLElement | null ) => void
38
32
onInputRefChanged ?: ( ref : React . RefObject < HTMLInputElement > ) => void
39
33
textInputProps ?: Partial < Omit < TextInputProps , 'onChange' > >
40
34
inputRef ?: React . RefObject < HTMLInputElement >
@@ -53,7 +47,6 @@ export function FilteredActionList({
53
47
filterValue : externalFilterValue ,
54
48
loadingType = FilteredActionListLoadingTypes . bodySpinner ,
55
49
onFilterChange,
56
- onListContainerRefChanged,
57
50
onInputRefChanged,
58
51
items,
59
52
textInputProps,
@@ -62,10 +55,14 @@ export function FilteredActionList({
62
55
groupMetadata,
63
56
showItemDividers,
64
57
className,
58
+ selectionVariant,
65
59
announcementsEnabled = true ,
66
60
...listProps
67
61
} : FilteredActionListProps ) : JSX . Element {
68
62
const [ filterValue , setInternalFilterValue ] = useProvidedStateOrCreate ( externalFilterValue , undefined , '' )
63
+ const [ enableAnnouncements , setEnableAnnouncements ] = useState ( false )
64
+ const [ selectedItems , setSelectedItems ] = useState < ( string | number | undefined ) [ ] > ( [ ] )
65
+
69
66
const onInputChange = useCallback (
70
67
( e : React . ChangeEvent < HTMLInputElement > ) => {
71
68
const value = e . target . value
@@ -77,70 +74,69 @@ export function FilteredActionList({
77
74
78
75
const scrollContainerRef = useRef < HTMLDivElement > ( null )
79
76
const inputRef = useProvidedRefOrCreate < HTMLInputElement > ( providedInputRef )
80
- const [ listContainerElement , setListContainerElement ] = useState < HTMLUListElement | null > ( null )
81
- const activeDescendantRef = useRef < HTMLElement > ( )
77
+ const listRef = useRef < HTMLUListElement > ( null )
82
78
const listId = useId ( )
83
79
const inputDescriptionTextId = useId ( )
84
- const onInputKeyPress : KeyboardEventHandler = useCallback (
85
- event => {
86
- if ( event . key === 'Enter' && activeDescendantRef . current ) {
87
- event . preventDefault ( )
88
- event . nativeEvent . stopImmediatePropagation ( )
89
80
90
- // Forward Enter key press to active descendant so that item gets activated
91
- const activeDescendantEvent = new KeyboardEvent ( event . type , event . nativeEvent )
92
- activeDescendantRef . current . dispatchEvent ( activeDescendantEvent )
93
- }
94
- } ,
95
- [ activeDescendantRef ] ,
96
- )
81
+ const keydownListener = useCallback (
82
+ ( event : React . KeyboardEvent < HTMLDivElement > ) => {
83
+ if ( event . key === 'ArrowDown' ) {
84
+ if ( listRef . current ) {
85
+ const firstSelectedItem = listRef . current . querySelector ( '[role="option"]' ) as HTMLElement | undefined
86
+ firstSelectedItem ?. focus ( )
97
87
98
- const listContainerRefCallback = useCallback (
99
- ( node : HTMLUListElement | null ) => {
100
- setListContainerElement ( node )
101
- onListContainerRefChanged ?.( node )
88
+ event . preventDefault ( )
89
+ }
90
+ } else if ( event . key === 'Enter' ) {
91
+ let firstItem
92
+ // If there are groups, it's not guaranteed that the first item is the actual first item in the first -
93
+ // as groups are rendered in the order of the groupId provided
94
+ if ( groupMetadata ) {
95
+ const firstGroup = groupMetadata [ 0 ] . groupId
96
+ firstItem = items . filter ( item => item . groupId === firstGroup ) [ 0 ]
97
+ } else {
98
+ firstItem = items [ 0 ]
99
+ }
100
+
101
+ if ( firstItem . onAction ) {
102
+ firstItem . onAction ( firstItem , event )
103
+ event . preventDefault ( )
104
+ }
105
+ }
102
106
} ,
103
- [ onListContainerRefChanged ] ,
107
+ [ items , groupMetadata ] ,
104
108
)
105
109
106
110
useEffect ( ( ) => {
107
111
onInputRefChanged ?.( inputRef )
108
112
} , [ inputRef , onInputRefChanged ] )
109
113
110
- useFocusZone (
111
- {
112
- containerRef : { current : listContainerElement } ,
113
- bindKeys : FocusKeys . ArrowVertical | FocusKeys . PageUpDown ,
114
- focusOutBehavior : 'wrap' ,
115
- focusableElementFilter : element => {
116
- return ! ( element instanceof HTMLInputElement )
117
- } ,
118
- activeDescendantFocus : inputRef ,
119
- onActiveDescendantChanged : ( current , previous , directlyActivated ) => {
120
- activeDescendantRef . current = current
114
+ useEffect ( ( ) => {
115
+ if ( items . length === 0 ) {
116
+ inputRef . current ?. focus ( )
117
+ } else {
118
+ const itemIds = items . filter ( item => item . selected ) . map ( item => item . id )
119
+ const removedItem = selectedItems . find ( item => ! itemIds . includes ( item ) )
121
120
122
- if ( current && scrollContainerRef . current && directlyActivated ) {
123
- scrollIntoView ( current , scrollContainerRef . current , menuScrollMargins )
121
+ if ( removedItem && document . activeElement !== inputRef . current ) {
122
+ const list = listRef . current
123
+ if ( list ) {
124
+ const firstSelectedItem = list . querySelector ( '[role="option"]' ) as HTMLElement
125
+ firstSelectedItem . focus ( )
124
126
}
125
- } ,
126
- } ,
127
- [
128
- // List container isn't in the DOM while loading. Need to re-bind focus zone when it changes.
129
- listContainerElement ,
130
- ] ,
131
- )
127
+ }
128
+ }
129
+ } , [ items , inputRef , selectedItems ] )
132
130
133
131
useEffect ( ( ) => {
134
- // if items changed, we want to instantly move active descendant into view
135
- if ( activeDescendantRef . current && scrollContainerRef . current ) {
136
- scrollIntoView ( activeDescendantRef . current , scrollContainerRef . current , {
137
- ...menuScrollMargins ,
138
- behavior : 'auto' ,
139
- } )
140
- }
132
+ const selectedItemIds = items . filter ( item => item . selected ) . map ( item => item . id )
133
+ setSelectedItems ( selectedItemIds )
141
134
} , [ items ] )
142
135
143
- useAnnouncements ( items , { current : listContainerElement } , inputRef , announcementsEnabled )
136
+ useEffect ( ( ) => {
137
+ setEnableAnnouncements ( announcementsEnabled )
138
+ } , [ announcementsEnabled ] )
139
+
144
140
useScrollFlash ( scrollContainerRef )
145
141
146
142
function getItemListForEachGroup ( groupId : string ) {
@@ -154,6 +150,8 @@ export function FilteredActionList({
154
150
return itemsInGroup
155
151
}
156
152
153
+ useAnnouncements ( items , listRef , inputRef , enableAnnouncements )
154
+
157
155
return (
158
156
< Box
159
157
display = "flex"
@@ -171,7 +169,7 @@ export function FilteredActionList({
171
169
color = "fg.default"
172
170
value = { filterValue }
173
171
onChange = { onInputChange }
174
- onKeyPress = { onInputKeyPress }
172
+ onKeyDown = { keydownListener }
175
173
placeholder = { placeholderText }
176
174
role = "combobox"
177
175
aria-expanded = "true"
@@ -189,33 +187,36 @@ export function FilteredActionList({
189
187
{ loading && scrollContainerRef . current && loadingType . appearsInBody ? (
190
188
< FilteredActionListBodyLoader loadingType = { loadingType } height = { scrollContainerRef . current . clientHeight } />
191
189
) : (
192
- < ActionList
193
- ref = { listContainerRefCallback }
194
- showDividers = { showItemDividers }
195
- { ...listProps }
196
- role = "listbox"
197
- id = { listId }
198
- sx = { { flexGrow : 1 } }
190
+ < ActionListContainerContext . Provider
191
+ value = { {
192
+ container : 'FilteredActionList' ,
193
+ listRole : 'listbox' ,
194
+ selectionAttribute : 'aria-selected' ,
195
+ selectionVariant,
196
+ enableFocusZone : true ,
197
+ } }
199
198
>
200
- { groupMetadata ?. length
201
- ? groupMetadata . map ( ( group , index ) => {
202
- return (
203
- < ActionList . Group key = { index } >
204
- < ActionList . GroupHeading variant = { group . header ?. variant ? group . header . variant : undefined } >
205
- { group . header ?. title ? group . header . title : `Group ${ group . groupId } ` }
206
- </ ActionList . GroupHeading >
207
- { getItemListForEachGroup ( group . groupId ) . map ( ( item , index ) => {
208
- const key = item . key ?? item . id ?. toString ( ) ?? index . toString ( )
209
- return < MappedActionListItem key = { key } { ...item } renderItem = { listProps . renderItem } />
210
- } ) }
211
- </ ActionList . Group >
212
- )
213
- } )
214
- : items . map ( ( item , index ) => {
215
- const key = item . key ?? item . id ?. toString ( ) ?? index . toString ( )
216
- return < MappedActionListItem key = { key } { ...item } renderItem = { listProps . renderItem } />
217
- } ) }
218
- </ ActionList >
199
+ < ActionList ref = { listRef } showDividers = { showItemDividers } { ...listProps } id = { listId } sx = { { flexGrow : 1 } } >
200
+ { groupMetadata ?. length
201
+ ? groupMetadata . map ( ( group , index ) => {
202
+ return (
203
+ < ActionList . Group key = { index } >
204
+ < ActionList . GroupHeading variant = { group . header ?. variant ? group . header . variant : undefined } >
205
+ { group . header ?. title ? group . header . title : `Group ${ group . groupId } ` }
206
+ </ ActionList . GroupHeading >
207
+ { getItemListForEachGroup ( group . groupId ) . map ( ( item , index ) => {
208
+ const key = item . key ?? item . id ?. toString ( ) ?? index . toString ( )
209
+ return < MappedActionListItem key = { key } { ...item } renderItem = { listProps . renderItem } />
210
+ } ) }
211
+ </ ActionList . Group >
212
+ )
213
+ } )
214
+ : items . map ( ( item , index ) => {
215
+ const key = item . key ?? item . id ?. toString ( ) ?? index . toString ( )
216
+ return < MappedActionListItem key = { key } { ...item } renderItem = { listProps . renderItem } />
217
+ } ) }
218
+ </ ActionList >
219
+ </ ActionListContainerContext . Provider >
219
220
) }
220
221
</ Box >
221
222
</ Box >
0 commit comments