diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 9a52f592130..2e8662a0bcc 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -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'; @@ -28,7 +28,6 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps { disallowTypeAhead: boolean } -// TODO; For now go with Node here, but maybe pare it down to just the essentials? Value, key, and maybe type? export interface AriaAutocompleteProps extends AutocompleteProps { /** * An optional filter function used to determine if a option should be included in the autocomplete list. @@ -37,10 +36,17 @@ export interface AriaAutocompleteProps extends AutocompleteProps { filter?: (textValue: string, inputValue: string, node: Node) => boolean, /** - * Whether or not to focus the first item in the collection after a filter is performed. + * Whether or not to focus the first item in the collection after a filter is performed. Note this is only applicable + * if virtual focus behavior is not turned off via `disableVirtualFocus`. * @default false */ - disableAutoFocusFirst?: boolean + disableAutoFocusFirst?: boolean, + + /** + * Whether the autocomplete should disable virtual focus, instead making the wrapped collection directly tabbable. + * @default false + */ + disableVirtualFocus?: boolean } export interface AriaAutocompleteOptions extends Omit, 'children'> { @@ -52,7 +58,7 @@ export interface AriaAutocompleteOptions extends Omit { /** Props for the autocomplete textfield/searchfield element. These should be passed to the textfield/searchfield aria hooks respectively. */ - textFieldProps: AriaTextFieldProps, + textFieldProps: AriaTextFieldProps, /** 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. */ @@ -72,7 +78,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut inputRef, collectionRef, filter, - disableAutoFocusFirst = false + disableAutoFocusFirst = false, + disableVirtualFocus = false } = props; let collectionId = useSlotId(); @@ -83,7 +90,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, 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' && !disableVirtualFocus; useEffect(() => { return () => clearTimeout(timeout.current); @@ -254,15 +261,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 +291,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(); } }; @@ -359,25 +371,28 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let textFieldProps = { value: state.inputValue, onChange - } as AriaTextFieldProps; + } as AriaTextFieldProps; + + 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' }; } diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 1c2cc4eb645..7b7839398d9 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -21,6 +21,7 @@ type FilterFn = (textValue: string, node: Node) => boolean; /** An immutable object representing a Node in a Collection. */ export class CollectionNode implements Node { + static readonly type; readonly type: string; readonly key: Key; readonly value: T | null = null; @@ -40,8 +41,8 @@ export class CollectionNode implements Node { readonly colSpan: number | null = null; readonly colIndex: number | null = null; - constructor(type: string, key: Key) { - this.type = type; + constructor(key: Key) { + this.type = (this.constructor as typeof CollectionNode).type; this.key = key; } @@ -49,8 +50,8 @@ export class CollectionNode implements Node { throw new Error('childNodes is not supported'); } - clone(): CollectionNode { - let node: Mutable> = new CollectionNode(this.type, this.key); + clone(): this { + let node: Mutable = new (this.constructor as any)(this.key); node.value = this.value; node.level = this.level; node.hasChildNodes = this.hasChildNodes; @@ -67,7 +68,6 @@ export class CollectionNode implements Node { node.render = this.render; node.colSpan = this.colSpan; node.colIndex = this.colIndex; - node.filter = this.filter; return node; } @@ -85,20 +85,24 @@ export class CollectionNode implements Node { export class FilterLessNode extends CollectionNode { // eslint-disable-next-line @typescript-eslint/no-unused-vars filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): FilterLessNode | null { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } } +export class LoaderNode extends FilterLessNode { + static readonly type = 'loader'; +} + export class ItemNode extends CollectionNode { static readonly type = 'item'; - constructor(key: Key) { - super(ItemNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): ItemNode | null { if (filterFn(this.textValue, this)) { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } return null; @@ -108,10 +112,6 @@ export class ItemNode extends CollectionNode { export class SectionNode extends CollectionNode { static readonly type = 'section'; - constructor(key: Key) { - super(SectionNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): SectionNode | null { let filteredSection = super.filter(collection, newCollection, filterFn); if (filteredSection) { @@ -259,6 +259,15 @@ export class BaseCollection implements ICollection> { this.keyMap.set(node.key, node); } + // Deeply add a node and its children to the collection from another collection, primarily used when filtering a collection + addDescendants(node: CollectionNode, oldCollection: BaseCollection): void { + this.addNode(node); + let children = oldCollection.getChildren(node.key); + for (let child of children) { + this.addDescendants(child as CollectionNode, oldCollection); + } + } + removeNode(key: Key): void { if (this.frozen) { throw new Error('Cannot remove a node to a frozen collection'); @@ -282,14 +291,10 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - filter(filterFn: FilterFn, newCollection?: BaseCollection): BaseCollection { - if (newCollection == null) { - newCollection = new BaseCollection(); - } - + filter(filterFn: FilterFn): this { + let newCollection = new (this.constructor as any)(); let [firstKey, lastKey] = filterChildren(this, newCollection, this.firstKey, filterFn); - newCollection.firstKey = firstKey; - newCollection.lastKey = lastKey; + newCollection?.commit(firstKey, lastKey); return newCollection; } } diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 52c4f79e0a8..83b1e834d57 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -133,10 +133,9 @@ export type CollectionNodeClass = { }; function createCollectionNodeClass(type: string): CollectionNodeClass { - let NodeClass = function (key: Key) { - return new CollectionNode(type, key); - } as any; - NodeClass.type = type; + let NodeClass = class extends CollectionNode { + static readonly type = type; + }; return NodeClass; } @@ -172,7 +171,6 @@ function useSSRCollectionNode(CollectionNodeClass: Collection } // @ts-ignore - // TODO: could just make this a div perhaps, but keep it in line with how it used to work return {children}; } diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index bcb6362c5af..36bdb3491a8 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -260,7 +260,6 @@ export class ElementNode extends BaseNode { private _node: CollectionNode | null; isMutated = true; private _index: number = 0; - hasSetProps = false; isHidden = false; constructor(type: string, ownerDocument: Document) { @@ -285,10 +284,11 @@ export class ElementNode extends BaseNode { return 0; } - get node(): CollectionNode | null { - if (this._node == null && process.env.NODE_ENV !== 'production') { - console.error('Attempted to access node before it was defined. Check if setProps wasn\'t called before attempting to access the node.'); + get node(): CollectionNode { + if (this._node == null) { + throw Error('Attempted to access node before it was defined. Check if setProps wasn\'t called before attempting to access the node.'); } + return this._node; } @@ -302,12 +302,12 @@ export class ElementNode extends BaseNode { */ private getMutableNode(): Mutable> { if (!this.isMutated) { - this.node = this.node!.clone(); + this.node = this.node.clone(); this.isMutated = true; } this.ownerDocument.markDirty(this); - return this.node!; + return this.node; } updateNode(): void { @@ -315,18 +315,18 @@ export class ElementNode extends BaseNode { let node = this.getMutableNode(); node.index = this.index; node.level = this.level; - node.parentKey = this.parentNode instanceof ElementNode ? this.parentNode.node!.key : null; - node.prevKey = this.previousVisibleSibling?.node!.key ?? null; - node.nextKey = nextSibling?.node!.key ?? null; + node.parentKey = this.parentNode instanceof ElementNode ? this.parentNode.node.key : null; + node.prevKey = this.previousVisibleSibling?.node.key ?? null; + node.nextKey = nextSibling?.node.key ?? null; node.hasChildNodes = !!this.firstChild; - node.firstChildKey = this.firstVisibleChild?.node!.key ?? null; - node.lastChildKey = this.lastVisibleChild?.node!.key ?? null; + node.firstChildKey = this.firstVisibleChild?.node.key ?? null; + node.lastChildKey = this.lastVisibleChild?.node.key ?? null; // Update the colIndex of sibling nodes if this node has a colSpan. if ((node.colSpan != null || node.colIndex != null) && nextSibling) { // This queues the next sibling for update, which means this happens recursively. let nextColIndex = (node.colIndex ?? node.index) + (node.colSpan ?? 1); - if (nextColIndex !== nextSibling.node!.colIndex) { + if (nextColIndex !== nextSibling.node.colIndex) { let siblingNode = nextSibling.getMutableNode(); siblingNode.colIndex = nextColIndex; } @@ -455,7 +455,7 @@ export class Document = BaseCollection> extend } let collection = this.getMutableCollection(); - if (!collection.getItem(element.node!.key)) { + if (!collection.getItem(element.node.key)) { for (let child of element) { this.addNode(child); } @@ -470,7 +470,7 @@ export class Document = BaseCollection> extend } let collection = this.getMutableCollection(); - collection.removeNode(node.node!.key); + collection.removeNode(node.node.key); } /** Finalizes the collection update, updating all nodes and freezing the collection. */ @@ -516,7 +516,7 @@ export class Document = BaseCollection> extend // Finally, update the collection. if (this.nextCollection) { - this.nextCollection.commit(this.firstVisibleChild?.node!.key ?? null, this.lastVisibleChild?.node!.key ?? null, this.isSSR); + this.nextCollection.commit(this.firstVisibleChild?.node.key ?? null, this.lastVisibleChild?.node.key ?? null, this.isSSR); if (!this.isSSR) { this.collection = this.nextCollection; this.nextCollection = null; diff --git a/packages/@react-aria/collections/src/index.ts b/packages/@react-aria/collections/src/index.ts index 38457e56542..1cf607da27f 100644 --- a/packages/@react-aria/collections/src/index.ts +++ b/packages/@react-aria/collections/src/index.ts @@ -13,7 +13,7 @@ export {CollectionBuilder, Collection, createLeafComponent, createBranchComponent} from './CollectionBuilder'; export {createHideableComponent, useIsHidden} from './Hidden'; export {useCachedChildren} from './useCachedChildren'; -export {BaseCollection, CollectionNode, ItemNode, SectionNode, FilterLessNode} from './BaseCollection'; +export {BaseCollection, CollectionNode, ItemNode, SectionNode, FilterLessNode, LoaderNode} from './BaseCollection'; export type {CollectionBuilderProps, CollectionProps} from './CollectionBuilder'; export type {CachedChildrenOptions} from './useCachedChildren'; diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 395eefa9fb7..74664dbfc3c 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -4,10 +4,6 @@ import {render} from '@testing-library/react'; class ItemNode extends CollectionNode { static type = 'item'; - - constructor(key) { - super(ItemNode.type, key); - } } const Item = createLeafComponent(ItemNode, () => { diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 24ad6d16da1..758cc6e75b4 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -30,10 +30,10 @@ import { ListStateContext, Provider, SectionProps, - SeparatorNode, Virtualizer } from 'react-aria-components'; import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; +import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import {centerPadding, control, controlBorderRadius, controlFont, controlSize, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; @@ -49,7 +49,6 @@ import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; import {createContext, CSSProperties, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; -import {createLeafComponent} from '@react-aria/collections'; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; @@ -700,6 +699,21 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps { + static readonly type = 'separator'; + + filter(collection: BaseCollection, newCollection: BaseCollection): CollectionNode | null { + let prevItem = newCollection.getItem(this.prevKey!); + if (prevItem && prevItem.type !== 'separator') { + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; + } + + return null; + } +} + export const Divider = /*#__PURE__*/ createLeafComponent(SeparatorNode, function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef, node: Node) { let listState = useContext(ListStateContext)!; diff --git a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx index cc75a2fe81a..05a591af3ea 100644 --- a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx +++ b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx @@ -11,7 +11,6 @@ */ import {createLeafComponent, FilterLessNode} from '@react-aria/collections'; -import {Key} from '@react-types/shared'; import {ReactNode} from 'react'; import {Skeleton} from './Skeleton'; @@ -23,10 +22,6 @@ let cache = new WeakMap(); class SkeletonNode extends FilterLessNode { static readonly type = 'skeleton'; - - constructor(key: Key) { - super(SkeletonNode.type, key); - } } /** 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", diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 05e40294c0c..0d609efdfe2 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -10,29 +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 {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; -import {InputContext} from './Input'; +import {FieldInputContext, SelectableCollectionContext} from './context'; 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 extends AriaAutocompleteProps, SlotProps {} - -interface InternalAutocompleteContextValue { - filter?: (nodeTextValue: string, node: Node) => boolean, - collectionProps: CollectionOptions, - collectionRef: RefObject -} +import React, {createContext, JSX, useRef} from 'react'; +export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} 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); /** * An autocomplete combines a TextField or SearchField with a Menu or ListBox, allowing users to search or filter a list of suggestions. @@ -61,13 +48,14 @@ export function Autocomplete(props: AutocompleteProps): JSX ) => boolean, - collectionProps, - collectionRef: mergedCollectionRef + [FieldInputContext, { + ...textFieldProps, + ref: inputRef + }], + [SelectableCollectionContext, { + ...collectionProps, + filter: filterFn, + ref: mergedCollectionRef }] ]}> {props.children} diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 92ad111b572..e7a0cb0693d 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -75,10 +75,6 @@ export interface BreadcrumbProps extends RenderProps, Glo class BreadcrumbNode extends FilterLessNode { static readonly type = 'item'; - - constructor(key: Key) { - super(BreadcrumbNode.type, key); - } } /** diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index c47ca47edc8..f78f0f0e1ea 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -12,18 +12,18 @@ import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; -import {Collection, CollectionBuilder, createLeafComponent, FilterLessNode, ItemNode} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createLeafComponent, ItemNode, LoaderNode} from '@react-aria/collections'; 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'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; +import {FieldInputContext, SelectableCollectionContext} from './context'; import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; 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,9 @@ 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 contextProps; + [contextProps] = useContextProps({}, null, SelectableCollectionContext); + 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; @@ -287,12 +289,10 @@ export interface GridListItemProps extends RenderProps void } -class GridListNode extends ItemNode {} - /** * A GridListItem represents an individual item in a GridList. */ -export const GridListItem = /*#__PURE__*/ createLeafComponent(GridListNode, function GridListItem(props: GridListItemProps, forwardedRef: ForwardedRef, item: Node) { +export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function GridListItem(props: GridListItemProps, forwardedRef: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); let ref = useObjectRef(forwardedRef); @@ -422,7 +422,9 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(GridListNode, func } }], [CollectionRendererContext, DefaultCollectionRenderer], - [ListStateContext, null] + [ListStateContext, null], + [SelectableCollectionContext, null], + [FieldInputContext, null] ]}> {renderProps.children} @@ -523,16 +525,7 @@ export interface GridListLoadMoreItemProps extends Omit { - static readonly type = 'loader'; - - constructor(key: Key) { - super(GridListLoaderNode.type, key); - } -} - -export const GridListLoadMoreItem = createLeafComponent(GridListLoaderNode, function GridListLoadingIndicator(props: GridListLoadMoreItemProps, ref: ForwardedRef, item: Node) { +export const GridListLoadMoreItem = createLeafComponent(LoaderNode, function GridListLoadingIndicator(props: GridListLoadMoreItemProps, ref: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/Header.tsx b/packages/react-aria-components/src/Header.tsx index d76d9df57d1..4ca1a87c6fc 100644 --- a/packages/react-aria-components/src/Header.tsx +++ b/packages/react-aria-components/src/Header.tsx @@ -12,17 +12,12 @@ import {ContextValue, useContextProps} from './utils'; import {createLeafComponent, FilterLessNode} from '@react-aria/collections'; -import {Key} from '@react-types/shared'; import React, {createContext, ForwardedRef, HTMLAttributes} from 'react'; export const HeaderContext = createContext, HTMLElement>>({}); class HeaderNode extends FilterLessNode { static readonly type = 'header'; - - constructor(key: Key) { - super(HeaderNode.type, key); - } } export const Header = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index e923ece946e..eb626392572 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -11,19 +11,19 @@ */ 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 {Collection, CollectionBuilder, createBranchComponent, createLeafComponent, ItemNode, LoaderNode, SectionNode} from '@react-aria/collections'; 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 {SelectableCollectionContext, SelectableCollectionContextValue} from './context'; import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; export interface ListBoxRenderProps { /** @@ -115,16 +115,13 @@ function StandaloneListBox({props, listBoxRef, collection}) { interface ListBoxInnerProps { state: ListState, - props: ListBoxProps & AriaListBoxOptions, - listBoxRef: RefObject + props: ListBoxProps & AriaListBoxOptions & {filter?: SelectableCollectionContextValue['filter']}, + listBoxRef: RefObject } function ListBoxInner({state: inputState, props, listBoxRef}: ListBoxInnerProps) { - let {filter, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; - 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 : null), [collectionRef, listBoxRef])); + [props, listBoxRef] = useContextProps(props, listBoxRef, SelectableCollectionContext); + let {dragAndDropHooks, layout = 'stack', orientation = 'vertical', filter} = props; let state = UNSTABLE_useFilteredListState(inputState, filter); let {collection, selectionManager} = state; let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState; @@ -239,7 +236,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}:
} slot={props.slot || undefined} onScroll={props.onScroll} data-drop-target={isRootDropTarget || undefined} @@ -305,12 +302,10 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re ); } -export class ListBoxSectionNode extends SectionNode {} - /** * A ListBoxSection represents a section within a ListBox. */ -export const ListBoxSection = /*#__PURE__*/ createBranchComponent(ListBoxSectionNode, ListBoxSectionInner); +export const ListBoxSection = /*#__PURE__*/ createBranchComponent(SectionNode, ListBoxSectionInner); export interface ListBoxItemRenderProps extends ItemRenderProps {} @@ -332,12 +327,10 @@ export interface ListBoxItemProps extends RenderProps void } -class ListBoxItemNode extends ItemNode {} - /** * A ListBoxItem represents an individual option in a ListBox. */ -export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ListBoxItemNode, function ListBoxItem(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { +export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function ListBoxItem(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { let ref = useObjectRef(forwardedRef); let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; @@ -474,14 +467,6 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe ); } -class ListBoxLoaderNode extends FilterLessNode { - static readonly type = 'loader'; - - constructor(key: Key) { - super(ListBoxLoaderNode.type, key); - } -} - const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); export interface ListBoxLoadMoreItemProps extends Omit, StyleProps, GlobalDOMAttributes { @@ -495,7 +480,7 @@ export interface ListBoxLoadMoreItemProps extends Omit, item: Node) { +export const ListBoxLoadMoreItem = createLeafComponent(LoaderNode, function ListBoxLoadingIndicator(props: ListBoxLoadMoreItemProps, ref: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 7068f5032ab..e3bfd9f4401 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -15,7 +15,8 @@ import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBra import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, RootMenuTriggerState, TreeState, useMenuTriggerState, useSubmenuTriggerState, useTreeState} from 'react-stately'; 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 {FieldInputContext, SelectableCollectionContext, SelectableCollectionContextValue} from './context'; +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'; @@ -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); @@ -110,17 +110,12 @@ const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject extends CollectionNode { static readonly type = 'submenutrigger'; - constructor(key: Key) { - super(SubmenuTriggerNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): CollectionNode | null { let triggerNode = collection.getItem(this.firstChildKey!); - // Note that this provides the SubmenuTrigger node rather than the MenuItemNode it wraps to the filter function. Probably more useful - // because that node has the proper parentKey information (aka the section if any, the menu item will just point to the SubmenuTrigger node) if (triggerNode && filterFn(triggerNode.textValue, this)) { - newCollection.addNode(triggerNode as CollectionNode); - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } return null; @@ -196,15 +191,16 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu { - props: MenuProps, + // For now we append filter and other autocomplete context props here for typescript, but eventually we can consider exposing these + // as top level props for users to use with standalone Menus + props: MenuProps & {filter?: SelectableCollectionContextValue['filter'], shouldUseVirtualFocus?: boolean}, collection: BaseCollection, - menuRef: RefObject + menuRef: RefObject } function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { - 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 : null), [collectionRef, ref])); + [props, ref] = useContextProps(props, ref, SelectableCollectionContext); + let {filter, ...autocompleteMenuProps} = props; let filteredCollection = useMemo(() => filter ? collection.filter(filter) : collection, [collection, filter]); let state = useTreeState({ ...props, @@ -213,7 +209,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne }); let triggerState = useContext(RootMenuTriggerStateContext); let {isVirtualized, CollectionRoot} = useContext(CollectionRendererContext); - let {menuProps} = useMenu({...props, ...autocompleteMenuProps, isVirtualized, onClose: props.onClose || triggerState?.close}, state, ref); + let {menuProps} = useMenu({...props, isVirtualized, onClose: props.onClose || triggerState?.close}, state, ref); let renderProps = useRenderProps({ defaultClassName: 'react-aria-Menu', className: props.className, @@ -240,7 +236,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne
} slot={props.slot || undefined} data-empty={state.collection.size === 0 || undefined} onScroll={props.onScroll}> @@ -251,7 +247,8 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [SectionContext, {name: 'MenuSection', render: MenuSectionInner}], [SubmenuTriggerContext, {parentMenuRef: ref, shouldUseVirtualFocus: autocompleteMenuProps?.shouldUseVirtualFocus}], [MenuItemContext, null], - [UNSTABLE_InternalAutocompleteContext, null], + [SelectableCollectionContext, null], + [FieldInputContext, 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. */ @@ -339,12 +336,10 @@ function MenuSectionInner(props: MenuSectionProps, ref: For ); } -class MenuSectionNode extends SectionNode {} - /** * A MenuSection represents a section within a Menu. */ -export const MenuSection = /*#__PURE__*/ createBranchComponent(MenuSectionNode, MenuSectionInner); +export const MenuSection = /*#__PURE__*/ createBranchComponent(SectionNode, MenuSectionInner); export interface MenuItemRenderProps extends ItemRenderProps { /** @@ -378,12 +373,10 @@ export interface MenuItemProps extends RenderProps>(null); -class MenuItemNode extends ItemNode {} - /** * A MenuItem represents an individual action in a Menu. */ -export const MenuItem = /*#__PURE__*/ createLeafComponent(MenuItemNode, function MenuItem(props: MenuItemProps, forwardedRef: ForwardedRef, item: Node) { +export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function MenuItem(props: MenuItemProps, forwardedRef: ForwardedRef, item: Node) { [props, forwardedRef] = useContextProps(props, forwardedRef, MenuItemContext); let id = useSlottedContext(MenuItemContext)?.id as string; let state = useContext(MenuStateContext)!; diff --git a/packages/react-aria-components/src/SearchField.tsx b/packages/react-aria-components/src/SearchField.tsx index 3695385688d..db871dc545a 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 './context'; +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 as unknown] = 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 {} @@ -24,14 +24,12 @@ export const SeparatorContext = createContext { static readonly type = 'separator'; - constructor(key: Key) { - super(SeparatorNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection): CollectionNode | null { let prevItem = newCollection.getItem(this.prevKey!); if (prevItem && prevItem.type !== 'separator') { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } return null; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 773fe0e871d..ef346e3afcf 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,5 +1,5 @@ import {AriaLabelingProps, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; -import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, FilterLessNode, useCachedChildren} from '@react-aria/collections'; +import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, FilterLessNode, LoaderNode, useCachedChildren} from '@react-aria/collections'; import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; @@ -10,21 +10,21 @@ import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, Mu import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useFocusRing, useHover, useLocale, useLocalizedStringFormatter, useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox, useVisuallyHidden} from 'react-aria'; +import {FieldInputContext, SelectableCollectionContext} from './context'; import {filterDOMProps, inertValue, isScrollable, LoadMoreSentinelProps, mergeRefs, useLayoutEffect, useLoadMoreSentinel, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore 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[] = []; columns: GridNode[] = []; rows: GridNode[] = []; rowHeaderColumnKeys: Set = new Set(); - head: CollectionNode = new CollectionNode('tableheader', -1); - body: CollectionNode = new CollectionNode('tablebody', -2); + head = new TableHeaderNode(-1); + body = new TableBodyNode(-2); columnsDirty = true; addNode(node: CollectionNode) { @@ -66,7 +66,6 @@ class TableCollection extends BaseCollection implements ITableCollection) => { switch (node.type) { @@ -161,7 +160,6 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection) => boolean): TableCollection { - let clone = this.clone(); - return super.filter(filterFn, clone) as TableCollection; - - } } interface ResizableTableContainerContextValue { @@ -371,7 +363,9 @@ interface TableInnerProps { function TableInner({props, forwardedRef: ref, selectionState, collection}: TableInnerProps) { - let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + let contextProps; + [contextProps] = useContextProps({}, null, SelectableCollectionContext); + let {filter, ...collectionProps} = contextProps; // eslint-disable-next-line @typescript-eslint/no-unused-vars let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let tableContainerContext = useContext(ResizableTableContainerContext); @@ -491,7 +485,9 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl [TableStateContext, filteredState], [TableColumnResizeStateContext, layoutState], [DragAndDropContext, {dragAndDropHooks, dragState, dropState}], - [DropIndicatorContext, {render: TableDropIndicatorWrapper}] + [DropIndicatorContext, {render: TableDropIndicatorWrapper}], + [SelectableCollectionContext, null], + [FieldInputContext, null] ]}> extends StyleRenderProps } -class TableHeaderNode extends FilterLessNode { +class TableHeaderNode extends FilterLessNode { static readonly type = 'tableheader'; - - constructor(key: Key) { - super(TableHeaderNode.type, key); - } } /** @@ -704,10 +696,6 @@ export interface ColumnProps extends RenderProps, GlobalDOMAt class TableColumnNode extends FilterLessNode { static readonly type = 'column'; - - constructor(key: Key) { - super(TableColumnNode.type, key); - } } /** @@ -946,12 +934,8 @@ export interface TableBodyProps extends Omit, 'disabledKey renderEmptyState?: (props: TableBodyRenderProps) => ReactNode } -class TableBodyNode extends CollectionNode { +class TableBodyNode extends CollectionNode { static readonly type = 'tablebody'; - - constructor(key: Key) { - super(TableBodyNode.type, key); - } } /** @@ -1058,15 +1042,13 @@ export interface RowProps extends StyleRenderProps, LinkDOMPr class TableRowNode extends CollectionNode { static readonly type = 'item'; - constructor(key: Key) { - super(TableRowNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): TableRowNode | null { let cells = collection.getChildren(this.key); for (let cell of cells) { if (filterFn(cell.textValue, cell)) { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } } @@ -1260,10 +1242,6 @@ export interface CellProps extends RenderProps, GlobalDOMAttrib class TableCellNode extends FilterLessNode { static readonly type = 'cell'; - - constructor(key: Key) { - super(TableCellNode.type, key); - } } /** @@ -1424,15 +1402,7 @@ export interface TableLoadMoreItemProps extends Omit { - static readonly type = 'loader'; - - constructor(key: Key) { - super(TableLoaderNode.type, key); - } -} - -export const TableLoadMoreItem = createLeafComponent(TableLoaderNode, function TableLoadingIndicator(props: TableLoadMoreItemProps, ref: ForwardedRef, item: Node) { +export const TableLoadMoreItem = createLeafComponent(LoaderNode, function TableLoadingIndicator(props: TableLoadMoreItemProps, ref: ForwardedRef, item: Node) { let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index e2ae91eaddc..d0a4dc8780d 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -237,10 +237,6 @@ function TabListInner({props, forwardedRef: ref}: TabListInner class TabItemNode extends FilterLessNode { static readonly type = 'item'; - - constructor(key: Key) { - super(TabItemNode.type, key); - } } /** diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 65c35afbfeb..b6724c7f6bf 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -21,8 +21,8 @@ import {LabelContext} from './Label'; import {ListState, Node, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useRef} from 'react'; +import {SelectableCollectionContext} from './context'; 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,9 @@ interface TagGroupInnerProps { } function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProps) { - let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + let contextProps; + [contextProps] = useContextProps({}, null, SelectableCollectionContext); + let {filter, ...collectionProps} = contextProps; // eslint-disable-next-line @typescript-eslint/no-unused-vars let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let tagListRef = useRef(null); @@ -201,12 +203,10 @@ export interface TagProps extends RenderProps, LinkDOMProps, Hov isDisabled?: boolean } -class TagItemNode extends ItemNode {} - /** * A Tag is an individual item within a TagList. */ -export const Tag = /*#__PURE__*/ createLeafComponent(TagItemNode, (props: TagProps, forwardedRef: ForwardedRef, item: Node) => { +export const Tag = /*#__PURE__*/ createLeafComponent(ItemNode, (props: TagProps, forwardedRef: ForwardedRef, item: Node) => { let state = useContext(ListStateContext)!; let ref = useObjectRef(forwardedRef); let {focusProps, isFocusVisible} = useFocusRing({within: false}); diff --git a/packages/react-aria-components/src/TextField.tsx b/packages/react-aria-components/src/TextField.tsx index 1f80249ca5a..3899bf22b07 100644 --- a/packages/react-aria-components/src/TextField.tsx +++ b/packages/react-aria-components/src/TextField.tsx @@ -14,7 +14,8 @@ import {AriaTextFieldProps, useTextField} from 'react-aria'; import {ContextValue, DOMProps, 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 './context'; +import {filterDOMProps} from '@react-aria/utils'; import {FormContext} from './Form'; import {GlobalDOMAttributes} from '@react-types/shared'; import {GroupContext} from './Group'; @@ -61,8 +62,8 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel [props, ref] = useContextProps(props, ref, TextFieldContext); let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; - let inputRef = useRef(null); - let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); + let inputRef = useRef(null); + [props, inputRef as unknown] = 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 { static readonly type = 'content'; - - constructor(key: Key) { - super(TreeContentNode.type, key); - } } export const TreeItemContent = /*#__PURE__*/ createLeafComponent(TreeContentNode, function TreeItemContent(props: TreeItemContentProps) { @@ -493,10 +489,6 @@ export interface TreeItemProps extends StyleRenderProps { static readonly type = 'item'; - - constructor(key: Key) { - super(TreeItemNode.type, key); - } } /** @@ -735,15 +727,7 @@ export interface TreeLoadMoreItemProps extends Omit { - static readonly type = 'loader'; - - constructor(key: Key) { - super(TreeLoaderNode.type, key); - } -} - -export const TreeLoadMoreItem = createLeafComponent(TreeLoaderNode, function TreeLoadingSentinel(props: TreeLoadMoreItemProps, ref: ForwardedRef, item: Node) { +export const TreeLoadMoreItem = createLeafComponent(LoaderNode, function TreeLoadingSentinel(props: TreeLoadMoreItemProps, ref: ForwardedRef, item: Node) { let {isVirtualized} = useContext(CollectionRendererContext); let state = useContext(TreeStateContext)!; let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/context.tsx b/packages/react-aria-components/src/context.tsx new file mode 100644 index 00000000000..4ce7a97be30 --- /dev/null +++ b/packages/react-aria-components/src/context.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaLabelingProps, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, ValueBase} from '@react-types/shared'; +import {AriaTextFieldProps} from '@react-aria/textfield'; +import {ContextValue} from './utils'; +import {createContext} from 'react'; + +export interface SelectableCollectionContextValue 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 +} + +interface FieldInputContextValue extends + DOMProps, + FocusEvents, + KeyboardEvents, + Pick, 'onChange' | 'value'>, + Pick {} + +export const SelectableCollectionContext = createContext, HTMLElement>>(null); +export const FieldInputContext = createContext>(null); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 7dadbbeac0f..e0384baca75 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -60,7 +60,7 @@ export {ProgressBar, ProgressBarContext} from './ProgressBar'; export {RadioGroup, Radio, RadioGroupContext, RadioContext, RadioGroupStateContext} from './RadioGroup'; export {SearchField, SearchFieldContext} from './SearchField'; export {Select, SelectValue, SelectContext, SelectValueContext, SelectStateContext} from './Select'; -export {Separator, SeparatorContext, SeparatorNode} from './Separator'; +export {Separator, SeparatorContext} from './Separator'; export {Slider, SliderOutput, SliderTrack, SliderThumb, SliderContext, SliderOutputContext, SliderTrackContext, SliderStateContext} from './Slider'; export {Switch, SwitchContext} from './Switch'; export {TableLoadMoreItem, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext} from './Table'; diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index a6500c4e152..4433cfe3321 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'; @@ -31,7 +31,8 @@ export default { args: { onAction: action('onAction'), selectionMode: 'multiple', - escapeKeyBehavior: 'clearSelection' + escapeKeyBehavior: 'clearSelection', + disableVirtualFocus: false }, argTypes: { onAction: { @@ -127,7 +128,7 @@ function AutocompleteWrapper(props) { export const AutocompleteExample: AutocompleteStory = { render: (args) => { return ( - +
@@ -145,7 +146,7 @@ export const AutocompleteExample: AutocompleteStory = { export const AutocompleteSearchfield: AutocompleteStory = { render: (args) => { return ( - +
@@ -305,7 +306,7 @@ export const AutocompleteMenuDynamic: AutocompleteStory = { return ( <> - +
@@ -327,7 +328,7 @@ export const AutocompleteMenuDynamic: AutocompleteStory = { export const AutocompleteOnActionOnMenuItems: AutocompleteStory = { render: (args) => { return ( - +
@@ -356,7 +357,7 @@ let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, export const AutocompleteDisabledKeys: AutocompleteStory = { render: (args) => { return ( - +
@@ -396,14 +397,14 @@ const AsyncExample = (args: any): React.ReactElement => { }; } }); - let {onSelectionChange, selectionMode, includeLoadState, escapeKeyBehavior} = args; + let {onSelectionChange, selectionMode, includeLoadState, escapeKeyBehavior, disableVirtualFocus} = args; let renderEmptyState; if (includeLoadState) { renderEmptyState = list.isLoading ? () => 'Loading' : () => 'No results found.'; } return ( - +
@@ -441,7 +442,7 @@ const CaseSensitiveFilter = (args) => { let defaultFilter = (itemText, input) => contains(itemText, input); return ( - filter={defaultFilter}> + filter={defaultFilter} disableVirtualFocus={args.disableVirtualFocus}>
@@ -480,7 +481,7 @@ export const AutocompleteWithListbox: AutocompleteStory = { height: 250 }}> {() => ( - +
@@ -557,7 +558,7 @@ export const AutocompleteWithVirtualizedListbox: AutocompleteStory = { height: 250 }}> {() => ( - +
@@ -904,7 +905,7 @@ export const AutocompleteWithAsyncListBox = (args) => { }); return ( - +
@@ -1111,7 +1112,7 @@ function AutocompleteNodeFiltering(args) { }; return ( - filter={filter}> + filter={filter} disableVirtualFocus={args.disableVirtualFocus}>
@@ -1135,3 +1136,66 @@ export const AutocompletePreserveFirstSectionStory: AutocompleteStory = { } } }; + + +let names = [ + {id: 1, name: 'David'}, + {id: 2, name: 'Sam'}, + {id: 3, name: 'Julia'} +]; + +const UserCustomFiltering = (args): React.ReactElement => { + let [value, setValue] = useState(''); + + let {contains} = useFilter({sensitivity: 'base'}); + + + let filter = (textValue, inputValue) => { + let index = inputValue.lastIndexOf('@'); + let filterText = ''; + if (index > -1) { + filterText = value.slice(index + 1); + } + + return contains(textValue, filterText); + }; + + let onAction = (key) => { + let index = value.lastIndexOf('@'); + if (index === -1) { + index = value.length; + } + let name = names.find(person => person.id === key)!.name; + setValue(value.slice(0, index).concat(name)); + }; + + return ( + +
+ + +