diff --git a/.gitignore b/.gitignore index 178fe850797..a46325f2191 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .cache .idea +.vscode .package-lock.json .parcel-cache build-storybook.log diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index e7585b8ab81..e3d74eb0776 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -17,6 +17,7 @@ import {createPortal} from 'react-dom'; import {FocusableContext} from '@react-aria/interactions'; import {forwardRefType, Key, Node} from '@react-types/shared'; import {Hidden} from './Hidden'; +import {mergeRefs} from '@react-aria/utils'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; import {useSyncExternalStore as useSyncExternalStoreShim} from 'use-sync-external-store/shim/index.js'; @@ -130,7 +131,7 @@ function createCollectionNodeClass(type: string): CollectionNodeClass { return NodeClass; } -function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass | string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { +function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass | string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node, ref?: ForwardedRef) => 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); @@ -169,7 +170,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection 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 Component = (forwardRef as forwardRefType)(({node}: {node: Node}, ref: ForwardedRef) => render(node.props, mergeRefs(node.props.ref, ref), node)); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let focusableProps = useContext(FocusableContext); let isShallow = useContext(ShallowRenderContext); @@ -186,10 +187,10 @@ export function createLeafComponent

(Collect ref, 'children' in props ? props.children : null, null, - node => ( + (node, ref) => ( // Forward FocusableContext to real DOM tree so tooltips work. - + ) ); @@ -200,10 +201,10 @@ export function createLeafComponent

(Collect } 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 Component = (forwardRef as forwardRefType)(({node}: {node: Node}, ref: ForwardedRef) => render(node.props, mergeRefs(node.props.ref, ref), node)); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let children = useChildren(props); - return useSSRCollectionNode(CollectionNodeClass, props, ref, null, children, node => ) ?? <>; + return useSSRCollectionNode(CollectionNodeClass, props, ref, null, children, (node, ref) => ) ?? <>; }); // @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 1bde2a2fdb0..57290afbbaf 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -329,7 +329,7 @@ export class ElementNode extends BaseNode { } } - setProps(obj: {[key: string]: any}, ref: ForwardedRef, CollectionNodeClass: CollectionNodeClass, rendered?: ReactNode, render?: (node: Node) => ReactElement): void { + setProps(obj: {[key: string]: any}, ref: ForwardedRef, CollectionNodeClass: CollectionNodeClass, rendered?: ReactNode, render?: (node: Node, ref?: ForwardedRef) => ReactElement): void { let node; let {value, textValue, id, ...props} = obj; if (this.node == null) { diff --git a/packages/@react-aria/collections/test/CollectionBuilder.test.js b/packages/@react-aria/collections/test/CollectionBuilder.test.js index 74664dbfc3c..b85b102789b 100644 --- a/packages/@react-aria/collections/test/CollectionBuilder.test.js +++ b/packages/@react-aria/collections/test/CollectionBuilder.test.js @@ -1,33 +1,33 @@ import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent} from '../src'; -import React from 'react'; +import React, {createRef} from 'react'; import {render} from '@testing-library/react'; class ItemNode extends CollectionNode { static type = 'item'; } -const Item = createLeafComponent(ItemNode, () => { - return

; +const Item = createLeafComponent(ItemNode, (props, ref) => { + return
; }); -const ItemsOld = createLeafComponent('item', () => { - return
; +const ItemsOld = createLeafComponent('item', (props, ref) => { + return
; }); -const SectionOld = createBranchComponent('section', () => { - return
; +const SectionOld = createBranchComponent('section', (props, ref) => { + return
; }); -const renderItems = (items, spyCollection) => ( +const renderItems = (items, spyCollection, children = () => null) => ( {items.map((item) => )}}> {collection => { spyCollection.current = collection; - return null; + return children(collection); }} ); -const renderItemsOld = (items, spyCollection) => ( +const renderItemsOld = (items, spyCollection, children = () => null) => ( @@ -40,7 +40,7 @@ const renderItemsOld = (items, spyCollection) => ( }> {collection => { spyCollection.current = collection; - return null; + return children(collection); }} ); @@ -70,4 +70,12 @@ describe('CollectionBuilder', () => { expect(spyCollection.current.keyMap.get('react-aria-2').firstChildKey).toBe('react-aria-1'); expect(spyCollection.current.keyMap.get('react-aria-1').type).toBe('item'); }); + + it('should support ref attachment to a rendered node', () => { + let spyRef = createRef(); + render(renderItems(['a'], {}, collection => Array.from(collection).map( + item => {item.render(item, spyRef)} + ))); + expect(spyRef.current).toBeEmptyDOMElement(); + }); }); diff --git a/packages/@react-aria/utils/src/mergeRefs.ts b/packages/@react-aria/utils/src/mergeRefs.ts index e53c9a71d8b..99a0e3c1634 100644 --- a/packages/@react-aria/utils/src/mergeRefs.ts +++ b/packages/@react-aria/utils/src/mergeRefs.ts @@ -18,6 +18,8 @@ import {MutableRefObject, Ref} from 'react'; export function mergeRefs(...refs: Array | MutableRefObject | null | undefined>): Ref { if (refs.length === 1 && refs[0]) { return refs[0]; + } else if (refs.every(ref => ref == null)) { + return null; } return (value: T | null) => { diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index c78b6c53ddc..3654afd6406 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -29,7 +29,7 @@ import {baseColor, focusRing, size, style} from '../style' with { type: 'macro' import ChevronIcon from '../ui-icons/Chevron'; import {Collection, DOMRef, DOMRefValue, GlobalDOMAttributes, LinkDOMProps, Node} from '@react-types/shared'; import {controlFont, controlSize, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {createContext, forwardRef, Fragment, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; // @ts-ignore import FolderIcon from '../s2wf-icons/S2_Icon_FolderBreadcrumb_20_N.svg'; import {forwardRefType} from './types'; @@ -385,6 +385,7 @@ let CollapsingCollectionRenderer: CollectionRenderer = { }; let useCollectionRender = (collection: Collection>) => { + let {CollectionNode} = DefaultCollectionRenderer; let {containerRef, onAction} = useContext(CollapseContext) ?? {}; let [visibleItems, setVisibleItems] = useState(collection.size); let {size = 'M'} = useContext(InternalBreadcrumbsContext); @@ -473,13 +474,13 @@ let useCollectionRender = (collection: Collection>) => { {visibleItems < collection.size && collection.size > 2 ? ( <> - {children[0].render?.(children[0])} + - {children.slice(sliceIndex).map(node => {node.render?.(node)})} + {children.slice(sliceIndex).map(node => )} ) : ( <> - {children.map(node => {node.render?.(node)})} + {children.map(node => )} )} diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index a653dfc9768..3d3090ff037 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ +import {ForwardedRef, ReactElement, ReactNode} from 'react'; import {Key} from '@react-types/shared'; import {LinkDOMProps} from './dom'; -import {ReactElement, ReactNode} from 'react'; export interface ItemProps extends LinkDOMProps { /** Rendered contents of the item or child items. */ @@ -223,5 +223,5 @@ export interface Node { /** @private */ shouldInvalidate?: (context: any) => boolean, /** A function that renders this node to a React Element in the DOM. */ - render?: (node: Node) => ReactElement + render?: (node: Node, ref?: ForwardedRef) => ReactElement } diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 70f4d8266c3..ce1b3a51e1d 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -108,7 +108,24 @@ export const Section = /*#__PURE__*/ createBranchComponent('section', > extends HTMLAttributes { + /** The collection of items to render. */ + collection: ICollection>, + /** The node of the item to render. */ + node: T, + /** The parent node of the item to render. */ + parent: T | null, + /** The content that should be rendered before the item. */ + before?: ReactNode[], + /** The content that should be rendered after the item. */ + after?: ReactNode[], + /** A function that renders this node to a React Element in the DOM. */ + render?: (node: Node, ref?: ForwardedRef) => ReactElement, + /** A ref to the rendered element of this node. */ + nodeRef?: ForwardedRef +} + +export interface CollectionBranchProps extends HTMLAttributes { /** The collection of items to render. */ collection: ICollection>, /** The parent node of the items to render. */ @@ -128,7 +145,7 @@ export interface CollectionRootProps extends HTMLAttributes { renderDropIndicator?: (target: ItemDropTarget) => ReactNode } -export interface CollectionRenderer { +export interface CollectionRenderer> { /** Whether this is a virtualized collection. */ isVirtualized?: boolean, /** A delegate object that provides layout information for items in the collection. */ @@ -138,15 +155,31 @@ export interface CollectionRenderer { /** A component that renders the root collection items. */ CollectionRoot: React.ComponentType, /** A component that renders the child collection items. */ - CollectionBranch: React.ComponentType + CollectionBranch: React.ComponentType, + /** A component that renders the collection item. */ + CollectionNode?: React.ComponentType> +} + +interface DefaultRenderer extends CollectionRenderer> { + /** A component that renders the collection item. */ + CollectionNode: React.ComponentType>> } -export const DefaultCollectionRenderer: CollectionRenderer = { +export const DefaultCollectionRenderer: DefaultRenderer = { CollectionRoot({collection, renderDropIndicator}) { return useCollectionRender(collection, null, renderDropIndicator); }, CollectionBranch({collection, parent, renderDropIndicator}) { return useCollectionRender(collection, parent, renderDropIndicator); + }, + CollectionNode({before = [], node, render = node.render, nodeRef, after = []}) { + return ( + <> + {before.map((el, i) => {el})} + {render!(node, nodeRef)} + {after.map((el, i) => {el})} + + ); } }; @@ -155,9 +188,11 @@ function useCollectionRender( parent: Node | null, renderDropIndicator?: (target: ItemDropTarget) => ReactNode ) { + let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); + return useCachedChildren({ items: parent ? collection.getChildren!(parent.key) : collection, - dependencies: [renderDropIndicator], + dependencies: [CollectionNode, parent, renderDropIndicator], children(node) { // Return a empty fragment since we don't want to render the content twice // If we don't skip the content node here, we end up rendering them twice in a Tree since we also render the content node in TreeItem @@ -165,18 +200,16 @@ function useCollectionRender( return <>; } - let rendered = node.render!(node); - if (!renderDropIndicator || node.type !== 'item') { - return rendered; + let pseudoProps: Pick>, 'before' | 'after'> = {}; + + if (renderDropIndicator && node.type === 'item') { + let beforeIndicator = renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'}); + let afterIndicator = renderAfterDropIndicators(collection, node, renderDropIndicator); + + pseudoProps = {before: new Array(beforeIndicator), after: new Array(afterIndicator)}; } - return ( - <> - {renderDropIndicator({type: 'item', key: node.key, dropPosition: 'before'})} - {rendered} - {renderAfterDropIndicators(collection, node, renderDropIndicator)} - - ); + return ; } }); } @@ -217,7 +250,7 @@ export function renderAfterDropIndicators(collection: ICollection> return afterIndicators; } -export const CollectionRendererContext = createContext(DefaultCollectionRenderer); +export const CollectionRendererContext = createContext>(DefaultCollectionRenderer); type PersistedKeysReturnValue = Set | null; export function usePersistedKeys(focusedKey: Key | null): PersistedKeysReturnValue { diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 0336e312bcc..adfa970cf71 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -629,6 +629,7 @@ class TreeItemNode extends CollectionNode { */ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, (props: TreeItemProps, ref: ForwardedRef, item: Node) => { let state = useContext(TreeStateContext)!; + let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); ref = useObjectRef(ref); let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; @@ -733,10 +734,10 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, { - switch (item.type) { + children: node => { + switch (node.type) { case 'content': { - return item.render!(item); + return ; } // Skip item since we don't render the nested rows as children of the parent row, the flattened collection // will render them each as siblings instead @@ -744,7 +745,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, ; default: - throw new Error('Unsupported element type in TreeRow: ' + item.type); + throw new Error('Unsupported element type in TreeRow: ' + node.type); } } }); diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 1a38e8adbf1..4051b788099 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import {CollectionBranchProps, CollectionRenderer, CollectionRendererContext, CollectionRootProps, renderAfterDropIndicators} from './Collection'; +import {CollectionBranchProps, CollectionNodeProps, CollectionRenderer, CollectionRendererContext, CollectionRootProps, DefaultCollectionRenderer, renderAfterDropIndicators} from './Collection'; import {DropTargetDelegate, ItemDropTarget, Node} from '@react-types/shared'; import {Layout, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; -import React, {createContext, JSX, ReactNode, useContext, useMemo} from 'react'; +import {mergeProps} from '@react-aria/utils'; +import React, {createContext, ForwardedRef, JSX, ReactNode, useContext, useMemo} from 'react'; import {useScrollView, VirtualizerItem} from '@react-aria/virtualizer'; type View = ReusableView, ReactNode>; @@ -53,13 +54,15 @@ const LayoutContext = createContext(null); export function Virtualizer(props: VirtualizerProps): JSX.Element { let {children, layout: layoutProp, layoutOptions} = props; let layout = useMemo(() => typeof layoutProp === 'function' ? new layoutProp() : layoutProp, [layoutProp]); - let renderer: CollectionRenderer = useMemo(() => ({ + let {CollectionNode} = useContext(CollectionRendererContext); + let renderer: CollectionRenderer, ReactNode>> = useMemo(() => ({ isVirtualized: true, layoutDelegate: layout, dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined, CollectionRoot, - CollectionBranch - }), [layout]); + CollectionBranch, + CollectionNode + }), [CollectionNode, layout]); return ( @@ -70,15 +73,14 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { ); } -function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator}: CollectionRootProps) { +function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator, ...props}: CollectionRootProps) { let {layout, layoutOptions} = useContext(LayoutContext)!; let layoutOptions2 = layout.useLayoutOptions?.(); let state = useVirtualizerState({ layout, collection, - renderView: (type, item) => { - return item?.render?.(item); - }, + // If we don't skip rendering the node here, we end up rendering them twice. + renderView: () => null, onVisibleRectChange(rect) { let element = scrollRef?.current; if (element) { @@ -103,9 +105,9 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat }, scrollRef!); return ( -
+
- {renderChildren(null, state.visibleViews, renderDropIndicator)} + {useRenderChildren(null, state.visibleViews, renderDropIndicator)}
); @@ -114,41 +116,36 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) { let virtualizer = useContext(VirtualizerContext); let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; - return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); -} -function renderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget) => ReactNode) { - return children.map(view => renderWrapper(parent, view, renderDropIndicator)); + return useRenderChildren(parentView, Array.from(parentView.children), renderDropIndicator); } -function renderWrapper( - parent: View | null, - reusableView: View, - renderDropIndicator?: (target: ItemDropTarget) => ReactNode -): ReactNode { - let rendered = ( - - {reusableView.rendered} - - ); +function useRenderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget) => ReactNode) { + let {CollectionNode = DefaultCollectionRenderer.CollectionNode} = useContext(CollectionRendererContext); + + return children.map(node => { + let {collection, layout} = node.virtualizer; + let pseudoProps: Pick, ReactNode>>, 'before' | 'after'> = {}; - let {collection, layout} = reusableView.virtualizer; - let node = reusableView.content; - if (node?.type === 'item' && renderDropIndicator && layout.getDropTargetLayoutInfo) { - rendered = ( - - {renderDropIndicatorWrapper(parent, reusableView, {type: 'item', key: reusableView.content!.key, dropPosition: 'before'}, renderDropIndicator)} - {rendered} - {renderAfterDropIndicators(collection, node, target => renderDropIndicatorWrapper(parent, reusableView, target, renderDropIndicator))} - + if (layout.getDropTargetLayoutInfo && renderDropIndicator && node.content?.type === 'item') { + let beforeIndicator = renderDropIndicatorWrapper(parent, node, {type: 'item', key: node.content!.key, dropPosition: 'before'}, renderDropIndicator); + let afterIndicator = renderAfterDropIndicators(collection, node.content, target => renderDropIndicatorWrapper(parent, node, target, renderDropIndicator)); + + pseudoProps = {before: new Array(beforeIndicator), after: new Array(afterIndicator)}; + } + + let render = (item: Node, itemRef?: ForwardedRef) => ( + + {item.render!(item, itemRef)} + ); - } - return rendered; + return ; + }); } function renderDropIndicatorWrapper(