13
13
import { AriaLabelingProps , BaseEvent , DOMProps , RefObject } from '@react-types/shared' ;
14
14
import { AutocompleteProps , AutocompleteState } from '@react-stately/autocomplete' ;
15
15
import { ChangeEvent , InputHTMLAttributes , KeyboardEvent as ReactKeyboardEvent , useCallback , useEffect , useMemo , useRef } from 'react' ;
16
- import { CLEAR_FOCUS_EVENT , FOCUS_EVENT , mergeProps , mergeRefs , UPDATE_ACTIVEDESCENDANT , useEffectEvent , useId , useLabels , useObjectRef } from '@react-aria/utils' ;
16
+ import { CLEAR_FOCUS_EVENT , FOCUS_EVENT , isCtrlKeyPressed , mergeProps , mergeRefs , UPDATE_ACTIVEDESCENDANT , useEffectEvent , useId , useLabels , useObjectRef } from '@react-aria/utils' ;
17
17
// @ts -ignore
18
18
import intlMessages from '../intl/*.json' ;
19
- import { useFilter , useLocalizedStringFormatter } from '@react-aria/i18n' ;
20
19
import { useKeyboard } from '@react-aria/interactions' ;
20
+ import { useLocalizedStringFormatter } from '@react-aria/i18n' ;
21
21
22
22
export interface CollectionOptions extends DOMProps , AriaLabelingProps {
23
23
/** Whether the collection items should use virtual focus instead of being focused directly. */
@@ -27,10 +27,10 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps {
27
27
}
28
28
export interface AriaAutocompleteProps extends AutocompleteProps {
29
29
/**
30
- * The filter function used to determine if a option should be included in the autocomplete list.
31
- * @default contains
30
+ * An optional filter function used to determine if a option should be included in the autocomplete list.
31
+ * Include this if the items you are providing to your wrapped collection aren't filtered by default.
32
32
*/
33
- defaultFilter ?: ( textValue : string , inputValue : string ) => boolean
33
+ filter ?: ( textValue : string , inputValue : string ) => boolean
34
34
}
35
35
36
36
export interface AriaAutocompleteOptions extends Omit < AriaAutocompleteProps , 'children' > {
@@ -48,7 +48,7 @@ export interface AutocompleteAria {
48
48
/** Ref to attach to the wrapped collection. */
49
49
collectionRef : RefObject < HTMLElement | null > ,
50
50
/** A filter function that returns if the provided collection node should be filtered out of the collection. */
51
- filterFn : ( nodeTextValue : string ) => boolean
51
+ filterFn ? : ( nodeTextValue : string ) => boolean
52
52
}
53
53
54
54
/**
@@ -57,27 +57,34 @@ export interface AutocompleteAria {
57
57
* @param props - Props for the autocomplete.
58
58
* @param state - State for the autocomplete, as returned by `useAutocompleteState`.
59
59
*/
60
- export function useAutocomplete ( props : AriaAutocompleteOptions , state : AutocompleteState ) : AutocompleteAria {
60
+ export function UNSTABLE_useAutocomplete ( props : AriaAutocompleteOptions , state : AutocompleteState ) : AutocompleteAria {
61
61
let {
62
62
collectionRef,
63
- defaultFilter ,
63
+ filter ,
64
64
inputRef
65
65
} = props ;
66
66
67
67
let collectionId = useId ( ) ;
68
68
let timeout = useRef < ReturnType < typeof setTimeout > | undefined > ( undefined ) ;
69
69
let delayNextActiveDescendant = useRef ( false ) ;
70
+ let queuedActiveDescendant = useRef ( null ) ;
70
71
let lastCollectionNode = useRef < HTMLElement > ( null ) ;
71
72
72
73
let updateActiveDescendant = useEffectEvent ( ( e ) => {
73
74
let { target} = e ;
75
+ if ( queuedActiveDescendant . current === target . id ) {
76
+ return ;
77
+ }
78
+
74
79
clearTimeout ( timeout . current ) ;
75
80
e . stopPropagation ( ) ;
76
81
77
82
if ( target !== collectionRef . current ) {
78
83
if ( delayNextActiveDescendant . current ) {
84
+ queuedActiveDescendant . current = target . id ;
79
85
timeout . current = setTimeout ( ( ) => {
80
86
state . setFocusedNodeId ( target . id ) ;
87
+ queuedActiveDescendant . current = null ;
81
88
} , 500 ) ;
82
89
} else {
83
90
state . setFocusedNodeId ( target . id ) ;
@@ -130,20 +137,18 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
130
137
collectionRef . current ?. dispatchEvent ( clearFocusEvent ) ;
131
138
} ) ;
132
139
133
- // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when deleting text
134
- // for screen reader announcements
135
- let lastInputValue = useRef < string | null > ( null ) ;
136
- useEffect ( ( ) => {
137
- if ( state . inputValue != null ) {
138
- if ( lastInputValue . current != null && lastInputValue . current !== state . inputValue && lastInputValue . current ?. length <= state . inputValue . length ) {
139
- focusFirstItem ( ) ;
140
- } else {
141
- clearVirtualFocus ( ) ;
142
- }
143
-
144
- lastInputValue . current = state . inputValue ;
140
+ // TODO: update to see if we can tell what kind of event (paste vs backspace vs typing) is happening instead
141
+ let onChange = ( e : ChangeEvent < HTMLInputElement > ) => {
142
+ // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when deleting text
143
+ // for screen reader announcements
144
+ if ( state . inputValue !== e . target . value && state . inputValue . length <= e . target . value . length ) {
145
+ focusFirstItem ( ) ;
146
+ } else {
147
+ clearVirtualFocus ( ) ;
145
148
}
146
- } , [ state . inputValue , focusFirstItem , clearVirtualFocus ] ) ;
149
+
150
+ state . setInputValue ( e . target . value ) ;
151
+ } ;
147
152
148
153
// For textfield specific keydown operations
149
154
let onKeyDown = ( e : BaseEvent < ReactKeyboardEvent < any > > ) => {
@@ -152,11 +157,21 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
152
157
}
153
158
154
159
switch ( e . key ) {
160
+ case 'a' :
161
+ if ( isCtrlKeyPressed ( e ) ) {
162
+ return ;
163
+ }
164
+ break ;
155
165
case 'Escape' :
156
166
// Early return for Escape here so it doesn't leak the Escape event from the simulated collection event below and
157
167
// close the dialog prematurely. Ideally that should be up to the discretion of the input element hence the check
158
168
// for isPropagationStopped
169
+ // Also set the inputValue to '' to cover Firefox case where Esc doesn't actually clear searchfields. Normally we already
170
+ // handle this in useSearchField, but we are directly setting the inputValue on the input element in RAC Autocomplete instead of
171
+ // passing it to the SearchField via props. This means that a controlled value set on the Autocomplete isn't synced up with the
172
+ // SearchField until the user makes a change to the field's value via typing
159
173
if ( e . isPropagationStopped ( ) ) {
174
+ state . setInputValue ( '' ) ;
160
175
return ;
161
176
}
162
177
break ;
@@ -242,19 +257,18 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
242
257
'aria-label' : stringFormatter . format ( 'collectionLabel' )
243
258
} ) ;
244
259
245
- let { contains} = useFilter ( { sensitivity : 'base' } ) ;
246
260
let filterFn = useCallback ( ( nodeTextValue : string ) => {
247
- if ( defaultFilter ) {
248
- return defaultFilter ( nodeTextValue , state . inputValue ) ;
261
+ if ( filter ) {
262
+ return filter ( nodeTextValue , state . inputValue ) ;
249
263
}
250
264
251
- return contains ( nodeTextValue , state . inputValue ) ;
252
- } , [ state . inputValue , defaultFilter , contains ] ) ;
265
+ return true ;
266
+ } , [ state . inputValue , filter ] ) ;
253
267
254
268
return {
255
269
inputProps : {
256
270
value : state . inputValue ,
257
- onChange : ( e : ChangeEvent < HTMLInputElement > ) => state . setInputValue ( e . target . value ) ,
271
+ onChange,
258
272
...keyboardProps ,
259
273
autoComplete : 'off' ,
260
274
'aria-haspopup' : 'listbox' ,
@@ -273,6 +287,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
273
287
disallowTypeAhead : true
274
288
} ) ,
275
289
collectionRef : mergedCollectionRef ,
276
- filterFn
290
+ filterFn : filter != null ? filterFn : undefined
277
291
} ;
278
292
}
0 commit comments