Skip to content

Commit 835f0aa

Browse files
authored
feat: RAC Autocomplete audit for release (#7475)
* mark things as unstable * review comments * update to fix async case * handle async case with loading spinners the collection might change to a loading state and thus have a size of 0. Dont reset focus first flag in that case * fix tests in 17 and 16 for some reason listbox doesnt have the custom events fire properly when typing the word as a whole * use useUpdateEffect instead of useEffect * prevent cmd + a from triggering select all items * fix escape erroneously clearing focused key and Firefox not clearing on Esc * fix flicker in Safari kinda gross that we need to be careful about useLayoutEffect vs useEffect here... * fix dupe keys in story
1 parent 4dd0993 commit 835f0aa

File tree

19 files changed

+338
-141
lines changed

19 files changed

+338
-141
lines changed

packages/@react-aria/autocomplete/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212
export {useSearchAutocomplete} from './useSearchAutocomplete';
13-
export {useAutocomplete} from './useAutocomplete';
13+
export {UNSTABLE_useAutocomplete} from './useAutocomplete';
1414

1515
export type {AriaSearchAutocompleteOptions, SearchAutocompleteAria} from './useSearchAutocomplete';
1616
export type {AriaSearchAutocompleteProps} from '@react-types/autocomplete';

packages/@react-aria/autocomplete/src/useAutocomplete.ts

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared';
1414
import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete';
1515
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';
1717
// @ts-ignore
1818
import intlMessages from '../intl/*.json';
19-
import {useFilter, useLocalizedStringFormatter} from '@react-aria/i18n';
2019
import {useKeyboard} from '@react-aria/interactions';
20+
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2121

2222
export interface CollectionOptions extends DOMProps, AriaLabelingProps {
2323
/** Whether the collection items should use virtual focus instead of being focused directly. */
@@ -27,10 +27,10 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps {
2727
}
2828
export interface AriaAutocompleteProps extends AutocompleteProps {
2929
/**
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.
3232
*/
33-
defaultFilter?: (textValue: string, inputValue: string) => boolean
33+
filter?: (textValue: string, inputValue: string) => boolean
3434
}
3535

3636
export interface AriaAutocompleteOptions extends Omit<AriaAutocompleteProps, 'children'> {
@@ -48,7 +48,7 @@ export interface AutocompleteAria {
4848
/** Ref to attach to the wrapped collection. */
4949
collectionRef: RefObject<HTMLElement | null>,
5050
/** 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
5252
}
5353

5454
/**
@@ -57,27 +57,34 @@ export interface AutocompleteAria {
5757
* @param props - Props for the autocomplete.
5858
* @param state - State for the autocomplete, as returned by `useAutocompleteState`.
5959
*/
60-
export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria {
60+
export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria {
6161
let {
6262
collectionRef,
63-
defaultFilter,
63+
filter,
6464
inputRef
6565
} = props;
6666

6767
let collectionId = useId();
6868
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
6969
let delayNextActiveDescendant = useRef(false);
70+
let queuedActiveDescendant = useRef(null);
7071
let lastCollectionNode = useRef<HTMLElement>(null);
7172

7273
let updateActiveDescendant = useEffectEvent((e) => {
7374
let {target} = e;
75+
if (queuedActiveDescendant.current === target.id) {
76+
return;
77+
}
78+
7479
clearTimeout(timeout.current);
7580
e.stopPropagation();
7681

7782
if (target !== collectionRef.current) {
7883
if (delayNextActiveDescendant.current) {
84+
queuedActiveDescendant.current = target.id;
7985
timeout.current = setTimeout(() => {
8086
state.setFocusedNodeId(target.id);
87+
queuedActiveDescendant.current = null;
8188
}, 500);
8289
} else {
8390
state.setFocusedNodeId(target.id);
@@ -130,20 +137,18 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
130137
collectionRef.current?.dispatchEvent(clearFocusEvent);
131138
});
132139

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();
145148
}
146-
}, [state.inputValue, focusFirstItem, clearVirtualFocus]);
149+
150+
state.setInputValue(e.target.value);
151+
};
147152

148153
// For textfield specific keydown operations
149154
let onKeyDown = (e: BaseEvent<ReactKeyboardEvent<any>>) => {
@@ -152,11 +157,21 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
152157
}
153158

154159
switch (e.key) {
160+
case 'a':
161+
if (isCtrlKeyPressed(e)) {
162+
return;
163+
}
164+
break;
155165
case 'Escape':
156166
// Early return for Escape here so it doesn't leak the Escape event from the simulated collection event below and
157167
// close the dialog prematurely. Ideally that should be up to the discretion of the input element hence the check
158168
// 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
159173
if (e.isPropagationStopped()) {
174+
state.setInputValue('');
160175
return;
161176
}
162177
break;
@@ -242,19 +257,18 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
242257
'aria-label': stringFormatter.format('collectionLabel')
243258
});
244259

245-
let {contains} = useFilter({sensitivity: 'base'});
246260
let filterFn = useCallback((nodeTextValue: string) => {
247-
if (defaultFilter) {
248-
return defaultFilter(nodeTextValue, state.inputValue);
261+
if (filter) {
262+
return filter(nodeTextValue, state.inputValue);
249263
}
250264

251-
return contains(nodeTextValue, state.inputValue);
252-
}, [state.inputValue, defaultFilter, contains]) ;
265+
return true;
266+
}, [state.inputValue, filter]);
253267

254268
return {
255269
inputProps: {
256270
value: state.inputValue,
257-
onChange: (e: ChangeEvent<HTMLInputElement>) => state.setInputValue(e.target.value),
271+
onChange,
258272
...keyboardProps,
259273
autoComplete: 'off',
260274
'aria-haspopup': 'listbox',
@@ -273,6 +287,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
273287
disallowTypeAhead: true
274288
}),
275289
collectionRef: mergedCollectionRef,
276-
filterFn
290+
filterFn: filter != null ? filterFn : undefined
277291
};
278292
}

packages/@react-aria/searchfield/src/useSearchField.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ export function useSearchField(
7373
}
7474

7575
if (key === 'Escape') {
76-
if (state.value === '') {
76+
// Also check the inputRef value for the case where the value was set directly on the input element instead of going through
77+
// the hook
78+
if (state.value === '' && (!inputRef.current || inputRef.current.value === '')) {
7779
e.continuePropagation();
7880
} else {
7981
state.setValue('');

packages/@react-aria/selection/src/useSelectableCollection.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, UPDATE_ACTIVEDESCENDANT, useEvent, useRouter} from '@react-aria/utils';
13+
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
1414
import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
1515
import {flushSync} from 'react-dom';
1616
import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react';
1717
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
1818
import {getInteractionModality} from '@react-aria/interactions';
19-
import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
19+
import {isNonContiguousSelectionModifier} from './utils';
2020
import {MultipleSelectionManager} from '@react-stately/selection';
2121
import {useLocale} from '@react-aria/i18n';
2222
import {useTypeSelect} from './useTypeSelect';
@@ -391,6 +391,10 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
391391
}
392392
};
393393

394+
// Ref to track whether the first item in the collection should be automatically focused. Specifically used for autocomplete when user types
395+
// to focus the first key AFTER the collection updates.
396+
// TODO: potentially expand the usage of this
397+
let shouldVirtualFocusFirst = useRef(false);
394398
// Add event listeners for custom virtual events. These handle updating the focused key in response to various keyboard events
395399
// at the autocomplete level
396400
// TODO: fix type later
@@ -401,21 +405,50 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
401405

402406
// If the user is typing forwards, autofocus the first option in the list.
403407
if (detail?.focusStrategy === 'first') {
404-
let keyToFocus = delegate.getFirstKey?.() ?? null;
405-
// If no focusable items exist in the list, make sure to clear any activedescendant that may still exist
406-
if (keyToFocus == null) {
407-
ref.current?.dispatchEvent(
408-
new CustomEvent(UPDATE_ACTIVEDESCENDANT, {
409-
cancelable: true,
410-
bubbles: true
411-
})
412-
);
413-
}
408+
shouldVirtualFocusFirst.current = true;
409+
}
410+
});
414411

412+
let updateActiveDescendant = useEffectEvent(() => {
413+
let keyToFocus = delegate.getFirstKey?.() ?? null;
414+
415+
// If no focusable items exist in the list, make sure to clear any activedescendant that may still exist
416+
if (keyToFocus == null) {
417+
ref.current?.dispatchEvent(
418+
new CustomEvent(UPDATE_ACTIVEDESCENDANT, {
419+
cancelable: true,
420+
bubbles: true
421+
})
422+
);
423+
} else {
415424
manager.setFocusedKey(keyToFocus);
425+
// Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key
426+
// If there wasn't a key to focus, we might be in a temporary loading state so we'll want to still focus the first key
427+
// after the collection updates after load
428+
shouldVirtualFocusFirst.current = false;
429+
}
430+
});
431+
432+
useUpdateLayoutEffect(() => {
433+
if (shouldVirtualFocusFirst.current) {
434+
updateActiveDescendant();
435+
}
436+
437+
}, [manager.collection, updateActiveDescendant]);
438+
439+
let resetFocusFirstFlag = useEffectEvent(() => {
440+
// If user causes the focused key to change in any other way, clear shouldVirtualFocusFirst so we don't
441+
// accidentally move focus from under them. Skip this if the collection was empty because we might be in a load
442+
// state and will still want to focus the first item after load
443+
if (manager.collection.size > 0) {
444+
shouldVirtualFocusFirst.current = false;
416445
}
417446
});
418447

448+
useUpdateLayoutEffect(() => {
449+
resetFocusFirstFlag();
450+
}, [manager.focusedKey, resetFocusFirstFlag]);
451+
419452
useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e) => {
420453
e.stopPropagation();
421454
manager.setFocused(false);

packages/@react-aria/selection/src/useSelectableItem.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212

1313
import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared';
1414
import {focusSafely} from '@react-aria/focus';
15-
import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
16-
import {mergeProps, openLink, UPDATE_ACTIVEDESCENDANT, useId, useRouter} from '@react-aria/utils';
15+
import {isCtrlKeyPressed, mergeProps, openLink, UPDATE_ACTIVEDESCENDANT, useId, useRouter} from '@react-aria/utils';
16+
import {isNonContiguousSelectionModifier} from './utils';
1717
import {MultipleSelectionManager} from '@react-stately/selection';
1818
import {PressProps, useLongPress, usePress} from '@react-aria/interactions';
1919
import {useEffect, useRef} from 'react';
@@ -160,6 +160,9 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
160160
};
161161

162162
// Focus the associated DOM node when this item becomes the focusedKey
163+
// TODO: can't make this useLayoutEffect bacause it breaks menus inside dialogs
164+
// However, if this is a useEffect, it runs twice and dispatches two UPDATE_ACTIVEDESCENDANT and immediately sets
165+
// aria-activeDescendant in useAutocomplete... I've worked around this for now
163166
useEffect(() => {
164167
let isFocused = key === manager.focusedKey;
165168
if (isFocused && manager.isFocused) {

packages/@react-aria/selection/src/utils.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {isAppleDevice, isMac} from '@react-aria/utils';
13+
import {isAppleDevice} from '@react-aria/utils';
1414

1515
interface Event {
1616
altKey: boolean,
@@ -23,11 +23,3 @@ export function isNonContiguousSelectionModifier(e: Event) {
2323
// On Windows and Ubuntu, Alt + Space has a system wide meaning.
2424
return isAppleDevice() ? e.altKey : e.ctrlKey;
2525
}
26-
27-
export function isCtrlKeyPressed(e: Event) {
28-
if (isMac()) {
29-
return e.metaKey;
30-
}
31-
32-
return e.ctrlKey;
33-
}

packages/@react-aria/utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export {useGlobalListeners} from './useGlobalListeners';
2424
export {useLabels} from './useLabels';
2525
export {useObjectRef} from './useObjectRef';
2626
export {useUpdateEffect} from './useUpdateEffect';
27+
export {useUpdateLayoutEffect} from './useUpdateLayoutEffect';
2728
export {useLayoutEffect} from './useLayoutEffect';
2829
export {useResizeObserver} from './useResizeObserver';
2930
export {useSyncRef} from './useSyncRef';
@@ -43,4 +44,5 @@ export {useDeepMemo} from './useDeepMemo';
4344
export {useFormReset} from './useFormReset';
4445
export {useLoadMore} from './useLoadMore';
4546
export {CLEAR_FOCUS_EVENT, FOCUS_EVENT, UPDATE_ACTIVEDESCENDANT} from './constants';
47+
export {isCtrlKeyPressed} from './keyboard';
4648
export {useEnterAnimation, useExitAnimation} from './animation';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {isMac} from './platform';
14+
15+
interface Event {
16+
altKey: boolean,
17+
ctrlKey: boolean,
18+
metaKey: boolean
19+
}
20+
21+
export function isCtrlKeyPressed(e: Event) {
22+
if (isMac()) {
23+
return e.metaKey;
24+
}
25+
26+
return e.ctrlKey;
27+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {EffectCallback, useRef} from 'react';
14+
import {useLayoutEffect} from './useLayoutEffect';
15+
16+
// Like useLayoutEffect, but only called for updates after the initial render.
17+
export function useUpdateLayoutEffect(effect: EffectCallback, dependencies: any[]) {
18+
const isInitialMount = useRef(true);
19+
const lastDeps = useRef<any[] | null>(null);
20+
21+
useLayoutEffect(() => {
22+
isInitialMount.current = true;
23+
return () => {
24+
isInitialMount.current = false;
25+
};
26+
}, []);
27+
28+
useLayoutEffect(() => {
29+
if (isInitialMount.current) {
30+
isInitialMount.current = false;
31+
} else if (!lastDeps.current || dependencies.some((dep, i) => !Object.is(dep, lastDeps[i]))) {
32+
effect();
33+
}
34+
lastDeps.current = dependencies;
35+
// eslint-disable-next-line react-hooks/exhaustive-deps
36+
}, dependencies);
37+
}

packages/@react-stately/autocomplete/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
export {useAutocompleteState} from './useAutocompleteState';
13+
export {UNSTABLE_useAutocompleteState} from './useAutocompleteState';
1414

1515
export type {AutocompleteProps, AutocompleteStateOptions, AutocompleteState} from './useAutocompleteState';

0 commit comments

Comments
 (0)