From c9ee11d9023213e7b1661e32777f79e19b5025cd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 26 Jun 2025 17:01:45 -0700 Subject: [PATCH 01/34] account for loaders in base collection filter --- .../collections/src/BaseCollection.ts | 19 +++-- .../stories/Autocomplete.stories.tsx | 83 +++++++++++++++++++ .../stories/ListBox.stories.tsx | 7 +- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index ac655733c6e..578888b62ee 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -296,11 +296,16 @@ export class BaseCollection implements ICollection> { 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; + if (lastNode != null) { + if ( + (lastNode.type !== 'section' && lastNode.type !== 'separator' && lastNode.parentKey === clonedNode.parentKey) || + (clonedNode.type === 'loader') + ) { + lastNode.nextKey = clonedNode.key; + clonedNode.prevKey = lastNode.key; + } else { + clonedNode.prevKey = null; + } } clonedNode.nextKey = null; @@ -338,7 +343,9 @@ function shouldKeepNode(node: Node, filterFn: (nodeValue: string) => boole } else { return false; } - } else if (node.type === 'header') { + } else if (node.type === 'header' || node.type === 'loader') { + // TODO what about tree multiple loaders? Should a loader still be preserved if its parent row is filtered out + // Actually how should a tree structure be filtered? Do levels no longer matter or does it filter at each level? return true; } else { return filterFn(node.textValue); diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 0a85e4f435d..ea72c88f78e 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -17,6 +17,7 @@ import React from 'react'; import styles from '../example/index.css'; import {useAsyncList, useListData, useTreeData} from 'react-stately'; import {useFilter} from 'react-aria'; +import { MyListBoxLoaderIndicator, renderEmptyState } from './ListBox.stories'; export default { title: 'React Aria Components', @@ -849,3 +850,85 @@ export const AutocompleteSelect = () => ( ); + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + + +export const AutocompleteWithAsyncListBox = (args) => { + 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(); + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + +
+ + + + Please select an option below. + + + renderEmptyState({isLoading: list.isLoading})}> + + {(item: Character) => ( + + {item.name} + + )} + + + + +
+
+ ); +}; + +AutocompleteWithAsyncListBox.story = { + args: { + delay: 50 + } +}; diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 88657dc87b2..0cb6464e674 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -20,7 +20,8 @@ import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox'; import {useAsyncList, useListData} from 'react-stately'; export default { - title: 'React Aria Components' + title: 'React Aria Components', + excludeStories: ['MyListBoxLoaderIndicator', 'renderEmptyState'] }; export const ListBoxExample = (args) => ( @@ -435,7 +436,7 @@ export function VirtualizedListBoxWaterfall({minSize = 80, maxSize = 100}) { ); } -let renderEmptyState = ({isLoading}) => { +export let renderEmptyState = ({isLoading}) => { return (
{isLoading ? : 'No results'} @@ -450,7 +451,7 @@ interface Character { birth_year: number } -const MyListBoxLoaderIndicator = (props) => { +export const MyListBoxLoaderIndicator = (props) => { let {orientation, ...otherProps} = props; return ( Date: Fri, 27 Jun 2025 13:52:52 -0700 Subject: [PATCH 02/34] rough implementation for listbox --- .../collections/src/CollectionBuilder.tsx | 22 +++++---- .../@react-aria/collections/src/Document.ts | 47 ++++++++++++++----- packages/react-aria-components/src/Header.tsx | 11 ++++- .../react-aria-components/src/ListBox.tsx | 33 +++++++++++-- .../react-aria-components/src/Separator.tsx | 11 ++++- 5 files changed, 96 insertions(+), 28 deletions(-) diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 80ec484bc02..846224ce7c7 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -127,15 +127,18 @@ function useCollectionDocument>(cr const SSRContext = createContext | null>(null); -function useSSRCollectionNode(Type: string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { +// TODO: make this any for now, but should be a node class +function useSSRCollectionNode(Type: any, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { +// function useSSRCollectionNode(Type: string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { // 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]); + // TODO: now we get the proper node class aka TreeNode + element?.setProps(props, ref, rendered, render, Type); + }, [props, ref, rendered, render, Type]); let parentNode = useContext(SSRContext); if (parentNode) { // Guard against double rendering in strict mode. @@ -154,12 +157,14 @@ function useSSRCollectionNode(Type: string, props: object, re } // @ts-ignore - return {children}; + // TODO: make div for now, may not actually matter + return
{children}
; } -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 { +// TODO: changed all of these to be any, but should be node class type +export function createLeafComponent(type: any, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent(type: any, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent

(type: any, 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); @@ -190,7 +195,8 @@ 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 { +// TODO: changed all of these to be any, but should be node class type +export function createBranchComponent(type: any, 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); diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 561b2f9d5ce..ff70845dc85 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -256,7 +256,9 @@ 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; + // TODO: running with assumption that setProps will be called before any other calls to node are made so theoretically + // node will be defined + node: CollectionNode | null; isMutated = true; private _index: number = 0; hasSetProps = false; @@ -264,7 +266,11 @@ export class ElementNode extends BaseNode { constructor(type: string, ownerDocument: Document) { super(ownerDocument); - this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); + this.node = null; + // TODO: move this line to setProps + // if () + // TODO: this is called by Document, seems like we need it? + // this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); } get index(): number { @@ -278,7 +284,7 @@ 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; @@ -290,12 +296,12 @@ export class ElementNode extends BaseNode { */ private getMutableNode(): Mutable> { if (!this.isMutated) { - this.node = this.node.clone(); + this.node = this.node!.clone(); this.isMutated = true; } this.ownerDocument.markDirty(this); - return this.node; + return this.node!; } updateNode(): void { @@ -303,27 +309,40 @@ 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 { + setProps(obj: {[key: string]: any}, ref: ForwardedRef, rendered?: ReactNode, render?: (node: Node) => ReactElement, type?: any): void { let node = this.getMutableNode(); let {value, textValue, id, ...props} = obj; + + + // if called for first time, aka this.node is undef, call + // this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); but make new TreeNode instead of COllectionNode + // Caveat is this assumes we don't need a node before setProps is called on it + // TODO: will get rid of type function check here when we migrate everything to use the class + if (node == null && typeof type === 'function') { + node = new type(`react-aria-${++this.ownerDocument.nodeId}`); + this.node = node; + // node.key = id; + // console.log('making node', node.type, node) + } + // console.log('setting props', props, node, node.props) props.ref = ref; node.props = props; node.rendered = rendered; @@ -331,7 +350,10 @@ 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) { + // TODO: still need to use this.hasSetProps so this can run twice (?) instead of setting node.key above + // If we set node.key = id and change this to if (this.node), setting refs fails. If we just check (this.node here), it will fail if the user provides an id if (this.hasSetProps) { + // if (this.node) { throw new Error('Cannot change the id of an item'); } node.key = id; @@ -341,6 +363,7 @@ export class ElementNode extends BaseNode { node.colSpan = props.colSpan; } + // TODO: still need this, see above comment this.hasSetProps = true; if (this.isConnected) { this.ownerDocument.queueUpdate(); diff --git a/packages/react-aria-components/src/Header.tsx b/packages/react-aria-components/src/Header.tsx index 2c855e941ee..12f4ff792ef 100644 --- a/packages/react-aria-components/src/Header.tsx +++ b/packages/react-aria-components/src/Header.tsx @@ -10,13 +10,20 @@ * governing permissions and limitations under the License. */ +import {CollectionNode, createLeafComponent} from '@react-aria/collections'; import {ContextValue, useContextProps} from './utils'; -import {createLeafComponent} 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 CollectionNode { + constructor(key: Key) { + super('header', 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 2d458417165..88cea59a715 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, CollectionNode, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; @@ -304,10 +304,19 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re ); } + +// todo make a class here + +class SectionNode extends CollectionNode { + constructor(key: Key) { + super('section', key); + } +} + /** * A ListBoxSection represents a section within a ListBox. */ -export const ListBoxSection = /*#__PURE__*/ createBranchComponent('section', ListBoxSectionInner); +export const ListBoxSection = /*#__PURE__*/ createBranchComponent(SectionNode, ListBoxSectionInner); export interface ListBoxItemRenderProps extends ItemRenderProps {} @@ -329,10 +338,19 @@ export interface ListBoxItemProps extends RenderProps void } + +// TODO create item type here + +class ItemNode extends CollectionNode { + constructor(key: Key) { + super('item', key); + } +} + /** * 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(ItemNode, function ListBoxItem(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { let ref = useObjectRef(forwardedRef); let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; @@ -466,6 +484,13 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe ); } +// TODO: can reuse this most likely +class LoaderNode extends CollectionNode { + constructor(key: Key) { + super('loader', key); + } +} + const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); export interface ListBoxLoadingSentinelProps extends Omit, StyleProps { @@ -479,7 +504,7 @@ export interface ListBoxLoadingSentinelProps extends Omit(props: ListBoxLoadingSentinelProps, ref: ForwardedRef, item: Node) { +export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent(LoaderNode, function ListBoxLoadingIndicator(props: ListBoxLoadingSentinelProps, 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/Separator.tsx b/packages/react-aria-components/src/Separator.tsx index 4e14b71b47d..53da40f1960 100644 --- a/packages/react-aria-components/src/Separator.tsx +++ b/packages/react-aria-components/src/Separator.tsx @@ -11,16 +11,23 @@ */ import {SeparatorProps as AriaSeparatorProps, useSeparator} from 'react-aria'; +import {CollectionNode, createLeafComponent} from '@react-aria/collections'; import {ContextValue, SlotProps, StyleProps, useContextProps} from './utils'; -import {createLeafComponent} from '@react-aria/collections'; import {filterDOMProps} from '@react-aria/utils'; +import {Key} from '@react-types/shared'; import React, {createContext, ElementType, ForwardedRef} from 'react'; export interface SeparatorProps extends AriaSeparatorProps, StyleProps, SlotProps {} export const SeparatorContext = createContext>({}); -export const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps, ref: ForwardedRef) { +class SeparatorNode extends CollectionNode { + constructor(key: Key) { + super('separator', key); + } +} + +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; From 290514a183f1f4a50a67219ba449ab700b57a20c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 8 Jul 2025 13:39:06 -0700 Subject: [PATCH 03/34] replace other instances of createLeaf/createBranch to use node classes --- .../test/CollectionBuilder.test.js | 10 +++- .../react-aria-components/src/Breadcrumbs.tsx | 11 +++- .../react-aria-components/src/Collection.tsx | 1 + .../react-aria-components/src/GridList.tsx | 19 ++++++- packages/react-aria-components/src/Menu.tsx | 29 ++++++++-- packages/react-aria-components/src/Table.tsx | 57 +++++++++++++++++-- packages/react-aria-components/src/Tabs.tsx | 11 +++- .../react-aria-components/src/TagGroup.tsx | 11 +++- packages/react-aria-components/src/Tree.tsx | 26 ++++++++- 9 files changed, 151 insertions(+), 24 deletions(-) diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 99e06728c9c..26c85880187 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -1,8 +1,14 @@ -import {Collection, CollectionBuilder, createLeafComponent} from '../src'; +import {Collection, CollectionBuilder, CollectionNode, createLeafComponent} from '../src'; import React from 'react'; import {render} from '@testing-library/react'; -const Item = createLeafComponent('item', () => { +class ItemNode extends CollectionNode { + constructor(key) { + super('item', key); + } +} + +const Item = createLeafComponent(ItemNode, () => { return
; }); diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index fab3127041d..a7406912d13 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, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext} from './Collection'; import {ContextValue, RenderProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps} from '@react-aria/utils'; @@ -72,10 +72,17 @@ export interface BreadcrumbProps extends RenderProps { id?: Key } +// TODO: perhaps this should be reuse ItemNode, for now just have it separate +class BreadcrumbNode extends CollectionNode { + constructor(key: Key) { + super('item', 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 9508272bbf9..dcd817eab53 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 ead3a3b1037..34a881d33c2 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -12,7 +12,7 @@ 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, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; @@ -280,10 +280,16 @@ export interface GridListItemProps extends RenderProps void } +class GridListNode extends CollectionNode { + constructor(key: Key) { + super('item', key); + } +} + /** * 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); @@ -511,7 +517,14 @@ export interface GridListLoadingSentinelProps extends Omit(props: GridListLoadingSentinelProps, ref: ForwardedRef, item: Node) { +// TODO: can probably reuse ListBox's loaderNode +class GridLoaderNode extends CollectionNode { + constructor(key: Key) { + super('loader', key); + } +} + +export const UNSTABLE_GridListLoadingSentinel = createLeafComponent(GridLoaderNode, function GridListLoadingIndicator(props: GridListLoadingSentinelProps, 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/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index ec734898577..b4359078706 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} 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, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; @@ -107,12 +107,19 @@ export interface SubmenuTriggerProps { const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject, shouldUseVirtualFocus?: boolean} | null>(null); +// todo: what logic should this have? +class SubMenuTriggerNode extends CollectionNode { + constructor(key: Key) { + super('submenutrigger', key); + } +} + /** * 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)!; @@ -318,10 +325,17 @@ function MenuSectionInner(props: MenuSectionProps, ref: For ); } +// todo can probably reuse the SectionNode from ListBox? +class SectionNode extends CollectionNode { + constructor(key: Key) { + super('section', key); + } +} + /** * A MenuSection represents a section within a Menu. */ -export const MenuSection = /*#__PURE__*/ createBranchComponent('section', MenuSectionInner); +export const MenuSection = /*#__PURE__*/ createBranchComponent(SectionNode, MenuSectionInner); export interface MenuItemRenderProps extends ItemRenderProps { /** @@ -355,10 +369,17 @@ export interface MenuItemProps extends RenderProps>(null); +// TODO maybe this needs to a separate node type? +class MenuItemNode extends CollectionNode { + constructor(key: Key) { + super('item', key); + } +} + /** * 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/Table.tsx b/packages/react-aria-components/src/Table.tsx index b0559e64713..ddbd4eaa579 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -549,11 +549,19 @@ export interface TableHeaderProps extends StyleRenderProps } +// TODO: will this have any logic? Maybe for ones like this where we aren't adding the filter function just yet we could +// keep it as returning the string instead of the class in createBranchComponent +class TableHeaderNode extends CollectionNode { + constructor(key: Key) { + super('tableheader', 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({ @@ -686,10 +694,18 @@ export interface ColumnProps extends RenderProps { maxWidth?: ColumnStaticSize | null } + +// TODO does this need to be separate or should ItemNode be generic enough that it can take an arbitrary "type"? +class ColumnNode extends CollectionNode { + constructor(key: Key) { + super('column', key); + } +} + /** * A column within a `
`. */ -export const Column = /*#__PURE__*/ createLeafComponent('column', (props: ColumnProps, forwardedRef: ForwardedRef, column: GridNode) => { +export const Column = /*#__PURE__*/ createLeafComponent(ColumnNode, (props: ColumnProps, forwardedRef: ForwardedRef, column: GridNode) => { let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); @@ -919,10 +935,18 @@ export interface TableBodyProps extends Omit, 'disabledKey /** Provides content to display when there are no rows in the table. */ renderEmptyState?: (props: TableBodyRenderProps) => ReactNode } + +// TODO: do we need this +class TableBodyNode extends CollectionNode { + constructor(key: Key) { + super('tablebody', 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; @@ -1020,11 +1044,18 @@ export interface RowProps extends StyleRenderProps, LinkDOMPr id?: Key } +// TODO: maybe can reuse the item node, but probably will have different filter logic here so splitting out for now +class TableRowNode extends CollectionNode { + constructor(key: Key) { + super('item', key); + } +} + /** * 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)!; @@ -1202,10 +1233,17 @@ export interface CellProps extends RenderProps { colSpan?: number } +// TODO: Also perhaps can just be ItemNode? +class CellNode extends CollectionNode { + constructor(key: Key) { + super('cell', key); + } +} + /** * A cell within a table row. */ -export const Cell = /*#__PURE__*/ createLeafComponent('cell', (props: CellProps, forwardedRef: ForwardedRef, cell: GridNode) => { +export const Cell = /*#__PURE__*/ createLeafComponent(CellNode, (props: CellProps, forwardedRef: ForwardedRef, cell: GridNode) => { let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {dragState} = useContext(DragAndDropContext); @@ -1359,7 +1397,14 @@ export interface TableLoadingSentinelProps extends Omit(props: TableLoadingSentinelProps, ref: ForwardedRef, item: Node) { +// TODO: can reuse this most likely +class LoaderNode extends CollectionNode { + constructor(key: Key) { + super('loader', key); + } +} + +export const UNSTABLE_TableLoadingSentinel = createLeafComponent(LoaderNode, function TableLoadingIndicator(props: TableLoadingSentinelProps, 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 b55227a9ee4..42454c20014 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -12,7 +12,7 @@ import {AriaLabelingProps, forwardRefType, HoverEvents, Key, LinkDOMProps, 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, CollectionNode, createHideableComponent, createLeafComponent} 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'; @@ -237,10 +237,17 @@ function TabListInner({props, forwardedRef: ref}: TabListInner ); } +// TODO probably can reuse ItemNode +class TabItemNode extends CollectionNode { + constructor(key: Key) { + super('item', 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 eccd36bd58e..a71968e2965 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -12,7 +12,7 @@ import {AriaTagGroupProps, useFocusRing, useHover, useTag, useTagGroup} from 'react-aria'; import {ButtonContext} from './Button'; -import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; +import {Collection, CollectionBuilder, CollectionNode, createLeafComponent} 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'; @@ -196,10 +196,17 @@ export interface TagProps extends RenderProps, LinkDOMProps, Hov isDisabled?: boolean } +// TODO probably can reuse item node +class TagItemNode extends CollectionNode { + constructor(key: Key) { + super('item', key); + } +} + /** * 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 41c359b12a0..1e5151c8397 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -449,7 +449,13 @@ 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 ContentNode extends CollectionNode { + constructor(key: Key) { + super('content', key); + } +} + +export const TreeItemContent = /*#__PURE__*/ createLeafComponent(ContentNode, function TreeItemContent(props: TreeItemContentProps) { let values = useContext(TreeItemContentContext)!; let renderProps = useRenderProps({ children: props.children, @@ -484,10 +490,17 @@ export interface TreeItemProps extends StyleRenderProps void } +// TODO: also might be able to reuse the ItemNode +class TreeItemNode extends CollectionNode { + constructor(key: Key) { + super('item', 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)!; @@ -708,7 +721,14 @@ export interface UNSTABLE_TreeLoadingIndicatorRenderProps { export interface TreeLoaderProps extends RenderProps, StyleRenderProps {} -export const UNSTABLE_TreeLoadingIndicator = createLeafComponent('loader', function TreeLoader(props: TreeLoaderProps, ref: ForwardedRef, item: Node) { +// TODO: can reuse this most likely +class LoaderNode extends CollectionNode { + constructor(key: Key) { + super('loader', key); + } +} + +export const UNSTABLE_TreeLoadingIndicator = createLeafComponent(LoaderNode, function TreeLoader(props: TreeLoaderProps, ref: ForwardedRef, item: Node) { let state = useContext(TreeStateContext)!; // This loader row is is non-interactable, but we want the same aria props calculated as a typical row // @ts-ignore From 296951100c48aeba5431c221238197a842ef3746 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 8 Jul 2025 16:52:54 -0700 Subject: [PATCH 04/34] fix bugs with subdialog filtering, arrow nav, dividers, etc --- .../collections/src/BaseCollection.ts | 179 ++++++------------ packages/react-aria-components/src/Header.tsx | 4 + .../react-aria-components/src/ListBox.tsx | 32 +++- packages/react-aria-components/src/Menu.tsx | 41 +++- .../react-aria-components/src/Separator.tsx | 10 +- .../stories/Autocomplete.stories.tsx | 2 +- 6 files changed, 134 insertions(+), 134 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 578888b62ee..3c6b6d8d70e 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -65,8 +65,17 @@ 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: (textValue: string) => boolean): CollectionNode | null { + let [firstKey, lastKey] = filterChildren(collection, newCollection, this.firstChildKey, filterFn); + let newNode: Mutable> = this.clone(); + newNode.firstChildKey = firstKey; + newNode.lastChildKey = lastKey; + return newNode; + } } /** @@ -213,141 +222,61 @@ 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 { + UNSTABLE_filter(filterFn: (textValue: 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; - } - } + let [firstKey, lastKey] = filterChildren(this, newCollection, this.firstKey, filterFn); + newCollection.firstKey = firstKey; + newCollection.lastKey = lastKey; + return newCollection; + } +} - // 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) { - if ( - (lastNode.type !== 'section' && lastNode.type !== 'separator' && lastNode.parentKey === clonedNode.parentKey) || - (clonedNode.type === 'loader') - ) { - lastNode.nextKey = clonedNode.key; - clonedNode.prevKey = lastNode.key; - } else { - clonedNode.prevKey = null; - } - } - - clonedNode.nextKey = null; - newCollection.addNode(clonedNode); - lastNode = clonedNode; - } +function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (textValue: string) => boolean): [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) { + 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' || node.type === 'loader') { - // TODO what about tree multiple loaders? Should a loader still be preserved if its parent row is filtered out - // Actually how should a tree structure be filtered? Do levels no longer matter or does it filter at each level? - return true; - } else { - return filterFn(node.textValue); } + + return [firstNode?.key ?? null, lastNode?.key ?? null]; } diff --git a/packages/react-aria-components/src/Header.tsx b/packages/react-aria-components/src/Header.tsx index 12f4ff792ef..fa31890ee62 100644 --- a/packages/react-aria-components/src/Header.tsx +++ b/packages/react-aria-components/src/Header.tsx @@ -21,6 +21,10 @@ class HeaderNode extends CollectionNode { constructor(key: Key) { super('header', key); } + + filter(): CollectionNode { + return this.clone(); + } } export const Header = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 88cea59a715..1d7a021205e 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, CollectionNode, createBranchComponent, createLeafComponent} from '@react-aria/collections'; +import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; @@ -307,10 +307,24 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re // todo make a class here -class SectionNode extends CollectionNode { +class SectionNode extends CollectionNode { constructor(key: Key) { super('section', key); } + + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | 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; + } } /** @@ -341,10 +355,18 @@ export interface ListBoxItemProps extends RenderProps { +class ItemNode extends CollectionNode { constructor(key: Key) { super('item', key); } + + filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { + if (filterFn(this.textValue)) { + return this.clone(); + } + + return null; + } } /** @@ -489,6 +511,10 @@ class LoaderNode extends CollectionNode { constructor(key: Key) { super('loader', key); } + + filter(): CollectionNode | null { + return this.clone(); + } } const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index b4359078706..1fbdbd1774a 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -107,11 +107,22 @@ export interface SubmenuTriggerProps { const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject, shouldUseVirtualFocus?: boolean} | null>(null); -// todo: what logic should this have? -class SubMenuTriggerNode extends CollectionNode { +class SubMenuTriggerNode extends CollectionNode { constructor(key: Key) { super('submenutrigger', key); } + + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { + let triggerNode = collection.getItem(this.firstChildKey!); + if (triggerNode && filterFn(triggerNode.textValue)) { + // TODO: perhaps should call super.filter for correctness, but basically add the menu item child of the submenutrigger + // to the keymap so it renders + newCollection.addNode(triggerNode as CollectionNode); + return this.clone(); + } + + return null; + } } /** @@ -326,10 +337,24 @@ function MenuSectionInner(props: MenuSectionProps, ref: For } // todo can probably reuse the SectionNode from ListBox? -class SectionNode extends CollectionNode { +class SectionNode extends CollectionNode { constructor(key: Key) { super('section', key); } + + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | 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; + } } /** @@ -370,10 +395,18 @@ export interface MenuItemProps extends RenderProps>(null); // TODO maybe this needs to a separate node type? -class MenuItemNode extends CollectionNode { +class MenuItemNode extends CollectionNode { constructor(key: Key) { super('item', key); } + + filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { + if (filterFn(this.textValue)) { + return this.clone(); + } + + return null; + } } /** diff --git a/packages/react-aria-components/src/Separator.tsx b/packages/react-aria-components/src/Separator.tsx index 53da40f1960..f0f376b6092 100644 --- a/packages/react-aria-components/src/Separator.tsx +++ b/packages/react-aria-components/src/Separator.tsx @@ -11,7 +11,7 @@ */ import {SeparatorProps as AriaSeparatorProps, useSeparator} from 'react-aria'; -import {CollectionNode, createLeafComponent} from '@react-aria/collections'; +import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {ContextValue, SlotProps, StyleProps, useContextProps} from './utils'; import {filterDOMProps} from '@react-aria/utils'; import {Key} from '@react-types/shared'; @@ -25,6 +25,14 @@ class SeparatorNode extends CollectionNode { constructor(key: Key) { super('separator', key); } + + filter(_, newCollection: BaseCollection): CollectionNode | null { + if (newCollection.getItem(this.prevKey!)) { + return this.clone(); + } + + return null; + } } export const Separator = /*#__PURE__*/ createLeafComponent(SeparatorNode, function Separator(props: SeparatorProps, ref: ForwardedRef) { diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index ea72c88f78e..6962f6ea8bf 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -13,11 +13,11 @@ 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 {MyListBoxItem, MyMenuItem} from './utils'; +import {MyListBoxLoaderIndicator, renderEmptyState} from './ListBox.stories'; import React from 'react'; import styles from '../example/index.css'; import {useAsyncList, useListData, useTreeData} from 'react-stately'; import {useFilter} from 'react-aria'; -import { MyListBoxLoaderIndicator, renderEmptyState } from './ListBox.stories'; export default { title: 'React Aria Components', From d8a6f06187a31371f948cf88822ac3517420f1c9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 8 Jul 2025 17:24:36 -0700 Subject: [PATCH 05/34] fix case where arrow nav wasnt working post filter --- packages/@react-aria/collections/src/BaseCollection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 3c6b6d8d70e..d20c0dabd51 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -247,6 +247,7 @@ function filterChildren(collection: BaseCollection, newCollection: BaseCol 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; From 6eb17532807db8d07dd768ce2d0cbbd1a2e1eac5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 22 Jul 2025 16:35:33 -0700 Subject: [PATCH 06/34] update types and class node structure --- .../collections/src/BaseCollection.ts | 2 +- .../collections/src/CollectionBuilder.tsx | 44 +++++++++++-------- .../@react-aria/collections/src/Document.ts | 22 ++++------ .../test/CollectionBuilder.test.js | 4 +- .../@react-stately/list/src/useListState.ts | 4 +- .../@react-types/shared/src/collections.d.ts | 2 +- .../react-aria-components/src/Breadcrumbs.tsx | 4 +- .../react-aria-components/src/GridList.tsx | 16 ++++--- packages/react-aria-components/src/Header.tsx | 4 +- .../react-aria-components/src/ListBox.tsx | 27 +++++++----- packages/react-aria-components/src/Menu.tsx | 28 +++++++----- .../react-aria-components/src/Separator.tsx | 4 +- packages/react-aria-components/src/Table.tsx | 36 ++++++++++----- packages/react-aria-components/src/Tabs.tsx | 4 +- .../react-aria-components/src/TagGroup.tsx | 4 +- packages/react-aria-components/src/Tree.tsx | 20 ++++++--- 16 files changed, 136 insertions(+), 89 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index f9288dbdcd9..4811ad87da4 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -233,7 +233,7 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - UNSTABLE_filter(filterFn: (textValue: string) => boolean): BaseCollection { + filter(filterFn: (textValue: string) => boolean): BaseCollection { let newCollection = new BaseCollection(); let [firstKey, lastKey] = filterChildren(this, newCollection, this.firstKey, filterFn); newCollection.firstKey = firstKey; diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 846224ce7c7..baf68a0f97e 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,24 +127,29 @@ function useCollectionDocument>(cr const SSRContext = createContext | null>(null); -// TODO: make this any for now, but should be a node class -function useSSRCollectionNode(Type: any, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { -// 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 +}; + +// TODO: discuss the former Type arg, renamed to CollectionNodeClass +function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { // 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) => { - // TODO: now we get the proper node class aka TreeNode - element?.setProps(props, ref, rendered, render, Type); - }, [props, ref, rendered, render, Type]); + // TODO: check setProps api + element?.setProps(props, ref, rendered, render, CollectionNodeClass); + }, [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); + // TODO: check this, maybe should just pass the CollectionNodeClass as a whole? + element = parentNode.ownerDocument.createElement(CollectionNodeClass.type); element.setProps(props, ref, rendered, render); parentNode.appendChild(element); parentNode.ownerDocument.updateCollection(); @@ -156,15 +161,16 @@ function useSSRCollectionNode(Type: any, props: object, ref: : null; } + // console.log('type', CollectionNodeClass, CollectionNodeClass.type) // @ts-ignore - // TODO: make div for now, may not actually matter - return
{children}
; + // TODO: could just make this a div perhaps, but keep it in line with how it used to work + return {children}; } -// TODO: changed all of these to be any, but should be node class type -export function createLeafComponent(type: any, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent(type: any, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent

(type: any, render: (props: P, ref: ForwardedRef, node?: any) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null { +// TODO: check the signature of the CollectionNodeClass here and other places (aka useSSRCollectionNode and branchCompoennt). If I use the generic it complains. Perhaps it should be unknown? Or maybe the definitions in Listbox and stuff shouldn't use a generic? +export function createLeafComponent(CollectionNodeClass: CollectionNodeClass, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent(CollectionNodeClass: CollectionNodeClass, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent

(CollectionNodeClass: CollectionNodeClass, 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); @@ -177,7 +183,7 @@ export function createLeafComponent

(type: a } return useSSRCollectionNode( - type, + CollectionNodeClass, props, ref, 'children' in props ? props.children : null, @@ -195,12 +201,12 @@ export function createLeafComponent

(type: a return Result; } -// TODO: changed all of these to be any, but should be node class type -export function createBranchComponent(type: any, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null, useChildren: (props: P) => ReactNode = useCollectionChildren): (props: P & React.RefAttributes) => ReactElement | null { +// TODO: check the signature of this too +export function createBranchComponent(CollectionNodeClass: CollectionNodeClass, 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 ff70845dc85..d09d4445d84 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'; @@ -267,10 +268,6 @@ export class ElementNode extends BaseNode { constructor(type: string, ownerDocument: Document) { super(ownerDocument); this.node = null; - // TODO: move this line to setProps - // if () - // TODO: this is called by Document, seems like we need it? - // this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); } get index(): number { @@ -327,22 +324,20 @@ export class ElementNode extends BaseNode { } } - setProps(obj: {[key: string]: any}, ref: ForwardedRef, rendered?: ReactNode, render?: (node: Node) => ReactElement, type?: any): void { + // TODO + setProps(obj: {[key: string]: any}, ref: ForwardedRef, rendered?: ReactNode, render?: (node: Node) => ReactElement, CollectionNodeClass?: CollectionNodeClass): void { let node = this.getMutableNode(); let {value, textValue, id, ...props} = obj; - // if called for first time, aka this.node is undef, call - // this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); but make new TreeNode instead of COllectionNode + // TODO: Flow here is that if this called for first time, aka this.node is undef, call + // this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); but make new TreeNode/MenuNode/etc instead of CollectionNode // Caveat is this assumes we don't need a node before setProps is called on it - // TODO: will get rid of type function check here when we migrate everything to use the class - if (node == null && typeof type === 'function') { - node = new type(`react-aria-${++this.ownerDocument.nodeId}`); + if (node == null && CollectionNodeClass) { + node = new CollectionNodeClass(`react-aria-${++this.ownerDocument.nodeId}`); this.node = node; - // node.key = id; - // console.log('making node', node.type, node) } - // console.log('setting props', props, node, node.props) + props.ref = ref; node.props = props; node.rendered = rendered; @@ -353,7 +348,6 @@ export class ElementNode extends BaseNode { // TODO: still need to use this.hasSetProps so this can run twice (?) instead of setting node.key above // If we set node.key = id and change this to if (this.node), setting refs fails. If we just check (this.node here), it will fail if the user provides an id if (this.hasSetProps) { - // if (this.node) { throw new Error('Cannot change the id of an item'); } node.key = id; diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 26c85880187..be5dbe8d60f 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -3,8 +3,10 @@ import React from 'react'; import {render} from '@testing-library/react'; class ItemNode extends CollectionNode { + static type = 'item'; + constructor(key) { - super('item', key); + super(ItemNode.type, key); } } diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts index 15813f1dee4..8456ec6cc24 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) => 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-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index dfdc0f23bff..b5d3dee3f7e 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) => boolean): Collection } export interface Node { diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 00e84baab4f..532033a052d 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -75,8 +75,10 @@ export interface BreadcrumbProps extends RenderProps, Glo // TODO: perhaps this should be reuse ItemNode, for now just have it separate class BreadcrumbNode extends CollectionNode { + static readonly type = 'item'; + constructor(key: Key) { - super('item', key); + super(BreadcrumbNode.type, key); } } diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 6fb986177ed..c5d654a5e0f 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -279,10 +279,13 @@ export interface GridListItemProps extends RenderProps void } +// TODO: add filter funct to this class GridListNode extends CollectionNode { + static readonly type = 'item'; constructor(key: Key) { - super('item', key); + super(GridListNode.type, key); } + } /** @@ -519,14 +522,17 @@ export interface GridListLoadMoreItemProps extends Omit { +// TODO: can probably reuse ListBox's loaderNode, but might keep separate +// add filter logic +class GridListLoaderNode extends CollectionNode { + static readonly type = 'loader'; + constructor(key: Key) { - super('loader', key); + super(GridListLoaderNode.type, key); } } -export const GridListLoadMoreItem = createLeafComponent(GridLoaderNode, function GridListLoadingIndicator(props: GridListLoadMoreItemProps, ref: ForwardedRef, item: Node) { +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 fa31890ee62..4092f8b9576 100644 --- a/packages/react-aria-components/src/Header.tsx +++ b/packages/react-aria-components/src/Header.tsx @@ -18,8 +18,10 @@ import React, {createContext, ForwardedRef, HTMLAttributes} from 'react'; export const HeaderContext = createContext, HTMLElement>>({}); class HeaderNode extends CollectionNode { + static readonly type = 'header'; + constructor(key: Key) { - super('header', key); + super(HeaderNode.type, key); } filter(): CollectionNode { diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index d17a5d99f8e..55df5953997 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -306,11 +306,12 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re } -// todo make a class here +// TODO: reuse +export class ListBoxSectionNode extends CollectionNode { + static readonly type = 'section'; -class SectionNode extends CollectionNode { constructor(key: Key) { - super('section', key); + super(ListBoxSectionNode.type, key); } filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { @@ -331,7 +332,7 @@ class SectionNode extends CollectionNode { /** * A ListBoxSection represents a section within a ListBox. */ -export const ListBoxSection = /*#__PURE__*/ createBranchComponent(SectionNode, ListBoxSectionInner); +export const ListBoxSection = /*#__PURE__*/ createBranchComponent(ListBoxSectionNode, ListBoxSectionInner); export interface ListBoxItemRenderProps extends ItemRenderProps {} @@ -353,12 +354,12 @@ export interface ListBoxItemProps extends RenderProps void } +// TODO: reusue +class ListBoxItemNode extends CollectionNode { + static readonly type = 'item'; -// TODO create item type here - -class ItemNode extends CollectionNode { constructor(key: Key) { - super('item', key); + super(ListBoxItemNode.type, key); } filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { @@ -373,7 +374,7 @@ class ItemNode extends CollectionNode { /** * A ListBoxItem represents an individual option in a ListBox. */ -export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, 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)!; @@ -511,9 +512,11 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe } // TODO: can reuse this most likely -class LoaderNode extends CollectionNode { +class ListBoxLoaderNode extends CollectionNode { + static readonly type = 'loader'; + constructor(key: Key) { - super('loader', key); + super(ListBoxLoaderNode.type, key); } filter(): CollectionNode | null { @@ -534,7 +537,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 e37c3e409e2..c69c492494d 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -107,9 +107,11 @@ export interface SubmenuTriggerProps { const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject, shouldUseVirtualFocus?: boolean} | null>(null); -class SubMenuTriggerNode extends CollectionNode { +class SubmenuTriggerNode extends CollectionNode { + static readonly type = 'submenutrigger'; + constructor(key: Key) { - super('submenutrigger', key); + super(SubmenuTriggerNode.type, key); } filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { @@ -130,7 +132,7 @@ class SubMenuTriggerNode extends CollectionNode { * * @version alpha */ -export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent(SubMenuTriggerNode, (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)!; @@ -203,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>, @@ -337,10 +339,13 @@ function MenuSectionInner(props: MenuSectionProps, ref: For ); } -// todo can probably reuse the SectionNode from ListBox? -class SectionNode extends CollectionNode { +// TODO: can probably reuse the SectionNode from ListBox? Do this last in case there is something different in the implementation? Or maybe keep them unique in case +// down the line we need to differentiate the two? +class MenuSectionNode extends CollectionNode { + static readonly type = 'section'; + constructor(key: Key) { - super('section', key); + super(MenuSectionNode.type, key); } filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { @@ -361,7 +366,7 @@ class SectionNode extends CollectionNode { /** * A MenuSection represents a section within a Menu. */ -export const MenuSection = /*#__PURE__*/ createBranchComponent(SectionNode, MenuSectionInner); +export const MenuSection = /*#__PURE__*/ createBranchComponent(MenuSectionNode, MenuSectionInner); export interface MenuItemRenderProps extends ItemRenderProps { /** @@ -395,10 +400,13 @@ export interface MenuItemProps extends RenderProps>(null); -// TODO maybe this needs to a separate node type? +// TODO maybe this needs to be a separate node type? Or maybe it should just reuse the ItemNode from ListBox (reuse later if need be) +// There is probably some merit to separating it like we already do for ListBoxItem/MenuItem/etc class MenuItemNode extends CollectionNode { + static readonly type = 'item'; + constructor(key: Key) { - super('item', key); + super(MenuItemNode.type, key); } filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { diff --git a/packages/react-aria-components/src/Separator.tsx b/packages/react-aria-components/src/Separator.tsx index a4496f6cb7d..4578c121755 100644 --- a/packages/react-aria-components/src/Separator.tsx +++ b/packages/react-aria-components/src/Separator.tsx @@ -22,8 +22,10 @@ export interface SeparatorProps extends AriaSeparatorProps, StyleProps, SlotProp export const SeparatorContext = createContext>({}); class SeparatorNode extends CollectionNode { + static readonly type = 'separator'; + constructor(key: Key) { - super('separator', key); + super(SeparatorNode.type, key); } filter(_, newCollection: BaseCollection): CollectionNode | null { diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 20cafe27b16..7a5bd7c8d74 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -547,8 +547,10 @@ export interface TableHeaderProps extends StyleRenderProps { + static readonly type = 'header'; + constructor(key: Key) { - super('tableheader', key); + super(TableHeaderNode.type, key); } } @@ -691,16 +693,18 @@ export interface ColumnProps extends RenderProps, GlobalDOMAt // TODO does this need to be separate or should ItemNode be generic enough that it can take an arbitrary "type"? -class ColumnNode extends CollectionNode { +class TableColumnNode extends CollectionNode { + static readonly type = 'column'; + constructor(key: Key) { - super('column', key); + super(TableColumnNode.type, key); } } /** * A column within a `

`. */ -export const Column = /*#__PURE__*/ createLeafComponent(ColumnNode, (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); @@ -935,8 +939,10 @@ export interface TableBodyProps extends Omit, 'disabledKey // TODO: do we need this class TableBodyNode extends CollectionNode { + static readonly type = 'header'; + constructor(key: Key) { - super('tablebody', key); + super(TableBodyNode.type, key); } } @@ -1043,8 +1049,10 @@ export interface RowProps extends StyleRenderProps, LinkDOMPr // TODO: maybe can reuse the item node, but probably will have different filter logic here so splitting out for now class TableRowNode extends CollectionNode { + static readonly type = 'item'; + constructor(key: Key) { - super('item', key); + super(TableRowNode.type, key); } } @@ -1233,16 +1241,18 @@ export interface CellProps extends RenderProps, GlobalDOMAttrib } // TODO: Also perhaps can just be ItemNode? -class CellNode extends CollectionNode { +class TableCellNode extends CollectionNode { + static readonly type = 'cell'; + constructor(key: Key) { - super('cell', key); + super(TableCellNode.type, key); } } /** * A cell within a table row. */ -export const Cell = /*#__PURE__*/ createLeafComponent(CellNode, (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); @@ -1398,13 +1408,15 @@ export interface TableLoadMoreItemProps extends Omit { +class TableLoaderNode extends CollectionNode { + static readonly type = 'loader'; + constructor(key: Key) { - super('loader', key); + super(TableLoaderNode.type, key); } } -export const TableLoadMoreItem = createLeafComponent(LoaderNode, function TableLoadingIndicator(props: TableLoadMoreItemProps, ref: ForwardedRef, item: Node) { +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 47d3550fc1c..8fb93051459 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -237,8 +237,10 @@ function TabListInner({props, forwardedRef: ref}: TabListInner // TODO probably can reuse ItemNode class TabItemNode extends CollectionNode { + static readonly type = 'item'; + constructor(key: Key) { - super('item', key); + super(TabItemNode.type, key); } } diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 047be34a0f9..abe017dfa3f 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -196,8 +196,10 @@ export interface TagProps extends RenderProps, LinkDOMProps, Hov // TODO probably can reuse item node class TagItemNode extends CollectionNode { + static readonly type = 'content'; + constructor(key: Key) { - super('item', key); + super(TagItemNode.type, key); } } diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 170f6c52fb5..21443d65c10 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -448,13 +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'> {} -class ContentNode extends CollectionNode { +class TreeContentNode extends CollectionNode { + static readonly type = 'content'; + constructor(key: Key) { - super('content', key); + super(TreeContentNode.type, key); } } -export const TreeItemContent = /*#__PURE__*/ createLeafComponent(ContentNode, function TreeItemContent(props: TreeItemContentProps) { +export const TreeItemContent = /*#__PURE__*/ createLeafComponent(TreeContentNode, function TreeItemContent(props: TreeItemContentProps) { let values = useContext(TreeItemContentContext)!; let renderProps = useRenderProps({ children: props.children, @@ -491,8 +493,10 @@ export interface TreeItemProps extends StyleRenderProps { + static readonly type = 'content'; + constructor(key: Key) { - super('item', key); + super(TreeItemNode.type, key); } } @@ -733,13 +737,15 @@ export interface TreeLoadMoreItemProps extends Omit { +class TreeLoaderNode extends CollectionNode { + static readonly type = 'content'; + constructor(key: Key) { - super('loader', key); + super(TreeLoaderNode.type, key); } } -export const TreeLoadMoreItem = createLeafComponent(LoaderNode, function TreeLoadingSentinel(props: TreeLoadMoreItemProps, ref: ForwardedRef, item: Node) { +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; From d1efa8d5dd38032c9b337df52396b0d331e414f5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 22 Jul 2025 17:18:03 -0700 Subject: [PATCH 07/34] prep stories --- .../stories/Autocomplete.stories.tsx | 147 +++++++++++++++++- .../stories/GridList.stories.tsx | 5 +- .../stories/Table.stories.tsx | 4 +- .../stories/TagGroup.stories.tsx | 7 +- .../stories/Tree.stories.tsx | 93 +++++------ 5 files changed, 202 insertions(+), 54 deletions(-) diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index afbb7d76ec5..21393fe6df8 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,15 +11,19 @@ */ 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, Link, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; import {Meta, StoryObj} from '@storybook/react'; +import {MyCheckbox} from './Table.stories'; import {MyListBoxItem, MyMenuItem} from './utils'; import {MyListBoxLoaderIndicator, renderEmptyState} from './ListBox.stories'; +import {MyTag} from './TagGroup.stories'; import React from 'react'; import styles from '../example/index.css'; +import {TreeExampleStaticRender} from './Tree.stories'; import {useAsyncList, useListData, useTreeData} from 'react-stately'; import {useFilter} from 'react-aria'; import './styles.css'; +import {MyGridListItem} from './GridList.stories'; export default { title: 'React Aria Components/Autocomplete', @@ -939,3 +943,144 @@ AutocompleteWithAsyncListBox.story = { delay: 50 } }; + +// TODO: I'm skipping Breadcrumbs, Tabs for now, not sure it makes sense to filter that via Autocomplete +// Filtering the Taggroup might make sense + +export const AutocompleteWithGridList = () => { + return ( + +
+ + + + + + 1,1 + 1,2 + 1,3 + 2,1 + 2,2 + 2,3 + 3,1 + 3,2 + 3,3 + +
+
+ ); +}; + +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 + + + + +
+
+ ); +}; + +export const AutocompleteWithTree = () => { + return ( + +
+ + + + + +
+
+ ); +}; 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 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..654fb465560 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 => ( From 77936ca3aab5716b9e13b72ecce0c29686d53375 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 23 Jul 2025 13:35:47 -0700 Subject: [PATCH 08/34] fix --- packages/react-aria-components/src/Table.tsx | 4 ++-- packages/react-aria-components/src/Tree.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 7a5bd7c8d74..2a85a18b36e 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -547,7 +547,7 @@ export interface TableHeaderProps extends StyleRenderProps { - static readonly type = 'header'; + static readonly type = 'tableheader'; constructor(key: Key) { super(TableHeaderNode.type, key); @@ -939,7 +939,7 @@ export interface TableBodyProps extends Omit, 'disabledKey // TODO: do we need this class TableBodyNode extends CollectionNode { - static readonly type = 'header'; + static readonly type = 'tablebody'; constructor(key: Key) { super(TableBodyNode.type, key); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 21443d65c10..ef4bb9bc917 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -493,7 +493,7 @@ export interface TreeItemProps extends StyleRenderProps { - static readonly type = 'content'; + static readonly type = 'item'; constructor(key: Key) { super(TreeItemNode.type, key); @@ -738,7 +738,7 @@ export interface TreeLoadMoreItemProps extends Omit { - static readonly type = 'content'; + static readonly type = 'loader'; constructor(key: Key) { super(TreeLoaderNode.type, key); From 799197773f1bb853e65657016899f7073c274d54 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 24 Jul 2025 10:27:52 -0700 Subject: [PATCH 09/34] add autocomplete gridlist filtering --- .../react-aria-components/src/GridList.tsx | 42 ++++++++++++------- .../stories/Autocomplete.stories.tsx | 20 ++++----- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index c5d654a5e0f..745fb8456b8 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -17,12 +17,13 @@ import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, I 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,25 @@ 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} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; 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,7 +131,7 @@ 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, @@ -135,9 +140,9 @@ function GridListInner({props, collection, gridListRef: ref}: 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 +168,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 +182,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 +202,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 +248,13 @@ function GridListInner({props, collection, gridListRef: ref}: data-layout={layout}> {isListDroppable && } @@ -279,13 +284,20 @@ export interface GridListItemProps extends RenderProps void } -// TODO: add filter funct to this +// TODO: reuse? class GridListNode extends CollectionNode { static readonly type = 'item'; constructor(key: Key) { super(GridListNode.type, key); } + filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { + if (filterFn(this.textValue)) { + return this.clone(); + } + + return null; + } } /** diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 21393fe6df8..9a6b7744270 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -957,17 +957,17 @@ export const AutocompleteWithGridList = () => { - 1,1 - 1,2 - 1,3 - 2,1 - 2,2 - 2,3 - 3,1 - 3,2 - 3,3 + Foo + Bar + Baz + Charizard + Blastoise + Pikachu + Venusaur + textValue is "text value check" + Blah
From e6158352dfd2bbbf9ba4dab0763ee336b093c9b7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 24 Jul 2025 17:03:50 -0700 Subject: [PATCH 10/34] taglist filter support --- .../react-aria-components/src/GridList.tsx | 4 ++++ .../react-aria-components/src/TagGroup.tsx | 20 +++++++++++++++---- .../stories/Autocomplete.stories.tsx | 1 + 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 745fb8456b8..46cf00780fe 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -542,6 +542,10 @@ class GridListLoaderNode extends CollectionNode { constructor(key: Key) { super(GridListLoaderNode.type, key); } + + filter(): CollectionNode | null { + return this.clone(); + } } export const GridListLoadMoreItem = createLeafComponent(GridListLoaderNode, function GridListLoadingIndicator(props: GridListLoadMoreItemProps, ref: ForwardedRef, item: Node) { diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index abe017dfa3f..d12940f7baf 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -18,10 +18,11 @@ import {ContextValue, DOMProps, Provider, RenderProps, SlotProps, StyleRenderPro 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,19 @@ interface TagGroupInnerProps { } function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProps) { + let {filter} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; 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])); @@ -96,7 +100,7 @@ function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProp ...props, ...domPropOverrides, label - }, state, tagListRef); + }, filteredState, tagListRef); return (
{ constructor(key: Key) { super(TagItemNode.type, key); } + + filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { + if (filterFn(this.textValue)) { + return this.clone(); + } + + return null; + } } /** diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 9a6b7744270..edcf998e178 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -946,6 +946,7 @@ AutocompleteWithAsyncListBox.story = { // TODO: I'm skipping Breadcrumbs, Tabs for now, not sure it makes sense to filter that via Autocomplete // Filtering the Taggroup might make sense +// TODO make all of the below examples async loading as well? export const AutocompleteWithGridList = () => { return ( From d02197ef9b37afef0836a1cefdf77bf7ab33bc88 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 25 Jul 2025 15:02:59 -0700 Subject: [PATCH 11/34] fixing lint --- packages/react-aria-components/stories/Autocomplete.stories.tsx | 2 +- packages/react-aria-components/stories/Tree.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index edcf998e178..a727152c2c5 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, Link, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; +import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; import {Meta, StoryObj} from '@storybook/react'; import {MyCheckbox} from './Table.stories'; import {MyListBoxItem, MyMenuItem} from './utils'; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 654fb465560..b21afa9adeb 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -144,7 +144,7 @@ const StaticTreeItemNoActions = (props: StaticTreeItemProps) => { ); }; -export function TreeExampleStaticRender(props: TreeProps) { +export function TreeExampleStaticRender(props: TreeProps) { return ( Photos From 361286bc49619becd4f843ba3baf562e7b10b89f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 25 Jul 2025 15:49:13 -0700 Subject: [PATCH 12/34] fix tag group keyboard nav and lint --- .../@react-aria/collections/src/Document.ts | 8 ++++---- packages/@react-spectrum/s2/src/ComboBox.tsx | 3 ++- .../s2/src/SkeletonCollection.tsx | 17 +++++++++++++++-- .../react-aria-components/src/Collection.tsx | 1 + .../react-aria-components/src/Separator.tsx | 4 +++- packages/react-aria-components/src/TagGroup.tsx | 2 +- packages/react-aria-components/src/index.ts | 2 +- 7 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index d09d4445d84..0ec859eb431 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -457,13 +457,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 { @@ -472,7 +472,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. */ @@ -518,7 +518,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-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..c7d0418a0c2 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 {CollectionNode, createLeafComponent} from '@react-aria/collections'; +import {Key} from '@react-types/shared'; import {ReactNode} from 'react'; import {Skeleton} from './Skeleton'; @@ -20,10 +21,22 @@ export interface SkeletonCollectionProps { let cache = new WeakMap(); +class SkeletonNode extends CollectionNode { + static readonly type = 'skeleton'; + + constructor(key: Key) { + super(SkeletonNode.type, key); + } + + filter(): CollectionNode | null { + return this.clone(); + } +} + /** * 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-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index b48e655195b..930023a7bb9 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -101,6 +101,7 @@ export const SectionContext = createContext(null); // TODO: should I update this since it is deprecated? /** @deprecated */ +// @ts-ignore export const Section = /*#__PURE__*/ createBranchComponent('section', (props: SectionProps, ref: ForwardedRef, section: Node): JSX.Element => { let {name, render} = useContext(SectionContext)!; if (process.env.NODE_ENV !== 'production') { diff --git a/packages/react-aria-components/src/Separator.tsx b/packages/react-aria-components/src/Separator.tsx index 4578c121755..b62eda3873e 100644 --- a/packages/react-aria-components/src/Separator.tsx +++ b/packages/react-aria-components/src/Separator.tsx @@ -21,13 +21,15 @@ export interface SeparatorProps extends AriaSeparatorProps, StyleProps, SlotProp export const SeparatorContext = createContext>({}); -class SeparatorNode extends CollectionNode { +export class SeparatorNode extends CollectionNode { static readonly type = 'separator'; constructor(key: Key) { super(SeparatorNode.type, key); } + // TODO: resolve this + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types filter(_, newCollection: BaseCollection): CollectionNode | null { if (newCollection.getItem(this.prevKey!)) { return this.clone(); diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index d12940f7baf..f8691386438 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -200,7 +200,7 @@ export interface TagProps extends RenderProps, LinkDOMProps, Hov // TODO probably can reuse item node class TagItemNode extends CollectionNode { - static readonly type = 'content'; + static readonly type = 'item'; constructor(key: Key) { super(TagItemNode.type, key); 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'; From 432a43c14e8318162eab2a73b6e22cd308ee2de3 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 25 Jul 2025 17:07:46 -0700 Subject: [PATCH 13/34] adding support for table filtering --- .../collections/src/BaseCollection.ts | 2 +- packages/@react-aria/collections/src/index.ts | 2 +- packages/@react-stately/table/src/index.ts | 2 +- .../@react-stately/table/src/useTableState.ts | 15 +++ packages/react-aria-components/src/Table.tsx | 80 +++++++++++++--- .../stories/Autocomplete.stories.tsx | 95 ++++++++++--------- packages/react-stately/src/index.ts | 2 +- 7 files changed, 138 insertions(+), 60 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 4811ad87da4..dd66a54e1b0 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -242,7 +242,7 @@ export class BaseCollection implements ICollection> { } } -function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (textValue: string) => boolean): [Key | null, Key | null] { +export function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (textValue: string) => boolean): [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 diff --git a/packages/@react-aria/collections/src/index.ts b/packages/@react-aria/collections/src/index.ts index 5052465351f..041235e843b 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, filterChildren} from './BaseCollection'; export type {CollectionBuilderProps, CollectionProps} from './CollectionBuilder'; export type {CachedChildrenOptions} from './useCachedChildren'; 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..e155dab387a 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 ListState. + */ +export function UNSTABLE_useFilteredTableState(state: TableState, filterFn: ((nodeValue: string) => 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-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 2a85a18b36e..d3fbf201ce4 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, filterChildren, 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,11 @@ 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'; + +export type Mutable = { + -readonly[P in keyof T]: T[P] +} class TableCollection extends BaseCollection implements ITableCollection { headerRows: GridNode[] = []; @@ -160,6 +165,7 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection boolean): TableCollection { + // TODO: ideally we wouldn't need to reimplement this but we need a TableCollection, not a BaseCollection + // Also need to handle the fact that a bunch of properites are private + let clone = this.clone() as Mutable>; + // @ts-ignore + let [firstKey, lastKey] = filterChildren(this, clone, this.firstKey, filterFn); + // @ts-ignore + clone.firstKey = firstKey; + // @ts-ignore + clone.lastKey = lastKey; + // @ts-ignore + return clone; + } } interface ResizableTableContainerContextValue { @@ -363,23 +383,25 @@ interface TableInnerProps { function TableInner({props, forwardedRef: ref, selectionState, collection}: TableInnerProps) { + let {filter} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; 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, 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 +427,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 +441,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 +470,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isDropTarget: isRootDropTarget, isFocused, isFocusVisible, - state + state: filteredState } }); @@ -459,7 +481,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 +497,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl return ( @@ -552,6 +574,10 @@ class TableHeaderNode extends CollectionNode { constructor(key: Key) { super(TableHeaderNode.type, key); } + + filter(): CollectionNode | null { + return this.clone(); + } } /** @@ -699,6 +725,10 @@ class TableColumnNode extends CollectionNode { constructor(key: Key) { super(TableColumnNode.type, key); } + + filter(): CollectionNode | null { + return this.clone(); + } } /** @@ -938,12 +968,17 @@ export interface TableBodyProps extends Omit, 'disabledKey } // TODO: do we need this +// These should probably be expecting TableCollection, will need to update others class TableBodyNode extends CollectionNode { static readonly type = 'tablebody'; constructor(key: Key) { super(TableBodyNode.type, key); } + + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { + return super.filter(collection, newCollection, filterFn); + } } /** @@ -1054,6 +1089,19 @@ class TableRowNode extends CollectionNode { constructor(key: Key) { super(TableRowNode.type, key); } + + // TODO: bug is that filtering retains all rows after before the last match + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { + // todo walk children and if any match, just return whole thing? + let cells = collection.getChildren(this.key); + for (let cell of cells) { + if (filterFn(cell.textValue)) { + return this.clone(); + } + } + + return null; + } } /** @@ -1247,6 +1295,10 @@ class TableCellNode extends CollectionNode { constructor(key: Key) { super(TableCellNode.type, key); } + + filter(): CollectionNode | null { + return this.clone(); + } } /** @@ -1414,6 +1466,10 @@ class TableLoaderNode extends CollectionNode { constructor(key: Key) { super(TableLoaderNode.type, key); } + + filter(): CollectionNode | null { + return this.clone(); + } } export const TableLoadMoreItem = createLeafComponent(TableLoaderNode, function TableLoadingIndicator(props: TableLoadMoreItemProps, ref: ForwardedRef, item: Node) { diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index a727152c2c5..eb4a7c5945a 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; +import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TableLayout, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; import {Meta, StoryObj} from '@storybook/react'; import {MyCheckbox} from './Table.stories'; import {MyListBoxItem, MyMenuItem} from './utils'; @@ -983,50 +983,57 @@ export const AutocompleteWithTable = () => { - - - - - - 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 - - -
+ + 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 + + + + ); 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'; From 3ec3fd642ec0b1c75d1f813d9cef848645ff990c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 28 Jul 2025 11:11:01 -0700 Subject: [PATCH 14/34] fix tableCollection filter so it doesnt need to call filterChildren directly --- .../collections/src/BaseCollection.ts | 9 ++++++--- .../collections/src/CollectionBuilder.tsx | 4 ---- .../@react-aria/collections/src/Document.ts | 1 - packages/@react-aria/collections/src/index.ts | 2 +- .../@react-stately/table/src/useTableState.ts | 2 +- .../react-aria-components/src/Breadcrumbs.tsx | 6 +++++- .../react-aria-components/src/GridList.tsx | 3 --- packages/react-aria-components/src/ListBox.tsx | 4 ---- packages/react-aria-components/src/Table.tsx | 18 +++++------------- 9 files changed, 18 insertions(+), 31 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index dd66a54e1b0..1daf8aceab4 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -233,8 +233,11 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - filter(filterFn: (textValue: string) => boolean): BaseCollection { - let newCollection = new BaseCollection(); + filter(filterFn: (textValue: string) => boolean, newCollection?: BaseCollection): BaseCollection { + if (newCollection == null) { + newCollection = new BaseCollection(); + } + let [firstKey, lastKey] = filterChildren(this, newCollection, this.firstKey, filterFn); newCollection.firstKey = firstKey; newCollection.lastKey = lastKey; @@ -242,7 +245,7 @@ export class BaseCollection implements ICollection> { } } -export function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (textValue: string) => boolean): [Key | null, Key | null] { +function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (textValue: string) => boolean): [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 diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index baf68a0f97e..b8f2dc0d3f6 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -140,7 +140,6 @@ function useSSRCollectionNode(CollectionNodeClass: Collection // 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) => { - // TODO: check setProps api element?.setProps(props, ref, rendered, render, CollectionNodeClass); }, [props, ref, rendered, render, CollectionNodeClass]); let parentNode = useContext(SSRContext); @@ -148,7 +147,6 @@ function useSSRCollectionNode(CollectionNodeClass: Collection // Guard against double rendering in strict mode. let element = parentNode.ownerDocument.nodesByProps.get(props); if (!element) { - // TODO: check this, maybe should just pass the CollectionNodeClass as a whole? element = parentNode.ownerDocument.createElement(CollectionNodeClass.type); element.setProps(props, ref, rendered, render); parentNode.appendChild(element); @@ -161,7 +159,6 @@ function useSSRCollectionNode(CollectionNodeClass: Collection : null; } - // console.log('type', CollectionNodeClass, CollectionNodeClass.type) // @ts-ignore // TODO: could just make this a div perhaps, but keep it in line with how it used to work return {children}; @@ -201,7 +198,6 @@ export function createLeafComponent

(Collect return Result; } -// TODO: check the signature of this too export function createBranchComponent(CollectionNodeClass: CollectionNodeClass, 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) => { diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 0ec859eb431..503f43c9617 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -324,7 +324,6 @@ export class ElementNode extends BaseNode { } } - // TODO setProps(obj: {[key: string]: any}, ref: ForwardedRef, rendered?: ReactNode, render?: (node: Node) => ReactElement, CollectionNodeClass?: CollectionNodeClass): void { let node = this.getMutableNode(); let {value, textValue, id, ...props} = obj; diff --git a/packages/@react-aria/collections/src/index.ts b/packages/@react-aria/collections/src/index.ts index 041235e843b..5052465351f 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, filterChildren} from './BaseCollection'; +export {BaseCollection, CollectionNode} from './BaseCollection'; export type {CollectionBuilderProps, CollectionProps} from './CollectionBuilder'; export type {CachedChildrenOptions} from './useCachedChildren'; diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index e155dab387a..c9d73059c7a 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -109,7 +109,7 @@ export function useTableState(props: TableStateProps): Tabl } /** - * Filters a collection using the provided filter function and returns a new ListState. + * Filters a collection using the provided filter function and returns a new TableState. */ export function UNSTABLE_useFilteredTableState(state: TableState, filterFn: ((nodeValue: string) => boolean) | null | undefined): TableState { let collection = useMemo(() => filterFn ? state.collection.filter!(filterFn) : state.collection, [state.collection, filterFn]) as ITableCollection; diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 532033a052d..bb30c2f1696 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -73,13 +73,17 @@ export interface BreadcrumbProps extends RenderProps, Glo id?: Key } -// TODO: perhaps this should be reuse ItemNode, for now just have it separate class BreadcrumbNode extends CollectionNode { static readonly type = 'item'; constructor(key: Key) { super(BreadcrumbNode.type, key); } + + // For, don't support Breadcrumb filtering + filter(): CollectionNode | null { + return this.clone(); + } } /** diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 46cf00780fe..badf9380341 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -284,7 +284,6 @@ export interface GridListItemProps extends RenderProps void } -// TODO: reuse? class GridListNode extends CollectionNode { static readonly type = 'item'; constructor(key: Key) { @@ -534,8 +533,6 @@ export interface GridListLoadMoreItemProps extends Omit { static readonly type = 'loader'; diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 55df5953997..115594bf236 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -305,8 +305,6 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re ); } - -// TODO: reuse export class ListBoxSectionNode extends CollectionNode { static readonly type = 'section'; @@ -354,7 +352,6 @@ export interface ListBoxItemProps extends RenderProps void } -// TODO: reusue class ListBoxItemNode extends CollectionNode { static readonly type = 'item'; @@ -511,7 +508,6 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe ); } -// TODO: can reuse this most likely class ListBoxLoaderNode extends CollectionNode { static readonly type = 'loader'; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index d3fbf201ce4..c3e91925cf9 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,5 +1,5 @@ import {AriaLabelingProps, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; -import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, filterChildren, useCachedChildren} from '@react-aria/collections'; +import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, useCachedChildren} from '@react-aria/collections'; import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; @@ -165,7 +165,7 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection boolean): TableCollection { - // TODO: ideally we wouldn't need to reimplement this but we need a TableCollection, not a BaseCollection - // Also need to handle the fact that a bunch of properites are private - let clone = this.clone() as Mutable>; - // @ts-ignore - let [firstKey, lastKey] = filterChildren(this, clone, this.firstKey, filterFn); - // @ts-ignore - clone.firstKey = firstKey; - // @ts-ignore - clone.lastKey = lastKey; - // @ts-ignore - return clone; + let clone = this.clone(); + return super.filter(filterFn, clone) as TableCollection; + } } From 4a69d504d482b4a8f730cb46a60f626dfeeaaf5d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 28 Jul 2025 13:36:25 -0700 Subject: [PATCH 15/34] create common use nodes for specific filtering patterns --- .../collections/src/BaseCollection.ts | 50 +++++++++++++++++++ packages/@react-aria/collections/src/index.ts | 2 +- .../s2/src/SkeletonCollection.tsx | 8 +-- .../react-aria-components/src/Breadcrumbs.tsx | 9 +--- .../react-aria-components/src/GridList.tsx | 24 ++------- packages/react-aria-components/src/Header.tsx | 8 +-- .../react-aria-components/src/ListBox.tsx | 46 ++--------------- packages/react-aria-components/src/Menu.tsx | 44 ++-------------- packages/react-aria-components/src/Table.tsx | 45 +++-------------- packages/react-aria-components/src/Tabs.tsx | 5 +- .../react-aria-components/src/TagGroup.tsx | 19 +------ 11 files changed, 79 insertions(+), 181 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 1daf8aceab4..ee6aa4361a4 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -78,6 +78,56 @@ export class CollectionNode implements Node { } } +// 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 { + // TODO: resolve this + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-unused-vars + filter(_, __, ___): FilterLessNode | null { + return this.clone(); + } +} + +export class ItemNode extends CollectionNode { + static readonly type = 'item'; + + constructor(key: Key) { + super(ItemNode.type, key); + } + + // TODO: resolve this + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + filter(_, __, filterFn: (textValue: string) => boolean): ItemNode | null { + if (filterFn(this.textValue)) { + 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: (textValue: string) => boolean): 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; + } +} + /** * An immutable Collection implementation. Updates are only allowed * when it is not marked as frozen. This can be subclassed to implement 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-spectrum/s2/src/SkeletonCollection.tsx b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx index c7d0418a0c2..cc75a2fe81a 100644 --- a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx +++ b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CollectionNode, 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'; @@ -21,16 +21,12 @@ export interface SkeletonCollectionProps { let cache = new WeakMap(); -class SkeletonNode extends CollectionNode { +class SkeletonNode extends FilterLessNode { static readonly type = 'skeleton'; constructor(key: Key) { super(SkeletonNode.type, key); } - - filter(): CollectionNode | null { - return this.clone(); - } } /** diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index bb30c2f1696..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, CollectionNode, 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,17 +73,12 @@ export interface BreadcrumbProps extends RenderProps, Glo id?: Key } -class BreadcrumbNode extends CollectionNode { +class BreadcrumbNode extends FilterLessNode { static readonly type = 'item'; constructor(key: Key) { super(BreadcrumbNode.type, key); } - - // For, don't support Breadcrumb filtering - filter(): CollectionNode | null { - return this.clone(); - } } /** diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index badf9380341..dcc423d6788 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -12,7 +12,7 @@ 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, CollectionNode, 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'; @@ -284,20 +284,7 @@ export interface GridListItemProps extends RenderProps void } -class GridListNode extends CollectionNode { - static readonly type = 'item'; - constructor(key: Key) { - super(GridListNode.type, key); - } - - filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { - if (filterFn(this.textValue)) { - return this.clone(); - } - - return null; - } -} +class GridListNode extends ItemNode {} /** * A GridListItem represents an individual item in a GridList. @@ -533,16 +520,13 @@ export interface GridListLoadMoreItemProps extends Omit { +// TODO: maybe make a general loader node +class GridListLoaderNode extends FilterLessNode { static readonly type = 'loader'; constructor(key: Key) { super(GridListLoaderNode.type, key); } - - filter(): CollectionNode | null { - return this.clone(); - } } export const GridListLoadMoreItem = createLeafComponent(GridListLoaderNode, function GridListLoadingIndicator(props: GridListLoadMoreItemProps, ref: ForwardedRef, item: Node) { diff --git a/packages/react-aria-components/src/Header.tsx b/packages/react-aria-components/src/Header.tsx index 4092f8b9576..d76d9df57d1 100644 --- a/packages/react-aria-components/src/Header.tsx +++ b/packages/react-aria-components/src/Header.tsx @@ -10,23 +10,19 @@ * governing permissions and limitations under the License. */ -import {CollectionNode, createLeafComponent} from '@react-aria/collections'; import {ContextValue, useContextProps} from './utils'; +import {createLeafComponent, FilterLessNode} from '@react-aria/collections'; import {Key} from '@react-types/shared'; import React, {createContext, ForwardedRef, HTMLAttributes} from 'react'; export const HeaderContext = createContext, HTMLElement>>({}); -class HeaderNode extends CollectionNode { +class HeaderNode extends FilterLessNode { static readonly type = 'header'; constructor(key: Key) { super(HeaderNode.type, key); } - - filter(): CollectionNode { - return this.clone(); - } } export const Header = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 115594bf236..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 {BaseCollection, Collection, CollectionBuilder, CollectionNode, 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,27 +305,7 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re ); } -export class ListBoxSectionNode extends CollectionNode { - static readonly type = 'section'; - - constructor(key: Key) { - super(ListBoxSectionNode.type, key); - } - - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | 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; - } -} +export class ListBoxSectionNode extends SectionNode {} /** * A ListBoxSection represents a section within a ListBox. @@ -352,21 +332,7 @@ export interface ListBoxItemProps extends RenderProps void } -class ListBoxItemNode extends CollectionNode { - static readonly type = 'item'; - - constructor(key: Key) { - super(ListBoxItemNode.type, key); - } - - filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { - if (filterFn(this.textValue)) { - return this.clone(); - } - - return null; - } -} +class ListBoxItemNode extends ItemNode {} /** * A ListBoxItem represents an individual option in a ListBox. @@ -508,16 +474,12 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe ); } -class ListBoxLoaderNode extends CollectionNode { +class ListBoxLoaderNode extends FilterLessNode { static readonly type = 'loader'; constructor(key: Key) { super(ListBoxLoaderNode.type, key); } - - filter(): CollectionNode | null { - return this.clone(); - } } const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index c69c492494d..7af3f5b1148 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, CollectionNode, 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'; @@ -339,29 +339,7 @@ function MenuSectionInner(props: MenuSectionProps, ref: For ); } -// TODO: can probably reuse the SectionNode from ListBox? Do this last in case there is something different in the implementation? Or maybe keep them unique in case -// down the line we need to differentiate the two? -class MenuSectionNode extends CollectionNode { - static readonly type = 'section'; - - constructor(key: Key) { - super(MenuSectionNode.type, key); - } - - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | 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; - } -} +class MenuSectionNode extends SectionNode {} /** * A MenuSection represents a section within a Menu. @@ -400,23 +378,7 @@ export interface MenuItemProps extends RenderProps>(null); -// TODO maybe this needs to be a separate node type? Or maybe it should just reuse the ItemNode from ListBox (reuse later if need be) -// There is probably some merit to separating it like we already do for ListBoxItem/MenuItem/etc -class MenuItemNode extends CollectionNode { - static readonly type = 'item'; - - constructor(key: Key) { - super(MenuItemNode.type, key); - } - - filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { - if (filterFn(this.textValue)) { - return this.clone(); - } - - return null; - } -} +class MenuItemNode extends ItemNode {} /** * A MenuItem represents an individual action in a Menu. diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index c3e91925cf9..e5312fb7d1c 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,5 +1,5 @@ import {AriaLabelingProps, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; -import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, 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'; @@ -558,18 +558,12 @@ export interface TableHeaderProps extends StyleRenderProps } -// TODO: will this have any logic? Maybe for ones like this where we aren't adding the filter function just yet we could -// keep it as returning the string instead of the class in createBranchComponent -class TableHeaderNode extends CollectionNode { +class TableHeaderNode extends FilterLessNode { static readonly type = 'tableheader'; constructor(key: Key) { super(TableHeaderNode.type, key); } - - filter(): CollectionNode | null { - return this.clone(); - } } /** @@ -709,18 +703,12 @@ export interface ColumnProps extends RenderProps, GlobalDOMAt maxWidth?: ColumnStaticSize | null } - -// TODO does this need to be separate or should ItemNode be generic enough that it can take an arbitrary "type"? -class TableColumnNode extends CollectionNode { +class TableColumnNode extends FilterLessNode { static readonly type = 'column'; constructor(key: Key) { super(TableColumnNode.type, key); } - - filter(): CollectionNode | null { - return this.clone(); - } } /** @@ -959,18 +947,12 @@ export interface TableBodyProps extends Omit, 'disabledKey renderEmptyState?: (props: TableBodyRenderProps) => ReactNode } -// TODO: do we need this -// These should probably be expecting TableCollection, will need to update others class TableBodyNode extends CollectionNode { static readonly type = 'tablebody'; constructor(key: Key) { super(TableBodyNode.type, key); } - - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { - return super.filter(collection, newCollection, filterFn); - } } /** @@ -1074,17 +1056,14 @@ export interface RowProps extends StyleRenderProps, LinkDOMPr id?: Key } -// TODO: maybe can reuse the item node, but probably will have different filter logic here so splitting out for now -class TableRowNode extends CollectionNode { +class TableRowNode extends CollectionNode { static readonly type = 'item'; constructor(key: Key) { super(TableRowNode.type, key); } - // TODO: bug is that filtering retains all rows after before the last match - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { - // todo walk children and if any match, just return whole thing? + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): TableRowNode | null { let cells = collection.getChildren(this.key); for (let cell of cells) { if (filterFn(cell.textValue)) { @@ -1280,17 +1259,12 @@ export interface CellProps extends RenderProps, GlobalDOMAttrib colSpan?: number } -// TODO: Also perhaps can just be ItemNode? -class TableCellNode extends CollectionNode { +class TableCellNode extends FilterLessNode { static readonly type = 'cell'; constructor(key: Key) { super(TableCellNode.type, key); } - - filter(): CollectionNode | null { - return this.clone(); - } } /** @@ -1451,17 +1425,12 @@ export interface TableLoadMoreItemProps extends Omit { +class TableLoaderNode extends FilterLessNode { static readonly type = 'loader'; constructor(key: Key) { super(TableLoaderNode.type, key); } - - filter(): CollectionNode | null { - return this.clone(); - } } export const TableLoadMoreItem = createLeafComponent(TableLoaderNode, function TableLoadingIndicator(props: TableLoadMoreItemProps, ref: ForwardedRef, item: Node) { diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 8fb93051459..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, CollectionNode, 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,8 +235,7 @@ function TabListInner({props, forwardedRef: ref}: TabListInner ); } -// TODO probably can reuse ItemNode -class TabItemNode extends CollectionNode { +class TabItemNode extends FilterLessNode { static readonly type = 'item'; constructor(key: Key) { diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index f8691386438..af016444d57 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -12,7 +12,7 @@ import {AriaTagGroupProps, useFocusRing, useHover, useTag, useTagGroup} from 'react-aria'; import {ButtonContext} from './Button'; -import {Collection, CollectionBuilder, CollectionNode, 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'; @@ -198,22 +198,7 @@ export interface TagProps extends RenderProps, LinkDOMProps, Hov isDisabled?: boolean } -// TODO probably can reuse item node -class TagItemNode extends CollectionNode { - static readonly type = 'item'; - - constructor(key: Key) { - super(TagItemNode.type, key); - } - - filter(_, __, filterFn: (textValue: string) => boolean): CollectionNode | null { - if (filterFn(this.textValue)) { - return this.clone(); - } - - return null; - } -} +class TagItemNode extends ItemNode {} /** * A Tag is an individual item within a TagList. From 73a19713ea6d8aba351e8fc1d00c37a546378411 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 28 Jul 2025 13:55:10 -0700 Subject: [PATCH 16/34] fix ssr --- packages/@react-aria/collections/src/CollectionBuilder.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index b8f2dc0d3f6..3fa7fd515e5 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -148,7 +148,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection let element = parentNode.ownerDocument.nodesByProps.get(props); if (!element) { element = parentNode.ownerDocument.createElement(CollectionNodeClass.type); - element.setProps(props, ref, rendered, render); + element.setProps(props, ref, rendered, render, CollectionNodeClass); parentNode.appendChild(element); parentNode.ownerDocument.updateCollection(); parentNode.ownerDocument.nodesByProps.set(props, element); From 1ead59b6f8c1da5df209d258cb8cf74272abacdd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 28 Jul 2025 14:48:01 -0700 Subject: [PATCH 17/34] refactor to accept a node rather than a string in the filter function --- .../@react-aria/autocomplete/src/useAutocomplete.ts | 8 ++++---- .../@react-aria/collections/src/BaseCollection.ts | 12 ++++++------ packages/@react-stately/list/src/useListState.ts | 2 +- packages/@react-stately/table/src/useTableState.ts | 2 +- packages/@react-types/shared/src/collections.d.ts | 2 +- packages/react-aria-components/src/Autocomplete.tsx | 3 ++- packages/react-aria-components/src/Menu.tsx | 4 ++-- packages/react-aria-components/src/Table.tsx | 4 ++-- 8 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 1c449ba292c..270d88d1463 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, BaseEvent, DOMProps, 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'; @@ -56,7 +56,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?: (node: Node) => boolean } /** @@ -316,9 +316,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl 'aria-label': stringFormatter.format('collectionLabel') }); - let filterFn = useCallback((nodeTextValue: string) => { + let filterFn = useCallback((node: Node) => { if (filter) { - return filter(nodeTextValue, state.inputValue); + return filter(node.textValue, state.inputValue); } return true; diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index ee6aa4361a4..044d496255c 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -69,7 +69,7 @@ export class CollectionNode implements Node { return node; } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): CollectionNode | null { let [firstKey, lastKey] = filterChildren(collection, newCollection, this.firstChildKey, filterFn); let newNode: Mutable> = this.clone(); newNode.firstChildKey = firstKey; @@ -97,8 +97,8 @@ export class ItemNode extends CollectionNode { // TODO: resolve this // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - filter(_, __, filterFn: (textValue: string) => boolean): ItemNode | null { - if (filterFn(this.textValue)) { + filter(_, __, filterFn: (node: Node) => boolean): ItemNode | null { + if (filterFn(this)) { return this.clone(); } @@ -113,7 +113,7 @@ export class SectionNode extends CollectionNode { super(SectionNode.type, key); } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): SectionNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): SectionNode | null { let filteredSection = super.filter(collection, newCollection, filterFn); if (filteredSection) { if (filteredSection.lastChildKey !== null) { @@ -283,7 +283,7 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - filter(filterFn: (textValue: string) => boolean, newCollection?: BaseCollection): BaseCollection { + filter(filterFn: (node: Node) => boolean, newCollection?: BaseCollection): BaseCollection { if (newCollection == null) { newCollection = new BaseCollection(); } @@ -295,7 +295,7 @@ export class BaseCollection implements ICollection> { } } -function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (textValue: string) => boolean): [Key | null, Key | null] { +function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (node: Node) => boolean): [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 diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts index 8456ec6cc24..a64f28f41be 100644 --- a/packages/@react-stately/list/src/useListState.ts +++ b/packages/@react-stately/list/src/useListState.ts @@ -73,7 +73,7 @@ export function useListState(props: ListProps): ListState(state: ListState, filterFn: ((nodeValue: string) => boolean) | null | undefined): ListState { +export function UNSTABLE_useFilteredListState(state: ListState, filterFn: ((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); diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index c9d73059c7a..3a69acddaef 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -111,7 +111,7 @@ 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) => boolean) | null | undefined): TableState { +export function UNSTABLE_useFilteredTableState(state: TableState, filterFn: ((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 diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index b5d3dee3f7e..ec51a0f9917 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. */ - filter?(filterFn: (nodeValue: string) => boolean): Collection + filter?(filterFn: (node: T) => boolean): Collection } export interface Node { diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 451598e5f40..1809eab6eb7 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -14,6 +14,7 @@ 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'; @@ -22,7 +23,7 @@ import {TextFieldContext} from './TextField'; export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} interface InternalAutocompleteContextValue { - filter?: (nodeTextValue: string) => boolean, + filter?: (node: Node) => boolean, collectionProps: CollectionOptions, collectionRef: RefObject } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 7af3f5b1148..0d51909008a 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -114,9 +114,9 @@ class SubmenuTriggerNode extends CollectionNode { super(SubmenuTriggerNode.type, key); } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): CollectionNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): CollectionNode | null { let triggerNode = collection.getItem(this.firstChildKey!); - if (triggerNode && filterFn(triggerNode.textValue)) { + if (triggerNode && filterFn(triggerNode)) { // TODO: perhaps should call super.filter for correctness, but basically add the menu item child of the submenutrigger // to the keymap so it renders newCollection.addNode(triggerNode as CollectionNode); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index e5312fb7d1c..a7dc094dd32 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1063,10 +1063,10 @@ class TableRowNode extends CollectionNode { super(TableRowNode.type, key); } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string) => boolean): TableRowNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): TableRowNode | null { let cells = collection.getChildren(this.key); for (let cell of cells) { - if (filterFn(cell.textValue)) { + if (filterFn(cell)) { return this.clone(); } } From 90c2056690e080722c2ada32dc0fb6b0f8d69777 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 28 Jul 2025 15:01:48 -0700 Subject: [PATCH 18/34] fix lint --- packages/@react-aria/collections/src/BaseCollection.ts | 9 +++------ packages/react-aria-components/src/Separator.tsx | 4 +--- packages/react-aria-components/src/Table.tsx | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 044d496255c..e1e61c0b898 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -81,9 +81,8 @@ export class CollectionNode implements Node { // 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 { - // TODO: resolve this - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-unused-vars - filter(_, __, ___): FilterLessNode | null { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): FilterLessNode | null { return this.clone(); } } @@ -95,9 +94,7 @@ export class ItemNode extends CollectionNode { super(ItemNode.type, key); } - // TODO: resolve this - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - filter(_, __, filterFn: (node: Node) => boolean): ItemNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): ItemNode | null { if (filterFn(this)) { return this.clone(); } diff --git a/packages/react-aria-components/src/Separator.tsx b/packages/react-aria-components/src/Separator.tsx index b62eda3873e..71795198085 100644 --- a/packages/react-aria-components/src/Separator.tsx +++ b/packages/react-aria-components/src/Separator.tsx @@ -28,9 +28,7 @@ export class SeparatorNode extends CollectionNode { super(SeparatorNode.type, key); } - // TODO: resolve this - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - filter(_, newCollection: BaseCollection): CollectionNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection): CollectionNode | null { if (newCollection.getItem(this.prevKey!)) { return this.clone(); } diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index a7dc094dd32..083caee296c 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -197,7 +197,7 @@ class TableCollection extends BaseCollection implements ITableCollection boolean): TableCollection { + filter(filterFn: (node: Node) => boolean): TableCollection { let clone = this.clone(); return super.filter(filterFn, clone) as TableCollection; From 3a8301e6be0193573ced15166a7c2a0258712b00 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 31 Jul 2025 10:48:51 -0700 Subject: [PATCH 19/34] make node param in autocomplete non breaking --- .../autocomplete/src/useAutocomplete.ts | 11 +++-- .../collections/src/BaseCollection.ts | 16 ++++--- .../@react-stately/list/src/useListState.ts | 2 +- .../@react-stately/table/src/useTableState.ts | 2 +- .../@react-types/shared/src/collections.d.ts | 2 +- .../src/Autocomplete.tsx | 2 +- packages/react-aria-components/src/Menu.tsx | 4 +- packages/react-aria-components/src/Table.tsx | 6 +-- .../stories/Autocomplete.stories.tsx | 43 ++++++++++++------- 9 files changed, 53 insertions(+), 35 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 270d88d1463..7e51def1805 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -27,12 +27,14 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps { /** Whether typeahead is disabled. */ disallowTypeAhead: boolean } + +// TODO: is in beta so technically could replace textValue with Node if we are comfortable with that 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. @@ -55,8 +57,9 @@ export interface AutocompleteAria { collectionProps: CollectionOptions, /** Ref to attach to the wrapped collection. */ collectionRef: RefObject, + // TODO: same as above, replace nodeTextValue? /** A filter function that returns if the provided collection node should be filtered out of the collection. */ - filter?: (node: Node) => boolean + filter?: (nodeTextValue: string, node: Node) => boolean } /** @@ -316,9 +319,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl 'aria-label': stringFormatter.format('collectionLabel') }); - let filterFn = useCallback((node: Node) => { + let filterFn = useCallback((nodeTextValue: string, node: Node) => { if (filter) { - return filter(node.textValue, state.inputValue); + return filter(nodeTextValue, state.inputValue, node); } return true; diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index e1e61c0b898..80128a6362f 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; @@ -69,7 +71,7 @@ export class CollectionNode implements Node { return node; } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): CollectionNode | null { + 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; @@ -82,7 +84,7 @@ export class CollectionNode implements Node { // 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: (node: Node) => boolean): FilterLessNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): FilterLessNode | null { return this.clone(); } } @@ -94,8 +96,8 @@ export class ItemNode extends CollectionNode { super(ItemNode.type, key); } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): ItemNode | null { - if (filterFn(this)) { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): ItemNode | null { + if (filterFn(this.textValue, this)) { return this.clone(); } @@ -110,7 +112,7 @@ export class SectionNode extends CollectionNode { super(SectionNode.type, key); } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): SectionNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): SectionNode | null { let filteredSection = super.filter(collection, newCollection, filterFn); if (filteredSection) { if (filteredSection.lastChildKey !== null) { @@ -280,7 +282,7 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - filter(filterFn: (node: Node) => boolean, newCollection?: BaseCollection): BaseCollection { + filter(filterFn: FilterFn, newCollection?: BaseCollection): BaseCollection { if (newCollection == null) { newCollection = new BaseCollection(); } @@ -292,7 +294,7 @@ export class BaseCollection implements ICollection> { } } -function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: (node: Node) => boolean): [Key | null, Key | null] { +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 diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts index a64f28f41be..c373633426e 100644 --- a/packages/@react-stately/list/src/useListState.ts +++ b/packages/@react-stately/list/src/useListState.ts @@ -73,7 +73,7 @@ export function useListState(props: ListProps): ListState(state: ListState, filterFn: ((node: Node) => boolean) | null | undefined): ListState { +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); diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index 3a69acddaef..d18d173161f 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -111,7 +111,7 @@ 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: ((node: Node) => boolean) | null | undefined): 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 diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index ec51a0f9917..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. */ - filter?(filterFn: (node: T) => boolean): Collection + filter?(filterFn: (nodeValue: string, node: T) => boolean): Collection } export interface Node { diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 1809eab6eb7..8f7b5dbc519 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -23,7 +23,7 @@ import {TextFieldContext} from './TextField'; export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} interface InternalAutocompleteContextValue { - filter?: (node: Node) => boolean, + filter?: (nodeTextValue: string, node: Node) => boolean, collectionProps: CollectionOptions, collectionRef: RefObject } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 0d51909008a..c8ed1cf1e22 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -114,9 +114,9 @@ class SubmenuTriggerNode extends CollectionNode { super(SubmenuTriggerNode.type, key); } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): CollectionNode | null { + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): CollectionNode | null { let triggerNode = collection.getItem(this.firstChildKey!); - if (triggerNode && filterFn(triggerNode)) { + if (triggerNode && filterFn(triggerNode.textValue, triggerNode)) { // TODO: perhaps should call super.filter for correctness, but basically add the menu item child of the submenutrigger // to the keymap so it renders newCollection.addNode(triggerNode as CollectionNode); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 083caee296c..f2c1c217e50 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -197,7 +197,7 @@ class TableCollection extends BaseCollection implements ITableCollection) => boolean): TableCollection { + filter(filterFn: (textValue: string, node: Node) => boolean): TableCollection { let clone = this.clone(); return super.filter(filterFn, clone) as TableCollection; @@ -1063,10 +1063,10 @@ class TableRowNode extends CollectionNode { super(TableRowNode.type, key); } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (node: Node) => boolean): TableRowNode | null { + 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)) { + if (filterFn(cell.textValue, cell)) { return this.clone(); } } diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index eb4a7c5945a..3a4e790705a 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -944,10 +944,6 @@ AutocompleteWithAsyncListBox.story = { } }; -// TODO: I'm skipping Breadcrumbs, Tabs for now, not sure it makes sense to filter that via Autocomplete -// Filtering the Taggroup might make sense -// TODO make all of the below examples async loading as well? - export const AutocompleteWithGridList = () => { return ( @@ -987,7 +983,8 @@ export const AutocompleteWithTable = () => { layout={TableLayout} layoutOptions={{ rowHeight: 25, - headingHeight: 25 + headingHeight: 25, + padding: 10 }}> @@ -999,7 +996,7 @@ export const AutocompleteWithTable = () => { Date Modified - + @@ -1007,7 +1004,7 @@ export const AutocompleteWithTable = () => { File folder 6/7/2020 - + @@ -1015,7 +1012,7 @@ export const AutocompleteWithTable = () => { File folder 4/7/2021 - + @@ -1023,7 +1020,7 @@ export const AutocompleteWithTable = () => { System file 11/20/2010 - + @@ -1079,16 +1076,32 @@ export const AutocompleteWithTagGroup = () => { ); }; -export const AutocompleteWithTree = () => { +function AutocompletePreserveFirstSection(args) { + let {contains} = useFilter({sensitivity: 'base'}); + let filter = (textValue, inputValue, node) => { + if (node.parentKey === 'Section 1') { + return true; + } + return contains(textValue, inputValue); + }; + return ( - +
- + - - + Please select an option below. + + + {item => dynamicRenderFuncSections(item)} +
-
+ ); +} + +export const AutocompletePreserveFirstSectionStory: AutocompleteStory = { + render: (args) => , + name: 'Autocomplete, never filter first section' }; From 9d65d5b28003b6a463bcb8c053fcdabf5d20e43d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 31 Jul 2025 16:37:04 -0700 Subject: [PATCH 20/34] adding tests, make sure we only apply autocomplete attributes if the wrapped collection is filterable --- .../autocomplete/src/useAutocomplete.ts | 29 +- .../collections/src/BaseCollection.ts | 4 +- .../collections/src/CollectionBuilder.tsx | 1 + .../react-aria-components/src/GridList.tsx | 5 +- packages/react-aria-components/src/Table.tsx | 5 +- .../react-aria-components/src/TagGroup.tsx | 5 +- packages/react-aria-components/src/Tree.tsx | 10 +- .../stories/Autocomplete.stories.tsx | 1 - .../test/AriaAutocomplete.test-util.tsx | 466 ++++++++++-------- .../test/Autocomplete.test.tsx | 161 +++++- 10 files changed, 463 insertions(+), 224 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 7e51def1805..7575de9d620 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,7 +13,7 @@ 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 @@ -28,7 +28,7 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps { disallowTypeAhead: boolean } -// TODO: is in beta so technically could replace textValue with Node if we are comfortable with that +// 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. @@ -57,7 +57,6 @@ export interface AutocompleteAria { collectionProps: CollectionOptions, /** Ref to attach to the wrapped collection. */ collectionRef: RefObject, - // TODO: same as above, replace nodeTextValue? /** A filter function that returns if the provided collection node should be filtered out of the collection. */ filter?: (nodeTextValue: string, node: Node) => boolean } @@ -76,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); @@ -355,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', @@ -373,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 80128a6362f..1c2cc4eb645 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -282,7 +282,7 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - filter(filterFn: FilterFn, newCollection?: BaseCollection): BaseCollection { + filter(filterFn: FilterFn, newCollection?: BaseCollection): BaseCollection { if (newCollection == null) { newCollection = new BaseCollection(); } @@ -294,7 +294,7 @@ export class BaseCollection implements ICollection> { } } -function filterChildren(collection: BaseCollection, newCollection: BaseCollection, firstChildKey: Key | null, filterFn: FilterFn): [Key | null, Key | null] { +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 diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 3fa7fd515e5..355ffbf8351 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -164,6 +164,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection return {children}; } +// TODO: have it still accept a string along side a collectionNodeClass, just have it default to a base node class if so // TODO: check the signature of the CollectionNodeClass here and other places (aka useSSRCollectionNode and branchCompoennt). If I use the generic it complains. Perhaps it should be unknown? Or maybe the definitions in Listbox and stuff shouldn't use a generic? export function createLeafComponent(CollectionNodeClass: CollectionNodeClass, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; export function createLeafComponent(CollectionNodeClass: CollectionNodeClass, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index dcc423d6788..c47ca47edc8 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -106,7 +106,9 @@ interface GridListInnerProps { function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { // TODO: for now, don't grab collection ref and collectionProps from the autocomplete, rely on the user tabbing to the gridlist // figure out if we want to support virtual focus for grids when wrapped in an autocomplete - let {filter} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + 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 gridlistState = useListState({ @@ -135,6 +137,7 @@ function GridListInner({props, collection, gridListRef: ref}: let {gridProps} = useGridList({ ...props, + ...DOMCollectionProps, keyboardDelegate, // Only tab navigation is supported in grid layout. keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior, diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index f2c1c217e50..d4afa93642c 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -375,7 +375,9 @@ interface TableInnerProps { function TableInner({props, forwardedRef: ref, selectionState, collection}: TableInnerProps) { - let {filter} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + 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 tableState = useTableState({ @@ -390,6 +392,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl let {dragAndDropHooks} = props; let {gridProps} = useTable({ ...props, + ...DOMCollectionProps, layoutDelegate, isVirtualized }, filteredState, ref); diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index af016444d57..65c35afbfeb 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -75,7 +75,9 @@ interface TagGroupInnerProps { } function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProps) { - let {filter} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + 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'] @@ -99,6 +101,7 @@ function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProp } = useTagGroup({ ...props, ...domPropOverrides, + ...DOMCollectionProps, label }, filteredState, tagListRef); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index ef4bb9bc917..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,7 @@ 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'> {} -class TreeContentNode extends CollectionNode { +class TreeContentNode extends FilterLessNode { static readonly type = 'content'; constructor(key: Key) { @@ -491,8 +491,7 @@ export interface TreeItemProps extends StyleRenderProps void } -// TODO: also might be able to reuse the ItemNode -class TreeItemNode extends CollectionNode { +class TreeItemNode extends FilterLessNode { static readonly type = 'item'; constructor(key: Key) { @@ -736,8 +735,7 @@ export interface TreeLoadMoreItemProps extends Omit { +class TreeLoaderNode extends FilterLessNode { static readonly type = 'loader'; constructor(key: Key) { diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 3a4e790705a..e5b96a6817c 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -19,7 +19,6 @@ import {MyListBoxLoaderIndicator, renderEmptyState} from './ListBox.stories'; import {MyTag} from './TagGroup.stories'; import React from 'react'; import styles from '../example/index.css'; -import {TreeExampleStaticRender} from './Tree.stories'; import {useAsyncList, useListData, useTreeData} from 'react-stately'; import {useFilter} from 'react-aria'; import './styles.css'; 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..39c28439389 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,97 @@ 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 + +); +// TODO: add GridList, Table, TagGroup make sure that it filters and doesn't have virtual focus +// Also test that it doesn't filter Tabs, Tree, Breadcrumbs +// Also test that it can do node specific filtering + + let AutocompleteWrapper = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, children?: ReactNode}) => { let {contains} = useFilter({sensitivity: 'base'}); let filter = (textValue, inputValue) => contains(textValue, inputValue); @@ -792,6 +882,37 @@ 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(within(wrappedComponent).findByText('Foo')).toBeTruthy(); + expect(within(wrappedComponent).findByText('Bar')).toBeTruthy(); + expect(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(within(wrappedComponent).findByText('Foo')).toBeTruthy(); + expect(within(wrappedComponent).findByText('Bar')).toBeTruthy(); + expect(within(wrappedComponent).findByText('Baz')).toBeTruthy(); + }); }); AriaAutocompleteTests({ @@ -914,3 +1035,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' +}); From 19b695ef4a12706d9aabe803f566b5477e304baa Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 31 Jul 2025 17:27:17 -0700 Subject: [PATCH 21/34] prevent breaking change in CollectionBuilder by still accepting string for CollectionNodeClass --- .../collections/src/CollectionBuilder.tsx | 26 +++++++++---- .../test/CollectionBuilder.test.js | 38 ++++++++++++++++++- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 355ffbf8351..1904e747a19 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -132,8 +132,20 @@ export type CollectionNodeClass = { readonly type: string }; -// TODO: discuss the former Type arg, renamed to CollectionNodeClass -function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { +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 @@ -164,11 +176,9 @@ function useSSRCollectionNode(CollectionNodeClass: Collection return {children}; } -// TODO: have it still accept a string along side a collectionNodeClass, just have it default to a base node class if so -// TODO: check the signature of the CollectionNodeClass here and other places (aka useSSRCollectionNode and branchCompoennt). If I use the generic it complains. Perhaps it should be unknown? Or maybe the definitions in Listbox and stuff shouldn't use a generic? -export function createLeafComponent(CollectionNodeClass: CollectionNodeClass, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent(CollectionNodeClass: CollectionNodeClass, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent

(CollectionNodeClass: CollectionNodeClass, 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); @@ -199,7 +209,7 @@ export function createLeafComponent

(Collect return Result; } -export function createBranchComponent(CollectionNodeClass: CollectionNodeClass, 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); diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index be5dbe8d60f..395eefa9fb7 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -1,4 +1,4 @@ -import {Collection, CollectionBuilder, CollectionNode, createLeafComponent} from '../src'; +import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent} from '../src'; import React from 'react'; import {render} from '@testing-library/react'; @@ -14,6 +14,14 @@ const Item = createLeafComponent(ItemNode, () => { return

; }); +const ItemsOld = createLeafComponent('item', () => { + return
; +}); + +const SectionOld = createBranchComponent('section', () => { + return
; +}); + const renderItems = (items, spyCollection) => ( {items.map((item) => )}}> {collection => { @@ -23,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 = {}; @@ -38,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'); + }); }); From 6066c6c08f350b6473390c5be7849dab9e7daa5e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Aug 2025 11:20:47 -0700 Subject: [PATCH 22/34] fix tests and pass submenutrigger node to filterFn --- packages/react-aria-components/src/Menu.tsx | 6 +- .../stories/Autocomplete.stories.tsx | 39 ++++++++--- .../test/Autocomplete.test.tsx | 64 ++++++++++++++++--- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index c8ed1cf1e22..7068f5032ab 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -116,9 +116,9 @@ class SubmenuTriggerNode extends CollectionNode { filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): CollectionNode | null { let triggerNode = collection.getItem(this.firstChildKey!); - if (triggerNode && filterFn(triggerNode.textValue, triggerNode)) { - // TODO: perhaps should call super.filter for correctness, but basically add the menu item child of the submenutrigger - // to the keymap so it renders + // 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(); } diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index e5b96a6817c..951339d4513 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -12,17 +12,17 @@ import {action} from '@storybook/addon-actions'; import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TableLayout, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; +import {LoadingSpinner, MyListBoxItem, MyMenuItem} from './utils'; import {Meta, StoryObj} from '@storybook/react'; import {MyCheckbox} from './Table.stories'; -import {MyListBoxItem, MyMenuItem} from './utils'; -import {MyListBoxLoaderIndicator, renderEmptyState} from './ListBox.stories'; +import {MyGridListItem} from './GridList.stories'; +import {MyListBoxLoaderIndicator} from './ListBox.stories'; import {MyTag} from './TagGroup.stories'; -import React from 'react'; +import React, {useState} from 'react'; import styles from '../example/index.css'; import {useAsyncList, useListData, useTreeData} from 'react-stately'; import {useFilter} from 'react-aria'; import './styles.css'; -import {MyGridListItem} from './GridList.stories'; export default { title: 'React Aria Components/Autocomplete', @@ -868,8 +868,23 @@ interface Character { 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) { @@ -879,6 +894,7 @@ export const AutocompleteWithAsyncListBox = (args) => { 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 @@ -913,7 +929,7 @@ export const AutocompleteWithAsyncListBox = (args) => { display: 'flex' }} aria-label="async virtualized listbox" - renderEmptyState={() => renderEmptyState({isLoading: list.isLoading})}> + renderEmptyState={() => renderEmptyState(list, cursor)}> {(item: Character) => ( { ); }; -function AutocompletePreserveFirstSection(args) { +function AutocompleteNodeFiltering(args) { let {contains} = useFilter({sensitivity: 'base'}); let filter = (textValue, inputValue, node) => { - if (node.parentKey === 'Section 1') { + if ((node.parentKey === 'Section 1' && textValue === 'Open View') || (node.parentKey === 'Section 2' && textValue === 'Appearance')) { return true; } return contains(textValue, inputValue); @@ -1101,6 +1117,11 @@ function AutocompletePreserveFirstSection(args) { } export const AutocompletePreserveFirstSectionStory: AutocompleteStory = { - render: (args) => , - name: 'Autocomplete, never filter first section' + 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/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 39c28439389..410a2aa2fb1 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -282,10 +282,6 @@ let StaticBreadcrumbs = (props) => ( Baz ); -// TODO: add GridList, Table, TagGroup make sure that it filters and doesn't have virtual focus -// Also test that it doesn't filter Tabs, Tree, Breadcrumbs -// Also test that it can do node specific filtering - let AutocompleteWrapper = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, children?: ReactNode}) => { let {contains} = useFilter({sensitivity: 'base'}); @@ -362,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(() => { @@ -896,9 +914,9 @@ describe('Autocomplete', () => { ); let wrappedComponent = getByTestId('wrapped'); - expect(within(wrappedComponent).findByText('Foo')).toBeTruthy(); - expect(within(wrappedComponent).findByText('Bar')).toBeTruthy(); - expect(within(wrappedComponent).findByText('Baz')).toBeTruthy(); + 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(); @@ -909,9 +927,35 @@ describe('Autocomplete', () => { expect(input).not.toHaveAttribute('aria-autocomplete'); expect(input).not.toHaveAttribute('aria-activedescendant'); - expect(within(wrappedComponent).findByText('Foo')).toBeTruthy(); - expect(within(wrappedComponent).findByText('Bar')).toBeTruthy(); - expect(within(wrappedComponent).findByText('Baz')).toBeTruthy(); + 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(); }); }); From d2b5e51d02685c017329c92f6a426a0fb4002091 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Aug 2025 13:13:34 -0700 Subject: [PATCH 23/34] small clean up --- packages/react-aria-components/src/Collection.tsx | 1 - packages/react-aria-components/stories/ListBox.stories.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 930023a7bb9..b48e655195b 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -101,7 +101,6 @@ export const SectionContext = createContext(null); // TODO: should I update this since it is deprecated? /** @deprecated */ -// @ts-ignore export const Section = /*#__PURE__*/ createBranchComponent('section', (props: SectionProps, ref: ForwardedRef, section: Node): JSX.Element => { let {name, render} = useContext(SectionContext)!; if (process.env.NODE_ENV !== 'production') { diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 87637b1f826..0fa5a0dc10b 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -24,7 +24,7 @@ import {useAsyncList, useListData} from 'react-stately'; export default { title: 'React Aria Components/ListBox', component: ListBox, - excludeStories: ['MyListBoxLoaderIndicator', 'renderEmptyState'] + excludeStories: ['MyListBoxLoaderIndicator'] } as Meta; export type ListBoxStory = StoryFn; @@ -546,7 +546,7 @@ export function VirtualizedListBoxWaterfall({minSize = 80, maxSize = 100}: {minS ); } -export let renderEmptyState = ({isLoading}) => { +let renderEmptyState = ({isLoading}) => { return (
{isLoading ? : 'No results'} From 2c897833e544d604fcdeb725dafb5c7e78120c8f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Aug 2025 09:41:26 -0700 Subject: [PATCH 24/34] small fixes --- packages/react-aria-components/src/Separator.tsx | 3 ++- packages/react-aria-components/src/Table.tsx | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/react-aria-components/src/Separator.tsx b/packages/react-aria-components/src/Separator.tsx index 71795198085..a4c4007a1e2 100644 --- a/packages/react-aria-components/src/Separator.tsx +++ b/packages/react-aria-components/src/Separator.tsx @@ -29,7 +29,8 @@ export class SeparatorNode extends CollectionNode { } filter(collection: BaseCollection, newCollection: BaseCollection): CollectionNode | null { - if (newCollection.getItem(this.prevKey!)) { + let prevItem = newCollection.getItem(this.prevKey!); + if (prevItem && prevItem.type !== 'separator') { return this.clone(); } diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index d4afa93642c..773fe0e871d 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -18,10 +18,6 @@ import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, React import ReactDOM from 'react-dom'; import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; -export type Mutable = { - -readonly[P in keyof T]: T[P] -} - class TableCollection extends BaseCollection implements ITableCollection { headerRows: GridNode[] = []; columns: GridNode[] = []; From 739e93f8dacf83d901e2ecf672558696f86d6d31 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Aug 2025 11:51:36 -0700 Subject: [PATCH 25/34] addressing more review comments --- .../autocomplete/src/useAutocomplete.ts | 6 +++--- packages/@react-aria/collections/src/Document.ts | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 7575de9d620..4efdb52c168 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -50,7 +50,7 @@ export interface AriaAutocompleteOptions extends Omit } -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). */ @@ -58,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, node: Node) => boolean + filter?: (nodeTextValue: string, node: Node) => boolean } /** @@ -67,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, diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 503f43c9617..ecaa48abd8b 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -259,7 +259,7 @@ export class ElementNode extends BaseNode { nodeType = 8; // COMMENT_NODE (we'd use ELEMENT_NODE but React DevTools will fail to get its dimensions) // TODO: running with assumption that setProps will be called before any other calls to node are made so theoretically // node will be defined - node: CollectionNode | null; + private _node: CollectionNode | null; isMutated = true; private _index: number = 0; hasSetProps = false; @@ -267,7 +267,7 @@ export class ElementNode extends BaseNode { constructor(type: string, ownerDocument: Document) { super(ownerDocument); - this.node = null; + this._node = null; } get index(): number { @@ -287,6 +287,17 @@ export class ElementNode extends BaseNode { return 0; } + get node(): CollectionNode | null { + if (this._node == null && process.env.NODE_ENV !== 'production') { + console.error('Attempted to access node before it was defined. Check if setProps wasn\'t called before attempting to access the node.'); + } + 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. From 3c2e92cf957beeb3865ba11f2c4d6c12b2511873 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Aug 2025 13:50:26 -0700 Subject: [PATCH 26/34] simplifying setProps logic since we have already have id when calling it --- .../collections/src/CollectionBuilder.tsx | 4 +-- .../@react-aria/collections/src/Document.ts | 26 +++++-------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 1904e747a19..52c4f79e0a8 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -152,7 +152,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection // 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, CollectionNodeClass); + element?.setProps(props, ref, CollectionNodeClass, rendered, render); }, [props, ref, rendered, render, CollectionNodeClass]); let parentNode = useContext(SSRContext); if (parentNode) { @@ -160,7 +160,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection let element = parentNode.ownerDocument.nodesByProps.get(props); if (!element) { element = parentNode.ownerDocument.createElement(CollectionNodeClass.type); - element.setProps(props, ref, rendered, render, CollectionNodeClass); + element.setProps(props, ref, CollectionNodeClass, rendered, render); parentNode.appendChild(element); parentNode.ownerDocument.updateCollection(); parentNode.ownerDocument.nodesByProps.set(props, element); diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index ecaa48abd8b..bcb6362c5af 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -257,8 +257,6 @@ 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) - // TODO: running with assumption that setProps will be called before any other calls to node are made so theoretically - // node will be defined private _node: CollectionNode | null; isMutated = true; private _index: number = 0; @@ -335,17 +333,14 @@ export class ElementNode extends BaseNode { } } - setProps(obj: {[key: string]: any}, ref: ForwardedRef, rendered?: ReactNode, render?: (node: Node) => ReactElement, CollectionNodeClass?: CollectionNodeClass): 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; - - - // TODO: Flow here is that if this called for first time, aka this.node is undef, call - // this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`); but make new TreeNode/MenuNode/etc instead of CollectionNode - // Caveat is this assumes we don't need a node before setProps is called on it - if (node == null && CollectionNodeClass) { - node = new CollectionNodeClass(`react-aria-${++this.ownerDocument.nodeId}`); + if (this._node == null) { + node = new CollectionNodeClass(id ?? `react-aria-${++this.ownerDocument.nodeId}`); this.node = node; + } else { + node = this.getMutableNode(); } props.ref = ref; @@ -355,20 +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) { - // TODO: still need to use this.hasSetProps so this can run twice (?) instead of setting node.key above - // If we set node.key = id and change this to if (this.node), setting refs fails. If we just check (this.node here), it will fail if the user provides an id - 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; } - // TODO: still need this, see above comment - this.hasSetProps = true; if (this.isConnected) { this.ownerDocument.queueUpdate(); } From 35b627e5bc455494b87c4c2e54e0b83fa57f9fe6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Aug 2025 14:21:42 -0700 Subject: [PATCH 27/34] forgot to use generic for autocomplete filter --- .../autocomplete/src/useAutocomplete.ts | 10 +++++----- packages/dev/s2-docs/src/SearchMenu.tsx | 16 ++++++++-------- packages/dev/s2-docs/src/SearchResultsMenu.tsx | 4 ++-- .../react-aria-components/src/Autocomplete.tsx | 14 +++++++------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 4efdb52c168..9a52f592130 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -29,12 +29,12 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps { } // 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 { +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, node: Node) => 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. @@ -43,7 +43,7 @@ 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. */ @@ -67,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, @@ -318,7 +318,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autoco 'aria-label': stringFormatter.format('collectionLabel') }); - let filterFn = useCallback((nodeTextValue: string, node: Node) => { + let filterFn = useCallback((nodeTextValue: string, node: Node) => { if (filter) { return filter(nodeTextValue, state.inputValue, 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/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 8f7b5dbc519..a073b1b5772 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -20,24 +20,24 @@ 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, node: Node) => 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; @@ -65,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 }] From 9408aa9711afd2a19e9982f08120d414c8941672 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 5 Aug 2025 14:31:53 -0700 Subject: [PATCH 28/34] ugh docs typescript --- packages/react-aria-components/docs/Autocomplete.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 8e75339a8f40bedabcc16332d065f093abff9283 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 7 Aug 2025 10:00:53 -0700 Subject: [PATCH 29/34] review comments --- packages/dev/s2-docs/src/SearchResultsMenu.tsx | 2 +- packages/react-aria-components/src/Autocomplete.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/src/SearchResultsMenu.tsx b/packages/dev/s2-docs/src/SearchResultsMenu.tsx index 3d2af6245b7..7ef19ce8806 100644 --- a/packages/dev/s2-docs/src/SearchResultsMenu.tsx +++ b/packages/dev/s2-docs/src/SearchResultsMenu.tsx @@ -30,7 +30,7 @@ interface SearchResultsMenuProps { searchRef: React.RefObject | null>, showCards: boolean, renderCardList: () => React.ReactNode, - filter?: AutocompleteProps['filter'], + filter?: AutocompleteProps['filter'], noResultsText?: (value: string) => string, closeSearchMenu: () => void, isPrimary?: boolean diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index a073b1b5772..05e40294c0c 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -37,7 +37,7 @@ export const UNSTABLE_InternalAutocompleteContext = createContext(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; From 57e57e0a540290f2e18ac2a39bbcb399b2e03dc2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 12 Aug 2025 14:40:38 -0700 Subject: [PATCH 30/34] add example testing the Autocomplete generic --- .../stories/Autocomplete.stories.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 951339d4513..a6500c4e152 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -18,6 +18,7 @@ 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'; @@ -166,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'} @@ -440,7 +441,7 @@ const CaseSensitiveFilter = (args) => { let defaultFilter = (itemText, input) => contains(itemText, input); return ( - + filter={defaultFilter}>
@@ -1091,17 +1092,26 @@ export const AutocompleteWithTagGroup = () => { ); }; +type MenuNode = { + name: string, + id?: string, + isSection?: boolean, + isMenu?: boolean, + children?: MenuNode[] +} + function AutocompleteNodeFiltering(args) { let {contains} = useFilter({sensitivity: 'base'}); - let filter = (textValue, inputValue, node) => { + 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}>
From 4d5fde280b142f5bfd9b7de0b545df5c3571ae69 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 18 Aug 2025 11:09:31 -0700 Subject: [PATCH 31/34] fix: Autocomplete context refactor (#8695) * autoimport.... * replace internal autocomplete context * add FieldInputContext in place of input context and search/textfield context in autocomplete * fix build * removing erroneous autoimports * add ability for user to provide independent filter text * fix lint * fix some more tests * bring back controlled input value at autocomplete level * adding prop to disable virtual focus * another stab at the types * clear autocomplete contexts so that they dont leak to nested collections * add tests for disallowVirtualFocus works with listbox and menu * fix types * refactor CollectionNode to read from static property and properly clone from subclass * naming from reviews and moving contexts out of autocomplete * review comments * properly add all descendants of a cloned node when filtering fixes case where a filtered table keyboard navigation was broken since we had cloned the old collection rather than creating a new one from scratch --- .../autocomplete/src/useAutocomplete.ts | 63 ++++++++----- .../collections/src/BaseCollection.ts | 49 +++++----- .../collections/src/CollectionBuilder.tsx | 8 +- .../@react-aria/collections/src/Document.ts | 30 +++--- packages/@react-aria/collections/src/index.ts | 2 +- .../test/CollectionBuilder.test.js | 4 - packages/@react-spectrum/s2/src/ComboBox.tsx | 18 +++- .../s2/src/SkeletonCollection.tsx | 5 - packages/react-aria-components/package.json | 1 + .../src/Autocomplete.tsx | 36 +++----- .../react-aria-components/src/Breadcrumbs.tsx | 4 - .../react-aria-components/src/GridList.tsx | 27 ++---- packages/react-aria-components/src/Header.tsx | 5 - .../react-aria-components/src/ListBox.tsx | 37 +++----- packages/react-aria-components/src/Menu.tsx | 41 ++++----- .../react-aria-components/src/SearchField.tsx | 9 +- .../react-aria-components/src/Separator.tsx | 10 +- packages/react-aria-components/src/Table.tsx | 62 ++++--------- packages/react-aria-components/src/Tabs.tsx | 4 - .../react-aria-components/src/TagGroup.tsx | 10 +- .../react-aria-components/src/TextField.tsx | 15 +-- packages/react-aria-components/src/Tree.tsx | 20 +--- .../react-aria-components/src/context.tsx | 34 +++++++ packages/react-aria-components/src/index.ts | 2 +- .../stories/Autocomplete.stories.tsx | 92 ++++++++++++++++--- .../test/AriaAutocomplete.test-util.tsx | 3 +- .../test/Autocomplete.test.tsx | 10 ++ yarn.lock | 1 + 28 files changed, 317 insertions(+), 285 deletions(-) create mode 100644 packages/react-aria-components/src/context.tsx diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 9a52f592130..2e8662a0bcc 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, BaseEvent, DOMProps, Node, RefObject} from '@react-types/shared'; +import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, Node, RefObject} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; @@ -28,7 +28,6 @@ export interface CollectionOptions extends DOMProps, AriaLabelingProps { disallowTypeAhead: boolean } -// TODO; For now go with Node here, but maybe pare it down to just the essentials? Value, key, and maybe type? export interface AriaAutocompleteProps extends AutocompleteProps { /** * An optional filter function used to determine if a option should be included in the autocomplete list. @@ -37,10 +36,17 @@ export interface AriaAutocompleteProps extends AutocompleteProps { filter?: (textValue: string, inputValue: string, node: Node) => boolean, /** - * Whether or not to focus the first item in the collection after a filter is performed. + * Whether or not to focus the first item in the collection after a filter is performed. Note this is only applicable + * if virtual focus behavior is not turned off via `disableVirtualFocus`. * @default false */ - disableAutoFocusFirst?: boolean + disableAutoFocusFirst?: boolean, + + /** + * Whether the autocomplete should disable virtual focus, instead making the wrapped collection directly tabbable. + * @default false + */ + disableVirtualFocus?: boolean } export interface AriaAutocompleteOptions extends Omit, 'children'> { @@ -52,7 +58,7 @@ export interface AriaAutocompleteOptions extends Omit { /** Props for the autocomplete textfield/searchfield element. These should be passed to the textfield/searchfield aria hooks respectively. */ - textFieldProps: AriaTextFieldProps, + textFieldProps: AriaTextFieldProps, /** Props for the collection, to be passed to collection's respective aria hook (e.g. useMenu). */ collectionProps: CollectionOptions, /** Ref to attach to the wrapped collection. */ @@ -72,7 +78,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut inputRef, collectionRef, filter, - disableAutoFocusFirst = false + disableAutoFocusFirst = false, + disableVirtualFocus = false } = props; let collectionId = useSlotId(); @@ -83,7 +90,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut // For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually // moving focus back to the subtriggers - let shouldUseVirtualFocus = getInteractionModality() !== 'virtual'; + let shouldUseVirtualFocus = getInteractionModality() !== 'virtual' && !disableVirtualFocus; useEffect(() => { return () => clearTimeout(timeout.current); @@ -254,15 +261,17 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } let shouldPerformDefaultAction = true; - if (focusedNodeId == null) { - shouldPerformDefaultAction = collectionRef.current?.dispatchEvent( - new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ) || false; - } else { - let item = document.getElementById(focusedNodeId); - shouldPerformDefaultAction = item?.dispatchEvent( - new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ) || false; + if (collectionRef.current !== null) { + if (focusedNodeId == null) { + shouldPerformDefaultAction = collectionRef.current?.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ) || false; + } else { + let item = document.getElementById(focusedNodeId); + shouldPerformDefaultAction = item?.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ) || false; + } } if (shouldPerformDefaultAction) { @@ -282,6 +291,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } break; } + } else { + // TODO: check if we can do this, want to stop textArea from using its default Enter behavior so items are properly triggered + e.preventDefault(); } }; @@ -359,25 +371,28 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let textFieldProps = { value: state.inputValue, onChange - } as AriaTextFieldProps; + } as AriaTextFieldProps; + + let virtualFocusProps = { + onKeyDown, + 'aria-activedescendant': state.focusedNodeId ?? undefined, + onBlur, + onFocus + }; if (collectionId) { textFieldProps = { ...textFieldProps, - onKeyDown, - autoComplete: 'off', - 'aria-haspopup': collectionId ? 'listbox' : undefined, + ...(shouldUseVirtualFocus && virtualFocusProps), + enterKeyHint: 'go', 'aria-controls': collectionId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', - 'aria-activedescendant': state.focusedNodeId ?? undefined, // This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions. autoCorrect: 'off', // This disable's the macOS Safari spell check auto corrections. spellCheck: 'false', - enterKeyHint: 'go', - onBlur, - onFocus + autoComplete: 'off' }; } diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 1c2cc4eb645..7b7839398d9 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -21,6 +21,7 @@ type FilterFn = (textValue: string, node: Node) => boolean; /** An immutable object representing a Node in a Collection. */ export class CollectionNode implements Node { + static readonly type; readonly type: string; readonly key: Key; readonly value: T | null = null; @@ -40,8 +41,8 @@ export class CollectionNode implements Node { readonly colSpan: number | null = null; readonly colIndex: number | null = null; - constructor(type: string, key: Key) { - this.type = type; + constructor(key: Key) { + this.type = (this.constructor as typeof CollectionNode).type; this.key = key; } @@ -49,8 +50,8 @@ export class CollectionNode implements Node { throw new Error('childNodes is not supported'); } - clone(): CollectionNode { - let node: Mutable> = new CollectionNode(this.type, this.key); + clone(): this { + let node: Mutable = new (this.constructor as any)(this.key); node.value = this.value; node.level = this.level; node.hasChildNodes = this.hasChildNodes; @@ -67,7 +68,6 @@ export class CollectionNode implements Node { node.render = this.render; node.colSpan = this.colSpan; node.colIndex = this.colIndex; - node.filter = this.filter; return node; } @@ -85,20 +85,24 @@ export class CollectionNode implements Node { export class FilterLessNode extends CollectionNode { // eslint-disable-next-line @typescript-eslint/no-unused-vars filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): FilterLessNode | null { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } } +export class LoaderNode extends FilterLessNode { + static readonly type = 'loader'; +} + export class ItemNode extends CollectionNode { static readonly type = 'item'; - constructor(key: Key) { - super(ItemNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): ItemNode | null { if (filterFn(this.textValue, this)) { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } return null; @@ -108,10 +112,6 @@ export class ItemNode extends CollectionNode { export class SectionNode extends CollectionNode { static readonly type = 'section'; - constructor(key: Key) { - super(SectionNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): SectionNode | null { let filteredSection = super.filter(collection, newCollection, filterFn); if (filteredSection) { @@ -259,6 +259,15 @@ export class BaseCollection implements ICollection> { this.keyMap.set(node.key, node); } + // Deeply add a node and its children to the collection from another collection, primarily used when filtering a collection + addDescendants(node: CollectionNode, oldCollection: BaseCollection): void { + this.addNode(node); + let children = oldCollection.getChildren(node.key); + for (let child of children) { + this.addDescendants(child as CollectionNode, oldCollection); + } + } + removeNode(key: Key): void { if (this.frozen) { throw new Error('Cannot remove a node to a frozen collection'); @@ -282,14 +291,10 @@ export class BaseCollection implements ICollection> { this.frozen = !isSSR; } - filter(filterFn: FilterFn, newCollection?: BaseCollection): BaseCollection { - if (newCollection == null) { - newCollection = new BaseCollection(); - } - + filter(filterFn: FilterFn): this { + let newCollection = new (this.constructor as any)(); let [firstKey, lastKey] = filterChildren(this, newCollection, this.firstKey, filterFn); - newCollection.firstKey = firstKey; - newCollection.lastKey = lastKey; + newCollection?.commit(firstKey, lastKey); return newCollection; } } diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 52c4f79e0a8..83b1e834d57 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -133,10 +133,9 @@ export type CollectionNodeClass = { }; function createCollectionNodeClass(type: string): CollectionNodeClass { - let NodeClass = function (key: Key) { - return new CollectionNode(type, key); - } as any; - NodeClass.type = type; + let NodeClass = class extends CollectionNode { + static readonly type = type; + }; return NodeClass; } @@ -172,7 +171,6 @@ function useSSRCollectionNode(CollectionNodeClass: Collection } // @ts-ignore - // TODO: could just make this a div perhaps, but keep it in line with how it used to work return {children}; } diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index bcb6362c5af..36bdb3491a8 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -260,7 +260,6 @@ export class ElementNode extends BaseNode { private _node: CollectionNode | null; isMutated = true; private _index: number = 0; - hasSetProps = false; isHidden = false; constructor(type: string, ownerDocument: Document) { @@ -285,10 +284,11 @@ export class ElementNode extends BaseNode { return 0; } - get node(): CollectionNode | null { - if (this._node == null && process.env.NODE_ENV !== 'production') { - console.error('Attempted to access node before it was defined. Check if setProps wasn\'t called before attempting to access the node.'); + get node(): CollectionNode { + if (this._node == null) { + throw Error('Attempted to access node before it was defined. Check if setProps wasn\'t called before attempting to access the node.'); } + return this._node; } @@ -302,12 +302,12 @@ export class ElementNode extends BaseNode { */ private getMutableNode(): Mutable> { if (!this.isMutated) { - this.node = this.node!.clone(); + this.node = this.node.clone(); this.isMutated = true; } this.ownerDocument.markDirty(this); - return this.node!; + return this.node; } updateNode(): void { @@ -315,18 +315,18 @@ export class ElementNode extends BaseNode { let node = this.getMutableNode(); node.index = this.index; node.level = this.level; - node.parentKey = this.parentNode instanceof ElementNode ? this.parentNode.node!.key : null; - node.prevKey = this.previousVisibleSibling?.node!.key ?? null; - node.nextKey = nextSibling?.node!.key ?? null; + node.parentKey = this.parentNode instanceof ElementNode ? this.parentNode.node.key : null; + node.prevKey = this.previousVisibleSibling?.node.key ?? null; + node.nextKey = nextSibling?.node.key ?? null; node.hasChildNodes = !!this.firstChild; - node.firstChildKey = this.firstVisibleChild?.node!.key ?? null; - node.lastChildKey = this.lastVisibleChild?.node!.key ?? null; + node.firstChildKey = this.firstVisibleChild?.node.key ?? null; + node.lastChildKey = this.lastVisibleChild?.node.key ?? null; // Update the colIndex of sibling nodes if this node has a colSpan. if ((node.colSpan != null || node.colIndex != null) && nextSibling) { // This queues the next sibling for update, which means this happens recursively. let nextColIndex = (node.colIndex ?? node.index) + (node.colSpan ?? 1); - if (nextColIndex !== nextSibling.node!.colIndex) { + if (nextColIndex !== nextSibling.node.colIndex) { let siblingNode = nextSibling.getMutableNode(); siblingNode.colIndex = nextColIndex; } @@ -455,7 +455,7 @@ export class Document = BaseCollection> extend } let collection = this.getMutableCollection(); - if (!collection.getItem(element.node!.key)) { + if (!collection.getItem(element.node.key)) { for (let child of element) { this.addNode(child); } @@ -470,7 +470,7 @@ export class Document = BaseCollection> extend } let collection = this.getMutableCollection(); - collection.removeNode(node.node!.key); + collection.removeNode(node.node.key); } /** Finalizes the collection update, updating all nodes and freezing the collection. */ @@ -516,7 +516,7 @@ export class Document = BaseCollection> extend // Finally, update the collection. if (this.nextCollection) { - this.nextCollection.commit(this.firstVisibleChild?.node!.key ?? null, this.lastVisibleChild?.node!.key ?? null, this.isSSR); + this.nextCollection.commit(this.firstVisibleChild?.node.key ?? null, this.lastVisibleChild?.node.key ?? null, this.isSSR); if (!this.isSSR) { this.collection = this.nextCollection; this.nextCollection = null; diff --git a/packages/@react-aria/collections/src/index.ts b/packages/@react-aria/collections/src/index.ts index 38457e56542..1cf607da27f 100644 --- a/packages/@react-aria/collections/src/index.ts +++ b/packages/@react-aria/collections/src/index.ts @@ -13,7 +13,7 @@ export {CollectionBuilder, Collection, createLeafComponent, createBranchComponent} from './CollectionBuilder'; export {createHideableComponent, useIsHidden} from './Hidden'; export {useCachedChildren} from './useCachedChildren'; -export {BaseCollection, CollectionNode, ItemNode, SectionNode, FilterLessNode} from './BaseCollection'; +export {BaseCollection, CollectionNode, ItemNode, SectionNode, FilterLessNode, LoaderNode} from './BaseCollection'; export type {CollectionBuilderProps, CollectionProps} from './CollectionBuilder'; export type {CachedChildrenOptions} from './useCachedChildren'; diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 395eefa9fb7..74664dbfc3c 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -4,10 +4,6 @@ import {render} from '@testing-library/react'; class ItemNode extends CollectionNode { static type = 'item'; - - constructor(key) { - super(ItemNode.type, key); - } } const Item = createLeafComponent(ItemNode, () => { diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 24ad6d16da1..758cc6e75b4 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -30,10 +30,10 @@ import { ListStateContext, Provider, SectionProps, - SeparatorNode, Virtualizer } from 'react-aria-components'; import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; +import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import {centerPadding, control, controlBorderRadius, controlFont, controlSize, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; @@ -49,7 +49,6 @@ import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; import {createContext, CSSProperties, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; -import {createLeafComponent} from '@react-aria/collections'; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; @@ -700,6 +699,21 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps { + static readonly type = 'separator'; + + filter(collection: BaseCollection, newCollection: BaseCollection): CollectionNode | null { + let prevItem = newCollection.getItem(this.prevKey!); + if (prevItem && prevItem.type !== 'separator') { + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; + } + + return null; + } +} + export const Divider = /*#__PURE__*/ createLeafComponent(SeparatorNode, function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef, node: Node) { let listState = useContext(ListStateContext)!; diff --git a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx index cc75a2fe81a..05a591af3ea 100644 --- a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx +++ b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx @@ -11,7 +11,6 @@ */ import {createLeafComponent, FilterLessNode} from '@react-aria/collections'; -import {Key} from '@react-types/shared'; import {ReactNode} from 'react'; import {Skeleton} from './Skeleton'; @@ -23,10 +22,6 @@ let cache = new WeakMap(); class SkeletonNode extends FilterLessNode { static readonly type = 'skeleton'; - - constructor(key: Key) { - super(SkeletonNode.type, key); - } } /** diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index c986fbd2af3..51c6ad6e953 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -51,6 +51,7 @@ "@react-aria/live-announcer": "^3.4.4", "@react-aria/overlays": "^3.28.0", "@react-aria/ssr": "^3.9.10", + "@react-aria/textfield": "^3.18.0", "@react-aria/toolbar": "3.0.0-beta.19", "@react-aria/utils": "^3.30.0", "@react-aria/virtualizer": "^4.1.8", diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index 05e40294c0c..0d609efdfe2 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -10,29 +10,16 @@ * governing permissions and limitations under the License. */ -import {AriaAutocompleteProps, CollectionOptions, useAutocomplete} from '@react-aria/autocomplete'; +import {AriaAutocompleteProps, useAutocomplete} from '@react-aria/autocomplete'; import {AutocompleteState, useAutocompleteState} from '@react-stately/autocomplete'; -import {InputContext} from './Input'; +import {FieldInputContext, SelectableCollectionContext} from './context'; import {mergeProps} from '@react-aria/utils'; -import {Node} from '@react-types/shared'; import {Provider, removeDataAttributes, SlotProps, SlottedContextValue, useSlottedContext} from './utils'; -import React, {createContext, JSX, RefObject, useRef} from 'react'; -import {SearchFieldContext} from './SearchField'; -import {TextFieldContext} from './TextField'; - -export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} - -interface InternalAutocompleteContextValue { - filter?: (nodeTextValue: string, node: Node) => boolean, - collectionProps: CollectionOptions, - collectionRef: RefObject -} +import React, {createContext, JSX, useRef} from 'react'; +export interface AutocompleteProps extends AriaAutocompleteProps, SlotProps {} export const AutocompleteContext = createContext>>>(null); export const AutocompleteStateContext = createContext(null); -// This context is to pass the register and filter down to whatever collection component is wrapped by the Autocomplete -// TODO: export from RAC, but rename to something more appropriate -export const UNSTABLE_InternalAutocompleteContext = createContext | null>(null); /** * An autocomplete combines a TextField or SearchField with a Menu or ListBox, allowing users to search or filter a list of suggestions. @@ -61,13 +48,14 @@ export function Autocomplete(props: AutocompleteProps): JSX ) => boolean, - collectionProps, - collectionRef: mergedCollectionRef + [FieldInputContext, { + ...textFieldProps, + ref: inputRef + }], + [SelectableCollectionContext, { + ...collectionProps, + filter: filterFn, + ref: mergedCollectionRef }] ]}> {props.children} diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 92ad111b572..e7a0cb0693d 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -75,10 +75,6 @@ export interface BreadcrumbProps extends RenderProps, Glo class BreadcrumbNode extends FilterLessNode { static readonly type = 'item'; - - constructor(key: Key) { - super(BreadcrumbNode.type, key); - } } /** diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index c47ca47edc8..f78f0f0e1ea 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -12,18 +12,18 @@ import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; -import {Collection, CollectionBuilder, createLeafComponent, FilterLessNode, ItemNode} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createLeafComponent, ItemNode, LoaderNode} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; +import {FieldInputContext, SelectableCollectionContext} from './context'; import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {TextContext} from './Text'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; export interface GridListRenderProps { /** @@ -106,7 +106,9 @@ interface GridListInnerProps { function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { // TODO: for now, don't grab collection ref and collectionProps from the autocomplete, rely on the user tabbing to the gridlist // figure out if we want to support virtual focus for grids when wrapped in an autocomplete - let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + let contextProps; + [contextProps] = useContextProps({}, null, SelectableCollectionContext); + let {filter, ...collectionProps} = contextProps; // eslint-disable-next-line @typescript-eslint/no-unused-vars let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props; @@ -287,12 +289,10 @@ export interface GridListItemProps extends RenderProps void } -class GridListNode extends ItemNode {} - /** * A GridListItem represents an individual item in a GridList. */ -export const GridListItem = /*#__PURE__*/ createLeafComponent(GridListNode, function GridListItem(props: GridListItemProps, forwardedRef: ForwardedRef, item: Node) { +export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function GridListItem(props: GridListItemProps, forwardedRef: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); let ref = useObjectRef(forwardedRef); @@ -422,7 +422,9 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(GridListNode, func } }], [CollectionRendererContext, DefaultCollectionRenderer], - [ListStateContext, null] + [ListStateContext, null], + [SelectableCollectionContext, null], + [FieldInputContext, null] ]}> {renderProps.children} @@ -523,16 +525,7 @@ export interface GridListLoadMoreItemProps extends Omit { - static readonly type = 'loader'; - - constructor(key: Key) { - super(GridListLoaderNode.type, key); - } -} - -export const GridListLoadMoreItem = createLeafComponent(GridListLoaderNode, function GridListLoadingIndicator(props: GridListLoadMoreItemProps, ref: ForwardedRef, item: Node) { +export const GridListLoadMoreItem = createLeafComponent(LoaderNode, function GridListLoadingIndicator(props: GridListLoadMoreItemProps, ref: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/Header.tsx b/packages/react-aria-components/src/Header.tsx index d76d9df57d1..4ca1a87c6fc 100644 --- a/packages/react-aria-components/src/Header.tsx +++ b/packages/react-aria-components/src/Header.tsx @@ -12,17 +12,12 @@ import {ContextValue, useContextProps} from './utils'; import {createLeafComponent, FilterLessNode} from '@react-aria/collections'; -import {Key} from '@react-types/shared'; import React, {createContext, ForwardedRef, HTMLAttributes} from 'react'; export const HeaderContext = createContext, HTMLElement>>({}); class HeaderNode extends FilterLessNode { static readonly type = 'header'; - - constructor(key: Key) { - super(HeaderNode.type, key); - } } export const Header = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index e923ece946e..eb626392572 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -11,19 +11,19 @@ */ import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria'; -import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent, FilterLessNode, ItemNode, SectionNode} from '@react-aria/collections'; +import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent, ItemNode, LoaderNode, SectionNode} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; -import {filterDOMProps, inertValue, LoadMoreSentinelProps, mergeRefs, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; import {HeaderContext} from './Header'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; +import {SelectableCollectionContext, SelectableCollectionContextValue} from './context'; import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; export interface ListBoxRenderProps { /** @@ -115,16 +115,13 @@ function StandaloneListBox({props, listBoxRef, collection}) { interface ListBoxInnerProps { state: ListState, - props: ListBoxProps & AriaListBoxOptions, - listBoxRef: RefObject + props: ListBoxProps & AriaListBoxOptions & {filter?: SelectableCollectionContextValue['filter']}, + listBoxRef: RefObject } function ListBoxInner({state: inputState, props, listBoxRef}: ListBoxInnerProps) { - let {filter, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; - props = useMemo(() => collectionProps ? ({...props, ...collectionProps}) : props, [props, collectionProps]); - let {dragAndDropHooks, layout = 'stack', orientation = 'vertical'} = props; - // Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens - listBoxRef = useObjectRef(useMemo(() => mergeRefs(listBoxRef, collectionRef !== undefined ? collectionRef as RefObject : null), [collectionRef, listBoxRef])); + [props, listBoxRef] = useContextProps(props, listBoxRef, SelectableCollectionContext); + let {dragAndDropHooks, layout = 'stack', orientation = 'vertical', filter} = props; let state = UNSTABLE_useFilteredListState(inputState, filter); let {collection, selectionManager} = state; let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState; @@ -239,7 +236,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}:
} slot={props.slot || undefined} onScroll={props.onScroll} data-drop-target={isRootDropTarget || undefined} @@ -305,12 +302,10 @@ function ListBoxSectionInner(props: ListBoxSectionProps, re ); } -export class ListBoxSectionNode extends SectionNode {} - /** * A ListBoxSection represents a section within a ListBox. */ -export const ListBoxSection = /*#__PURE__*/ createBranchComponent(ListBoxSectionNode, ListBoxSectionInner); +export const ListBoxSection = /*#__PURE__*/ createBranchComponent(SectionNode, ListBoxSectionInner); export interface ListBoxItemRenderProps extends ItemRenderProps {} @@ -332,12 +327,10 @@ export interface ListBoxItemProps extends RenderProps void } -class ListBoxItemNode extends ItemNode {} - /** * A ListBoxItem represents an individual option in a ListBox. */ -export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ListBoxItemNode, function ListBoxItem(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { +export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function ListBoxItem(props: ListBoxItemProps, forwardedRef: ForwardedRef, item: Node) { let ref = useObjectRef(forwardedRef); let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; @@ -474,14 +467,6 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe ); } -class ListBoxLoaderNode extends FilterLessNode { - static readonly type = 'loader'; - - constructor(key: Key) { - super(ListBoxLoaderNode.type, key); - } -} - const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); export interface ListBoxLoadMoreItemProps extends Omit, StyleProps, GlobalDOMAttributes { @@ -495,7 +480,7 @@ export interface ListBoxLoadMoreItemProps extends Omit, item: Node) { +export const ListBoxLoadMoreItem = createLeafComponent(LoaderNode, function ListBoxLoadingIndicator(props: ListBoxLoadMoreItemProps, ref: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 7068f5032ab..e3bfd9f4401 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -15,7 +15,8 @@ import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBra import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, RootMenuTriggerState, TreeState, useMenuTriggerState, useSubmenuTriggerState, useTreeState} from 'react-stately'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; -import {filterDOMProps, mergeRefs, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {FieldInputContext, SelectableCollectionContext, SelectableCollectionContextValue} from './context'; +import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {FocusStrategy, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents} from '@react-types/shared'; import {HeaderContext} from './Header'; import {KeyboardContext} from './Keyboard'; @@ -39,7 +40,6 @@ import React, { } from 'react'; import {SeparatorContext} from './Separator'; import {TextContext} from './Text'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; export const MenuContext = createContext, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); @@ -110,17 +110,12 @@ const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject extends CollectionNode { static readonly type = 'submenutrigger'; - constructor(key: Key) { - super(SubmenuTriggerNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): CollectionNode | null { let triggerNode = collection.getItem(this.firstChildKey!); - // Note that this provides the SubmenuTrigger node rather than the MenuItemNode it wraps to the filter function. Probably more useful - // because that node has the proper parentKey information (aka the section if any, the menu item will just point to the SubmenuTrigger node) if (triggerNode && filterFn(triggerNode.textValue, this)) { - newCollection.addNode(triggerNode as CollectionNode); - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } return null; @@ -196,15 +191,16 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu { - props: MenuProps, + // For now we append filter and other autocomplete context props here for typescript, but eventually we can consider exposing these + // as top level props for users to use with standalone Menus + props: MenuProps & {filter?: SelectableCollectionContextValue['filter'], shouldUseVirtualFocus?: boolean}, collection: BaseCollection, - menuRef: RefObject + menuRef: RefObject } function MenuInner({props, collection, menuRef: ref}: MenuInnerProps) { - let {filter, collectionProps: autocompleteMenuProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; - // Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens - ref = useObjectRef(useMemo(() => mergeRefs(ref, collectionRef !== undefined ? collectionRef as RefObject : null), [collectionRef, ref])); + [props, ref] = useContextProps(props, ref, SelectableCollectionContext); + let {filter, ...autocompleteMenuProps} = props; let filteredCollection = useMemo(() => filter ? collection.filter(filter) : collection, [collection, filter]); let state = useTreeState({ ...props, @@ -213,7 +209,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne }); let triggerState = useContext(RootMenuTriggerStateContext); let {isVirtualized, CollectionRoot} = useContext(CollectionRendererContext); - let {menuProps} = useMenu({...props, ...autocompleteMenuProps, isVirtualized, onClose: props.onClose || triggerState?.close}, state, ref); + let {menuProps} = useMenu({...props, isVirtualized, onClose: props.onClose || triggerState?.close}, state, ref); let renderProps = useRenderProps({ defaultClassName: 'react-aria-Menu', className: props.className, @@ -240,7 +236,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne
} slot={props.slot || undefined} data-empty={state.collection.size === 0 || undefined} onScroll={props.onScroll}> @@ -251,7 +247,8 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [SectionContext, {name: 'MenuSection', render: MenuSectionInner}], [SubmenuTriggerContext, {parentMenuRef: ref, shouldUseVirtualFocus: autocompleteMenuProps?.shouldUseVirtualFocus}], [MenuItemContext, null], - [UNSTABLE_InternalAutocompleteContext, null], + [SelectableCollectionContext, null], + [FieldInputContext, null], [SelectionManagerContext, state.selectionManager], /* Ensure root MenuTriggerState is defined, in case Menu is rendered outside a MenuTrigger. */ /* We assume the context can never change between defined and undefined. */ @@ -339,12 +336,10 @@ function MenuSectionInner(props: MenuSectionProps, ref: For ); } -class MenuSectionNode extends SectionNode {} - /** * A MenuSection represents a section within a Menu. */ -export const MenuSection = /*#__PURE__*/ createBranchComponent(MenuSectionNode, MenuSectionInner); +export const MenuSection = /*#__PURE__*/ createBranchComponent(SectionNode, MenuSectionInner); export interface MenuItemRenderProps extends ItemRenderProps { /** @@ -378,12 +373,10 @@ export interface MenuItemProps extends RenderProps>(null); -class MenuItemNode extends ItemNode {} - /** * A MenuItem represents an individual action in a Menu. */ -export const MenuItem = /*#__PURE__*/ createLeafComponent(MenuItemNode, function MenuItem(props: MenuItemProps, forwardedRef: ForwardedRef, item: Node) { +export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function MenuItem(props: MenuItemProps, forwardedRef: ForwardedRef, item: Node) { [props, forwardedRef] = useContextProps(props, forwardedRef, MenuItemContext); let id = useSlottedContext(MenuItemContext)?.id as string; let state = useContext(MenuStateContext)!; diff --git a/packages/react-aria-components/src/SearchField.tsx b/packages/react-aria-components/src/SearchField.tsx index 3695385688d..db871dc545a 100644 --- a/packages/react-aria-components/src/SearchField.tsx +++ b/packages/react-aria-components/src/SearchField.tsx @@ -15,7 +15,8 @@ import {ButtonContext} from './Button'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {createHideableComponent} from '@react-aria/collections'; import {FieldErrorContext} from './FieldError'; -import {filterDOMProps, mergeProps} from '@react-aria/utils'; +import {FieldInputContext} from './context'; +import {filterDOMProps} from '@react-aria/utils'; import {FormContext} from './Form'; import {GlobalDOMAttributes} from '@react-types/shared'; import {GroupContext} from './Group'; @@ -59,7 +60,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useRef(null); - let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); + [props, inputRef as unknown] = useContextProps(props, inputRef, FieldInputContext); let [labelRef, label] = useSlot( !props['aria-label'] && !props['aria-labelledby'] ); @@ -72,7 +73,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search ...removeDataAttributes(props), label, validationBehavior - }, state, mergedInputRef); + }, state, inputRef); let renderProps = useRenderProps({ ...props, @@ -100,7 +101,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search {} @@ -24,14 +24,12 @@ export const SeparatorContext = createContext { static readonly type = 'separator'; - constructor(key: Key) { - super(SeparatorNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection): CollectionNode | null { let prevItem = newCollection.getItem(this.prevKey!); if (prevItem && prevItem.type !== 'separator') { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } return null; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 773fe0e871d..ef346e3afcf 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,5 +1,5 @@ import {AriaLabelingProps, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; -import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, FilterLessNode, useCachedChildren} from '@react-aria/collections'; +import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, FilterLessNode, LoaderNode, useCachedChildren} from '@react-aria/collections'; import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; @@ -10,21 +10,21 @@ import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, Mu import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useFocusRing, useHover, useLocale, useLocalizedStringFormatter, useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox, useVisuallyHidden} from 'react-aria'; +import {FieldInputContext, SelectableCollectionContext} from './context'; import {filterDOMProps, inertValue, isScrollable, LoadMoreSentinelProps, mergeRefs, useLayoutEffect, useLoadMoreSentinel, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; class TableCollection extends BaseCollection implements ITableCollection { headerRows: GridNode[] = []; columns: GridNode[] = []; rows: GridNode[] = []; rowHeaderColumnKeys: Set = new Set(); - head: CollectionNode = new CollectionNode('tableheader', -1); - body: CollectionNode = new CollectionNode('tablebody', -2); + head = new TableHeaderNode(-1); + body = new TableBodyNode(-2); columnsDirty = true; addNode(node: CollectionNode) { @@ -66,7 +66,6 @@ class TableCollection extends BaseCollection implements ITableCollection) => { switch (node.type) { @@ -161,7 +160,6 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection) => boolean): TableCollection { - let clone = this.clone(); - return super.filter(filterFn, clone) as TableCollection; - - } } interface ResizableTableContainerContextValue { @@ -371,7 +363,9 @@ interface TableInnerProps { function TableInner({props, forwardedRef: ref, selectionState, collection}: TableInnerProps) { - let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + let contextProps; + [contextProps] = useContextProps({}, null, SelectableCollectionContext); + let {filter, ...collectionProps} = contextProps; // eslint-disable-next-line @typescript-eslint/no-unused-vars let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let tableContainerContext = useContext(ResizableTableContainerContext); @@ -491,7 +485,9 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl [TableStateContext, filteredState], [TableColumnResizeStateContext, layoutState], [DragAndDropContext, {dragAndDropHooks, dragState, dropState}], - [DropIndicatorContext, {render: TableDropIndicatorWrapper}] + [DropIndicatorContext, {render: TableDropIndicatorWrapper}], + [SelectableCollectionContext, null], + [FieldInputContext, null] ]}> extends StyleRenderProps } -class TableHeaderNode extends FilterLessNode { +class TableHeaderNode extends FilterLessNode { static readonly type = 'tableheader'; - - constructor(key: Key) { - super(TableHeaderNode.type, key); - } } /** @@ -704,10 +696,6 @@ export interface ColumnProps extends RenderProps, GlobalDOMAt class TableColumnNode extends FilterLessNode { static readonly type = 'column'; - - constructor(key: Key) { - super(TableColumnNode.type, key); - } } /** @@ -946,12 +934,8 @@ export interface TableBodyProps extends Omit, 'disabledKey renderEmptyState?: (props: TableBodyRenderProps) => ReactNode } -class TableBodyNode extends CollectionNode { +class TableBodyNode extends CollectionNode { static readonly type = 'tablebody'; - - constructor(key: Key) { - super(TableBodyNode.type, key); - } } /** @@ -1058,15 +1042,13 @@ export interface RowProps extends StyleRenderProps, LinkDOMPr class TableRowNode extends CollectionNode { static readonly type = 'item'; - constructor(key: Key) { - super(TableRowNode.type, key); - } - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): TableRowNode | null { let cells = collection.getChildren(this.key); for (let cell of cells) { if (filterFn(cell.textValue, cell)) { - return this.clone(); + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; } } @@ -1260,10 +1242,6 @@ export interface CellProps extends RenderProps, GlobalDOMAttrib class TableCellNode extends FilterLessNode { static readonly type = 'cell'; - - constructor(key: Key) { - super(TableCellNode.type, key); - } } /** @@ -1424,15 +1402,7 @@ export interface TableLoadMoreItemProps extends Omit { - static readonly type = 'loader'; - - constructor(key: Key) { - super(TableLoaderNode.type, key); - } -} - -export const TableLoadMoreItem = createLeafComponent(TableLoaderNode, function TableLoadingIndicator(props: TableLoadMoreItemProps, ref: ForwardedRef, item: Node) { +export const TableLoadMoreItem = createLeafComponent(LoaderNode, function TableLoadingIndicator(props: TableLoadMoreItemProps, ref: ForwardedRef, item: Node) { let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index e2ae91eaddc..d0a4dc8780d 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -237,10 +237,6 @@ function TabListInner({props, forwardedRef: ref}: TabListInner class TabItemNode extends FilterLessNode { static readonly type = 'item'; - - constructor(key: Key) { - super(TabItemNode.type, key); - } } /** diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 65c35afbfeb..b6724c7f6bf 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -21,8 +21,8 @@ import {LabelContext} from './Label'; import {ListState, Node, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useRef} from 'react'; +import {SelectableCollectionContext} from './context'; import {TextContext} from './Text'; -import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete'; export interface TagGroupProps extends Omit, 'children' | 'items' | 'label' | 'description' | 'errorMessage' | 'keyboardDelegate'>, DOMProps, SlotProps, GlobalDOMAttributes {} @@ -75,7 +75,9 @@ interface TagGroupInnerProps { } function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProps) { - let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + let contextProps; + [contextProps] = useContextProps({}, null, SelectableCollectionContext); + let {filter, ...collectionProps} = contextProps; // eslint-disable-next-line @typescript-eslint/no-unused-vars let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {}; let tagListRef = useRef(null); @@ -201,12 +203,10 @@ export interface TagProps extends RenderProps, LinkDOMProps, Hov isDisabled?: boolean } -class TagItemNode extends ItemNode {} - /** * A Tag is an individual item within a TagList. */ -export const Tag = /*#__PURE__*/ createLeafComponent(TagItemNode, (props: TagProps, forwardedRef: ForwardedRef, item: Node) => { +export const Tag = /*#__PURE__*/ createLeafComponent(ItemNode, (props: TagProps, forwardedRef: ForwardedRef, item: Node) => { let state = useContext(ListStateContext)!; let ref = useObjectRef(forwardedRef); let {focusProps, isFocusVisible} = useFocusRing({within: false}); diff --git a/packages/react-aria-components/src/TextField.tsx b/packages/react-aria-components/src/TextField.tsx index 1f80249ca5a..3899bf22b07 100644 --- a/packages/react-aria-components/src/TextField.tsx +++ b/packages/react-aria-components/src/TextField.tsx @@ -14,7 +14,8 @@ import {AriaTextFieldProps, useTextField} from 'react-aria'; import {ContextValue, DOMProps, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {createHideableComponent} from '@react-aria/collections'; import {FieldErrorContext} from './FieldError'; -import {filterDOMProps, mergeProps} from '@react-aria/utils'; +import {FieldInputContext} from './context'; +import {filterDOMProps} from '@react-aria/utils'; import {FormContext} from './Form'; import {GlobalDOMAttributes} from '@react-types/shared'; import {GroupContext} from './Group'; @@ -61,8 +62,8 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel [props, ref] = useContextProps(props, ref, TextFieldContext); let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; - let inputRef = useRef(null); - let [inputContextProps, mergedInputRef] = useContextProps({}, inputRef, InputContext); + let inputRef = useRef(null); + [props, inputRef as unknown] = useContextProps(props, inputRef, FieldInputContext); let [labelRef, label] = useSlot( !props['aria-label'] && !props['aria-labelledby'] ); @@ -72,16 +73,16 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel inputElementType, label, validationBehavior - }, mergedInputRef); + }, inputRef); // Intercept setting the input ref so we can determine what kind of element we have. // useTextField uses this to determine what props to include. let inputOrTextAreaRef = useCallback((el) => { - mergedInputRef.current = el; + inputRef.current = el; if (el) { setInputElementType(el instanceof HTMLTextAreaElement ? 'textarea' : 'input'); } - }, [mergedInputRef]); + }, [inputRef]); let renderProps = useRenderProps({ ...props, @@ -110,7 +111,7 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel { static readonly type = 'content'; - - constructor(key: Key) { - super(TreeContentNode.type, key); - } } export const TreeItemContent = /*#__PURE__*/ createLeafComponent(TreeContentNode, function TreeItemContent(props: TreeItemContentProps) { @@ -493,10 +489,6 @@ export interface TreeItemProps extends StyleRenderProps { static readonly type = 'item'; - - constructor(key: Key) { - super(TreeItemNode.type, key); - } } /** @@ -735,15 +727,7 @@ export interface TreeLoadMoreItemProps extends Omit { - static readonly type = 'loader'; - - constructor(key: Key) { - super(TreeLoaderNode.type, key); - } -} - -export const TreeLoadMoreItem = createLeafComponent(TreeLoaderNode, function TreeLoadingSentinel(props: TreeLoadMoreItemProps, ref: ForwardedRef, item: Node) { +export const TreeLoadMoreItem = createLeafComponent(LoaderNode, function TreeLoadingSentinel(props: TreeLoadMoreItemProps, ref: ForwardedRef, item: Node) { let {isVirtualized} = useContext(CollectionRendererContext); let state = useContext(TreeStateContext)!; let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; diff --git a/packages/react-aria-components/src/context.tsx b/packages/react-aria-components/src/context.tsx new file mode 100644 index 00000000000..4ce7a97be30 --- /dev/null +++ b/packages/react-aria-components/src/context.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaLabelingProps, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, ValueBase} from '@react-types/shared'; +import {AriaTextFieldProps} from '@react-aria/textfield'; +import {ContextValue} from './utils'; +import {createContext} from 'react'; + +export interface SelectableCollectionContextValue extends DOMProps, AriaLabelingProps { + filter?: (nodeTextValue: string, node: Node) => boolean, + /** Whether the collection items should use virtual focus instead of being focused directly. */ + shouldUseVirtualFocus?: boolean, + /** Whether typeahead is disabled. */ + disallowTypeAhead?: boolean +} + +interface FieldInputContextValue extends + DOMProps, + FocusEvents, + KeyboardEvents, + Pick, 'onChange' | 'value'>, + Pick {} + +export const SelectableCollectionContext = createContext, HTMLElement>>(null); +export const FieldInputContext = createContext>(null); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 7dadbbeac0f..e0384baca75 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -60,7 +60,7 @@ export {ProgressBar, ProgressBarContext} from './ProgressBar'; export {RadioGroup, Radio, RadioGroupContext, RadioContext, RadioGroupStateContext} from './RadioGroup'; export {SearchField, SearchFieldContext} from './SearchField'; export {Select, SelectValue, SelectContext, SelectValueContext, SelectStateContext} from './Select'; -export {Separator, SeparatorContext, SeparatorNode} from './Separator'; +export {Separator, SeparatorContext} from './Separator'; export {Slider, SliderOutput, SliderTrack, SliderThumb, SliderContext, SliderOutputContext, SliderTrackContext, SliderStateContext} from './Slider'; export {Switch, SwitchContext} from './Switch'; export {TableLoadMoreItem, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext} from './Table'; diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index a6500c4e152..4433cfe3321 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TableLayout, TagGroup, TagList, Text, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; +import {Autocomplete, Button, Cell, Collection, Column, DialogTrigger, GridList, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, OverlayArrow, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Table, TableBody, TableHeader, TableLayout, TagGroup, TagList, Text, TextArea, TextField, Tooltip, TooltipTrigger, Virtualizer} from 'react-aria-components'; import {LoadingSpinner, MyListBoxItem, MyMenuItem} from './utils'; import {Meta, StoryObj} from '@storybook/react'; import {MyCheckbox} from './Table.stories'; @@ -31,7 +31,8 @@ export default { args: { onAction: action('onAction'), selectionMode: 'multiple', - escapeKeyBehavior: 'clearSelection' + escapeKeyBehavior: 'clearSelection', + disableVirtualFocus: false }, argTypes: { onAction: { @@ -127,7 +128,7 @@ function AutocompleteWrapper(props) { export const AutocompleteExample: AutocompleteStory = { render: (args) => { return ( - +
@@ -145,7 +146,7 @@ export const AutocompleteExample: AutocompleteStory = { export const AutocompleteSearchfield: AutocompleteStory = { render: (args) => { return ( - +
@@ -305,7 +306,7 @@ export const AutocompleteMenuDynamic: AutocompleteStory = { return ( <> - +
@@ -327,7 +328,7 @@ export const AutocompleteMenuDynamic: AutocompleteStory = { export const AutocompleteOnActionOnMenuItems: AutocompleteStory = { render: (args) => { return ( - +
@@ -356,7 +357,7 @@ let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, export const AutocompleteDisabledKeys: AutocompleteStory = { render: (args) => { return ( - +
@@ -396,14 +397,14 @@ const AsyncExample = (args: any): React.ReactElement => { }; } }); - let {onSelectionChange, selectionMode, includeLoadState, escapeKeyBehavior} = args; + let {onSelectionChange, selectionMode, includeLoadState, escapeKeyBehavior, disableVirtualFocus} = args; let renderEmptyState; if (includeLoadState) { renderEmptyState = list.isLoading ? () => 'Loading' : () => 'No results found.'; } return ( - +
@@ -441,7 +442,7 @@ const CaseSensitiveFilter = (args) => { let defaultFilter = (itemText, input) => contains(itemText, input); return ( - filter={defaultFilter}> + filter={defaultFilter} disableVirtualFocus={args.disableVirtualFocus}>
@@ -480,7 +481,7 @@ export const AutocompleteWithListbox: AutocompleteStory = { height: 250 }}> {() => ( - +
@@ -557,7 +558,7 @@ export const AutocompleteWithVirtualizedListbox: AutocompleteStory = { height: 250 }}> {() => ( - +
@@ -904,7 +905,7 @@ export const AutocompleteWithAsyncListBox = (args) => { }); return ( - +
@@ -1111,7 +1112,7 @@ function AutocompleteNodeFiltering(args) { }; return ( - filter={filter}> + filter={filter} disableVirtualFocus={args.disableVirtualFocus}>
@@ -1135,3 +1136,66 @@ export const AutocompletePreserveFirstSectionStory: AutocompleteStory = { } } }; + + +let names = [ + {id: 1, name: 'David'}, + {id: 2, name: 'Sam'}, + {id: 3, name: 'Julia'} +]; + +const UserCustomFiltering = (args): React.ReactElement => { + let [value, setValue] = useState(''); + + let {contains} = useFilter({sensitivity: 'base'}); + + + let filter = (textValue, inputValue) => { + let index = inputValue.lastIndexOf('@'); + let filterText = ''; + if (index > -1) { + filterText = value.slice(index + 1); + } + + return contains(textValue, filterText); + }; + + let onAction = (key) => { + let index = value.lastIndexOf('@'); + if (index === -1) { + index = value.length; + } + let name = names.find(person => person.id === key)!.name; + setValue(value.slice(0, index).concat(name)); + }; + + return ( + +
+ + +