Skip to content

fix: (WIP) Autocomplete context refactor #8695

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: baseCollection_filter
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 36 additions & 22 deletions packages/@react-aria/autocomplete/src/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {AriaLabelingProps, BaseEvent, DOMProps, Node, RefObject} from '@react-types/shared';
import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, Node, RefObject} from '@react-types/shared';
import {AriaTextFieldProps} from '@react-aria/textfield';
import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete';
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useLabels, useObjectRef, useSlotId} from '@react-aria/utils';
Expand Down Expand Up @@ -40,7 +40,12 @@ export interface AriaAutocompleteProps<T> extends AutocompleteProps {
* Whether or not to focus the first item in the collection after a filter is performed.
* @default false
*/
disableAutoFocusFirst?: boolean
disableAutoFocusFirst?: boolean,

/**
* Whether the autocomplete should disable virtual focus, instead making the wrapped collection directly tabbable.
*/
disallowVirtualFocus?: boolean
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disableVirtualFocus i think is a better name, it's not really about allowing or not, it's just whether it is or not

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I debated about this myself a bit haha. I went with the above since it means the default is false as per our API guidelines, but I certainly do like disable better (as can be seen from disableAutoFocusFirst)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another thing to discuss perhaps: is it a problem that virtual focus doesn't work with grid collection just yet but will be enabled by default later?

}

export interface AriaAutocompleteOptions<T> extends Omit<AriaAutocompleteProps<T>, 'children'> {
Expand All @@ -52,7 +57,7 @@ export interface AriaAutocompleteOptions<T> extends Omit<AriaAutocompleteProps<T

export interface AutocompleteAria<T> {
/** Props for the autocomplete textfield/searchfield element. These should be passed to the textfield/searchfield aria hooks respectively. */
textFieldProps: AriaTextFieldProps,
textFieldProps: AriaTextFieldProps<FocusableElement>,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe more appropriate to make these types more broad

/** 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. */
Expand All @@ -72,7 +77,8 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
inputRef,
collectionRef,
filter,
disableAutoFocusFirst = false
disableAutoFocusFirst = false,
disallowVirtualFocus = false
} = props;

let collectionId = useSlotId();
Expand All @@ -83,7 +89,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut

// 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';
let shouldUseVirtualFocus = getInteractionModality() !== 'virtual' && !disallowVirtualFocus;

useEffect(() => {
return () => clearTimeout(timeout.current);
Expand Down Expand Up @@ -254,15 +260,17 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
}

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 (collectionRef.current !== null) {
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) {
Expand All @@ -282,6 +290,9 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
}
break;
}
} else {
// TODO: check if we can do this, want to stop textArea from using its default Enter behavior so items are properly triggered
e.preventDefault();
}
};

Expand Down Expand Up @@ -359,25 +370,28 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
let textFieldProps = {
value: state.inputValue,
onChange
} as AriaTextFieldProps<HTMLInputElement>;
} as AriaTextFieldProps<FocusableElement>;

let virtualFocusProps = {
onKeyDown,
'aria-activedescendant': state.focusedNodeId ?? undefined,
onBlur,
onFocus
};

if (collectionId) {
textFieldProps = {
...textFieldProps,
onKeyDown,
autoComplete: 'off',
'aria-haspopup': collectionId ? 'listbox' : undefined,
...(shouldUseVirtualFocus && virtualFocusProps),
enterKeyHint: 'go',
'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
autoComplete: 'off'
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/react-aria-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@react-aria/live-announcer": "^3.4.4",
"@react-aria/overlays": "^3.28.0",
"@react-aria/ssr": "^3.9.10",
"@react-aria/textfield": "^3.18.0",
"@react-aria/toolbar": "3.0.0-beta.19",
"@react-aria/utils": "^3.30.0",
"@react-aria/virtualizer": "^4.1.8",
Expand Down
56 changes: 36 additions & 20 deletions packages/react-aria-components/src/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,44 @@
* governing permissions and limitations under the License.
*/

import {AriaAutocompleteProps, CollectionOptions, useAutocomplete} from '@react-aria/autocomplete';
import {AriaAutocompleteProps, useAutocomplete} from '@react-aria/autocomplete';
import {AriaLabelingProps, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, ValueBase} from '@react-types/shared';
import {AriaTextFieldProps} from '@react-aria/textfield';
import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete';
import {InputContext} from './Input';
import {ContextValue, Provider, removeDataAttributes, SlotProps, SlottedContextValue, useSlottedContext} from './utils';
import {mergeProps} from '@react-aria/utils';
import {Node} from '@react-types/shared';
import {Provider, removeDataAttributes, SlotProps, SlottedContextValue, useSlottedContext} from './utils';
import React, {createContext, JSX, RefObject, useRef} from 'react';
import {SearchFieldContext} from './SearchField';
import {TextFieldContext} from './TextField';
import React, {createContext, JSX, useRef} from 'react';

export interface AutocompleteProps<T> extends AriaAutocompleteProps<T>, SlotProps {}

interface InternalAutocompleteContextValue<T> {
// TODO: naming
// IMO I think this could also contain the props that useSelectableCollection takes (minus the selection options?)
interface CollectionContextValue<T> extends DOMProps, AriaLabelingProps {
filter?: (nodeTextValue: string, node: Node<T>) => boolean,
collectionProps: CollectionOptions,
collectionRef: RefObject<HTMLElement | null>
/** Whether the collection items should use virtual focus instead of being focused directly. */
shouldUseVirtualFocus?: boolean,
/** Whether typeahead is disabled. */
disallowTypeAhead?: boolean
}

// TODO: naming
interface FieldInputContextValue<T = FocusableElement> extends
DOMProps,
FocusEvents<T>,
KeyboardEvents,
Pick<ValueBase<string>, 'onChange' | 'value'>,
Pick<AriaTextFieldProps, 'enterKeyHint' | 'aria-controls' | 'aria-autocomplete' | 'aria-activedescendant' | 'spellCheck' | 'autoCorrect' | 'autoComplete'> {}

export const AutocompleteContext = createContext<SlottedContextValue<Partial<AutocompleteProps<any>>>>(null);
export const AutocompleteStateContext = createContext<AutocompleteState | null>(null);
// This context is to pass the register and filter down to whatever collection component is wrapped by the Autocomplete
// TODO: export from RAC, but rename to something more appropriate
export const UNSTABLE_InternalAutocompleteContext = createContext<InternalAutocompleteContextValue<any> | null>(null);

// TODO export from RAC, maybe move up and out of Autocomplete
// also can't make this use ContextValue (so that we can call useContextProps) like FieldInput for a similar reason. The HTMLElement type for the ref
// makes useContextProps complain since it doesn't mesh up with HTMLDivElement
Comment on lines +44 to +46
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, move up
what is the ts error? Maybe we can fix it. though is useContextProps the correct thing to use anyways? Would we ever expect these to be passed as actual props? or only through the context?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the error is as follows:
ideally we define the CollectionContext like so

export const CollectionContext = createContext<ContextValue<CollectionContextValue<any>, HTMLElement>>(null);

so that a consuming component like Menu can then call useContextProps like

[collectionProps, ref] = useContextProps(collectionProps, ref, CollectionContext);

in order to merge its own set of collectionProps and its own ref with the ones being provided by the Autocomplete via context. However, the ref type in Menu might be something like RefObject<HTMLDivElement | null> vs the CollectionContext's RefObject<HTMLElement | null> which then conflicts and typescript complains. The context's ref just needs to be something that we can dispatch events on and thus should be as generic as possible, but ContextValue doesn't take a generic it seems...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it isn't necessary to use useContextProps, hence the current approach working in Menu/Listbox working due to casting the final ref merge as the desired ref type, but it would be nice to have that work IMO

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated with another attempt at the types

export const CollectionContext = createContext<ContextValue<CollectionContextValue<any>, HTMLElement>>(null);
// TODO: too restrictive to type this as a HTMLInputElement? Needed for the ref merging that happens in TextField/SearchField
// Attempted to use FocusableElement but as mentioned above, SearchField and TextField complain since they expect HTMLInputElement for their hooks and stuff
export const FieldInputContext = createContext<ContextValue<FieldInputContextValue, FocusableElement>>(null);

/**
* An autocomplete combines a TextField or SearchField with a Menu or ListBox, allowing users to search or filter a list of suggestions.
Expand Down Expand Up @@ -61,13 +76,14 @@ export function Autocomplete<T extends object>(props: AutocompleteProps<T>): JSX
<Provider
values={[
[AutocompleteStateContext, state],
[SearchFieldContext, textFieldProps],
[TextFieldContext, textFieldProps],
[InputContext, {ref: inputRef}],
[UNSTABLE_InternalAutocompleteContext, {
filter: filterFn as (nodeTextValue: string, node: Node<T>) => boolean,
collectionProps,
collectionRef: mergedCollectionRef
[FieldInputContext, {
...textFieldProps,
ref: inputRef
}],
[CollectionContext, {
...collectionProps,
filter: filterFn,
ref: mergedCollectionRef
}]
]}>
{props.children}
Expand Down
6 changes: 4 additions & 2 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicat
import {ButtonContext} from './Button';
import {CheckboxContext} from './RSPContexts';
import {Collection, CollectionBuilder, createLeafComponent, FilterLessNode, ItemNode} from '@react-aria/collections';
import {CollectionContext} from './Autocomplete';
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection';
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils';
import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
Expand All @@ -23,7 +24,6 @@ import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, Pre
import {ListStateContext} from './ListBox';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {TextContext} from './Text';
import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete';

export interface GridListRenderProps {
/**
Expand Down Expand Up @@ -106,7 +106,9 @@ interface GridListInnerProps<T extends object> {
function GridListInner<T extends object>({props, collection, gridListRef: ref}: GridListInnerProps<T>) {
// TODO: for now, don't grab collection ref and collectionProps from the autocomplete, rely on the user tabbing to the gridlist
// figure out if we want to support virtual focus for grids when wrapped in an autocomplete
let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {};
let contextProps;
[contextProps] = useContextProps({}, null, CollectionContext);
let {filter, ...collectionProps} = contextProps;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {};
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props;
Expand Down
13 changes: 8 additions & 5 deletions packages/react-aria-components/src/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@

import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria';
import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent, FilterLessNode, ItemNode, SectionNode} from '@react-aria/collections';
import {CollectionContext} from './Autocomplete';
import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection';
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils';
import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
import {DragAndDropHooks} from './useDragAndDrop';
import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately';
import {filterDOMProps, inertValue, LoadMoreSentinelProps, mergeRefs, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared';
import {HeaderContext} from './Header';
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {SeparatorContext} from './Separator';
import {TextContext} from './Text';
import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete';

export interface ListBoxRenderProps {
/**
Expand Down Expand Up @@ -120,11 +120,14 @@ interface ListBoxInnerProps<T> {
}

function ListBoxInner<T extends object>({state: inputState, props, listBoxRef}: ListBoxInnerProps<T>) {
let {filter, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {};
// TODO: bit silly that I have to do this, alternative is to use "props" in useContextProps and update the ListBoxInnerProps type to
// include filter
let contextProps;
// TODO: still had to use unknown here to stop typescript from complaining about the difference in the ref type difference between the menu ref and the more generic ref from the context
[contextProps, listBoxRef as unknown] = useContextProps({}, listBoxRef, CollectionContext);
let {filter, ...collectionProps} = contextProps;
props = useMemo(() => collectionProps ? ({...props, ...collectionProps}) : props, [props, collectionProps]);
let {dragAndDropHooks, layout = 'stack', orientation = 'vertical'} = props;
// Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens
listBoxRef = useObjectRef(useMemo(() => mergeRefs(listBoxRef, collectionRef !== undefined ? collectionRef as RefObject<HTMLDivElement> : null), [collectionRef, listBoxRef]));
let state = UNSTABLE_useFilteredListState(inputState, filter);
let {collection, selectionManager} = state;
let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState;
Expand Down
15 changes: 9 additions & 6 deletions packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
import {AriaMenuProps, FocusScope, mergeProps, useHover, useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from 'react-aria';
import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, ItemNode, SectionNode} from '@react-aria/collections';
import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, RootMenuTriggerState, TreeState, useMenuTriggerState, useSubmenuTriggerState, useTreeState} from 'react-stately';
import {CollectionContext} from './Autocomplete';
import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection';
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {filterDOMProps, mergeRefs, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {FocusStrategy, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents} from '@react-types/shared';
import {HeaderContext} from './Header';
import {KeyboardContext} from './Keyboard';
Expand All @@ -39,7 +40,6 @@ import React, {
} from 'react';
import {SeparatorContext} from './Separator';
import {TextContext} from './Text';
import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete';

export const MenuContext = createContext<ContextValue<MenuProps<any>, HTMLDivElement>>(null);
export const MenuStateContext = createContext<TreeState<any> | null>(null);
Expand Down Expand Up @@ -202,9 +202,12 @@ interface MenuInnerProps<T> {
}

function MenuInner<T extends object>({props, collection, menuRef: ref}: MenuInnerProps<T>) {
let {filter, collectionProps: autocompleteMenuProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {};
// Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens
ref = useObjectRef(useMemo(() => mergeRefs(ref, collectionRef !== undefined ? collectionRef as RefObject<HTMLDivElement> : null), [collectionRef, ref]));
// TODO: bit silly that I have to do this, alternative is to use "props" in useContextProps and update the MenuInnerProps type to
// include filter
let contextProps;
// TODO: still had to use unknown here to stop typescript from complaining...
[contextProps, ref as unknown] = useContextProps({}, ref, CollectionContext);
let {filter, ...autocompleteMenuProps} = contextProps;
let filteredCollection = useMemo(() => filter ? collection.filter(filter) : collection, [collection, filter]);
let state = useTreeState({
...props,
Expand Down Expand Up @@ -251,7 +254,7 @@ function MenuInner<T extends object>({props, collection, menuRef: ref}: MenuInne
[SectionContext, {name: 'MenuSection', render: MenuSectionInner}],
[SubmenuTriggerContext, {parentMenuRef: ref, shouldUseVirtualFocus: autocompleteMenuProps?.shouldUseVirtualFocus}],
[MenuItemContext, null],
[UNSTABLE_InternalAutocompleteContext, null],
[CollectionContext, null],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should ListBox and GridList also clear this now that we support nested grids? ex TagGroup inside Cell?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also clear the new input context

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think ListBox needs to since it doesn't support nested collections or interactive elements in its options, but GridList/TableView is a good call. I'll also clear the input context as well

[SelectionManagerContext, state.selectionManager],
/* Ensure root MenuTriggerState is defined, in case Menu is rendered outside a MenuTrigger. */
/* We assume the context can never change between defined and undefined. */
Expand Down
Loading