forked from adobe/react-spectrum
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathuseAutocomplete.ts
364 lines (328 loc) · 14.2 KB
/
useAutocomplete.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared';
import {AriaTextFieldProps} from '@react-aria-nutrient/textfield';
import {AutocompleteProps, AutocompleteState} from '@react-stately-nutrient/autocomplete';
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria-nutrient/utils';
import {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria-nutrient/focus';
import {getInteractionModality} from '@react-aria-nutrient/interactions';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react';
import {useLocalizedStringFormatter} from '@react-aria-nutrient/i18n';
export interface CollectionOptions extends DOMProps, AriaLabelingProps {
/** Whether the collection items should use virtual focus instead of being focused directly. */
shouldUseVirtualFocus: boolean,
/** Whether typeahead is disabled. */
disallowTypeAhead: boolean
}
export interface AriaAutocompleteProps extends AutocompleteProps {
/**
* An optional filter function used to determine if a option should be included in the autocomplete list.
* Include this if the items you are providing to your wrapped collection aren't filtered by default.
*/
filter?: (textValue: string, inputValue: string) => boolean,
/**
* Whether or not to focus the first item in the collection after a filter is performed.
* @default false
*/
disableAutoFocusFirst?: boolean
}
export interface AriaAutocompleteOptions extends Omit<AriaAutocompleteProps, 'children'> {
/** The ref for the wrapped collection element. */
inputRef: RefObject<HTMLInputElement | null>,
/** The ref for the wrapped collection element. */
collectionRef: RefObject<HTMLElement | null>
}
export interface AutocompleteAria {
/** Props for the autocomplete textfield/searchfield element. These should be passed to the textfield/searchfield aria hooks respectively. */
textFieldProps: AriaTextFieldProps,
/** Props for the collection, to be passed to collection's respective aria hook (e.g. useMenu). */
collectionProps: CollectionOptions,
/** Ref to attach to the wrapped collection. */
collectionRef: RefObject<HTMLElement | null>,
/** A filter function that returns if the provided collection node should be filtered out of the collection. */
filter?: (nodeTextValue: string) => boolean
}
/**
* Provides the behavior and accessibility implementation for an autocomplete component.
* An autocomplete combines a text input with a collection, allowing users to filter the collection's contents match a query.
* @param props - Props for the autocomplete.
* @param state - State for the autocomplete, as returned by `useAutocompleteState`.
*/
export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria {
let {
inputRef,
collectionRef,
filter,
disableAutoFocusFirst = false
} = props;
let collectionId = useId();
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
let delayNextActiveDescendant = useRef(false);
let queuedActiveDescendant = useRef<string | null>(null);
let lastCollectionNode = useRef<HTMLElement>(null);
// For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually
// moving focus back to the subtriggers
let shouldUseVirtualFocus = getInteractionModality() !== 'virtual';
useEffect(() => {
return () => clearTimeout(timeout.current);
}, []);
let updateActiveDescendant = useEffectEvent((e: Event) => {
// Ensure input is focused if the user clicks on the collection directly.
if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) {
inputRef.current.focus();
}
let target = e.target as Element | null;
if (e.isTrusted || !target || queuedActiveDescendant.current === target.id) {
return;
}
clearTimeout(timeout.current);
if (target !== collectionRef.current) {
if (delayNextActiveDescendant.current) {
queuedActiveDescendant.current = target.id;
timeout.current = setTimeout(() => {
state.setFocusedNodeId(target.id);
}, 500);
} else {
queuedActiveDescendant.current = target.id;
state.setFocusedNodeId(target.id);
}
} else {
queuedActiveDescendant.current = null;
state.setFocusedNodeId(null);
}
delayNextActiveDescendant.current = false;
});
let callbackRef = useCallback((collectionNode) => {
if (collectionNode != null) {
// When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement
// of the letter you just typed. If we recieve another focus event then we clear the queued update
// We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles
// React 19's extra call of the callback ref in strict mode
lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant);
lastCollectionNode.current = collectionNode;
collectionNode.addEventListener('focusin', updateActiveDescendant);
} else {
lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant);
}
}, [updateActiveDescendant]);
// Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection
let mergedCollectionRef = useObjectRef(useMemo(() => mergeRefs(collectionRef, callbackRef), [collectionRef, callbackRef]));
let focusFirstItem = useEffectEvent(() => {
delayNextActiveDescendant.current = true;
collectionRef.current?.dispatchEvent(
new CustomEvent(FOCUS_EVENT, {
cancelable: true,
bubbles: true,
detail: {
focusStrategy: 'first'
}
})
);
});
let clearVirtualFocus = useEffectEvent((clearFocusKey?: boolean) => {
moveVirtualFocus(getActiveElement());
queuedActiveDescendant.current = null;
state.setFocusedNodeId(null);
let clearFocusEvent = new CustomEvent(CLEAR_FOCUS_EVENT, {
cancelable: true,
bubbles: true,
detail: {
clearFocusKey
}
});
clearTimeout(timeout.current);
delayNextActiveDescendant.current = false;
collectionRef.current?.dispatchEvent(clearFocusEvent);
});
// TODO: update to see if we can tell what kind of event (paste vs backspace vs typing) is happening instead
let onChange = (value: string) => {
// Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when deleting text
// for screen reader announcements
if (state.inputValue !== value && state.inputValue.length <= value.length && !disableAutoFocusFirst) {
focusFirstItem();
} else {
// Fully clear focused key when backspacing since the list may change and thus we'd want to start fresh again
clearVirtualFocus(true);
}
state.setInputValue(value);
};
let keyDownTarget = useRef<Element | null>(null);
// For textfield specific keydown operations
let onKeyDown = (e: BaseEvent<ReactKeyboardEvent<any>>) => {
keyDownTarget.current = e.target as Element;
if (e.nativeEvent.isComposing) {
return;
}
let focusedNodeId = queuedActiveDescendant.current;
switch (e.key) {
case 'a':
if (isCtrlKeyPressed(e)) {
return;
}
break;
case 'Escape':
// Early return for Escape here so it doesn't leak the Escape event from the simulated collection event below and
// close the dialog prematurely. Ideally that should be up to the discretion of the input element hence the check
// for isPropagationStopped
if (e.isDefaultPrevented()) {
return;
}
break;
case ' ':
// Space shouldn't trigger onAction so early return.
return;
case 'Tab':
// Don't propogate Tab down to the collection, otherwise we will try to focus the collection via useSelectableCollection's Tab handler (aka shift tab logic)
// We want FocusScope to handle Tab if one exists (aka sub dialog), so special casepropogate
if ('continuePropagation' in e) {
e.continuePropagation();
}
return;
case 'Home':
case 'End':
case 'PageDown':
case 'PageUp':
case 'ArrowUp':
case 'ArrowDown': {
if ((e.key === 'Home' || e.key === 'End') && focusedNodeId == null && e.shiftKey) {
return;
}
// Prevent these keys from moving the text cursor in the input
e.preventDefault();
// Move virtual focus into the wrapped collection
let focusCollection = new CustomEvent(FOCUS_EVENT, {
cancelable: true,
bubbles: true
});
collectionRef.current?.dispatchEvent(focusCollection);
break;
}
}
// Emulate the keyboard events that happen in the input field in the wrapped collection. This is for triggering things like onAction via Enter
// or moving focus from one item to another. Stop propagation on the input event if it isn't already stopped so it doesn't leak out. For events
// like ESC, the dispatched event below will bubble out of the collection and be stopped if handled by useSelectableCollection, otherwise will bubble
// as expected
if (!e.isPropagationStopped()) {
e.stopPropagation();
}
let shouldPerformDefaultAction = true;
if (focusedNodeId == null) {
shouldPerformDefaultAction = collectionRef.current?.dispatchEvent(
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
) || false;
} else {
let item = document.getElementById(focusedNodeId);
shouldPerformDefaultAction = item?.dispatchEvent(
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
) || false;
}
if (shouldPerformDefaultAction) {
switch (e.key) {
case 'ArrowLeft':
case 'ArrowRight': {
// Clear the activedescendant so NVDA announcements aren't interrupted but retain the focused key in the collection so the
// user's keyboard navigation restarts from where they left off
clearVirtualFocus();
break;
}
}
}
};
let onKeyUpCapture = useEffectEvent((e) => {
// Dispatch simulated key up events for things like triggering links in listbox
// Make sure to stop the propagation of the input keyup event so that the simulated keyup/down pair
// is detected by usePress instead of the original keyup originating from the input
if (e.target === keyDownTarget.current) {
e.stopImmediatePropagation();
let focusedNodeId = queuedActiveDescendant.current;
if (focusedNodeId == null) {
collectionRef.current?.dispatchEvent(
new KeyboardEvent(e.type, e)
);
} else {
let item = document.getElementById(focusedNodeId);
item?.dispatchEvent(
new KeyboardEvent(e.type, e)
);
}
}
});
useEffect(() => {
document.addEventListener('keyup', onKeyUpCapture, true);
return () => {
document.removeEventListener('keyup', onKeyUpCapture, true);
};
}, [onKeyUpCapture]);
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria-nutrient/autocomplete');
let collectionProps = useLabels({
id: collectionId,
'aria-label': stringFormatter.format('collectionLabel')
});
let filterFn = useCallback((nodeTextValue: string) => {
if (filter) {
return filter(nodeTextValue, state.inputValue);
}
return true;
}, [state.inputValue, filter]);
// Be sure to clear/restore the virtual + collection focus when blurring/refocusing the field so we only show the
// focus ring on the virtually focused collection when are actually interacting with the Autocomplete
let onBlur = (e: ReactFocusEvent) => {
if (!e.isTrusted) {
return;
}
let lastFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null;
if (lastFocusedNode) {
dispatchVirtualBlur(lastFocusedNode, e.relatedTarget);
}
};
let onFocus = (e: ReactFocusEvent) => {
if (!e.isTrusted) {
return;
}
let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null;
if (curFocusedNode) {
let target = e.target;
queueMicrotask(() => {
dispatchVirtualBlur(target, curFocusedNode);
dispatchVirtualFocus(curFocusedNode, target);
});
}
};
return {
textFieldProps: {
value: state.inputValue,
onChange,
onKeyDown,
autoComplete: 'off',
'aria-haspopup': 'listbox',
'aria-controls': collectionId,
// TODO: readd proper logic for completionMode = complete (aria-autocomplete: both)
'aria-autocomplete': 'list',
'aria-activedescendant': state.focusedNodeId ?? undefined,
// This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions.
autoCorrect: 'off',
// This disable's the macOS Safari spell check auto corrections.
spellCheck: 'false',
enterKeyHint: 'go',
onBlur,
onFocus
},
collectionProps: mergeProps(collectionProps, {
shouldUseVirtualFocus,
disallowTypeAhead: true
}),
collectionRef: mergedCollectionRef,
filter: filter != null ? filterFn : undefined
};
}