diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 1c449ba292c..9a52f592130 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared'; +import {AriaLabelingProps, BaseEvent, DOMProps, 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, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore @@ -27,12 +27,14 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps { /** Whether typeahead is disabled. */ disallowTypeAhead: boolean } -export interface AriaAutocompleteProps extends AutocompleteProps { + +// 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. * Include this if the items you are providing to your wrapped collection aren't filtered by default. */ - filter?: (textValue: string, inputValue: string) => boolean, + filter?: (textValue: string, inputValue: string, node: Node) => boolean, /** * Whether or not to focus the first item in the collection after a filter is performed. @@ -41,14 +43,14 @@ export interface AriaAutocompleteProps extends AutocompleteProps { disableAutoFocusFirst?: boolean } -export interface AriaAutocompleteOptions extends Omit { +export interface AriaAutocompleteOptions extends Omit, 'children'> { /** The ref for the wrapped collection element. */ inputRef: RefObject, /** The ref for the wrapped collection element. */ collectionRef: RefObject } -export interface AutocompleteAria { +export interface AutocompleteAria { /** Props for the autocomplete textfield/searchfield element. These should be passed to the textfield/searchfield aria hooks respectively. */ textFieldProps: AriaTextFieldProps, /** Props for the collection, to be passed to collection's respective aria hook (e.g. useMenu). */ @@ -56,7 +58,7 @@ export interface AutocompleteAria { /** Ref to attach to the wrapped collection. */ collectionRef: RefObject, /** A filter function that returns if the provided collection node should be filtered out of the collection. */ - filter?: (nodeTextValue: string) => boolean + filter?: (nodeTextValue: string, node: Node) => boolean } /** @@ -65,7 +67,7 @@ export interface AutocompleteAria { * @param props - Props for the autocomplete. * @param state - State for the autocomplete, as returned by `useAutocompleteState`. */ -export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { +export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { inputRef, collectionRef, @@ -73,7 +75,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl disableAutoFocusFirst = false } = props; - let collectionId = useId(); + let collectionId = useSlotId(); let timeout = useRef | undefined>(undefined); let delayNextActiveDescendant = useRef(false); let queuedActiveDescendant = useRef(null); @@ -316,9 +318,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl 'aria-label': stringFormatter.format('collectionLabel') }); - let filterFn = useCallback((nodeTextValue: string) => { + let filterFn = useCallback((nodeTextValue: string, node: Node) => { if (filter) { - return filter(nodeTextValue, state.inputValue); + return filter(nodeTextValue, state.inputValue, node); } return true; @@ -352,13 +354,19 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl } }; - return { - textFieldProps: { - value: state.inputValue, - onChange, + // 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; + + if (collectionId) { + textFieldProps = { + ...textFieldProps, onKeyDown, autoComplete: 'off', - 'aria-haspopup': 'listbox', + 'aria-haspopup': collectionId ? 'listbox' : undefined, 'aria-controls': collectionId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', @@ -370,7 +378,11 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl enterKeyHint: 'go', onBlur, onFocus - }, + }; + } + + return { + textFieldProps, collectionProps: mergeProps(collectionProps, { shouldUseVirtualFocus, disallowTypeAhead: true diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 1f6cd64dacb..1c2cc4eb645 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -17,6 +17,8 @@ export type Mutable = { -readonly[P in keyof T]: T[P] } +type FilterFn = (textValue: string, node: Node) => boolean; + /** An immutable object representing a Node in a Collection. */ export class CollectionNode implements Node { readonly type: string; @@ -65,8 +67,64 @@ export class CollectionNode implements Node { node.render = this.render; node.colSpan = this.colSpan; node.colIndex = this.colIndex; + node.filter = this.filter; return node; } + + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): CollectionNode | null { + let [firstKey, lastKey] = filterChildren(collection, newCollection, this.firstChildKey, filterFn); + let newNode: Mutable> = this.clone(); + newNode.firstChildKey = firstKey; + newNode.lastChildKey = lastKey; + return newNode; + } +} + +// TODO: naming, but essentially these nodes shouldn't be affected by filtering (BaseNode)? +// Perhaps this filter logic should be in CollectionNode instead and the current logic of CollectionNode's filter should move to Table +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(); + } +} + +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(); + } + + return null; + } +} + +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) { + if (filteredSection.lastChildKey !== null) { + let lastChild = collection.getItem(filteredSection.lastChildKey); + if (lastChild && lastChild.type !== 'header') { + return filteredSection; + } + } + } + + return null; + } } /** @@ -224,134 +282,65 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - // TODO: this is pretty specific to menu, will need to check if it is generic enough - // Will need to handle varying levels I assume but will revisit after I get searchable menu working for base menu - // TODO: an alternative is to simply walk the collection and add all item nodes that match the filter and any sections/separators we encounter - // to an array, then walk that new array and fix all the next/Prev keys while adding them to the new collection - UNSTABLE_filter(filterFn: (nodeValue: string) => boolean): BaseCollection { - let newCollection = new BaseCollection(); - // This tracks the absolute last node we've visited in the collection when filtering, used for setting up the filteredCollection's lastKey and - // for updating the next/prevKey for every non-filtered node. - let lastNode: Mutable> | null = null; - - for (let node of this) { - if (node.type === 'section' && node.hasChildNodes) { - let clonedSection: Mutable> = (node as CollectionNode).clone(); - let lastChildInSection: Mutable> | null = null; - for (let child of this.getChildren(node.key)) { - if (shouldKeepNode(child, filterFn, this, newCollection)) { - let clonedChild: Mutable> = (child as CollectionNode).clone(); - // eslint-disable-next-line max-depth - if (lastChildInSection == null) { - clonedSection.firstChildKey = clonedChild.key; - } - - // eslint-disable-next-line max-depth - if (newCollection.firstKey == null) { - newCollection.firstKey = clonedSection.key; - } - - // eslint-disable-next-line max-depth - if (lastChildInSection && lastChildInSection.parentKey === clonedChild.parentKey) { - lastChildInSection.nextKey = clonedChild.key; - clonedChild.prevKey = lastChildInSection.key; - } else { - clonedChild.prevKey = null; - } - - clonedChild.nextKey = null; - newCollection.addNode(clonedChild); - lastChildInSection = clonedChild; - } - } + filter(filterFn: FilterFn, newCollection?: BaseCollection): BaseCollection { + if (newCollection == null) { + newCollection = new BaseCollection(); + } - // Add newly filtered section to collection if it has any valid child nodes, otherwise remove it and its header if any - if (lastChildInSection) { - if (lastChildInSection.type !== 'header') { - clonedSection.lastChildKey = lastChildInSection.key; - - // If the old prev section was filtered out, will need to attach to whatever came before - // eslint-disable-next-line max-depth - if (lastNode == null) { - clonedSection.prevKey = null; - } else if (lastNode.type === 'section' || lastNode.type === 'separator') { - lastNode.nextKey = clonedSection.key; - clonedSection.prevKey = lastNode.key; - } - clonedSection.nextKey = null; - lastNode = clonedSection; - newCollection.addNode(clonedSection); - } else { - if (newCollection.firstKey === clonedSection.key) { - newCollection.firstKey = null; - } - newCollection.removeNode(lastChildInSection.key); - } - } - } else if (node.type === 'separator') { - // will need to check if previous section key exists, if it does then we add the separator to the collection. - // After the full collection is created we'll need to remove it it is the last node in the section (aka no following section after the separator) - let clonedSeparator: Mutable> = (node as CollectionNode).clone(); - clonedSeparator.nextKey = null; - if (lastNode?.type === 'section') { - lastNode.nextKey = clonedSeparator.key; - clonedSeparator.prevKey = lastNode.key; - lastNode = clonedSeparator; - newCollection.addNode(clonedSeparator); - } - } else { - // At this point, the node is either a subdialogtrigger node or a standard row/item - let clonedNode: Mutable> = (node as CollectionNode).clone(); - if (shouldKeepNode(clonedNode, filterFn, this, newCollection)) { - if (newCollection.firstKey == null) { - newCollection.firstKey = clonedNode.key; - } - - if (lastNode != null && (lastNode.type !== 'section' && lastNode.type !== 'separator') && lastNode.parentKey === clonedNode.parentKey) { - lastNode.nextKey = clonedNode.key; - clonedNode.prevKey = lastNode.key; - } else { - clonedNode.prevKey = null; - } - - clonedNode.nextKey = null; - newCollection.addNode(clonedNode); - lastNode = clonedNode; - } + let [firstKey, lastKey] = filterChildren(this, newCollection, this.firstKey, filterFn); + newCollection.firstKey = firstKey; + newCollection.lastKey = lastKey; + return newCollection; + } +} + +function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: FilterFn): [Key | null, Key | null] { + // loop over the siblings for firstChildKey + // create new nodes based on calling node.filter for each child + // if it returns null then don't include it, otherwise update its prev/next keys + // add them to the newCollection + if (firstChildKey == null) { + return [null, null]; + } + + let firstNode: Node | null = null; + let lastNode: Node | null = null; + let currentNode = collection.getItem(firstChildKey); + + while (currentNode != null) { + let newNode: Mutable> | null = (currentNode as CollectionNode).filter(collection, newCollection, filterFn); + if (newNode != null) { + newNode.nextKey = null; + if (lastNode) { + newNode.prevKey = lastNode.key; + lastNode.nextKey = newNode.key; } - } - if (lastNode?.type === 'separator' && lastNode.nextKey === null) { - let lastSection; - if (lastNode.prevKey != null) { - lastSection = newCollection.getItem(lastNode.prevKey) as Mutable>; - lastSection.nextKey = null; + if (firstNode == null) { + firstNode = newNode; } - newCollection.removeNode(lastNode.key); - lastNode = lastSection; - } - newCollection.lastKey = lastNode?.key || null; + newCollection.addNode(newNode); + lastNode = newNode; + } - return newCollection; + currentNode = currentNode.nextKey ? collection.getItem(currentNode.nextKey) : null; } -} -function shouldKeepNode(node: Node, filterFn: (nodeValue: string) => boolean, oldCollection: BaseCollection, newCollection: BaseCollection): boolean { - if (node.type === 'subdialogtrigger' || node.type === 'submenutrigger') { - // Subdialog wrapper should only have one child, if it passes the filter add it to the new collection since we don't need to - // do any extra handling for its first/next key - let triggerChild = [...oldCollection.getChildren(node.key)][0]; - if (triggerChild && filterFn(triggerChild.textValue)) { - let clonedChild: Mutable> = (triggerChild as CollectionNode).clone(); - newCollection.addNode(clonedChild); - return true; + // TODO: this is pretty specific to dividers but doesn't feel like there is a good way to get around it since we only can know + // to filter the last separator in a collection only after performing a filter for the rest of the contents after it + // Its gross that it needs to live here, might be nice if somehow we could have this live in the separator code + if (lastNode && lastNode.type === 'separator') { + let prevKey = lastNode.prevKey; + newCollection.removeNode(lastNode.key); + + if (prevKey) { + lastNode = newCollection.getItem(prevKey) as Mutable>; + lastNode.nextKey = null; } else { - return false; + lastNode = null; } - } else if (node.type === 'header') { - return true; - } else { - return filterFn(node.textValue); } + + return [firstNode?.key ?? null, lastNode?.key ?? null]; } diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 9ce77767063..52c4f79e0a8 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -10,12 +10,12 @@ * governing permissions and limitations under the License. */ -import {BaseCollection} from './BaseCollection'; +import {BaseCollection, CollectionNode} from './BaseCollection'; import {BaseNode, Document, ElementNode} from './Document'; import {CachedChildrenOptions, useCachedChildren} from './useCachedChildren'; import {createPortal} from 'react-dom'; import {FocusableContext} from '@react-aria/interactions'; -import {forwardRefType, Node} from '@react-types/shared'; +import {forwardRefType, Key, Node} from '@react-types/shared'; import {Hidden} from './Hidden'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -127,22 +127,40 @@ function useCollectionDocument>(cr const SSRContext = createContext | null>(null); -function useSSRCollectionNode(Type: string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { +export type CollectionNodeClass = { + new (key: Key): CollectionNode, + readonly type: string +}; + +function createCollectionNodeClass(type: string): CollectionNodeClass { + let NodeClass = function (key: Key) { + return new CollectionNode(type, key); + } as any; + NodeClass.type = type; + return NodeClass; +} + +function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass | string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { + // To prevent breaking change, if CollectionNodeClass is a string, create a CollectionNodeClass using the string as the type + if (typeof CollectionNodeClass === 'string') { + CollectionNodeClass = createCollectionNodeClass(CollectionNodeClass); + } + // During SSR, portals are not supported, so the collection children will be wrapped in an SSRContext. // Since SSR occurs only once, we assume that the elements are rendered in order and never re-render. // Therefore we can create elements in our collection document during render so that they are in the // collection by the time we need to use the collection to render to the real DOM. // After hydration, we switch to client rendering using the portal. let itemRef = useCallback((element: ElementNode | null) => { - element?.setProps(props, ref, rendered, render); - }, [props, ref, rendered, render]); + element?.setProps(props, ref, CollectionNodeClass, rendered, render); + }, [props, ref, rendered, render, CollectionNodeClass]); let parentNode = useContext(SSRContext); if (parentNode) { // Guard against double rendering in strict mode. let element = parentNode.ownerDocument.nodesByProps.get(props); if (!element) { - element = parentNode.ownerDocument.createElement(Type); - element.setProps(props, ref, rendered, render); + element = parentNode.ownerDocument.createElement(CollectionNodeClass.type); + element.setProps(props, ref, CollectionNodeClass, rendered, render); parentNode.appendChild(element); parentNode.ownerDocument.updateCollection(); parentNode.ownerDocument.nodesByProps.set(props, element); @@ -154,13 +172,13 @@ function useSSRCollectionNode(Type: string, props: object, re } // @ts-ignore - return {children}; + // TODO: could just make this a div perhaps, but keep it in line with how it used to work + return {children}; } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent

(type: string, render: (props: P, ref: ForwardedRef, node?: any) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null { +export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent

(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node?: any) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null { let Component = ({node}) => render(node.props, node.props.ref, node); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let focusableProps = useContext(FocusableContext); @@ -173,7 +191,7 @@ export function createLeafComponent

(type: s } return useSSRCollectionNode( - type, + CollectionNodeClass, props, ref, 'children' in props ? props.children : null, @@ -191,11 +209,11 @@ export function createLeafComponent

(type: s return Result; } -export function createBranchComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null, useChildren: (props: P) => ReactNode = useCollectionChildren): (props: P & React.RefAttributes) => ReactElement | null { +export function createBranchComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null, useChildren: (props: P) => ReactNode = useCollectionChildren): (props: P & React.RefAttributes) => ReactElement | null { let Component = ({node}) => render(node.props, node.props.ref, node); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let children = useChildren(props); - return useSSRCollectionNode(type, props, ref, null, children, node => ) ?? <>; + return useSSRCollectionNode(CollectionNodeClass, props, ref, null, children, node => ) ?? <>; }); // @ts-ignore Result.displayName = render.name; diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 561b2f9d5ce..bcb6362c5af 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -11,6 +11,7 @@ */ import {BaseCollection, CollectionNode, Mutable} from './BaseCollection'; +import {CollectionNodeClass} from './CollectionBuilder'; import {CSSProperties, ForwardedRef, ReactElement, ReactNode} from 'react'; import {Node} from '@react-types/shared'; @@ -256,7 +257,7 @@ export class BaseNode { */ export class ElementNode extends BaseNode { nodeType = 8; // COMMENT_NODE (we'd use ELEMENT_NODE but React DevTools will fail to get its dimensions) - node: CollectionNode; + private _node: CollectionNode | null; isMutated = true; private _index: number = 0; hasSetProps = false; @@ -264,7 +265,7 @@ export class ElementNode extends BaseNode { constructor(type: string, ownerDocument: Document) { super(ownerDocument); - this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); + this._node = null; } get index(): number { @@ -278,24 +279,35 @@ export class ElementNode extends BaseNode { get level(): number { if (this.parentNode instanceof ElementNode) { - return this.parentNode.level + (this.node.type === 'item' ? 1 : 0); + return this.parentNode.level + (this.node?.type === 'item' ? 1 : 0); } 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.'); + } + return this._node; + } + + set node(node: CollectionNode) { + this._node = node; + } + /** * Lazily gets a mutable instance of a Node. If the node has already * been cloned during this update cycle, it just returns the existing one. */ 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 { @@ -303,27 +315,34 @@ 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; } } } - setProps(obj: {[key: string]: any}, ref: ForwardedRef, rendered?: ReactNode, render?: (node: Node) => ReactElement): void { - let node = this.getMutableNode(); + setProps(obj: {[key: string]: any}, ref: ForwardedRef, CollectionNodeClass: CollectionNodeClass, rendered?: ReactNode, render?: (node: Node) => ReactElement): void { + let node; let {value, textValue, id, ...props} = obj; + if (this._node == null) { + node = new CollectionNodeClass(id ?? `react-aria-${++this.ownerDocument.nodeId}`); + this.node = node; + } else { + node = this.getMutableNode(); + } + props.ref = ref; node.props = props; node.rendered = rendered; @@ -331,17 +350,13 @@ export class ElementNode extends BaseNode { node.value = value; node.textValue = textValue || (typeof props.children === 'string' ? props.children : '') || obj['aria-label'] || ''; if (id != null && id !== node.key) { - if (this.hasSetProps) { - throw new Error('Cannot change the id of an item'); - } - node.key = id; + throw new Error('Cannot change the id of an item'); } if (props.colSpan != null) { node.colSpan = props.colSpan; } - this.hasSetProps = true; if (this.isConnected) { this.ownerDocument.queueUpdate(); } @@ -440,13 +455,13 @@ 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); } } - collection.addNode(element.node); + collection.addNode(element.node!); } private removeNode(node: ElementNode): void { @@ -455,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. */ @@ -501,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 5052465351f..38457e56542 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} from './BaseCollection'; +export {BaseCollection, CollectionNode, ItemNode, SectionNode, FilterLessNode} 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 99e06728c9c..395eefa9fb7 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -1,8 +1,24 @@ -import {Collection, CollectionBuilder, createLeafComponent} from '../src'; +import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent} from '../src'; import React from 'react'; import {render} from '@testing-library/react'; -const Item = createLeafComponent('item', () => { +class ItemNode extends CollectionNode { + static type = 'item'; + + constructor(key) { + super(ItemNode.type, key); + } +} + +const Item = createLeafComponent(ItemNode, () => { + return

; +}); + +const ItemsOld = createLeafComponent('item', () => { + return
; +}); + +const SectionOld = createBranchComponent('section', () => { return
; }); @@ -15,6 +31,24 @@ const renderItems = (items, spyCollection) => ( ); +const renderItemsOld = (items, spyCollection) => ( + + + {items.map((item) => ( + + ))} + + + }> + {collection => { + spyCollection.current = collection; + return null; + }} + +); + describe('CollectionBuilder', () => { it('should be frozen even in case of empty initial collection', () => { let spyCollection = {}; @@ -30,4 +64,14 @@ describe('CollectionBuilder', () => { expect(spyCollection.current.firstKey).toBe(null); expect(spyCollection.current.lastKey).toBe(null); }); + + it('should still support using strings for the collection node class in createLeafComponent/createBranchComponent', () => { + let spyCollection = {}; + render(renderItemsOld(['a'], spyCollection)); + expect(spyCollection.current.frozen).toBe(true); + expect(spyCollection.current.firstKey).toBe('react-aria-2'); + expect(spyCollection.current.keyMap.get('react-aria-2').type).toBe('section'); + expect(spyCollection.current.keyMap.get('react-aria-2').firstChildKey).toBe('react-aria-1'); + expect(spyCollection.current.keyMap.get('react-aria-1').type).toBe('item'); + }); }); diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index f2ebd0b2526..24ad6d16da1 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -30,6 +30,7 @@ import { ListStateContext, Provider, SectionProps, + SeparatorNode, Virtualizer } from 'react-aria-components'; import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; @@ -699,7 +700,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps, node: Node) { +export const Divider = /*#__PURE__*/ createLeafComponent(SeparatorNode, function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef, node: Node) { let listState = useContext(ListStateContext)!; let nextNode = node.nextKey != null && listState.collection.getItem(node.nextKey); diff --git a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx index a3e3ada3381..cc75a2fe81a 100644 --- a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx +++ b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {createLeafComponent} from '@react-aria/collections'; +import {createLeafComponent, FilterLessNode} from '@react-aria/collections'; +import {Key} from '@react-types/shared'; import {ReactNode} from 'react'; import {Skeleton} from './Skeleton'; @@ -20,10 +21,18 @@ export interface SkeletonCollectionProps { let cache = new WeakMap(); +class SkeletonNode extends FilterLessNode { + static readonly type = 'skeleton'; + + constructor(key: Key) { + super(SkeletonNode.type, key); + } +} + /** * A SkeletonCollection generates placeholder content within a collection component such as CardView. */ -export const SkeletonCollection = createLeafComponent('skeleton', (props: SkeletonCollectionProps, ref, node) => { +export const SkeletonCollection = createLeafComponent(SkeletonNode, (props: SkeletonCollectionProps, ref, node) => { // Cache rendering based on node object identity. This allows the children function to randomize // its content (e.g. heights) and preserve on re-renders. // TODO: do we need a `dependencies` prop here? diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts index 15813f1dee4..c373633426e 100644 --- a/packages/@react-stately/list/src/useListState.ts +++ b/packages/@react-stately/list/src/useListState.ts @@ -73,8 +73,8 @@ export function useListState(props: ListProps): ListState(state: ListState, filter: ((nodeValue: string) => boolean) | null | undefined): ListState { - let collection = useMemo(() => filter ? state.collection.UNSTABLE_filter!(filter) : state.collection, [state.collection, filter]); +export function UNSTABLE_useFilteredListState(state: ListState, filterFn: ((nodeValue: string, node: Node) => boolean) | null | undefined): ListState { + let collection = useMemo(() => filterFn ? state.collection.filter!(filterFn) : state.collection, [state.collection, filterFn]); let selectionManager = state.selectionManager.withCollection(collection); useFocusedKeyReset(collection, selectionManager); return { diff --git a/packages/@react-stately/table/src/index.ts b/packages/@react-stately/table/src/index.ts index 3bc9b63806f..eb8a04ef4f7 100644 --- a/packages/@react-stately/table/src/index.ts +++ b/packages/@react-stately/table/src/index.ts @@ -16,7 +16,7 @@ export type {TableHeaderProps, TableBodyProps, ColumnProps, RowProps, CellProps} export type {TreeGridState, TreeGridStateProps} from './useTreeGridState'; export {useTableColumnResizeState} from './useTableColumnResizeState'; -export {useTableState} from './useTableState'; +export {useTableState, UNSTABLE_useFilteredTableState} from './useTableState'; export {TableHeader} from './TableHeader'; export {TableBody} from './TableBody'; export {Column} from './Column'; diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 0addeb10a35..d18d173161f 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -107,3 +107,18 @@ export function useTableState(props: TableStateProps): Tabl } }; } + +/** + * Filters a collection using the provided filter function and returns a new TableState. + */ +export function UNSTABLE_useFilteredTableState(state: TableState, filterFn: ((nodeValue: string, node: Node) => boolean) | null | undefined): TableState { + let collection = useMemo(() => filterFn ? state.collection.filter!(filterFn) : state.collection, [state.collection, filterFn]) as ITableCollection; + let selectionManager = state.selectionManager.withCollection(collection); + // TODO: handle focus key reset? That logic is in useGridState + + return { + ...state, + collection, + selectionManager + }; +} diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index dfdc0f23bff..a653dfc9768 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -183,7 +183,7 @@ export interface Collection extends Iterable { getTextValue?(key: Key): string, /** Filters the collection using the given function. */ - UNSTABLE_filter?(filterFn: (nodeValue: string) => boolean): Collection + filter?(filterFn: (nodeValue: string, node: T) => boolean): Collection } export interface Node { diff --git a/packages/dev/s2-docs/src/SearchMenu.tsx b/packages/dev/s2-docs/src/SearchMenu.tsx index d7957b03f44..8a47421189e 100644 --- a/packages/dev/s2-docs/src/SearchMenu.tsx +++ b/packages/dev/s2-docs/src/SearchMenu.tsx @@ -125,7 +125,7 @@ const getCurrentLibrary = (currentPage: Page) => { export default function SearchMenu(props: SearchMenuProps) { let {pages, currentPage, toggleShowSearchMenu, closeSearchMenu, isSearchOpen} = props; - + const currentLibrary = getCurrentLibrary(currentPage); let [selectedLibrary, setSelectedLibrary] = useState<'react-spectrum' | 'react-aria' | 'internationalized'>(currentLibrary); let [searchValue, setSearchValue] = useState(''); @@ -140,14 +140,14 @@ export default function SearchMenu(props: SearchMenuProps) { }, { id: 'react-aria', - label: 'React Aria', + label: 'React Aria', description: 'Style-free components and hooks for building accessible UIs', icon: }, { id: 'internationalized', label: 'Internationalized', - description: 'Framework-agnostic internationalization utilities', + description: 'Framework-agnostic internationalization utilities', icon: } ]; @@ -158,7 +158,7 @@ export default function SearchMenu(props: SearchMenuProps) { const currentTab = allTabs.splice(currentTabIndex, 1)[0]; allTabs.unshift(currentTab); } - + return allTabs; }; @@ -184,13 +184,13 @@ export default function SearchMenu(props: SearchMenuProps) { } else if (page.url.includes('react-internationalized')) { library = 'internationalized'; } - + return library === selectedLibrary; }) .map(page => { const name = page.url.replace(/^\//, '').replace(/\.html$/, ''); const title = page.tableOfContents?.[0]?.title || name; - + return { id: name, name: title, @@ -257,7 +257,7 @@ export default function SearchMenu(props: SearchMenuProps) { let {contains} = useFilter({sensitivity: 'base'}); - let filter: AutocompleteProps['filter'] = (textValue, inputValue) => { + let filter: AutocompleteProps['filter'] = (textValue, inputValue) => { return textValue != null && contains(textValue, inputValue); }; @@ -294,7 +294,7 @@ export default function SearchMenu(props: SearchMenuProps) { return (
| null>, showCards: boolean, renderCardList: () => React.ReactNode, - filter?: AutocompleteProps['filter'], + filter?: AutocompleteProps['filter'], noResultsText?: (value: string) => string, closeSearchMenu: () => void, isPrimary?: boolean @@ -48,7 +48,7 @@ function CloseButton({closeSearchMenu}: {closeSearchMenu: () => void}) { -
+
); } diff --git a/packages/react-aria-components/docs/Autocomplete.mdx b/packages/react-aria-components/docs/Autocomplete.mdx index b793dacc64e..f52f6fe5dca 100644 --- a/packages/react-aria-components/docs/Autocomplete.mdx +++ b/packages/react-aria-components/docs/Autocomplete.mdx @@ -217,7 +217,7 @@ import type {AutocompleteProps, Key} from 'react-aria-components'; import {Menu, MenuItem} from 'react-aria-components'; import {MySearchField} from './SearchField'; -interface MyAutocompleteProps extends Omit { +interface MyAutocompleteProps extends Omit, 'children'> { label?: string, placeholder?: string, items?: Iterable; diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 451598e5f40..05e40294c0c 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -14,29 +14,30 @@ import {AriaAutocompleteProps, CollectionOptions, useAutocomplete} from '@react- 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'; import {TextFieldContext} from './TextField'; -export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} +export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} -interface InternalAutocompleteContextValue { - filter?: (nodeTextValue: string) => boolean, +interface InternalAutocompleteContextValue { + filter?: (nodeTextValue: string, node: Node) => boolean, collectionProps: CollectionOptions, collectionRef: RefObject } -export const AutocompleteContext = createContext>>(null); +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); +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. */ -export function Autocomplete(props: AutocompleteProps): JSX.Element { +export function Autocomplete(props: AutocompleteProps): JSX.Element { let ctx = useSlottedContext(AutocompleteContext, props.slot); props = mergeProps(ctx, props); let {filter, disableAutoFocusFirst} = props; @@ -64,7 +65,7 @@ export function Autocomplete(props: AutocompleteProps): JSX.Element { [TextFieldContext, textFieldProps], [InputContext, {ref: inputRef}], [UNSTABLE_InternalAutocompleteContext, { - filter: filterFn, + filter: filterFn as (nodeTextValue: string, node: Node) => boolean, collectionProps, collectionRef: mergedCollectionRef }] diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 832a5121158..92ad111b572 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import {AriaBreadcrumbsProps, useBreadcrumbs} from 'react-aria'; -import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createLeafComponent, FilterLessNode} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext} from './Collection'; import {ContextValue, RenderProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; @@ -73,10 +73,18 @@ export interface BreadcrumbProps extends RenderProps, Glo id?: Key } +class BreadcrumbNode extends FilterLessNode { + static readonly type = 'item'; + + constructor(key: Key) { + super(BreadcrumbNode.type, key); + } +} + /** * A Breadcrumb represents an individual item in a `` list. */ -export const Breadcrumb = /*#__PURE__*/ createLeafComponent('item', function Breadcrumb(props: BreadcrumbProps, ref: ForwardedRef, node: Node) { +export const Breadcrumb = /*#__PURE__*/ createLeafComponent(BreadcrumbNode, function Breadcrumb(props: BreadcrumbProps, ref: ForwardedRef, node: Node) { // Recreating useBreadcrumbItem because we want to use composition instead of having the link builtin. let isCurrent = node.nextKey == null; let {isDisabled, onAction} = useSlottedContext(BreadcrumbsContext)!; diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index ed1bc25174b..b48e655195b 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -99,6 +99,7 @@ interface SectionContextValue { export const SectionContext = createContext(null); +// TODO: should I update this since it is deprecated? /** @deprecated */ export const Section = /*#__PURE__*/ createBranchComponent('section', (props: SectionProps, ref: ForwardedRef, section: Node): JSX.Element => { let {name, render} = useContext(SectionContext)!; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 9cb34f49795..c47ca47edc8 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -12,17 +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} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createLeafComponent, FilterLessNode, ItemNode} 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, useListState} from 'react-stately'; +import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; 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 { /** @@ -103,21 +104,27 @@ 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) || {}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props; let {CollectionRoot, isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate} = useContext(CollectionRendererContext); - let state = useListState({ + let gridlistState = useListState({ ...props, collection, children: undefined, layoutDelegate }); + let filteredState = UNSTABLE_useFilteredListState(gridlistState, filter); let collator = useCollator({usage: 'search', sensitivity: 'base'}); - let {disabledBehavior, disabledKeys} = state.selectionManager; + let {disabledBehavior, disabledKeys} = filteredState.selectionManager; let {direction} = useLocale(); let keyboardDelegate = useMemo(() => ( new ListKeyboardDelegate({ - collection, + collection: filteredState.collection, collator, ref, disabledKeys, @@ -126,18 +133,19 @@ function GridListInner({props, collection, gridListRef: ref}: layout, direction }) - ), [collection, ref, layout, disabledKeys, disabledBehavior, layoutDelegate, collator, direction]); + ), [filteredState.collection, ref, layout, disabledKeys, disabledBehavior, layoutDelegate, collator, direction]); let {gridProps} = useGridList({ ...props, + ...DOMCollectionProps, keyboardDelegate, // Only tab navigation is supported in grid layout. keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior, isVirtualized, shouldSelectOnPressUp: props.shouldSelectOnPressUp - }, state, ref); + }, filteredState, ref); - let selectionManager = state.selectionManager; + let selectionManager = filteredState.selectionManager; let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState; let isListDroppable = !!dragAndDropHooks?.useDroppableCollectionState; let dragHooksProvided = useRef(isListDraggable); @@ -163,7 +171,7 @@ function GridListInner({props, collection, gridListRef: ref}: if (isListDraggable && dragAndDropHooks) { dragState = dragAndDropHooks.useDraggableCollectionState!({ - collection, + collection: filteredState.collection, selectionManager, preview: dragAndDropHooks.renderDragPreview ? preview : undefined }); @@ -177,12 +185,12 @@ function GridListInner({props, collection, gridListRef: ref}: if (isListDroppable && dragAndDropHooks) { dropState = dragAndDropHooks.useDroppableCollectionState!({ - collection, + collection: filteredState.collection, selectionManager }); let keyboardDelegate = new ListKeyboardDelegate({ - collection, + collection: filteredState.collection, disabledKeys: selectionManager.disabledKeys, disabledBehavior: selectionManager.disabledBehavior, ref @@ -197,14 +205,14 @@ function GridListInner({props, collection, gridListRef: ref}: } let {focusProps, isFocused, isFocusVisible} = useFocusRing(); - let isEmpty = state.collection.size === 0; + let isEmpty = filteredState.collection.size === 0; let renderValues = { isDropTarget: isRootDropTarget, isEmpty, isFocused, isFocusVisible, layout, - state + state: filteredState }; let renderProps = useRenderProps({ className: props.className, @@ -243,13 +251,13 @@ function GridListInner({props, collection, gridListRef: ref}: data-layout={layout}> {isListDroppable && } @@ -279,10 +287,12 @@ export interface GridListItemProps extends RenderProps void } +class GridListNode extends ItemNode {} + /** * A GridListItem represents an individual item in a GridList. */ -export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function GridListItem(props: GridListItemProps, forwardedRef: ForwardedRef, item: Node) { +export const GridListItem = /*#__PURE__*/ createLeafComponent(GridListNode, function GridListItem(props: GridListItemProps, forwardedRef: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); let ref = useObjectRef(forwardedRef); @@ -513,7 +523,16 @@ export interface GridListLoadMoreItemProps extends Omit, item: Node) { +// TODO: maybe make a general loader node +class GridListLoaderNode extends FilterLessNode { + static readonly type = 'loader'; + + constructor(key: Key) { + super(GridListLoaderNode.type, key); + } +} + +export const GridListLoadMoreItem = createLeafComponent(GridListLoaderNode, 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 2c855e941ee..d76d9df57d1 100644 --- a/packages/react-aria-components/src/Header.tsx +++ b/packages/react-aria-components/src/Header.tsx @@ -11,12 +11,21 @@ */ import {ContextValue, useContextProps} from './utils'; -import {createLeafComponent} from '@react-aria/collections'; +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>>({}); -export const Header = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes, ref: ForwardedRef) { +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) { [props, ref] = useContextProps(props, ref, HeaderContext); return (
diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 01a9862f1c0..e923ece946e 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -11,7 +11,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} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent, FilterLessNode, ItemNode, 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'; @@ -305,10 +305,12 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re ); } +export class ListBoxSectionNode extends SectionNode {} + /** * A ListBoxSection represents a section within a ListBox. */ -export const ListBoxSection = /*#__PURE__*/ createBranchComponent('section', ListBoxSectionInner); +export const ListBoxSection = /*#__PURE__*/ createBranchComponent(ListBoxSectionNode, ListBoxSectionInner); export interface ListBoxItemRenderProps extends ItemRenderProps {} @@ -330,10 +332,12 @@ export interface ListBoxItemProps extends RenderProps void } +class ListBoxItemNode extends ItemNode {} + /** * A ListBoxItem represents an individual option in a ListBox. */ -export const ListBoxItem = /*#__PURE__*/ createLeafComponent('item', function ListBoxItem(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { +export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ListBoxItemNode, function ListBoxItem(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { let ref = useObjectRef(forwardedRef); let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; @@ -470,6 +474,14 @@ 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 { @@ -483,7 +495,7 @@ export interface ListBoxLoadMoreItemProps extends Omit, item: Node) { +export const ListBoxLoadMoreItem = createLeafComponent(ListBoxLoaderNode, 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 cda7ba63bd1..7068f5032ab 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -11,7 +11,7 @@ */ import {AriaMenuProps, FocusScope, mergeProps, useHover, useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from 'react-aria'; -import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; +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 {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; @@ -107,12 +107,32 @@ export interface SubmenuTriggerProps { const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject, shouldUseVirtualFocus?: boolean} | null>(null); +class SubmenuTriggerNode 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(); + } + + return null; + } +} + /** * A submenu trigger is used to wrap a submenu's trigger item and the submenu itself. * * @version alpha */ -export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigger', (props: SubmenuTriggerProps, ref: ForwardedRef, item) => { +export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent(SubmenuTriggerNode, (props: SubmenuTriggerProps, ref: ForwardedRef, item) => { let {CollectionBranch} = useContext(CollectionRendererContext); let state = useContext(MenuStateContext)!; let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; @@ -185,7 +205,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne 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])); - let filteredCollection = useMemo(() => filter ? collection.UNSTABLE_filter(filter) : collection, [collection, filter]); + let filteredCollection = useMemo(() => filter ? collection.filter(filter) : collection, [collection, filter]); let state = useTreeState({ ...props, collection: filteredCollection as ICollection>, @@ -319,10 +339,12 @@ function MenuSectionInner(props: MenuSectionProps, ref: For ); } +class MenuSectionNode extends SectionNode {} + /** * A MenuSection represents a section within a Menu. */ -export const MenuSection = /*#__PURE__*/ createBranchComponent('section', MenuSectionInner); +export const MenuSection = /*#__PURE__*/ createBranchComponent(MenuSectionNode, MenuSectionInner); export interface MenuItemRenderProps extends ItemRenderProps { /** @@ -356,10 +378,12 @@ export interface MenuItemProps extends RenderProps>(null); +class MenuItemNode extends ItemNode {} + /** * A MenuItem represents an individual action in a Menu. */ -export const MenuItem = /*#__PURE__*/ createLeafComponent('item', function MenuItem(props: MenuItemProps, forwardedRef: ForwardedRef, item: Node) { +export const MenuItem = /*#__PURE__*/ createLeafComponent(MenuItemNode, 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/Separator.tsx b/packages/react-aria-components/src/Separator.tsx index f3b9e7b24f1..a4c4007a1e2 100644 --- a/packages/react-aria-components/src/Separator.tsx +++ b/packages/react-aria-components/src/Separator.tsx @@ -11,17 +11,34 @@ */ import {SeparatorProps as AriaSeparatorProps, useSeparator} from 'react-aria'; +import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {ContextValue, SlotProps, StyleProps, useContextProps} from './utils'; -import {createLeafComponent} from '@react-aria/collections'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; -import {GlobalDOMAttributes} from '@react-types/shared'; +import {GlobalDOMAttributes, Key} from '@react-types/shared'; import React, {createContext, ElementType, ForwardedRef} from 'react'; export interface SeparatorProps extends AriaSeparatorProps, StyleProps, SlotProps, GlobalDOMAttributes {} export const SeparatorContext = createContext>({}); -export const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps, ref: ForwardedRef) { +export class SeparatorNode extends CollectionNode { + 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(); + } + + return null; + } +} + +export const Separator = /*#__PURE__*/ createLeafComponent(SeparatorNode, function Separator(props: SeparatorProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, SeparatorContext); let {elementType, orientation, style, className, slot, ...otherProps} = props; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 18d838e4efc..773fe0e871d 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,12 +1,12 @@ import {AriaLabelingProps, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; -import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, useCachedChildren} from '@react-aria/collections'; +import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, FilterLessNode, useCachedChildren} from '@react-aria/collections'; import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; 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'; -import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, MultipleSelectionState, Node, SelectionBehavior, SelectionMode, SortDirection, TableState, useMultipleSelectionState, useTableColumnResizeState, useTableState} from 'react-stately'; +import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, MultipleSelectionState, Node, SelectionBehavior, SelectionMode, SortDirection, TableState, UNSTABLE_useFilteredTableState, useMultipleSelectionState, useTableColumnResizeState, useTableState} from 'react-stately'; 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'; @@ -16,6 +16,7 @@ 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[] = []; @@ -160,6 +161,7 @@ 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 { @@ -363,23 +371,28 @@ interface TableInnerProps { function TableInner({props, forwardedRef: ref, selectionState, collection}: TableInnerProps) { + let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let tableContainerContext = useContext(ResizableTableContainerContext); ref = useObjectRef(useMemo(() => mergeRefs(ref, tableContainerContext?.tableRef), [ref, tableContainerContext?.tableRef])); - let state = useTableState({ + let tableState = useTableState({ ...props, collection, children: undefined, UNSAFE_selectionState: selectionState }); + let filteredState = UNSTABLE_useFilteredTableState(tableState, filter); let {isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate, CollectionRoot} = useContext(CollectionRendererContext); let {dragAndDropHooks} = props; let {gridProps} = useTable({ ...props, + ...DOMCollectionProps, layoutDelegate, isVirtualized - }, state, ref); - let selectionManager = state.selectionManager; + }, filteredState, ref); + let selectionManager = filteredState.selectionManager; let hasDragHooks = !!dragAndDropHooks?.useDraggableCollectionState; let hasDropHooks = !!dragAndDropHooks?.useDroppableCollectionState; let dragHooksProvided = useRef(hasDragHooks); @@ -405,7 +418,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl if (hasDragHooks && dragAndDropHooks) { dragState = dragAndDropHooks.useDraggableCollectionState!({ - collection, + collection: filteredState.collection, selectionManager, preview: dragAndDropHooks.renderDragPreview ? preview : undefined }); @@ -419,12 +432,12 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl if (hasDropHooks && dragAndDropHooks) { dropState = dragAndDropHooks.useDroppableCollectionState!({ - collection, + collection: filteredState.collection, selectionManager }); let keyboardDelegate = new ListKeyboardDelegate({ - collection, + collection: filteredState.collection, disabledKeys: selectionManager.disabledKeys, disabledBehavior: selectionManager.disabledBehavior, ref, @@ -448,7 +461,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isDropTarget: isRootDropTarget, isFocused, isFocusVisible, - state + state: filteredState } }); @@ -459,7 +472,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl if (tableContainerContext) { layoutState = tableContainerContext.useTableColumnResizeState({ tableWidth: tableContainerContext.tableWidth - }, state); + }, filteredState); if (!isVirtualized) { style = { ...style, @@ -475,7 +488,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl return ( @@ -544,11 +557,19 @@ export interface TableHeaderProps extends StyleRenderProps } +class TableHeaderNode extends FilterLessNode { + static readonly type = 'tableheader'; + + constructor(key: Key) { + super(TableHeaderNode.type, key); + } +} + /** * A header within a ``, containing the table columns. */ export const TableHeader = /*#__PURE__*/ createBranchComponent( - 'tableheader', + TableHeaderNode, (props: TableHeaderProps, ref: ForwardedRef) => { let collection = useContext(TableStateContext)!.collection as TableCollection; let headerRows = useCachedChildren({ @@ -681,10 +702,18 @@ export interface ColumnProps extends RenderProps, GlobalDOMAt maxWidth?: ColumnStaticSize | null } +class TableColumnNode extends FilterLessNode { + static readonly type = 'column'; + + constructor(key: Key) { + super(TableColumnNode.type, key); + } +} + /** * A column within a `
`. */ -export const Column = /*#__PURE__*/ createLeafComponent('column', (props: ColumnProps, forwardedRef: ForwardedRef, column: GridNode) => { +export const Column = /*#__PURE__*/ createLeafComponent(TableColumnNode, (props: ColumnProps, forwardedRef: ForwardedRef, column: GridNode) => { let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); @@ -916,10 +945,19 @@ export interface TableBodyProps extends Omit, 'disabledKey /** Provides content to display when there are no rows in the table. */ renderEmptyState?: (props: TableBodyRenderProps) => ReactNode } + +class TableBodyNode extends CollectionNode { + static readonly type = 'tablebody'; + + constructor(key: Key) { + super(TableBodyNode.type, key); + } +} + /** * The body of a `
`, containing the table rows. */ -export const TableBody = /*#__PURE__*/ createBranchComponent('tablebody', (props: TableBodyProps, ref: ForwardedRef) => { +export const TableBody = /*#__PURE__*/ createBranchComponent(TableBodyNode, (props: TableBodyProps, ref: ForwardedRef) => { let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let collection = state.collection; @@ -1017,11 +1055,30 @@ export interface RowProps extends StyleRenderProps, LinkDOMPr id?: Key } +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(); + } + } + + return null; + } +} + /** * A row within a `
`. */ export const Row = /*#__PURE__*/ createBranchComponent( - 'item', + TableRowNode, (props: RowProps, forwardedRef: ForwardedRef, item: GridNode) => { let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; @@ -1201,10 +1258,18 @@ export interface CellProps extends RenderProps, GlobalDOMAttrib colSpan?: number } +class TableCellNode extends FilterLessNode { + static readonly type = 'cell'; + + constructor(key: Key) { + super(TableCellNode.type, key); + } +} + /** * A cell within a table row. */ -export const Cell = /*#__PURE__*/ createLeafComponent('cell', (props: CellProps, forwardedRef: ForwardedRef, cell: GridNode) => { +export const Cell = /*#__PURE__*/ createLeafComponent(TableCellNode, (props: CellProps, forwardedRef: ForwardedRef, cell: GridNode) => { let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {dragState} = useContext(DragAndDropContext); @@ -1359,7 +1424,15 @@ export interface TableLoadMoreItemProps extends Omit, item: Node) { +class TableLoaderNode extends FilterLessNode { + static readonly type = 'loader'; + + constructor(key: Key) { + super(TableLoaderNode.type, key); + } +} + +export const TableLoadMoreItem = createLeafComponent(TableLoaderNode, 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 907c336dbd1..e2ae91eaddc 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -12,7 +12,7 @@ import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; import {AriaTabListProps, AriaTabPanelProps, mergeProps, Orientation, useFocusRing, useHover, useTab, useTabList, useTabPanel} from 'react-aria'; -import {Collection, CollectionBuilder, createHideableComponent, createLeafComponent} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createHideableComponent, createLeafComponent, FilterLessNode} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps, inertValue, useObjectRef} from '@react-aria/utils'; @@ -235,10 +235,18 @@ function TabListInner({props, forwardedRef: ref}: TabListInner ); } +class TabItemNode extends FilterLessNode { + static readonly type = 'item'; + + constructor(key: Key) { + super(TabItemNode.type, key); + } +} + /** * A Tab provides a title for an individual item within a TabList. */ -export const Tab = /*#__PURE__*/ createLeafComponent('item', (props: TabProps, forwardedRef: ForwardedRef, item: Node) => { +export const Tab = /*#__PURE__*/ createLeafComponent(TabItemNode, (props: TabProps, forwardedRef: ForwardedRef, item: Node) => { let state = useContext(TabListStateContext)!; let ref = useObjectRef(forwardedRef); let {tabProps, isSelected, isDisabled, isPressed} = useTab({key: item.key, ...props}, state, ref); diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index c23d711296b..65c35afbfeb 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -12,16 +12,17 @@ import {AriaTagGroupProps, useFocusRing, useHover, useTag, useTagGroup} from 'react-aria'; import {ButtonContext} from './Button'; -import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createLeafComponent, ItemNode} from '@react-aria/collections'; 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'; import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents} from '@react-types/shared'; import {LabelContext} from './Label'; -import {ListState, Node, useListState} from 'react-stately'; +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 {TextContext} from './Text'; +import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; export interface TagGroupProps extends Omit, 'children' | 'items' | 'label' | 'description' | 'errorMessage' | 'keyboardDelegate'>, DOMProps, SlotProps, GlobalDOMAttributes {} @@ -74,16 +75,21 @@ interface TagGroupInnerProps { } function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProps) { + let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let tagListRef = useRef(null); let [labelRef, label] = useSlot( !props['aria-label'] && !props['aria-labelledby'] ); - let state = useListState({ + let tagGroupState = useListState({ ...props, children: undefined, collection }); + let filteredState = UNSTABLE_useFilteredListState(tagGroupState, filter); + // Prevent DOM props from going to two places. let domProps = filterDOMProps(props, {global: true}); let domPropOverrides = Object.fromEntries(Object.entries(domProps).map(([k]) => [k, undefined])); @@ -95,8 +101,9 @@ function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProp } = useTagGroup({ ...props, ...domPropOverrides, + ...DOMCollectionProps, label - }, state, tagListRef); + }, filteredState, tagListRef); return (
, LinkDOMProps, Hov isDisabled?: boolean } +class TagItemNode extends ItemNode {} + /** * A Tag is an individual item within a TagList. */ -export const Tag = /*#__PURE__*/ createLeafComponent('item', (props: TagProps, forwardedRef: ForwardedRef, item: Node) => { +export const Tag = /*#__PURE__*/ createLeafComponent(TagItemNode, (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/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 1deea65fc38..44017e38fbf 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -13,7 +13,7 @@ import {AriaTreeItemOptions, AriaTreeProps, DraggableItemResult, DropIndicatorAria, DropIndicatorProps, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSelectionCheckbox, useHover, useId, useLocale, useTree, useTreeItem, useVisuallyHidden} from 'react-aria'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; -import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, useCachedChildren} from '@react-aria/collections'; +import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, FilterLessNode, useCachedChildren} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents, RefObject, SelectionMode} from '@react-types/shared'; @@ -448,7 +448,15 @@ export interface TreeItemContentRenderProps extends TreeItemRenderProps {} // need to do a bunch of check to figure out what is the Content and what are the actual collection elements (aka child rows) of the TreeItem export interface TreeItemContentProps extends Pick, 'children'> {} -export const TreeItemContent = /*#__PURE__*/ createLeafComponent('content', function TreeItemContent(props: TreeItemContentProps) { +class TreeContentNode extends FilterLessNode { + static readonly type = 'content'; + + constructor(key: Key) { + super(TreeContentNode.type, key); + } +} + +export const TreeItemContent = /*#__PURE__*/ createLeafComponent(TreeContentNode, function TreeItemContent(props: TreeItemContentProps) { let values = useContext(TreeItemContentContext)!; let renderProps = useRenderProps({ children: props.children, @@ -483,10 +491,18 @@ export interface TreeItemProps extends StyleRenderProps void } +class TreeItemNode extends FilterLessNode { + static readonly type = 'item'; + + constructor(key: Key) { + super(TreeItemNode.type, key); + } +} + /** * A TreeItem represents an individual item in a Tree. */ -export const TreeItem = /*#__PURE__*/ createBranchComponent('item', (props: TreeItemProps, ref: ForwardedRef, item: Node) => { +export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, (props: TreeItemProps, ref: ForwardedRef, item: Node) => { let state = useContext(TreeStateContext)!; ref = useObjectRef(ref); let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; @@ -719,7 +735,15 @@ export interface TreeLoadMoreItemProps extends Omit(props: TreeLoadMoreItemProps, ref: ForwardedRef, item: Node) { +class TreeLoaderNode extends FilterLessNode { + static readonly type = 'loader'; + + constructor(key: Key) { + super(TreeLoaderNode.type, key); + } +} + +export const TreeLoadMoreItem = createLeafComponent(TreeLoaderNode, 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/index.ts b/packages/react-aria-components/src/index.ts index e0384baca75..7dadbbeac0f 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} from './Separator'; +export {Separator, SeparatorContext, SeparatorNode} 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 254d873c633..a6500c4e152 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,10 +11,15 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Collection, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Text, TextField, 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, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; +import {LoadingSpinner, MyListBoxItem, MyMenuItem} from './utils'; import {Meta, StoryObj} from '@storybook/react'; -import {MyListBoxItem, MyMenuItem} from './utils'; -import React from 'react'; +import {MyCheckbox} from './Table.stories'; +import {MyGridListItem} from './GridList.stories'; +import {MyListBoxLoaderIndicator} from './ListBox.stories'; +import {MyTag} from './TagGroup.stories'; +import {Node} from '@react-types/shared'; +import React, {useState} from 'react'; import styles from '../example/index.css'; import {useAsyncList, useListData, useTreeData} from 'react-stately'; import {useFilter} from 'react-aria'; @@ -162,7 +167,7 @@ export const AutocompleteSearchfield: AutocompleteStory = { // Note that the trigger items in this array MUST have an id, even if the underlying MenuItem might apply its own // id. If it is omitted, we can't build the collection node for the trigger node and an error will throw -let dynamicAutocompleteSubdialog = [ +let dynamicAutocompleteSubdialog: MenuNode[] = [ {name: 'Section 1', isSection: true, children: [ {name: 'Command Palette'}, {name: 'Open View'} @@ -436,7 +441,7 @@ const CaseSensitiveFilter = (args) => { let defaultFilter = (itemText, input) => contains(itemText, input); return ( - + filter={defaultFilter}>
@@ -856,3 +861,277 @@ export const AutocompleteSelect = (): React.ReactElement => ( ); + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +let renderEmptyState = (list, cursor) => { + let emptyStateContent; + if (list.loadingState === 'loading') { + emptyStateContent = ; + } else if (list.loadingState === 'idle' && !cursor) { + emptyStateContent = 'No results'; + } + return ( +
+ {emptyStateContent} +
+ ); +}; + + +export const AutocompleteWithAsyncListBox = (args) => { + let [cursor, setCursor] = useState(null); + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + await new Promise(resolve => setTimeout(resolve, args.delay)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + setCursor(json.next); + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + +
+ + + + Please select an option below. + + + renderEmptyState(list, cursor)}> + + {(item: Character) => ( + + {item.name} + + )} + + + + +
+
+ ); +}; + +AutocompleteWithAsyncListBox.story = { + args: { + delay: 50 + } +}; + +export const AutocompleteWithGridList = () => { + return ( + +
+ + + + + + Foo + Bar + Baz + Charizard + Blastoise + Pikachu + Venusaur + textValue is "text value check" + Blah + +
+
+ ); +}; + +export const AutocompleteWithTable = () => { + return ( + +
+ + + + + +
+ + + + + Name + Type + Date Modified + + + + + + + Games + File folder + 6/7/2020 + + + + + + Program Files + File folder + 4/7/2021 + + + + + + bootmgr + System file + 11/20/2010 + + + + + + log.txt + Text Document + 1/18/2016 + + +
+ + + + ); +}; + +export const AutocompleteWithTagGroup = () => { + return ( + +
+ + + + + + + + News + Travel + Gaming + + Shopping + + + + + + + I am a tooltip + + + + +
+
+ ); +}; + +type MenuNode = { + name: string, + id?: string, + isSection?: boolean, + isMenu?: boolean, + children?: MenuNode[] +} + +function AutocompleteNodeFiltering(args) { + let {contains} = useFilter({sensitivity: 'base'}); + let filter = (textValue: string, inputValue: string, node: Node) => { + if ((node.parentKey === 'Section 1' && textValue === 'Open View') || (node.parentKey === 'Section 2' && textValue === 'Appearance')) { + return true; + } + + return contains(textValue, inputValue); + }; + + return ( + filter={filter}> +
+ + + + Please select an option below. + + + {item => dynamicRenderFuncSections(item)} + +
+ + ); +} + +export const AutocompletePreserveFirstSectionStory: AutocompleteStory = { + render: (args) => , + name: 'Autocomplete, per node filtering', + parameters: { + description: { + data: 'It should never filter out Open View or Appearance' + } + } +}; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index a58143c296e..6625dca0475 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -48,7 +48,8 @@ import './styles.css'; export default { title: 'React Aria Components/GridList', - component: GridList + component: GridList, + excludeStories: ['MyGridListItem'] } as Meta; export type GridListStory = StoryFn; @@ -77,7 +78,7 @@ export const GridListExample: GridListStory = (args) => ( ); -const MyGridListItem = (props: GridListItemProps) => { +export const MyGridListItem = (props: GridListItemProps) => { return ( ; export type ListBoxStory = StoryFn; @@ -560,7 +561,7 @@ interface Character { birth_year: number } -const MyListBoxLoaderIndicator = (props) => { +export const MyListBoxLoaderIndicator = (props) => { let {orientation, ...otherProps} = props; return ( ; export type TableStory = StoryFn; @@ -529,7 +529,7 @@ DndTableExample.args = { isLoading: false }; -const MyCheckbox = ({children, ...props}: CheckboxProps) => { +export const MyCheckbox = ({children, ...props}: CheckboxProps) => { return ( {({isIndeterminate}) => ( diff --git a/packages/react-aria-components/stories/TagGroup.stories.tsx b/packages/react-aria-components/stories/TagGroup.stories.tsx index 3b925421906..dd9fe232604 100644 --- a/packages/react-aria-components/stories/TagGroup.stories.tsx +++ b/packages/react-aria-components/stories/TagGroup.stories.tsx @@ -32,7 +32,8 @@ const meta: Meta = { control: 'inline-radio', options: ['toggle', 'replace'] } - } + }, + excludeStories: ['MyTag'] }; export default meta; @@ -70,8 +71,7 @@ export const TagGroupExample: Story = { ) }; - -function MyTag(props: TagProps) { +export function MyTag(props: TagProps) { return ( ( diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 4a5369ed429..b21afa9adeb 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -23,7 +23,8 @@ import './styles.css'; export default { title: 'React Aria Components/Tree', - component: Tree + component: Tree, + excludeStories: ['TreeExampleStaticRender'] } as Meta; export type TreeStory = StoryFn; @@ -143,52 +144,54 @@ const StaticTreeItemNoActions = (props: StaticTreeItemProps) => { ); }; -const TreeExampleStaticRender = (args: TreeProps): JSX.Element => ( - - Photos - - - - Projects-1A +export function TreeExampleStaticRender(props: TreeProps) { + return ( + + Photos + + + + Projects-1A + + + + Projects-2 + + + Projects-3 - - Projects-2 - - - Projects-3 - - - classNames(styles, 'tree-item', { - focused: isFocused, - 'focus-visible': isFocusVisible, - selected: isSelected, - hovered: isHovered - })}> - - Reports - - - classNames(styles, 'tree-item', { - focused: isFocused, - 'focus-visible': isFocusVisible, - selected: isSelected, - hovered: isHovered - })}> - - {({isFocused}) => ( - {`${isFocused} Tests`} - )} - - - -); + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + Reports + + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + {({isFocused}) => ( + {`${isFocused} Tests`} + )} + + + + ); +} const TreeExampleStaticNoActionsRender = (args: TreeProps): JSX.Element => ( diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index f6da9dfe07c..596fc5450ed 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -30,7 +30,9 @@ import userEvent from '@testing-library/user-event'; interface AriaAutocompleteTestProps extends AriaBaseTestProps { renderers: { // needs to wrap a menu with at three items, all enabled. The items should be Foo, Bar, and Baz with ids 1, 2, and 3 respectively - standard: () => ReturnType, + standard?: () => ReturnType, + // needs 3 items with content Foo, Bar Baz and needs to be a component that doesn't support virtual focus with the Autocomplete (e.g. GridList, Table, TagGroup, collection components that have left/right navigation). + noVirtualFocus?: () => ReturnType, // needs at two sections with titles containing Section 1 and Section 2. The first section should have Foo, Bar, Baz with ids 1, 2, and 3. The second section // should have Copy, Cut, Paste with ids 4, 5, 6 sections?: () => ReturnType, @@ -59,7 +61,7 @@ interface AriaAutocompleteTestProps extends AriaBaseTestProps { // (branch off Lvl 1 Bar 2) -> Lvl 2 Bar 1, Lvl 2 Bar 2, Lvl 2 Bar 3 -> (branch off Lvl 2 Bar 2) -> Lvl 3 Bar 1, Lvl 3 Bar 2, Lvl 3 Bar 3 subdialogAndMenu?: () => ReturnType }, - ariaPattern?: 'menu' | 'listbox', + ariaPattern?: 'menu' | 'listbox' | 'grid', selectionListener?: jest.Mock, actionListener?: jest.Mock } @@ -82,6 +84,8 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' collectionNodeRole = 'listbox'; collectionItemRole = 'option'; collectionSelectableItemRole = 'option'; + } else if (ariaPattern === 'grid') { + collectionNodeRole = 'grid'; } }); @@ -91,212 +95,307 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' act(() => jest.runAllTimers()); }); - describe('standard interactions', function () { - it('has default behavior (input field renders with expected attributes)', async function () { - 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'); - expect(input).toHaveAttribute('enterkeyhint', 'go'); + let filterTests = (renderer) => { + describe('default text filtering', function () { + it('should support filtering', async function () { + let {getByRole} = renderer(); + let input = getByRole('searchbox'); + expect(input).toHaveValue(''); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(options).toHaveLength(3); - let menu = getByRole(collectionNodeRole); - expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')!); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('F'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole(collectionItemRole); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Foo'); + + expect(input).toHaveValue('F'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(document.activeElement).toBe(input); + + await user.keyboard('{Backspace}'); + options = within(menu).getAllByRole(collectionItemRole); + expect(options).toHaveLength(3); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(document.activeElement).toBe(input); + }); }); + }; - it('should support keyboard navigation', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); + if (renderers.standard) { + describe('standard interactions', function () { + it('has default behavior (input field renders with expected attributes)', async function () { + 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'); + expect(input).toHaveAttribute('enterkeyhint', 'go'); - await user.tab(); - expect(document.activeElement).toBe(input); + let menu = getByRole(collectionNodeRole); + expect(menu).toHaveAttribute('id', input.getAttribute('aria-controls')!); + }); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[2].id); - await user.keyboard('{ArrowUp}'); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + it('should support keyboard navigation', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - expect(document.activeElement).toBe(input); - }); + await user.tab(); + expect(document.activeElement).toBe(input); - it('should clear the focused key when using ArrowLeft and ArrowRight but preserves it internally for future keyboard operations', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - await user.tab(); - expect(document.activeElement).toBe(input); + expect(document.activeElement).toBe(input); + }); - await user.keyboard('{ArrowDown}'); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - await user.keyboard('{ArrowRight}'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - // Old focused key was options[0] so should move one down - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - await user.keyboard('{ArrowLeft}'); - expect(input).not.toHaveAttribute('aria-activedescendant'); - expect(document.activeElement).toBe(input); - await user.keyboard('{ArrowUp}'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - }); + it('should clear the focused key when using ArrowLeft and ArrowRight but preserves it internally for future keyboard operations', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - it('should completely clear the focused key when Backspacing', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.tab(); + expect(document.activeElement).toBe(input); - await user.tab(); - expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{ArrowRight}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + // Old focused key was options[0] so should move one down + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + await user.keyboard('{ArrowLeft}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + }); - await user.keyboard('B'); - act(() => jest.runAllTimers()); - let options = within(menu).getAllByRole(collectionItemRole); - let firstActiveDescendant = options[0].id; - expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant); - expect(options[0]).toHaveTextContent('Bar'); - await user.keyboard('{Backspace}'); - act(() => jest.runAllTimers()); - expect(input).not.toHaveAttribute('aria-activedescendant'); + it('should completely clear the focused key when Backspacing', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - options = within(menu).getAllByRole(collectionItemRole); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - expect(firstActiveDescendant).not.toEqual(options[0].id); - expect(options[0]).toHaveTextContent('Foo'); - }); + await user.tab(); + expect(document.activeElement).toBe(input); - it('should completely clear the focused key when pasting', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('B'); + act(() => jest.runAllTimers()); + let options = within(menu).getAllByRole(collectionItemRole); + let firstActiveDescendant = options[0].id; + expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant); + expect(options[0]).toHaveTextContent('Bar'); + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.tab(); - expect(document.activeElement).toBe(input); + options = within(menu).getAllByRole(collectionItemRole); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(firstActiveDescendant).not.toEqual(options[0].id); + expect(options[0]).toHaveTextContent('Foo'); + }); - await user.keyboard('B'); - act(() => jest.runAllTimers()); - let options = within(menu).getAllByRole(collectionItemRole); - let firstActiveDescendant = options[0].id; - expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant); + it('should completely clear the focused key when pasting', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.paste('az'); - act(() => jest.runAllTimers()); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.tab(); + expect(document.activeElement).toBe(input); - options = within(menu).getAllByRole(collectionItemRole); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - expect(firstActiveDescendant).not.toEqual(options[0].id); - expect(options[0]).toHaveTextContent('Baz'); - }); + await user.keyboard('B'); + act(() => jest.runAllTimers()); + let options = within(menu).getAllByRole(collectionItemRole); + let firstActiveDescendant = options[0].id; + expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant); - it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.paste('az'); + act(() => jest.runAllTimers()); + expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.tab(); - expect(document.activeElement).toBe(input); + options = within(menu).getAllByRole(collectionItemRole); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(firstActiveDescendant).not.toEqual(options[0].id); + expect(options[0]).toHaveTextContent('Baz'); + }); - await user.keyboard('a'); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - act(() => jest.advanceTimersByTime(500)); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - }); + it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - it('should maintain the newest focused item as the activescendant if set after autofocusing the first option', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.tab(); + expect(document.activeElement).toBe(input); - await user.tab(); - expect(document.activeElement).toBe(input); + await user.keyboard('a'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + act(() => jest.advanceTimersByTime(500)); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + }); - await user.keyboard('a'); - let options = within(menu).getAllByRole(collectionItemRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); - }); + it('should maintain the newest focused item as the activescendant if set after autofocusing the first option', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); - it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox') as HTMLInputElement; + await user.tab(); + expect(document.activeElement).toBe(input); - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('Bar'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(3); + await user.keyboard('a'); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + }); - await user.keyboard('{ArrowLeft}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + it('should not move the text input cursor when using Home/End/ArrowUp/ArrowDown', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox') as HTMLInputElement; - await user.keyboard('{Home}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('Bar'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(3); - await user.keyboard('{End}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); - await user.keyboard('{ArrowDown}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); + await user.keyboard('{Home}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); - await user.keyboard('{ArrowUp}'); - act(() => jest.runAllTimers()); - expect(input.selectionStart).toBe(2); - }); + await user.keyboard('{End}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); - it('should focus the input when clicking on an item', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox') as HTMLInputElement; - let menu = getByRole(collectionNodeRole); - let options = within(menu).getAllByRole(collectionItemRole); + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); - await user.click(options[0]); - expect(document.activeElement).toBe(input); - }); + await user.keyboard('{ArrowUp}'); + act(() => jest.runAllTimers()); + expect(input.selectionStart).toBe(2); + }); - if (ariaPattern === 'menu') { - it('should update the aria-activedescendant when hovering over an item', async function () { - let {getByRole} = renderers.standard(); - let input = getByRole('searchbox'); + it('should focus the input when clicking on an item', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox') as HTMLInputElement; let menu = getByRole(collectionNodeRole); let options = within(menu).getAllByRole(collectionItemRole); + + await user.click(options[0]); + expect(document.activeElement).toBe(input); + }); + + if (ariaPattern === 'menu') { + it('should update the aria-activedescendant when hovering over an item', async function () { + let {getByRole} = renderers.standard!(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + // Need to press to set a modality + await user.click(input); + await user.hover(options[1]); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(document.activeElement).toBe(input); + }); + } + }); + + filterTests(renderers.standard); + } + + if (renderers.noVirtualFocus) { + describe('no virtual focus', function () { + it('should not support virtual focus navigation from the input', async function () { + 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'); + expect(input).toHaveAttribute('enterkeyhint', 'go'); expect(input).not.toHaveAttribute('aria-activedescendant'); + let collection = getByRole(collectionNodeRole); + expect(collection).toHaveAttribute('id', input.getAttribute('aria-controls')!); + await user.tab(); expect(document.activeElement).toBe(input); - // Need to press to set a modality - await user.click(input); - await user.hover(options[1]); - act(() => jest.runAllTimers()); - expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + await user.keyboard('{ArrowDown}'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.keyboard('Foo'); + expect(input).toHaveValue('Foo'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + }); + + it('should properly filter the wrapper collection component when typing in the autocomplete', async function () { + let {getByRole} = renderers.noVirtualFocus!(); + let input = getByRole('searchbox'); + + let collection = getByRole(collectionNodeRole); + expect(await within(collection).findByText('Foo')).toBeTruthy(); + expect(await within(collection).findByText('Bar')).toBeTruthy(); + expect(await within(collection).findByText('Baz')).toBeTruthy(); + + await user.tab(); expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('Foo'); + expect(input).toHaveValue('Foo'); + + expect(await within(collection).findByText('Foo')).toBeTruthy(); + expect(await within(collection).queryByText('Bar')).toBeFalsy(); + expect(await within(collection).queryByText('Baz')).toBeFalsy(); + + await user.keyboard('{Backspace}'); + await user.keyboard('{Backspace}'); + await user.keyboard('{Backspace}'); + await user.keyboard('Ba'); + expect(input).toHaveValue('Ba'); + + expect(await within(collection).queryByText('Foo')).toBeFalsy(); + expect(await within(collection).findByText('Bar')).toBeTruthy(); + expect(await within(collection).findByText('Baz')).toBeTruthy(); }); - } - }); + }); + } if (renderers.defaultValue) { describe('default text value', function () { @@ -346,7 +445,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' }); it('should not trigger the wrapped element\'s actionListener when hitting Space', async function () { - let {getByRole} = renderers.standard(); + let {getByRole} = renderers.standard!(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); expect(input).not.toHaveAttribute('aria-activedescendant'); @@ -495,39 +594,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' }); } - let filterTests = (renderer) => { - describe('default text filtering', function () { - it('should support filtering', async function () { - let {getByRole} = renderer(); - let input = getByRole('searchbox'); - expect(input).toHaveValue(''); - let menu = getByRole(collectionNodeRole); - let options = within(menu).getAllByRole(collectionItemRole); - expect(options).toHaveLength(3); - - await user.tab(); - expect(document.activeElement).toBe(input); - await user.keyboard('F'); - act(() => jest.runAllTimers()); - options = within(menu).getAllByRole(collectionItemRole); - expect(options).toHaveLength(1); - expect(options[0]).toHaveTextContent('Foo'); - - expect(input).toHaveValue('F'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); - expect(document.activeElement).toBe(input); - - await user.keyboard('{Backspace}'); - options = within(menu).getAllByRole(collectionItemRole); - expect(options).toHaveLength(3); - expect(input).not.toHaveAttribute('aria-activedescendant'); - expect(document.activeElement).toBe(input); - }); - }); - }; - - filterTests(renderers.standard); - if (renderers.controlled) { describe('controlled text value', function () { filterTests(renderers.controlled); diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 550507b0edb..410a2aa2fb1 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -12,7 +12,7 @@ import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutocomplete.test-util'; -import {Autocomplete, Button, Dialog, DialogTrigger, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Text, TextField} from '..'; +import {Autocomplete, Breadcrumb, Breadcrumbs, Button, Cell, Column, Dialog, DialogTrigger, GridList, GridListItem, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, Tag, TagGroup, TagList, Text, TextField, Tree, TreeItem, TreeItemContent} from '..'; import React, {ReactNode} from 'react'; import {useAsyncList} from 'react-stately'; import {useFilter} from '@react-aria/i18n'; @@ -67,7 +67,6 @@ let MenuWithSections = (props) => ( ); -// TODO: add tests for nested submenus and subdialogs let SubMenus = (props) => ( Foo @@ -197,6 +196,93 @@ let ListBoxWithSections = (props) => ( ); +let StaticGridList = (props) => ( + + Foo + Bar + Baz + +); + +let StaticTable = (props) => ( + + + Column 1 + Column 2 + Column 3 + + + + Foo + Row 1 Cell 2 + Row 1 Cell 3 + + + Bar + Row 2 Cell 2 + Row 2 Cell 3 + + + Baz + Row 3 Cell 2 + Row 3 Cell 3 + + +
+); + +let StaticTagGroup = (props) => ( + + + + Foo + Bar + Baz + + +); + +let StaticTabs = (props) => ( + + + Foo + Bar + Baz + + Foo content + Bar content + Baz content + +); + +let StaticTree = (props) => ( + + + + Foo + + + + + Bar + + + + + Baz + + + +); + +let StaticBreadcrumbs = (props) => ( + + Foo + Bar + Baz + +); + let AutocompleteWrapper = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, children?: ReactNode}) => { let {contains} = useFilter({sensitivity: 'base'}); let filter = (textValue, inputValue) => contains(textValue, inputValue); @@ -272,6 +358,28 @@ let AsyncFiltering = ({autocompleteProps = {}, inputProps = {}}: {autocompletePr ); }; +let CustomFiltering = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, children?: ReactNode}) => { + let [inputValue, setInputValue] = React.useState(''); + let {contains} = useFilter({sensitivity: 'base'}); + let filter = (textValue, inputValue, node) => { + if (node.parentKey === 'sec1') { + return true; + } + return contains(textValue, inputValue); + }; + + return ( + + + + + Please select an option below. + + {children} + + ); +}; + describe('Autocomplete', () => { let user; beforeAll(() => { @@ -792,6 +900,63 @@ describe('Autocomplete', () => { dialogs = queryAllByRole('dialog'); expect(dialogs).toHaveLength(0); }); + + it.each` + Name | Component + ${'Tabs'} | ${StaticTabs} + ${'Tree'} | ${StaticTree} + ${'Breadcrumbs'} | ${StaticBreadcrumbs} + `('$Name doesnt get filtered by Autocomplete', async function ({Component}) { + let {getByRole, getByTestId} = render( + + + + ); + + let wrappedComponent = getByTestId('wrapped'); + expect(await within(wrappedComponent).findByText('Foo')).toBeTruthy(); + expect(await within(wrappedComponent).findByText('Bar')).toBeTruthy(); + expect(await within(wrappedComponent).findByText('Baz')).toBeTruthy(); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('Foo'); + expect(input).toHaveValue('Foo'); + expect(input).not.toHaveAttribute('aria-controls'); + expect(input).not.toHaveAttribute('aria-autocomplete'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + expect(await within(wrappedComponent).findByText('Foo')).toBeTruthy(); + expect(await within(wrappedComponent).findByText('Bar')).toBeTruthy(); + expect(await within(wrappedComponent).findByText('Baz')).toBeTruthy(); + }); + + it('should allow user to filter by node information', async () => { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + let menu = getByRole('menu'); + let sections = within(menu).getAllByRole('group'); + expect(sections.length).toBe(2); + let options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(6); + + await user.keyboard('Copy'); + sections = within(menu).getAllByRole('group'); + options = within(menu).getAllByRole('menuitem'); + expect(options).toHaveLength(4); + expect(within(sections[0]).getByText('Foo')).toBeTruthy(); + expect(within(sections[0]).getByText('Bar')).toBeTruthy(); + expect(within(sections[0]).getByText('Baz')).toBeTruthy(); + expect(within(sections[1]).getByText('Copy')).toBeTruthy(); + }); }); AriaAutocompleteTests({ @@ -914,3 +1079,39 @@ AriaAutocompleteTests({ actionListener: onAction, selectionListener: onSelectionChange }); + +AriaAutocompleteTests({ + prefix: 'rac-static-table', + renderers: { + noVirtualFocus: () => render( + + + + ) + }, + ariaPattern: 'grid' +}); + +AriaAutocompleteTests({ + prefix: 'rac-static-gridlist', + renderers: { + noVirtualFocus: () => render( + + + + ) + }, + ariaPattern: 'grid' +}); + +AriaAutocompleteTests({ + prefix: 'rac-static-taggroup', + renderers: { + noVirtualFocus: () => render( + + + + ) + }, + ariaPattern: 'grid' +}); diff --git a/packages/react-stately/src/index.ts b/packages/react-stately/src/index.ts index e732d241e81..cfffea50e21 100644 --- a/packages/react-stately/src/index.ts +++ b/packages/react-stately/src/index.ts @@ -53,7 +53,7 @@ export {useSearchFieldState} from '@react-stately/searchfield'; export {useSelectState} from '@react-stately/select'; export {useSliderState} from '@react-stately/slider'; export {useMultipleSelectionState} from '@react-stately/selection'; -export {useTableState, TableHeader, TableBody, Column, Row, Cell, useTableColumnResizeState} from '@react-stately/table'; +export {useTableState, TableHeader, TableBody, Column, Row, Cell, useTableColumnResizeState, UNSTABLE_useFilteredTableState} from '@react-stately/table'; export {useTabListState} from '@react-stately/tabs'; export {useToastState, ToastQueue, useToastQueue} from '@react-stately/toast'; export {useToggleState, useToggleGroupState} from '@react-stately/toggle';