diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 9ce77767063..12bd9ed948d 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -15,20 +15,37 @@ 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, Node, RefObject} from '@react-types/shared'; import {Hidden} from './Hidden'; -import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, Ref, useCallback, useContext, useMemo, useRef, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; -import {useLayoutEffect} from '@react-aria/utils'; +import {useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {useSyncExternalStore as useSyncExternalStoreShim} from 'use-sync-external-store/shim/index.js'; const ShallowRenderContext = createContext(false); const CollectionDocumentContext = createContext> | null>(null); +export interface CollectionProps extends CachedChildrenOptions {} + +export interface CollectionChildren> { + (collection: C): ReactNode +} + +export interface CollectionRenderProps> { + /** A hook that will be called before the collection builder to build the content. */ + useCollectionContent?: (content: ReactNode) => ReactNode, + /** A hook that will be called by the collection builder to render the children. */ + useCollectionChildren?: (children: CollectionChildren) => CollectionChildren + // TODO: Do we also want useCollection() to wrap createCollection()? +} + +interface CollectionRef, E extends Element> extends RefObject, CollectionRenderProps {} + export interface CollectionBuilderProps> { content: ReactNode, - children: (collection: C) => ReactNode, - createCollection?: () => C + children: CollectionChildren, + createCollection?: () => C, + collectionRef?: CollectionRef } /** @@ -37,13 +54,14 @@ export interface CollectionBuilderProps> { export function CollectionBuilder>(props: CollectionBuilderProps): ReactElement { // If a document was provided above us, we're already in a hidden tree. Just render the content. let doc = useContext(CollectionDocumentContext); + let content = props.collectionRef?.useCollectionContent?.(props.content) ?? props.content; if (doc) { // The React types prior to 18 did not allow returning ReactNode from components // even though the actual implementation since React 16 did. // We must return ReactElement so that TS does not complain that // is not a valid JSX element with React 16 and 17 types. // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/20544 - return props.content as ReactElement; + return content as ReactElement; } // Otherwise, render a hidden copy of the children so that we can build the collection before constructing the state. @@ -52,14 +70,15 @@ export function CollectionBuilder>(props: Colle // This is fine. CollectionDocumentContext never changes after mounting. // eslint-disable-next-line react-hooks/rules-of-hooks let {collection, document} = useCollectionDocument(props.createCollection); + let children = props.collectionRef?.useCollectionChildren?.(props.children) ?? props.children; return ( <> - {props.content} + {content} - + ); } @@ -157,6 +176,12 @@ function useSSRCollectionNode(Type: string, props: object, re return {children}; } +export function useCollectionRef, E extends Element>(props: CollectionRenderProps, ref: Ref): CollectionRef { + let refObject = useObjectRef(ref) as CollectionRef; + + return Object.assign(refObject, props); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; @@ -206,8 +231,6 @@ function useCollectionChildren(options: CachedChildrenOptions< return useCachedChildren({...options, addIdAndValue: true}); } -export interface CollectionProps extends CachedChildrenOptions {} - const CollectionContext = createContext | null>(null); /** A Collection renders a list of items, automatically managing caching and keys. */ diff --git a/packages/@react-aria/collections/src/index.ts b/packages/@react-aria/collections/src/index.ts index 5052465351f..630540f814a 100644 --- a/packages/@react-aria/collections/src/index.ts +++ b/packages/@react-aria/collections/src/index.ts @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ -export {CollectionBuilder, Collection, createLeafComponent, createBranchComponent} from './CollectionBuilder'; +export {CollectionBuilder, Collection, createLeafComponent, createBranchComponent, useCollectionRef} from './CollectionBuilder'; export {createHideableComponent, useIsHidden} from './Hidden'; export {useCachedChildren} from './useCachedChildren'; export {BaseCollection, CollectionNode} from './BaseCollection'; -export type {CollectionBuilderProps, CollectionProps} from './CollectionBuilder'; +export type {CollectionBuilderProps, CollectionProps, CollectionRenderProps} from './CollectionBuilder'; export type {CachedChildrenOptions} from './useCachedChildren'; diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 99e06728c9c..35e152cf0b6 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, createLeafComponent} from '../src'; +import {Collection, CollectionBuilder, createLeafComponent, useCollectionRef} from '../src'; import React from 'react'; import {render} from '@testing-library/react'; @@ -6,8 +6,8 @@ const Item = createLeafComponent('item', () => { return
; }); -const renderItems = (items, spyCollection) => ( - {items.map((item) => )}}> +const renderItems = (items, spyCollection, collectionRef) => ( + {items.map((item) => )}} collectionRef={collectionRef}> {collection => { spyCollection.current = collection; return null; @@ -30,4 +30,29 @@ describe('CollectionBuilder', () => { expect(spyCollection.current.firstKey).toBe(null); expect(spyCollection.current.lastKey).toBe(null); }); + + it('should support modifying the content via useCollectionChildren', () => { + let spyCollection = {}; + let ref = {current: null}; + let TestBench = () => { + let collectionRef = useCollectionRef({useCollectionContent: () => false}, ref); + return renderItems(['a'], spyCollection, collectionRef); + }; + render(); + expect(spyCollection.current.frozen).toBe(true); + expect(spyCollection.current.firstKey).toBe(null); + expect(spyCollection.current.lastKey).toBe(null); + }); + + it('should support modifying the rendered children via useCollectionChildren', () => { + let spyCollection = {}; + let ref = {current: null}; + let TestBench = () => { + let collectionRef = useCollectionRef({useCollectionChildren: (children) => (c) =>
}, ref); + return renderItems([], spyCollection, collectionRef); + }; + render(); + expect(spyCollection.current.frozen).toBe(true); + expect(ref.current).not.toBe(null); + }); }); diff --git a/packages/@react-aria/utils/src/mergeRefs.ts b/packages/@react-aria/utils/src/mergeRefs.ts index e53c9a71d8b..2924428883d 100644 --- a/packages/@react-aria/utils/src/mergeRefs.ts +++ b/packages/@react-aria/utils/src/mergeRefs.ts @@ -20,7 +20,7 @@ export function mergeRefs(...refs: Array | MutableRefObject | null return refs[0]; } - return (value: T | null) => { + let callbackRef = (value: T | null) => { let hasCleanup = false; const cleanups = refs.map(ref => { @@ -41,6 +41,8 @@ export function mergeRefs(...refs: Array | MutableRefObject | null }; } }; + + return Object.assign(callbackRef, ...refs.filter(Boolean)); } function setRef(ref: Ref | MutableRefObject | null | undefined, value: T) { diff --git a/packages/@react-aria/utils/src/useObjectRef.ts b/packages/@react-aria/utils/src/useObjectRef.ts index 52473e0cb0c..d71561255be 100644 --- a/packages/@react-aria/utils/src/useObjectRef.ts +++ b/packages/@react-aria/utils/src/useObjectRef.ts @@ -49,6 +49,7 @@ export function useObjectRef(ref?: ((instance: T | null) => (() => void) | vo return useMemo( () => ({ + ...ref, get current() { return objRef.current; }, @@ -64,6 +65,6 @@ export function useObjectRef(ref?: ((instance: T | null) => (() => void) | vo } } }), - [refEffect] + [ref, refEffect] ); } diff --git a/packages/@react-aria/utils/test/mergeRefs.test.tsx b/packages/@react-aria/utils/test/mergeRefs.test.tsx index e71a61d904a..ece8ea82a6c 100644 --- a/packages/@react-aria/utils/test/mergeRefs.test.tsx +++ b/packages/@react-aria/utils/test/mergeRefs.test.tsx @@ -32,6 +32,18 @@ describe('mergeRefs', () => { expect(ref1.current).toBe(ref2.current); }); + it('should support additional properties on the refs', () => { + // We mock refs here because they are only mutable in React18+ + let ref1 = {current: null}; + let ref2 = {current: null, foo: 'bar'}; + let ref3 = (() => {}) as any; + ref3.baz = 'foo'; + + let ref = mergeRefs(ref1, ref2, ref3) as any; + expect(ref.foo).toBe('bar'); + expect(ref.baz).toBe('foo'); + }); + if (parseInt(React.version.split('.')[0], 10) >= 19) { it('merge Ref Cleanup', () => { const cleanUp = jest.fn(); diff --git a/packages/@react-aria/utils/test/useObjectRef.test.js b/packages/@react-aria/utils/test/useObjectRef.test.js index e77e0302267..73bca7d1e99 100644 --- a/packages/@react-aria/utils/test/useObjectRef.test.js +++ b/packages/@react-aria/utils/test/useObjectRef.test.js @@ -63,6 +63,17 @@ describe('useObjectRef', () => { expect(ref).toHaveBeenCalledTimes(1); }); + it('should support additional properties on the ref', () => { + const TextField = React.forwardRef((props, forwardedRef) => { + const ref = useObjectRef(forwardedRef); + return ; + }); + + let ref = {current: null, foo: 'bar'}; + render(); + expect(ref.foo).toBe('bar'); + }); + /** * This describe would completely fail if `useObjectRef` did not account * for order of execution and rendering, especially when other components diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 7965b8f8db6..3bdd2a09e99 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -146,7 +146,8 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef - + {/* @ts-expect-error */} + {collection => ( - }> + {/* @ts-expect-error */} + } collectionRef={ref}> {collection => } diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 832a5121158..c67f5206884 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -38,7 +38,7 @@ export const Breadcrumbs = /*#__PURE__*/ (forwardRef as forwardRefType)(function let DOMProps = filterDOMProps(props, {global: true}); return ( - }> + } collectionRef={ref}> {collection => (
    + {collection => } ); diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index f98cbdc36ab..12fcab4004f 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -90,7 +90,7 @@ export const GridList = /*#__PURE__*/ (forwardRef as forwardRefType)(function Gr [props, ref] = useContextProps(props, ref, GridListContext); return ( - }> + } collectionRef={ref}> {collection => } ); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 969fccceb9e..c75fcbb1bb7 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -100,7 +100,7 @@ export const ListBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Lis } return ( - }> + } collectionRef={ref}> {collection => } ); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index cda7ba63bd1..33557c2dde0 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -169,7 +169,7 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu}> + } collectionRef={ref}> {collection => } ); diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index 2248f429380..9ff254c0390 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -94,7 +94,7 @@ export const Select = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sele ), [children, isDisabled, isInvalid, isRequired]); return ( - + {collection => } ); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 031d4b051eb..db69fadd882 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -348,7 +348,7 @@ export const Table = forwardRef(function Table(props: TableProps, ref: Forwarded ); return ( - new TableCollection()}> + new TableCollection()} collectionRef={ref}> {collection => } ); diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 907c336dbd1..56e26a409cb 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -131,7 +131,7 @@ export const Tabs = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tabs(p ), [children, orientation]); return ( - + {collection => } ); diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index c23d711296b..71a04acd0c1 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -61,7 +61,7 @@ export const TagListContext = createContext, HTML export const TagGroup = /*#__PURE__*/ (forwardRef as forwardRefType)(function TagGroup(props: TagGroupProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, TagGroupContext); return ( - + {collection => } ); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 778bb168065..a70470c990b 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -164,7 +164,7 @@ export const Tree = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tree}> + } collectionRef={ref}> {collection => } );