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 7 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
60 changes: 41 additions & 19 deletions packages/@react-aria/autocomplete/src/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, m
import {getInteractionModality} from '@react-aria/interactions';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react';
import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useLocalizedStringFormatter} from '@react-aria/i18n';

export interface CollectionOptions extends DOMProps, AriaLabelingProps {
Expand All @@ -40,7 +40,14 @@ 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,

// TODO: thoughts?
/**
* If provided, the autocomplete will use this string when filtering the collection rather than the input ref's text. Useful for
* custom filtering situations like rich text editors.
*/
filterText?: string
}

export interface AriaAutocompleteOptions<T> extends Omit<AriaAutocompleteProps<T>, 'children'> {
Expand Down Expand Up @@ -72,7 +79,8 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
inputRef,
collectionRef,
filter,
disableAutoFocusFirst = false
disableAutoFocusFirst = false,
filterText
} = props;

let collectionId = useSlotId();
Expand Down Expand Up @@ -171,7 +179,8 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
lastInputType.current = inputType;
});

let onChange = (value: string) => {
let [updated, setUpdated] = useState(false);
let onChange = () => {
// Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when modifying the text via
// copy paste/backspacing/undo/redo for screen reader announcements
if (lastInputType.current === 'insertText' && !disableAutoFocusFirst) {
Expand All @@ -185,8 +194,9 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
dispatchVirtualFocus(inputRef.current!, null);
}
}

state.setInputValue(value);
// TODO: a problem with this is that we can't tell if a programatic change to the input field has happened aka Escape in searchfield
// Trigger a state update so that our filter function is updated, reflecting that the user has updated the field
setUpdated((last) => !last);
};

let keyDownTarget = useRef<Element | null>(null);
Expand All @@ -209,6 +219,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
// close the dialog prematurely. Ideally that should be up to the discretion of the input element hence the check
// for isPropagationStopped
if (e.isDefaultPrevented()) {
setUpdated((last) => !last);
return;
}
break;
Expand Down Expand Up @@ -254,15 +265,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 +295,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 @@ -320,11 +336,19 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut

let filterFn = useCallback((nodeTextValue: string, node: Node<T>) => {
if (filter) {
return filter(nodeTextValue, state.inputValue, node);
let textToFilterBy;
if (filterText != null) {
textToFilterBy = filterText;
} else {
textToFilterBy = inputRef.current?.value || '';
}

return filter(nodeTextValue, textToFilterBy, node);
}

return true;
}, [state.inputValue, filter]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updated, filterText, filter, inputRef]);

// 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
Expand Down Expand Up @@ -357,7 +381,6 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
// Only apply the autocomplete specific behaviors if the collection component wrapped by it is actually
// being filtered/allows filtering by the Autocomplete.
let textFieldProps = {
value: state.inputValue,
onChange
} as AriaTextFieldProps<HTMLInputElement>;

Expand All @@ -366,7 +389,6 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
...textFieldProps,
onKeyDown,
autoComplete: 'off',
'aria-haspopup': collectionId ? 'listbox' : undefined,
'aria-controls': collectionId,
// TODO: readd proper logic for completionMode = complete (aria-autocomplete: both)
'aria-autocomplete': 'list',
Expand Down
33 changes: 1 addition & 32 deletions packages/@react-stately/autocomplete/src/useAutocompleteState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,15 @@
*/

import {ReactNode, useState} from 'react';
import {useControlledState} from '@react-stately/utils';

export interface AutocompleteState {
/** The current value of the autocomplete input. */
inputValue: string,
/** Sets the value of the autocomplete input. */
setInputValue(value: string): void,
/** The id of the current aria-activedescendant of the autocomplete input. */
focusedNodeId: string | null,
/** Sets the id of the current aria-activedescendant of the autocomplete input. */
setFocusedNodeId(value: string | null): void
}

export interface AutocompleteProps {
/** The value of the autocomplete input (controlled). */
inputValue?: string,
/** The default value of the autocomplete input (uncontrolled). */
defaultInputValue?: string,
/** Handler that is called when the autocomplete input value changes. */
onInputChange?: (value: string) => void,
/** The children wrapped by the autocomplete. Consists of at least an input element and a collection element to filter. */
children: ReactNode
}
Expand All @@ -41,29 +30,9 @@ export interface AutocompleteStateOptions extends Omit<AutocompleteProps, 'child
/**
* Provides state management for an autocomplete component.
*/
export function useAutocompleteState(props: AutocompleteStateOptions): AutocompleteState {
let {
onInputChange: propsOnInputChange,
inputValue: propsInputValue,
defaultInputValue: propsDefaultInputValue = ''
} = props;

let onInputChange = (value) => {
if (propsOnInputChange) {
propsOnInputChange(value);
}
};

export function useAutocompleteState(): AutocompleteState {
let [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
let [inputValue, setInputValue] = useControlledState(
propsInputValue,
propsDefaultInputValue!,
onInputChange
);

return {
inputValue,
setInputValue,
focusedNodeId,
setFocusedNodeId
};
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
55 changes: 36 additions & 19 deletions packages/react-aria-components/src/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,45 @@
* 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, 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';

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,
collectionRef?: RefObject<HTMLElement | null>
}

// TODO: naming, may omit value since that is specific controlled textfields
// for a case like a rich text editor, the specific value to filter against would need to come from
// the user. Though I guess they could have a separate "filterText" that they pass to Autocomplete that they would
// only set when the user types a certain symbol. That value wouldn't actually affect the textarea's value, just controlling filtering
interface FieldInputContextValue extends
DOMProps,
FocusEvents<HTMLInputElement>,
Pick<KeyboardEvents, 'onKeyDown'>,
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
export const CollectionContext = createContext<CollectionContextValue<any> | null>(null);
// TODO: too restrictive to type this as a HTMLInputElement? Needed for the ref merging that happens in TextField/SearchField
export const FieldInputContext = createContext<ContextValue<FieldInputContextValue, HTMLInputElement>>(null);

/**
* An autocomplete combines a TextField or SearchField with a Menu or ListBox, allowing users to search or filter a list of suggestions.
Expand All @@ -41,7 +57,7 @@ export function Autocomplete<T extends object>(props: AutocompleteProps<T>): JSX
let ctx = useSlottedContext(AutocompleteContext, props.slot);
props = mergeProps(ctx, props);
let {filter, disableAutoFocusFirst} = props;
let state = useAutocompleteState(props);
let state = useAutocompleteState();
let inputRef = useRef<HTMLInputElement | null>(null);
let collectionRef = useRef<HTMLElement>(null);
let {
Expand All @@ -61,12 +77,13 @@ 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,
[FieldInputContext, {
...textFieldProps,
ref: inputRef
}],
[CollectionContext, {
...collectionProps,
filter: filterFn,
collectionRef: mergedCollectionRef
}]
]}>
Expand Down
4 changes: 2 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,7 @@ 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 {filter, ...collectionProps} = useContext(CollectionContext) || {};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {};
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props;
Expand Down
4 changes: 2 additions & 2 deletions packages/react-aria-components/src/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

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';
Expand All @@ -23,7 +24,6 @@ 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,7 +120,7 @@ interface ListBoxInnerProps<T> {
}

function ListBoxInner<T extends object>({state: inputState, props, listBoxRef}: ListBoxInnerProps<T>) {
let {filter, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {};
let {filter, collectionRef, ...collectionProps} = useContext(CollectionContext) || {};
Copy link
Member

Choose a reason for hiding this comment

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

are we still using UNSTABLE_useFilteredListState as the name for this? or should these also be updated out of UNSTABLE?

not actually attached to this line, but it's nearby

Copy link
Member Author

Choose a reason for hiding this comment

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

good question, I think I'm going to lean towards keeping these UNSTABLE for now since it is used in GridList who's Autocomplete virtual behavior isn't completely ironed out yet (I don't think there will be any changes there but just for safety).

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
Expand Down
6 changes: 3 additions & 3 deletions packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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';
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,7 +202,7 @@ interface MenuInnerProps<T> {
}

function MenuInner<T extends object>({props, collection, menuRef: ref}: MenuInnerProps<T>) {
let {filter, collectionProps: autocompleteMenuProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {};
let {filter, collectionRef, ...autocompleteMenuProps} = useContext(CollectionContext) || {};
// 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]));
let filteredCollection = useMemo(() => filter ? collection.filter(filter) : collection, [collection, filter]);
Expand Down Expand Up @@ -251,7 +251,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