Skip to content

Commit 4a37eb8

Browse files
authored
feat: (React Aria) Implement filtering on a per CollectionNode basis (#8641)
* account for loaders in base collection filter * rough implementation for listbox * replace other instances of createLeaf/createBranch to use node classes * fix bugs with subdialog filtering, arrow nav, dividers, etc * fix case where arrow nav wasnt working post filter * update types and class node structure * prep stories * fix * add autocomplete gridlist filtering * taglist filter support * fixing lint * fix tag group keyboard nav and lint * adding support for table filtering * fix tableCollection filter so it doesnt need to call filterChildren directly * create common use nodes for specific filtering patterns * fix ssr * refactor to accept a node rather than a string in the filter function * fix lint * make node param in autocomplete non breaking * adding tests, make sure we only apply autocomplete attributes if the wrapped collection is filterable * prevent breaking change in CollectionBuilder by still accepting string for CollectionNodeClass * fix tests and pass submenutrigger node to filterFn * small clean up * small fixes * addressing more review comments * simplifying setProps logic since we have already have id when calling it * forgot to use generic for autocomplete filter * ugh docs typescript * review comments * add example testing the Autocomplete generic * fix: Autocomplete context refactor (#8695) * autoimport.... * replace internal autocomplete context * add FieldInputContext in place of input context and search/textfield context in autocomplete * fix build * removing erroneous autoimports * add ability for user to provide independent filter text * fix lint * fix some more tests * bring back controlled input value at autocomplete level * adding prop to disable virtual focus * another stab at the types * clear autocomplete contexts so that they dont leak to nested collections * add tests for disallowVirtualFocus works with listbox and menu * fix types * refactor CollectionNode to read from static property and properly clone from subclass * naming from reviews and moving contexts out of autocomplete * review comments * properly add all descendants of a cloned node when filtering fixes case where a filtered table keyboard navigation was broken since we had cloned the old collection rather than creating a new one from scratch * support filtering when there are sections in gridlist * fix mobile screen reader detection for disabling virtual focus fixes case where opening a nested autocomplete subdialog in a autocomplete menu via ENTER didnt allow the user to navigate the subdialogs options via keyboard * review comments
1 parent f821086 commit 4a37eb8

40 files changed

+1505
-607
lines changed

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

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

13-
import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared';
13+
import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, Node, RefObject} from '@react-types/shared';
1414
import {AriaTextFieldProps} from '@react-aria/textfield';
1515
import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete';
16-
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils';
16+
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useLabels, useObjectRef, useSlotId} from '@react-aria/utils';
1717
import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus';
1818
import {getInteractionModality} from '@react-aria/interactions';
1919
// @ts-ignore
@@ -27,36 +27,44 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps {
2727
/** Whether typeahead is disabled. */
2828
disallowTypeAhead: boolean
2929
}
30-
export interface AriaAutocompleteProps extends AutocompleteProps {
30+
31+
export interface AriaAutocompleteProps<T> extends AutocompleteProps {
3132
/**
3233
* An optional filter function used to determine if a option should be included in the autocomplete list.
3334
* Include this if the items you are providing to your wrapped collection aren't filtered by default.
3435
*/
35-
filter?: (textValue: string, inputValue: string) => boolean,
36+
filter?: (textValue: string, inputValue: string, node: Node<T>) => boolean,
37+
38+
/**
39+
* Whether or not to focus the first item in the collection after a filter is performed. Note this is only applicable
40+
* if virtual focus behavior is not turned off via `disableVirtualFocus`.
41+
* @default false
42+
*/
43+
disableAutoFocusFirst?: boolean,
3644

3745
/**
38-
* Whether or not to focus the first item in the collection after a filter is performed.
46+
* Whether the autocomplete should disable virtual focus, instead making the wrapped collection directly tabbable.
3947
* @default false
4048
*/
41-
disableAutoFocusFirst?: boolean
49+
disableVirtualFocus?: boolean
4250
}
4351

44-
export interface AriaAutocompleteOptions extends Omit<AriaAutocompleteProps, 'children'> {
52+
export interface AriaAutocompleteOptions<T> extends Omit<AriaAutocompleteProps<T>, 'children'> {
4553
/** The ref for the wrapped collection element. */
4654
inputRef: RefObject<HTMLInputElement | null>,
4755
/** The ref for the wrapped collection element. */
4856
collectionRef: RefObject<HTMLElement | null>
4957
}
5058

51-
export interface AutocompleteAria {
59+
export interface AutocompleteAria<T> {
5260
/** Props for the autocomplete textfield/searchfield element. These should be passed to the textfield/searchfield aria hooks respectively. */
53-
textFieldProps: AriaTextFieldProps,
61+
textFieldProps: AriaTextFieldProps<FocusableElement>,
5462
/** Props for the collection, to be passed to collection's respective aria hook (e.g. useMenu). */
5563
collectionProps: CollectionOptions,
5664
/** Ref to attach to the wrapped collection. */
5765
collectionRef: RefObject<HTMLElement | null>,
5866
/** A filter function that returns if the provided collection node should be filtered out of the collection. */
59-
filter?: (nodeTextValue: string) => boolean
67+
filter?: (nodeTextValue: string, node: Node<T>) => boolean
6068
}
6169

6270
/**
@@ -65,24 +73,25 @@ export interface AutocompleteAria {
6573
* @param props - Props for the autocomplete.
6674
* @param state - State for the autocomplete, as returned by `useAutocompleteState`.
6775
*/
68-
export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria {
76+
export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: AutocompleteState): AutocompleteAria<T> {
6977
let {
7078
inputRef,
7179
collectionRef,
7280
filter,
73-
disableAutoFocusFirst = false
81+
disableAutoFocusFirst = false,
82+
disableVirtualFocus = false
7483
} = props;
7584

76-
let collectionId = useId();
85+
let collectionId = useSlotId();
7786
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
7887
let delayNextActiveDescendant = useRef(false);
7988
let queuedActiveDescendant = useRef<string | null>(null);
8089
let lastCollectionNode = useRef<HTMLElement>(null);
8190

8291
// For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually
8392
// moving focus back to the subtriggers
84-
let shouldUseVirtualFocus = getInteractionModality() !== 'virtual';
85-
93+
let isMobileScreenReader = getInteractionModality() === 'virtual' && (isIOS() || isAndroid());
94+
let shouldUseVirtualFocus = !isMobileScreenReader && !disableVirtualFocus;
8695
useEffect(() => {
8796
return () => clearTimeout(timeout.current);
8897
}, []);
@@ -252,15 +261,17 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
252261
}
253262

254263
let shouldPerformDefaultAction = true;
255-
if (focusedNodeId == null) {
256-
shouldPerformDefaultAction = collectionRef.current?.dispatchEvent(
257-
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
258-
) || false;
259-
} else {
260-
let item = document.getElementById(focusedNodeId);
261-
shouldPerformDefaultAction = item?.dispatchEvent(
262-
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
263-
) || false;
264+
if (collectionRef.current !== null) {
265+
if (focusedNodeId == null) {
266+
shouldPerformDefaultAction = collectionRef.current?.dispatchEvent(
267+
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
268+
) || false;
269+
} else {
270+
let item = document.getElementById(focusedNodeId);
271+
shouldPerformDefaultAction = item?.dispatchEvent(
272+
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
273+
) || false;
274+
}
264275
}
265276

266277
if (shouldPerformDefaultAction) {
@@ -280,6 +291,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
280291
}
281292
break;
282293
}
294+
} else {
295+
// TODO: check if we can do this, want to stop textArea from using its default Enter behavior so items are properly triggered
296+
e.preventDefault();
283297
}
284298
};
285299

@@ -316,9 +330,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
316330
'aria-label': stringFormatter.format('collectionLabel')
317331
});
318332

319-
let filterFn = useCallback((nodeTextValue: string) => {
333+
let filterFn = useCallback((nodeTextValue: string, node: Node<T>) => {
320334
if (filter) {
321-
return filter(nodeTextValue, state.inputValue);
335+
return filter(nodeTextValue, state.inputValue, node);
322336
}
323337

324338
return true;
@@ -352,25 +366,38 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
352366
}
353367
};
354368

355-
return {
356-
textFieldProps: {
357-
value: state.inputValue,
358-
onChange,
359-
onKeyDown,
360-
autoComplete: 'off',
361-
'aria-haspopup': 'listbox',
369+
// Only apply the autocomplete specific behaviors if the collection component wrapped by it is actually
370+
// being filtered/allows filtering by the Autocomplete.
371+
let textFieldProps = {
372+
value: state.inputValue,
373+
onChange
374+
} as AriaTextFieldProps<FocusableElement>;
375+
376+
let virtualFocusProps = {
377+
onKeyDown,
378+
'aria-activedescendant': state.focusedNodeId ?? undefined,
379+
onBlur,
380+
onFocus
381+
};
382+
383+
if (collectionId) {
384+
textFieldProps = {
385+
...textFieldProps,
386+
...(shouldUseVirtualFocus && virtualFocusProps),
387+
enterKeyHint: 'go',
362388
'aria-controls': collectionId,
363389
// TODO: readd proper logic for completionMode = complete (aria-autocomplete: both)
364390
'aria-autocomplete': 'list',
365-
'aria-activedescendant': state.focusedNodeId ?? undefined,
366391
// This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions.
367392
autoCorrect: 'off',
368393
// This disable's the macOS Safari spell check auto corrections.
369394
spellCheck: 'false',
370-
enterKeyHint: 'go',
371-
onBlur,
372-
onFocus
373-
},
395+
autoComplete: 'off'
396+
};
397+
}
398+
399+
return {
400+
textFieldProps,
374401
collectionProps: mergeProps(collectionProps, {
375402
shouldUseVirtualFocus,
376403
disallowTypeAhead: true

0 commit comments

Comments
 (0)