1
+ import type { ScrollIntoViewOptions } from '@primer/behaviors'
2
+ import { scrollIntoView , FocusKeys } from '@primer/behaviors'
3
+ import type { KeyboardEventHandler } from 'react'
1
4
import React , { useCallback , useEffect , useRef , useState } from 'react'
2
5
import styled from 'styled-components'
3
6
import Box from '../Box'
@@ -6,6 +9,7 @@ import TextInput from '../TextInput'
6
9
import { get } from '../constants'
7
10
import { ActionList } from '../ActionList'
8
11
import type { GroupedListProps , ListPropsBase , ItemInput } from '../SelectPanel/types'
12
+ import { useFocusZone } from '../hooks/useFocusZone'
9
13
import { useId } from '../hooks/useId'
10
14
import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate'
11
15
import { useProvidedStateOrCreate } from '../hooks/useProvidedStateOrCreate'
@@ -14,13 +18,13 @@ import {VisuallyHidden} from '../VisuallyHidden'
14
18
import type { SxProp } from '../sx'
15
19
import type { FilteredActionListLoadingType } from './FilteredActionListLoaders'
16
20
import { FilteredActionListLoadingTypes , FilteredActionListBodyLoader } from './FilteredActionListLoaders'
17
- import classes from './FilteredActionList.module.css'
18
- import { ActionListContainerContext } from '../ActionList/ActionListContainerContext'
19
21
20
22
import { isValidElementType } from 'react-is'
21
23
import type { RenderItemFn } from '../deprecated/ActionList/List'
22
24
import { useAnnouncements } from './useAnnouncements'
23
25
26
+ const menuScrollMargins : ScrollIntoViewOptions = { startMargin : 0 , endMargin : 8 }
27
+
24
28
export interface FilteredActionListProps
25
29
extends Partial < Omit < GroupedListProps , keyof ListPropsBase > > ,
26
30
ListPropsBase ,
@@ -30,10 +34,10 @@ export interface FilteredActionListProps
30
34
placeholderText ?: string
31
35
filterValue ?: string
32
36
onFilterChange : ( value : string , e : React . ChangeEvent < HTMLInputElement > ) => void
37
+ onListContainerRefChanged ?: ( ref : HTMLElement | null ) => void
33
38
onInputRefChanged ?: ( ref : React . RefObject < HTMLInputElement > ) => void
34
39
textInputProps ?: Partial < Omit < TextInputProps , 'onChange' > >
35
40
inputRef ?: React . RefObject < HTMLInputElement >
36
- message ?: React . ReactNode
37
41
className ?: string
38
42
announcementsEnabled ?: boolean
39
43
}
@@ -49,23 +53,19 @@ export function FilteredActionList({
49
53
filterValue : externalFilterValue ,
50
54
loadingType = FilteredActionListLoadingTypes . bodySpinner ,
51
55
onFilterChange,
56
+ onListContainerRefChanged,
52
57
onInputRefChanged,
53
58
items,
54
59
textInputProps,
55
60
inputRef : providedInputRef ,
56
61
sx,
57
62
groupMetadata,
58
63
showItemDividers,
59
- message,
60
64
className,
61
- selectionVariant,
62
65
announcementsEnabled = true ,
63
66
...listProps
64
67
} : FilteredActionListProps ) : JSX . Element {
65
68
const [ filterValue , setInternalFilterValue ] = useProvidedStateOrCreate ( externalFilterValue , undefined , '' )
66
- const [ enableAnnouncements , setEnableAnnouncements ] = useState ( false )
67
- const [ selectedItems , setSelectedItems ] = useState < ( string | number | undefined ) [ ] > ( [ ] )
68
-
69
69
const onInputChange = useCallback (
70
70
( e : React . ChangeEvent < HTMLInputElement > ) => {
71
71
const value = e . target . value
@@ -77,69 +77,70 @@ export function FilteredActionList({
77
77
78
78
const scrollContainerRef = useRef < HTMLDivElement > ( null )
79
79
const inputRef = useProvidedRefOrCreate < HTMLInputElement > ( providedInputRef )
80
- const listRef = useRef < HTMLUListElement > ( null )
80
+ const [ listContainerElement , setListContainerElement ] = useState < HTMLUListElement | null > ( null )
81
+ const activeDescendantRef = useRef < HTMLElement > ( )
81
82
const listId = useId ( )
82
83
const inputDescriptionTextId = useId ( )
84
+ const onInputKeyPress : KeyboardEventHandler = useCallback (
85
+ event => {
86
+ if ( event . key === 'Enter' && activeDescendantRef . current ) {
87
+ event . preventDefault ( )
88
+ event . nativeEvent . stopImmediatePropagation ( )
83
89
84
- const keydownListener = useCallback (
85
- ( event : React . KeyboardEvent < HTMLDivElement > ) => {
86
- if ( event . key === 'ArrowDown' ) {
87
- if ( listRef . current ) {
88
- const firstSelectedItem = listRef . current . querySelector ( '[role="option"]' ) as HTMLElement | undefined
89
- firstSelectedItem ?. focus ( )
90
-
91
- event . preventDefault ( )
92
- }
93
- } else if ( event . key === 'Enter' ) {
94
- let firstItem
95
- // If there are groups, it's not guaranteed that the first item is the actual first item in the first -
96
- // as groups are rendered in the order of the groupId provided
97
- if ( groupMetadata ) {
98
- const firstGroup = groupMetadata [ 0 ] . groupId
99
- firstItem = items . filter ( item => item . groupId === firstGroup ) [ 0 ]
100
- } else {
101
- firstItem = items [ 0 ]
102
- }
103
-
104
- if ( firstItem . onAction ) {
105
- firstItem . onAction ( firstItem , event )
106
- event . preventDefault ( )
107
- }
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 )
108
93
}
109
94
} ,
110
- [ items , groupMetadata ] ,
95
+ [ activeDescendantRef ] ,
96
+ )
97
+
98
+ const listContainerRefCallback = useCallback (
99
+ ( node : HTMLUListElement | null ) => {
100
+ setListContainerElement ( node )
101
+ onListContainerRefChanged ?.( node )
102
+ } ,
103
+ [ onListContainerRefChanged ] ,
111
104
)
112
105
113
106
useEffect ( ( ) => {
114
107
onInputRefChanged ?.( inputRef )
115
108
} , [ inputRef , onInputRefChanged ] )
116
109
117
- useEffect ( ( ) => {
118
- if ( items . length === 0 ) {
119
- inputRef . current ?. focus ( )
120
- } else {
121
- const itemIds = items . filter ( item => item . selected ) . map ( item => item . id )
122
- const removedItem = selectedItems . find ( item => ! itemIds . includes ( item ) )
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
123
121
124
- if ( removedItem && document . activeElement !== inputRef . current ) {
125
- const list = listRef . current
126
- if ( list ) {
127
- const firstSelectedItem = list . querySelector ( '[role="option"]' ) as HTMLElement
128
- firstSelectedItem . focus ( )
122
+ if ( current && scrollContainerRef . current && directlyActivated ) {
123
+ scrollIntoView ( current , scrollContainerRef . current , menuScrollMargins )
129
124
}
130
- }
131
- }
132
- } , [ items , inputRef , selectedItems ] )
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
+ )
133
132
134
133
useEffect ( ( ) => {
135
- const selectedItemIds = items . filter ( item => item . selected ) . map ( item => item . id )
136
- setSelectedItems ( selectedItemIds )
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
+ }
137
141
} , [ items ] )
138
142
139
- useEffect ( ( ) => {
140
- setEnableAnnouncements ( announcementsEnabled )
141
- } , [ announcementsEnabled ] )
142
-
143
+ useAnnouncements ( items , { current : listContainerElement } , inputRef , announcementsEnabled )
143
144
useScrollFlash ( scrollContainerRef )
144
145
145
146
function getItemListForEachGroup ( groupId : string ) {
@@ -153,49 +154,6 @@ export function FilteredActionList({
153
154
return itemsInGroup
154
155
}
155
156
156
- function getBodyContent ( ) {
157
- if ( loading && scrollContainerRef . current && loadingType . appearsInBody ) {
158
- return < FilteredActionListBodyLoader loadingType = { loadingType } height = { scrollContainerRef . current . clientHeight } />
159
- }
160
- if ( message ) {
161
- return message
162
- }
163
-
164
- return (
165
- < ActionListContainerContext . Provider
166
- value = { {
167
- container : 'FilteredActionList' ,
168
- listRole : 'listbox' ,
169
- selectionAttribute : 'aria-selected' ,
170
- selectionVariant,
171
- enableFocusZone : true ,
172
- } }
173
- >
174
- < ActionList ref = { listRef } showDividers = { showItemDividers } { ...listProps } id = { listId } sx = { { flexGrow : 1 } } >
175
- { groupMetadata ?. length
176
- ? groupMetadata . map ( ( group , index ) => {
177
- return (
178
- < ActionList . Group key = { index } >
179
- < ActionList . GroupHeading variant = { group . header ?. variant ? group . header . variant : undefined } >
180
- { group . header ?. title ? group . header . title : `Group ${ group . groupId } ` }
181
- </ ActionList . GroupHeading >
182
- { getItemListForEachGroup ( group . groupId ) . map ( ( item , index ) => {
183
- const key = item . key ?? item . id ?. toString ( ) ?? index . toString ( )
184
- return < MappedActionListItem key = { key } { ...item } renderItem = { listProps . renderItem } />
185
- } ) }
186
- </ ActionList . Group >
187
- )
188
- } )
189
- : items . map ( ( item , index ) => {
190
- const key = item . key ?? item . id ?. toString ( ) ?? index . toString ( )
191
- return < MappedActionListItem key = { key } { ...item } renderItem = { listProps . renderItem } />
192
- } ) }
193
- </ ActionList >
194
- </ ActionListContainerContext . Provider >
195
- )
196
- }
197
- useAnnouncements ( items , listRef , inputRef , enableAnnouncements )
198
-
199
157
return (
200
158
< Box
201
159
display = "flex"
@@ -213,7 +171,7 @@ export function FilteredActionList({
213
171
color = "fg.default"
214
172
value = { filterValue }
215
173
onChange = { onInputChange }
216
- onKeyDown = { keydownListener }
174
+ onKeyPress = { onInputKeyPress }
217
175
placeholder = { placeholderText }
218
176
role = "combobox"
219
177
aria-expanded = "true"
@@ -227,9 +185,39 @@ export function FilteredActionList({
227
185
/>
228
186
</ StyledHeader >
229
187
< VisuallyHidden id = { inputDescriptionTextId } > Items will be filtered as you type</ VisuallyHidden >
230
- < div ref = { scrollContainerRef } className = { classes . Container } >
231
- { getBodyContent ( ) }
232
- </ div >
188
+ < Box ref = { scrollContainerRef } overflow = "auto" display = "flex" flexGrow = { 1 } >
189
+ { loading && scrollContainerRef . current && loadingType . appearsInBody ? (
190
+ < FilteredActionListBodyLoader loadingType = { loadingType } height = { scrollContainerRef . current . clientHeight } />
191
+ ) : (
192
+ < ActionList
193
+ ref = { listContainerRefCallback }
194
+ showDividers = { showItemDividers }
195
+ { ...listProps }
196
+ role = "listbox"
197
+ id = { listId }
198
+ sx = { { flexGrow : 1 } }
199
+ >
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
+ ) }
220
+ </ Box >
233
221
</ Box >
234
222
)
235
223
}
0 commit comments