From c111744a93bc747c9fc69d8de67e072e91ebbe68 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 8 Aug 2025 13:48:09 -0700 Subject: [PATCH 01/18] autoimport.... --- packages/react-aria-components/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index c986fbd2af3..51c6ad6e953 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -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", From 24dd7cbfdfbdb52dd908c8c1c342b8a5ba532b17 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 8 Aug 2025 14:09:36 -0700 Subject: [PATCH 02/18] replace internal autocomplete context --- .../autocomplete/src/useAutocomplete.ts | 2 +- packages/react-aria-components/package.json | 2 + .../src/Autocomplete.tsx | 41 +++++++++++++++---- .../react-aria-components/src/GridList.tsx | 4 +- .../react-aria-components/src/ListBox.tsx | 4 +- packages/react-aria-components/src/Menu.tsx | 6 +-- packages/react-aria-components/src/Table.tsx | 4 +- .../react-aria-components/src/TagGroup.tsx | 4 +- .../test/AriaAutocomplete.test-util.tsx | 2 - 9 files changed, 48 insertions(+), 21 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 9a52f592130..b200bd20331 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -320,6 +320,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let filterFn = useCallback((nodeTextValue: string, node: Node) => { if (filter) { + // TODO: perhaps try to use inputRef value instead of state here? I think I tried that before and it was too late? return filter(nodeTextValue, state.inputValue, node); } @@ -366,7 +367,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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', diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 51c6ad6e953..5eaf6a3749c 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -65,6 +65,8 @@ "@react-types/grid": "^3.3.4", "@react-types/shared": "^3.31.0", "@react-types/table": "^3.13.2", + "@react-types/text": "^3.3.19", + "@react-types/textfield": "^3.12.4", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "^3.42.0", diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 05e40294c0c..19fe392ed98 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -11,10 +11,11 @@ */ import {AriaAutocompleteProps, CollectionOptions, 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 {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'; @@ -28,11 +29,32 @@ interface InternalAutocompleteContextValue { collectionRef: RefObject } +// TODO: naming +// IMO I think this could also contain the props that useSelectableCollection takes (minus the selection options?) +interface CollectionContextValue extends DOMProps, AriaLabelingProps { + filter?: (nodeTextValue: string, node: Node) => boolean, + /** Whether the collection items should use virtual focus instead of being focused directly. */ + shouldUseVirtualFocus?: boolean, + /** Whether typeahead is disabled. */ + disallowTypeAhead?: boolean, + collectionRef?: RefObject +} + +// TODO: may omit value since that is specific to textfields +// I could see this sending down a input ref +interface FieldInputContextValue extends + DOMProps, + FocusEvents, + Pick, + Pick, 'onChange' | 'value'>, + Pick {} + export const AutocompleteContext = createContext>>>(null); export const AutocompleteStateContext = createContext(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 | null>(null); + +// TODO export from RAC, maybe move up and out of Autocomplete +export const CollectionContext = createContext | null>(null); +export const FieldInputContext = createContext(null); /** * An autocomplete combines a TextField or SearchField with a Menu or ListBox, allowing users to search or filter a list of suggestions. @@ -64,11 +86,16 @@ export function Autocomplete(props: AutocompleteProps): JSX [SearchFieldContext, textFieldProps], [TextFieldContext, textFieldProps], [InputContext, {ref: inputRef}], - [UNSTABLE_InternalAutocompleteContext, { - filter: filterFn as (nodeTextValue: string, node: Node) => boolean, - collectionProps, + [CollectionContext, { + ...collectionProps, + filter: filterFn, collectionRef: mergedCollectionRef }] + // [UNSTABLE_InternalAutocompleteContext, { + // filter: filterFn as (nodeTextValue: string, node: Node) => boolean, + // collectionProps, + // collectionRef: mergedCollectionRef + // }] ]}> {props.children} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index c47ca47edc8..d61bc035377 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -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'; @@ -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 { /** @@ -106,7 +106,7 @@ interface GridListInnerProps { function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { // 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; diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index e923ece946e..67a27b0e5b2 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -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'; @@ -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 { /** @@ -120,7 +120,7 @@ interface ListBoxInnerProps { } function ListBoxInner({state: inputState, props, listBoxRef}: ListBoxInnerProps) { - let {filter, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + let {filter, collectionRef, ...collectionProps} = useContext(CollectionContext) || {}; 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 diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 7068f5032ab..c9c7c0e3030 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -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'; @@ -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, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); @@ -202,7 +202,7 @@ interface MenuInnerProps { } function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { - 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 : null), [collectionRef, ref])); let filteredCollection = useMemo(() => filter ? collection.filter(filter) : collection, [collection, filter]); @@ -251,7 +251,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [SectionContext, {name: 'MenuSection', render: MenuSectionInner}], [SubmenuTriggerContext, {parentMenuRef: ref, shouldUseVirtualFocus: autocompleteMenuProps?.shouldUseVirtualFocus}], [MenuItemContext, null], - [UNSTABLE_InternalAutocompleteContext, null], + [CollectionContext, null], [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. */ diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 773fe0e871d..532350959cf 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -3,6 +3,7 @@ import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBra import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; +import {CollectionContext} from './Autocomplete'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; import {ColumnSize, ColumnStaticSize, TableCollection as ITableCollection, TableProps as SharedTableProps} from '@react-types/table'; import {ContextValue, DEFAULT_SLOT, DOMProps, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; @@ -16,7 +17,6 @@ import {GridNode} from '@react-types/grid'; import intlMessages from '../intl/*.json'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; class TableCollection extends BaseCollection implements ITableCollection { headerRows: GridNode[] = []; @@ -371,7 +371,7 @@ interface TableInnerProps { function TableInner({props, forwardedRef: ref, selectionState, collection}: TableInnerProps) { - 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 tableContainerContext = useContext(ResizableTableContainerContext); diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 65c35afbfeb..0c2f83d1e58 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -13,6 +13,7 @@ import {AriaTagGroupProps, useFocusRing, useHover, useTag, useTagGroup} from 'react-aria'; import {ButtonContext} from './Button'; import {Collection, CollectionBuilder, createLeafComponent, ItemNode} from '@react-aria/collections'; +import {CollectionContext} from './Autocomplete'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, usePersistedKeys} from './Collection'; import {ContextValue, DOMProps, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {filterDOMProps, mergeProps, useObjectRef} from '@react-aria/utils'; @@ -22,7 +23,6 @@ import {ListState, Node, UNSTABLE_useFilteredListState, useListState} from 'reac import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useRef} from 'react'; import {TextContext} from './Text'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; export interface TagGroupProps extends Omit, 'children' | 'items' | 'label' | 'description' | 'errorMessage' | 'keyboardDelegate'>, DOMProps, SlotProps, GlobalDOMAttributes {} @@ -75,7 +75,7 @@ interface TagGroupInnerProps { } function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProps) { - 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 tagListRef = useRef(null); diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index 596fc5450ed..5c6098d8109 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -132,7 +132,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' let {getByRole} = renderers.standard!(); let input = getByRole('searchbox'); expect(input).toHaveAttribute('aria-controls'); - expect(input).toHaveAttribute('aria-haspopup', 'listbox'); expect(input).toHaveAttribute('aria-autocomplete', 'list'); expect(input).toHaveAttribute('autoCorrect', 'off'); expect(input).toHaveAttribute('spellCheck', 'false'); @@ -343,7 +342,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' let {getByRole} = renderers.noVirtualFocus!(); let input = getByRole('searchbox'); expect(input).toHaveAttribute('aria-controls'); - expect(input).toHaveAttribute('aria-haspopup', 'listbox'); expect(input).toHaveAttribute('aria-autocomplete', 'list'); expect(input).toHaveAttribute('autoCorrect', 'off'); expect(input).toHaveAttribute('spellCheck', 'false'); From d8969a71da1dd5f31e274d92113498bc1888c59c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 8 Aug 2025 14:56:11 -0700 Subject: [PATCH 03/18] add FieldInputContext in place of input context and search/textfield context in autocomplete --- .../src/Autocomplete.tsx | 36 +++++++------------ .../react-aria-components/src/SearchField.tsx | 9 ++--- .../react-aria-components/src/TextField.tsx | 15 ++++---- 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 19fe392ed98..79ee11012bc 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -10,25 +10,16 @@ * 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 {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 extends AriaAutocompleteProps, SlotProps {} -interface InternalAutocompleteContextValue { - filter?: (nodeTextValue: string, node: Node) => boolean, - collectionProps: CollectionOptions, - collectionRef: RefObject -} - // TODO: naming // IMO I think this could also contain the props that useSelectableCollection takes (minus the selection options?) interface CollectionContextValue extends DOMProps, AriaLabelingProps { @@ -40,11 +31,13 @@ interface CollectionContextValue extends DOMProps, AriaLabelingProps { collectionRef?: RefObject } -// TODO: may omit value since that is specific to textfields -// I could see this sending down a input ref +// 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, + FocusEvents, Pick, Pick, 'onChange' | 'value'>, Pick {} @@ -54,7 +47,8 @@ export const AutocompleteStateContext = createContext( // TODO export from RAC, maybe move up and out of Autocomplete export const CollectionContext = createContext | null>(null); -export const FieldInputContext = createContext(null); +// TODO: too restrictive to type this as a HTMLInputElement? Needed for the ref merging that happens in TextField/SearchField +export const FieldInputContext = createContext>(null); /** * An autocomplete combines a TextField or SearchField with a Menu or ListBox, allowing users to search or filter a list of suggestions. @@ -83,19 +77,15 @@ export function Autocomplete(props: AutocompleteProps): JSX ) => boolean, - // collectionProps, - // collectionRef: mergedCollectionRef - // }] ]}> {props.children} diff --git a/packages/react-aria-components/src/SearchField.tsx b/packages/react-aria-components/src/SearchField.tsx index 3695385688d..fae0606696a 100644 --- a/packages/react-aria-components/src/SearchField.tsx +++ b/packages/react-aria-components/src/SearchField.tsx @@ -15,7 +15,8 @@ import {ButtonContext} from './Button'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {createHideableComponent} from '@react-aria/collections'; import {FieldErrorContext} from './FieldError'; -import {filterDOMProps, mergeProps} from '@react-aria/utils'; +import {FieldInputContext} from './Autocomplete'; +import {filterDOMProps} from '@react-aria/utils'; import {FormContext} from './Form'; import {GlobalDOMAttributes} from '@react-types/shared'; import {GroupContext} from './Group'; @@ -59,7 +60,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useRef(null); - let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); + [props, inputRef] = useContextProps(props, inputRef, FieldInputContext); let [labelRef, label] = useSlot( !props['aria-label'] && !props['aria-labelledby'] ); @@ -72,7 +73,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search ...removeDataAttributes(props), label, validationBehavior - }, state, mergedInputRef); + }, state, inputRef); let renderProps = useRenderProps({ ...props, @@ -100,7 +101,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search (null); + [props, inputRef] = useContextProps(props, inputRef, FieldInputContext); let [labelRef, label] = useSlot( !props['aria-label'] && !props['aria-labelledby'] ); @@ -72,16 +73,16 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel inputElementType, label, validationBehavior - }, mergedInputRef); + }, inputRef); // Intercept setting the input ref so we can determine what kind of element we have. // useTextField uses this to determine what props to include. let inputOrTextAreaRef = useCallback((el) => { - mergedInputRef.current = el; + inputRef.current = el; if (el) { setInputElementType(el instanceof HTMLTextAreaElement ? 'textarea' : 'input'); } - }, [mergedInputRef]); + }, [inputRef]); let renderProps = useRenderProps({ ...props, @@ -110,7 +111,7 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel Date: Fri, 8 Aug 2025 14:57:51 -0700 Subject: [PATCH 04/18] fix build --- yarn.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/yarn.lock b/yarn.lock index 009a250feb7..5e9af690323 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25957,6 +25957,7 @@ __metadata: "@react-aria/live-announcer": "npm:^3.4.4" "@react-aria/overlays": "npm:^3.28.0" "@react-aria/ssr": "npm:^3.9.10" + "@react-aria/textfield": "npm:^3.18.0" "@react-aria/toolbar": "npm:3.0.0-beta.19" "@react-aria/utils": "npm:^3.30.0" "@react-aria/virtualizer": "npm:^4.1.8" @@ -25970,6 +25971,8 @@ __metadata: "@react-types/grid": "npm:^3.3.4" "@react-types/shared": "npm:^3.31.0" "@react-types/table": "npm:^3.13.2" + "@react-types/text": "npm:^3.3.19" + "@react-types/textfield": "npm:^3.12.4" "@swc/helpers": "npm:^0.5.0" "@tailwindcss/postcss": "npm:^4.0.0" client-only: "npm:^0.0.1" From 3a81f3155d267a6817324d726ab1f68be61789c2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 8 Aug 2025 14:59:18 -0700 Subject: [PATCH 05/18] removing erroneous autoimports --- packages/react-aria-components/package.json | 2 -- yarn.lock | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 5eaf6a3749c..51c6ad6e953 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -65,8 +65,6 @@ "@react-types/grid": "^3.3.4", "@react-types/shared": "^3.31.0", "@react-types/table": "^3.13.2", - "@react-types/text": "^3.3.19", - "@react-types/textfield": "^3.12.4", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "^3.42.0", diff --git a/yarn.lock b/yarn.lock index 5e9af690323..d7b46139a35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25971,8 +25971,6 @@ __metadata: "@react-types/grid": "npm:^3.3.4" "@react-types/shared": "npm:^3.31.0" "@react-types/table": "npm:^3.13.2" - "@react-types/text": "npm:^3.3.19" - "@react-types/textfield": "npm:^3.12.4" "@swc/helpers": "npm:^0.5.0" "@tailwindcss/postcss": "npm:^4.0.0" client-only: "npm:^0.0.1" From db91b3ac9bd7c524f383d3a6142088e6fde358d6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 8 Aug 2025 16:50:28 -0700 Subject: [PATCH 06/18] add ability for user to provide independent filter text --- .../autocomplete/src/useAutocomplete.ts | 60 ++++++++++----- .../autocomplete/src/useAutocompleteState.ts | 33 +------- .../stories/Autocomplete.stories.tsx | 75 +++++++++++++++++-- .../test/AriaAutocomplete.test-util.tsx | 2 +- .../test/Autocomplete.test.tsx | 6 +- 5 files changed, 114 insertions(+), 62 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index b200bd20331..c3a9bf97f2c 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -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 { @@ -40,7 +40,14 @@ export interface AriaAutocompleteProps 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 extends Omit, 'children'> { @@ -72,7 +79,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut inputRef, collectionRef, filter, - disableAutoFocusFirst = false + disableAutoFocusFirst = false, + filterText } = props; let collectionId = useSlotId(); @@ -171,7 +179,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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) { @@ -185,8 +194,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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(null); @@ -209,6 +219,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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; @@ -254,15 +265,17 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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) { @@ -282,6 +295,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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(); } }; @@ -320,12 +336,19 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let filterFn = useCallback((nodeTextValue: string, node: Node) => { if (filter) { - // TODO: perhaps try to use inputRef value instead of state here? I think I tried that before and it was too late? - 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 @@ -358,7 +381,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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; diff --git a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts index e9b4ce28d53..4f034ba1ad2 100644 --- a/packages/@react-stately/autocomplete/src/useAutocompleteState.ts +++ b/packages/@react-stately/autocomplete/src/useAutocompleteState.ts @@ -11,13 +11,8 @@ */ 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. */ @@ -25,12 +20,6 @@ export interface AutocompleteState { } 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 } @@ -41,29 +30,9 @@ export interface AutocompleteStateOptions extends Omit { - if (propsOnInputChange) { - propsOnInputChange(value); - } - }; - +export function useAutocompleteState(): AutocompleteState { let [focusedNodeId, setFocusedNodeId] = useState(null); - let [inputValue, setInputValue] = useControlledState( - propsInputValue, - propsDefaultInputValue!, - onInputChange - ); - return { - inputValue, - setInputValue, focusedNodeId, setFocusedNodeId }; diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 951339d4513..bfaf7125765 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TableLayout, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; +import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TableLayout, TagGroup, TagList, Text, TextArea, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; import {LoadingSpinner, MyListBoxItem, MyMenuItem} from './utils'; import {Meta, StoryObj} from '@storybook/react'; import {MyCheckbox} from './Table.stories'; @@ -144,9 +144,9 @@ export const AutocompleteExample: AutocompleteStory = { export const AutocompleteSearchfield: AutocompleteStory = { render: (args) => { return ( - +
- + Please select an option below. @@ -402,9 +402,9 @@ const AsyncExample = (args: any): React.ReactElement => { } return ( - +
- + Please select an option below. @@ -479,9 +479,9 @@ export const AutocompleteWithListbox: AutocompleteStory = { height: 250 }}> {() => ( - +
- + Please select an option below. @@ -1125,3 +1125,64 @@ export const AutocompletePreserveFirstSectionStory: AutocompleteStory = { } } }; + + +let names = [ + {id: 1, name: 'David'}, + {id: 2, name: 'Sam'}, + {id: 3, name: 'Julia'} +]; + +const UserCustomFiltering = (args): React.ReactElement => { + let [filterText, setFilterText] = useState(''); + let [value, setValue] = useState(''); + + let onChange = (value: string) => { + setValue(value); + let index = value.lastIndexOf('@'); + if (index === -1) { + setFilterText(''); + return; + } + + let after = value.slice(index + 1); + setFilterText(after); + }; + + let onAction = (key) => { + let index = value.lastIndexOf('@'); + let name = names.find(person => person.id === key)!.name; + setValue(value.slice(0, index).concat(name)); + setFilterText(''); + }; + + return ( + +
+ + +