diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 29b902a6d4e..b425d6cc1f1 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -243,6 +243,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject = ( + parent: ReusableView | null, + reusableView: ReusableView, + children: ReusableView[], + renderChildren: (views: ReusableView[]) => ReactElement[] +) => ReactElement; + interface VirtualizerProps extends Omit, 'children'> { children: (type: string, content: T) => V, - renderWrapper?: ( - parent: ReusableView | null, - reusableView: ReusableView, - children: ReusableView[], - renderChildren: (views: ReusableView[]) => ReactElement[] - ) => ReactElement, + renderWrapper?: RenderWrapper, layout: Layout, collection: Collection, focusedKey?: Key, @@ -35,8 +37,6 @@ interface VirtualizerProps extends Omit | null>(null); - function Virtualizer(props: VirtualizerProps, ref: RefObject) { let { children: renderView, @@ -61,7 +61,6 @@ function Virtualizer(props: Virtualize layout, collection, renderView, - renderWrapper: renderWrapper || defaultRenderWrapper, onVisibleRectChange(rect) { ref.current.scrollLeft = rect.x; ref.current.scrollTop = rect.y; @@ -81,9 +80,7 @@ function Virtualizer(props: Virtualize onScrollEnd={state.endScrolling} sizeToFit={sizeToFit} scrollDirection={scrollDirection}> - - {state.visibleViews} - + {renderChildren(null, state.visibleViews, renderWrapper || defaultRenderWrapper)} ); } @@ -96,7 +93,7 @@ interface VirtualizerOptions { } // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function useVirtualizer(props: VirtualizerOptions, state: VirtualizerState, ref: RefObject) { +export function useVirtualizer(props: VirtualizerOptions, state: VirtualizerState, ref: RefObject) { let {isLoading, onLoadMore} = props; let {setVisibleRect, virtualizer} = state; @@ -153,6 +150,17 @@ export function useVirtualizer(props: const _Virtualizer = React.forwardRef(Virtualizer) as (props: VirtualizerProps & {ref?: RefObject}) => ReactElement; export {_Virtualizer as Virtualizer}; +function renderChildren(parent: ReusableView | null, views: ReusableView[], renderWrapper: RenderWrapper) { + return views.map(view => { + return renderWrapper( + parent, + view, + view.children ? Array.from(view.children) : [], + childViews => renderChildren(view, childViews, renderWrapper) + ); + }); +} + function defaultRenderWrapper( parent: ReusableView | null, reusableView: ReusableView diff --git a/packages/@react-aria/virtualizer/src/index.ts b/packages/@react-aria/virtualizer/src/index.ts index 9c86c63d46c..bddb90c3fbb 100644 --- a/packages/@react-aria/virtualizer/src/index.ts +++ b/packages/@react-aria/virtualizer/src/index.ts @@ -12,7 +12,7 @@ export type {RTLOffsetType} from './utils'; export type {VirtualizerItemOptions} from './useVirtualizerItem'; -export {useVirtualizer, Virtualizer, VirtualizerContext} from './Virtualizer'; +export {useVirtualizer, Virtualizer} from './Virtualizer'; export {useVirtualizerItem} from './useVirtualizerItem'; export {VirtualizerItem, layoutInfoToStyle} from './VirtualizerItem'; export {ScrollView, useScrollView} from './ScrollView'; diff --git a/packages/@react-spectrum/list/src/ListViewLayout.ts b/packages/@react-spectrum/list/src/ListViewLayout.ts index cddc1936d4f..2044b803bd6 100644 --- a/packages/@react-spectrum/list/src/ListViewLayout.ts +++ b/packages/@react-spectrum/list/src/ListViewLayout.ts @@ -1,3 +1,14 @@ +/* + * Copyright 2024 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 {InvalidationContext, LayoutInfo, Rect} from '@react-stately/virtualizer'; import {LayoutNode, ListLayout} from '@react-stately/layout'; import {Node} from '@react-types/shared'; diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 9a49f6ed5b9..0e4de9e3eaa 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -550,11 +550,10 @@ function TableVirtualizer(props: TableVirtualizerProps) { getDefaultMinWidth }, tableState); - let state = useVirtualizerState({ + let state = useVirtualizerState, ReactNode>({ layout, collection, renderView, - renderWrapper, onVisibleRectChange(rect) { bodyRef.current.scrollTop = rect.y; setScrollLeft(bodyRef.current, direction, rect.x); @@ -623,6 +622,8 @@ function TableVirtualizer(props: TableVirtualizerProps) { scrollPadding = columnResizeState.getColumnWidth(firstColumn.key); } + let visibleViews = renderChildren(null, state.visibleViews, renderWrapper); + return ( @@ -641,7 +642,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { }} ref={headerRef}> - {state.visibleViews[0]} + {visibleViews[0]} (props: TableVirtualizerProps) { onScrollStart={state.startScrolling} onScrollEnd={state.endScrolling} onScroll={onScroll}> - {state.visibleViews[1]} + {visibleViews[1]}
@@ -690,6 +691,17 @@ function TableVirtualizer(props: TableVirtualizerProps) { ); } +function renderChildren(parent: View | null, views: View[], renderWrapper: TableVirtualizerProps['renderWrapper']) { + return views.map(view => { + return renderWrapper( + parent, + view, + view.children ? Array.from(view.children) : [], + childViews => renderChildren(view, childViews, renderWrapper) + ); + }); +} + function useStyle(layoutInfo: LayoutInfo, parent: LayoutInfo | null) { let {direction} = useLocale(); let style = layoutInfoToStyle(layoutInfo, direction, parent); diff --git a/packages/@react-spectrum/table/src/TableViewLayout.ts b/packages/@react-spectrum/table/src/TableViewLayout.ts index 5b112d0c0bd..365bb65b763 100644 --- a/packages/@react-spectrum/table/src/TableViewLayout.ts +++ b/packages/@react-spectrum/table/src/TableViewLayout.ts @@ -1,3 +1,15 @@ +/* + * Copyright 2024 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 {DropTarget} from '@react-types/shared'; import {GridNode} from '@react-types/grid'; import {LayoutInfo, Rect} from '@react-stately/virtualizer'; import {LayoutNode, TableLayout} from '@react-stately/layout'; @@ -66,4 +78,10 @@ export class TableViewLayout extends TableLayout { protected isStickyColumn(node: GridNode) { return node.props?.isDragButtonCell || node.props?.isSelectionCell; } + + getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget { + // Offset for height of header row + y -= this.virtualizer.layout.getVisibleLayoutInfos(new Rect(x, y, 1, 1)).find(info => info.type === 'headerrow')?.rect.height; + return super.getDropTargetFromPoint(x, y, isValidDropTarget); + } } diff --git a/packages/@react-stately/data/src/useListData.ts b/packages/@react-stately/data/src/useListData.ts index 4dbf5211272..a8329b1e593 100644 --- a/packages/@react-stately/data/src/useListData.ts +++ b/packages/@react-stately/data/src/useListData.ts @@ -140,7 +140,7 @@ export function useListData(options: ListOptions): ListData { let { initialItems = [], initialSelectedKeys, - getKey = (item: any) => item.id || item.key, + getKey = (item: any) => item.id ?? item.key, filter, initialFilterText = '' } = options; diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts new file mode 100644 index 00000000000..bfbcceed306 --- /dev/null +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -0,0 +1,215 @@ +/* + * Copyright 2024 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 {DropTarget, DropTargetDelegate, ItemDropTarget, Key, Node} from '@react-types/shared'; +import {Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; + +export interface GridLayoutOptions { + /** + * The minimum item size. + * @default 200 x 200 + */ + minItemSize?: Size, + /** + * The maximum item size. + * @default Infinity + */ + maxItemSize?: Size, + /** + * The minimum space required between items. + * @default 18 x 18 + */ + minSpace?: Size, + /** + * The maximum number of columns. + * @default Infinity + */ + maxColumns?: number, + /** + * The thickness of the drop indicator. + * @default 2 + */ + dropIndicatorThickness?: number +} + +export class GridLayout extends Layout, O> implements DropTargetDelegate { + protected minItemSize: Size; + protected maxItemSize: Size; + protected minSpace: Size; + protected maxColumns: number; + protected dropIndicatorThickness: number; + protected itemSize: Size; + protected numColumns: number; + protected horizontalSpacing: number; + protected layoutInfos: LayoutInfo[]; + + constructor(options: GridLayoutOptions) { + super(); + this.minItemSize = options.minItemSize || new Size(200, 200); + this.maxItemSize = options.maxItemSize || new Size(Infinity, Infinity); + this.minSpace = options.minSpace || new Size(18, 18); + this.maxColumns = options.maxColumns || Infinity; + this.dropIndicatorThickness = options.dropIndicatorThickness || 2; + } + + validate(): void { + let visibleWidth = this.virtualizer.visibleRect.width; + + // The max item width is always the entire viewport. + // If the max item height is infinity, scale in proportion to the max width. + let maxItemWidth = Math.min(this.maxItemSize.width, visibleWidth); + let maxItemHeight = Number.isFinite(this.maxItemSize.height) + ? this.maxItemSize.height + : Math.floor((this.minItemSize.height / this.minItemSize.width) * maxItemWidth); + + // Compute the number of rows and columns needed to display the content + let columns = Math.floor(visibleWidth / (this.minItemSize.width + this.minSpace.width)); + this.numColumns = Math.max(1, Math.min(this.maxColumns, columns)); + + // Compute the available width (minus the space between items) + let width = visibleWidth - (this.minSpace.width * Math.max(0, this.numColumns)); + + // Compute the item width based on the space available + let itemWidth = Math.floor(width / this.numColumns); + itemWidth = Math.max(this.minItemSize.width, Math.min(maxItemWidth, itemWidth)); + + // Compute the item height, which is proportional to the item width + let t = ((itemWidth - this.minItemSize.width) / (maxItemWidth - this.minItemSize.width)); + let itemHeight = this.minItemSize.height + Math.floor((maxItemHeight - this.minItemSize.height) * t); + itemHeight = Math.max(this.minItemSize.height, Math.min(maxItemHeight, itemHeight)); + this.itemSize = new Size(itemWidth, itemHeight); + + // Compute the horizontal spacing and content height + this.horizontalSpacing = Math.floor((visibleWidth - this.numColumns * this.itemSize.width) / (this.numColumns + 1)); + + this.layoutInfos = []; + for (let node of this.virtualizer.collection) { + this.layoutInfos.push(this.getLayoutInfoForNode(node)); + } + } + + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let firstVisibleItem = this.getIndexAtPoint(rect.x, rect.y); + let lastVisibleItem = this.getIndexAtPoint(rect.maxX, rect.maxY); + let result = this.layoutInfos.slice(firstVisibleItem, lastVisibleItem + 1); + let persistedIndices = [...this.virtualizer.persistedKeys].map(key => this.virtualizer.collection.getItem(key).index).sort((a, b) => a - b); + let persistedBefore = []; + for (let index of persistedIndices) { + if (index < firstVisibleItem) { + persistedBefore.push(this.layoutInfos[index]); + } else if (index > lastVisibleItem) { + result.push(this.layoutInfos[index]); + } + } + result.unshift(...persistedBefore); + return result; + } + + protected getIndexAtPoint(x: number, y: number) { + let itemHeight = this.itemSize.height + this.minSpace.height; + let itemWidth = this.itemSize.width + this.horizontalSpacing; + return Math.max(0, + Math.min( + this.virtualizer.collection.size - 1, + Math.floor(y / itemHeight) * this.numColumns + Math.floor((x - this.horizontalSpacing) / itemWidth) + ) + ); + } + + getLayoutInfo(key: Key): LayoutInfo | null { + let node = this.virtualizer.collection.getItem(key); + return node ? this.layoutInfos[node.index] : null; + } + + protected getLayoutInfoForNode(node: Node): LayoutInfo { + let idx = node.index; + let row = Math.floor(idx / this.numColumns); + let column = idx % this.numColumns; + let x = this.horizontalSpacing + column * (this.itemSize.width + this.horizontalSpacing); + let y = this.minSpace.height + row * (this.itemSize.height + this.minSpace.height); + let rect = new Rect(x, y, this.itemSize.width, this.itemSize.height); + return new LayoutInfo(node.type, node.key, rect); + } + + getContentSize(): Size { + let numRows = Math.ceil(this.virtualizer.collection.size / this.numColumns); + let contentHeight = this.minSpace.height + numRows * (this.itemSize.height + this.minSpace.height); + return new Size(this.virtualizer.visibleRect.width, contentHeight); + } + + getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget { + if (this.layoutInfos.length === 0) { + return {type: 'root'}; + } + + x += this.virtualizer.visibleRect.x; + y += this.virtualizer.visibleRect.y; + let index = this.getIndexAtPoint(x, y); + + let layoutInfo = this.layoutInfos[index]; + let target: DropTarget = { + type: 'item', + key: layoutInfo.key, + dropPosition: 'on' + }; + + let pos = this.numColumns === 1 ? y : x; + let layoutInfoPos = this.numColumns === 1 ? layoutInfo.rect.y : layoutInfo.rect.x; + let size = this.numColumns === 1 ? layoutInfo.rect.height : layoutInfo.rect.width; + if (isValidDropTarget(target)) { + // If dropping on the item is accepted, try the before/after positions + // if within 5px of the start or end of the item. + if (pos < layoutInfoPos + 5) { + target.dropPosition = 'before'; + } else if (pos > layoutInfoPos + size - 5) { + target.dropPosition = 'after'; + } + } else { + // If dropping on the item isn't accepted, try the target before or after depending on the position. + let mid = layoutInfoPos + size / 2; + if (pos <= mid && isValidDropTarget({...target, dropPosition: 'before'})) { + target.dropPosition = 'before'; + } else if (pos >= mid && isValidDropTarget({...target, dropPosition: 'after'})) { + target.dropPosition = 'after'; + } + } + + return target; + } + + getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + let layoutInfo = this.getLayoutInfo(target.key); + let rect: Rect; + if (this.numColumns === 1) { + // Flip from vertical to horizontal if only one column is visible. + rect = new Rect( + layoutInfo.rect.x, + target.dropPosition === 'before' + ? layoutInfo.rect.y - this.minSpace.height / 2 - this.dropIndicatorThickness / 2 + : layoutInfo.rect.maxY + this.minSpace.height / 2 - this.dropIndicatorThickness / 2, + layoutInfo.rect.width, + this.dropIndicatorThickness + ); + } else { + rect = new Rect( + target.dropPosition === 'before' + ? layoutInfo.rect.x - this.horizontalSpacing / 2 - this.dropIndicatorThickness / 2 + : layoutInfo.rect.maxX + this.horizontalSpacing / 2 - this.dropIndicatorThickness / 2, + layoutInfo.rect.y, + this.dropIndicatorThickness, + layoutInfo.rect.height + ); + } + + return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect); + } +} diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index e2bf838f6c1..e3f452b0a91 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Collection, DropTarget, DropTargetDelegate, Key, Node} from '@react-types/shared'; +import {Collection, DropTarget, DropTargetDelegate, ItemDropTarget, Key, Node} from '@react-types/shared'; import {getChildNodes} from '@react-stately/collections'; import {InvalidationContext, Layout, LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; @@ -22,7 +22,9 @@ export interface ListLayoutOptions { /** The fixed height of a section header in px. */ headingHeight?: number, /** The estimated height of a section header, when the height is variable. */ - estimatedHeadingHeight?: number + estimatedHeadingHeight?: number, + /** The thickness of the drop indicator. */ + dropIndicatorThickness?: number } // A wrapper around LayoutInfo that supports hierarchy @@ -49,6 +51,7 @@ export class ListLayout extends Layout, O> implements DropTa protected estimatedRowHeight: number; protected headingHeight: number; protected estimatedHeadingHeight: number; + protected dropIndicatorThickness: number; protected layoutNodes: Map; protected contentSize: Size; protected collection: Collection>; @@ -71,6 +74,7 @@ export class ListLayout extends Layout, O> implements DropTa this.estimatedRowHeight = options.estimatedRowHeight; this.headingHeight = options.headingHeight; this.estimatedHeadingHeight = options.estimatedHeadingHeight; + this.dropIndicatorThickness = options.dropIndicatorThickness || 2; this.layoutNodes = new Map(); this.rootNodes = []; this.lastWidth = 0; @@ -447,4 +451,18 @@ export class ListLayout extends Layout, O> implements DropTa return target; } + + getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + let layoutInfo = this.getLayoutInfo(target.key); + let rect: Rect; + if (target.dropPosition === 'before') { + rect = new Rect(layoutInfo.rect.x, layoutInfo.rect.y - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness); + } else if (target.dropPosition === 'after') { + rect = new Rect(layoutInfo.rect.x, layoutInfo.rect.maxY - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness); + } else { + rect = layoutInfo.rect; + } + + return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect); + } } diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 2ed3de631b5..8087889dad4 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {DropTarget, Key} from '@react-types/shared'; +import {DropTarget, ItemDropTarget, Key} from '@react-types/shared'; import {getChildNodes} from '@react-stately/collections'; import {GridNode} from '@react-types/grid'; import {InvalidationContext, LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; @@ -508,9 +508,6 @@ export class TableLayout exten x += this.virtualizer.visibleRect.x; y += this.virtualizer.visibleRect.y; - // Offset for height of header row - y -= this.virtualizer.layout.getVisibleLayoutInfos(new Rect(x, y, 1, 1)).find(info => info.type === 'headerrow')?.rect.height; - // Custom variation of this.virtualizer.keyAtPoint that ignores body let key: Key; let point = new Point(x, y); @@ -554,4 +551,10 @@ export class TableLayout exten return target; } + + getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + let layoutInfo = super.getDropTargetLayoutInfo(target); + layoutInfo.parentKey = this.collection.body.key; + return layoutInfo; + } } diff --git a/packages/@react-stately/layout/src/index.ts b/packages/@react-stately/layout/src/index.ts index bbfb764150a..4cede431d32 100644 --- a/packages/@react-stately/layout/src/index.ts +++ b/packages/@react-stately/layout/src/index.ts @@ -9,7 +9,9 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +export type {GridLayoutOptions} from './GridLayout'; export type {ListLayoutOptions, LayoutNode} from './ListLayout'; export type {TableLayoutProps} from './TableLayout'; +export {GridLayout} from './GridLayout'; export {ListLayout} from './ListLayout'; export {TableLayout} from './TableLayout'; diff --git a/packages/@react-stately/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index 27cad06e708..806a9b0becb 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -11,7 +11,7 @@ */ import {InvalidationContext} from './types'; -import {Key, LayoutDelegate} from '@react-types/shared'; +import {ItemDropTarget, Key, LayoutDelegate} from '@react-types/shared'; import {LayoutInfo} from './LayoutInfo'; import {Rect} from './Rect'; import {Size} from './Size'; @@ -32,7 +32,7 @@ import {Virtualizer} from './Virtualizer'; */ export abstract class Layout implements LayoutDelegate { /** The Virtualizer the layout is currently attached to. */ - virtualizer: Virtualizer; + virtualizer: Virtualizer; /** * Returns whether the layout should invalidate in response to @@ -78,6 +78,11 @@ export abstract class Layout implements LayoutDelegat */ updateItemSize?(key: Key, size: Size): boolean; + /** + * Returns a LayoutInfo for the given drop target. + */ + getDropTargetLayoutInfo?(target: ItemDropTarget): LayoutInfo; + getItemRect(key: Key): Rect { return this.getLayoutInfo(key)?.rect; } diff --git a/packages/@react-stately/virtualizer/src/ReusableView.ts b/packages/@react-stately/virtualizer/src/ReusableView.ts index 1b7b899a3b7..535c678d279 100644 --- a/packages/@react-stately/virtualizer/src/ReusableView.ts +++ b/packages/@react-stately/virtualizer/src/ReusableView.ts @@ -22,7 +22,7 @@ let KEY = 0; */ export class ReusableView { /** The Virtualizer this view is a part of. */ - virtualizer: Virtualizer; + virtualizer: Virtualizer; /** The LayoutInfo this view is currently representing. */ layoutInfo: LayoutInfo | null; @@ -39,7 +39,7 @@ export class ReusableView { children: Set>; reusableViews: Map[]>; - constructor(virtualizer: Virtualizer) { + constructor(virtualizer: Virtualizer) { this.virtualizer = virtualizer; this.key = ++KEY; this.parent = null; diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index 36ed878f948..4626c0e482a 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -37,12 +37,12 @@ import {Size} from './Size'; * to render elements for each layout info. The virtualizer manages a set of {@link ReusableView} objects, * which are reused as the user scrolls by swapping their content with cached elements returned by the delegate. */ -export class Virtualizer { +export class Virtualizer { /** * The virtualizer delegate. The delegate is used by the virtualizer * to create and configure views. */ - delegate: VirtualizerDelegate; + delegate: VirtualizerDelegate; /** The current content of the virtualizer. */ readonly collection: Collection; @@ -62,7 +62,7 @@ export class Virtualizer { private _invalidationContext: InvalidationContext | null; private _overscanManager: OverscanManager; - constructor(delegate: VirtualizerDelegate) { + constructor(delegate: VirtualizerDelegate) { this.delegate = delegate; this.contentSize = new Size; this.visibleRect = new Rect; @@ -242,7 +242,7 @@ export class Virtualizer { } /** Performs layout and updates visible views as needed. */ - render(opts: VirtualizerRenderOptions): W[] { + render(opts: VirtualizerRenderOptions): ReusableView[] { let mutableThis: Mutable = this; let needsLayout = false; let offsetChanged = false; @@ -315,21 +315,11 @@ export class Virtualizer { this.updateSubviews(); } - return this.getChildren(null); + return Array.from(this._rootView.children); } - getChildren(key: Key | null): W[] { - let parent = key == null ? this._rootView : this._visibleViews.get(key); - let renderChildren = (parent: ReusableView, views: ReusableView[]) => views.map(view => { - return this.delegate.renderWrapper( - parent, - view, - view.children ? Array.from(view.children) : [], - childViews => renderChildren(view, childViews) - ); - }); - - return renderChildren(parent, Array.from(parent.children)); + getVisibleView(key: Key): ReusableView | undefined { + return this._visibleViews.get(key); } invalidate(context: InvalidationContext) { diff --git a/packages/@react-stately/virtualizer/src/types.ts b/packages/@react-stately/virtualizer/src/types.ts index 0fc290019ee..bb5fa65cc61 100644 --- a/packages/@react-stately/virtualizer/src/types.ts +++ b/packages/@react-stately/virtualizer/src/types.ts @@ -13,7 +13,6 @@ import {Collection, Key} from '@react-types/shared'; import {Layout} from './Layout'; import {Rect} from './Rect'; -import {ReusableView} from './ReusableView'; export interface InvalidationContext { contentChanged?: boolean, @@ -23,15 +22,9 @@ export interface InvalidationContext { layoutOptions?: O } -export interface VirtualizerDelegate { +export interface VirtualizerDelegate { setVisibleRect(rect: Rect): void, renderView(type: string, content: T): V, - renderWrapper( - parent: ReusableView | null, - reusableView: ReusableView, - children: ReusableView[], - renderChildren: (views: ReusableView[]) => W[] - ): W, invalidate(ctx: InvalidationContext): void } diff --git a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts index fa913bfffbb..3a5485e362e 100644 --- a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts +++ b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts @@ -20,14 +20,8 @@ import {useCallback, useMemo, useRef, useState} from 'react'; import {useLayoutEffect} from '@react-aria/utils'; import {Virtualizer} from './Virtualizer'; -interface VirtualizerProps { +interface VirtualizerProps { renderView(type: string, content: T): V, - renderWrapper( - parent: ReusableView | null, - reusableView: ReusableView, - children: ReusableView[], - renderChildren: (views: ReusableView[]) => W[] - ): W, layout: Layout, collection: Collection, onVisibleRectChange(rect: Rect): void, @@ -35,29 +29,28 @@ interface VirtualizerProps { layoutOptions?: O } -export interface VirtualizerState { - visibleViews: W[], +export interface VirtualizerState { + visibleViews: ReusableView[], setVisibleRect: (rect: Rect) => void, contentSize: Size, - virtualizer: Virtualizer, + virtualizer: Virtualizer, isScrolling: boolean, startScrolling: () => void, endScrolling: () => void } -export function useVirtualizerState(opts: VirtualizerProps): VirtualizerState { +export function useVirtualizerState(opts: VirtualizerProps): VirtualizerState { let [visibleRect, setVisibleRect] = useState(new Rect(0, 0, 0, 0)); let [isScrolling, setScrolling] = useState(false); let [invalidationContext, setInvalidationContext] = useState({}); let visibleRectChanged = useRef(false); - let [virtualizer] = useState(() => new Virtualizer({ + let [virtualizer] = useState(() => new Virtualizer({ setVisibleRect(rect) { setVisibleRect(rect); visibleRectChanged.current = true; }, // TODO: should changing these invalidate the entire cache? renderView: opts.renderView, - renderWrapper: opts.renderWrapper, invalidate: setInvalidationContext })); diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index eda26567f98..c7173a06b78 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -40,6 +40,7 @@ "@internationalized/date": "^3.5.4", "@internationalized/string": "^3.2.3", "@react-aria/color": "3.0.0-beta.33", + "@react-aria/dnd": "^3.6.1", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", "@react-aria/menu": "^3.14.1", diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index aca86dd6c09..d6ffd8e6711 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {CollectionBase, DropTargetDelegate, Key, LayoutDelegate} from '@react-types/shared'; +import {CollectionBase, DropTargetDelegate, ItemDropTarget, Key, LayoutDelegate} from '@react-types/shared'; import {createPortal} from 'react-dom'; import {forwardRefType, Hidden, StyleProps} from './utils'; import {Collection as ICollection, Node, SelectionBehavior, SelectionMode, SectionProps as SharedSectionProps} from 'react-stately'; @@ -986,13 +986,15 @@ export function createBranchComponent>, - parent: Node + parent: Node, + renderDropIndicator?: (target: ItemDropTarget) => ReactNode } export interface CollectionRootProps extends HTMLAttributes { collection: ICollection>, focusedKey?: Key | null, - scrollRef?: RefObject + scrollRef?: RefObject, + renderDropIndicator?: (target: ItemDropTarget) => ReactNode } export interface CollectionRenderer { @@ -1004,22 +1006,38 @@ export interface CollectionRenderer { } export const DefaultCollectionRenderer: CollectionRenderer = { - CollectionRoot({collection}) { - return useCachedChildren({ - items: collection, - children(child) { - return child.render!(child); - } - }); + CollectionRoot({collection, renderDropIndicator}) { + return useCollectionRender(collection, null, renderDropIndicator); }, - CollectionBranch({collection, parent}) { - return useCachedChildren({ - items: collection.getChildren!(parent.key), - children(child) { - return child.render!(child); - } - }); + CollectionBranch({collection, parent, renderDropIndicator}) { + return useCollectionRender(collection, parent, renderDropIndicator); } }; +function useCollectionRender( + collection: ICollection>, + parent: Node | null, + renderDropIndicator?: (target: ItemDropTarget) => ReactNode +) { + return useCachedChildren({ + items: parent ? collection.getChildren!(parent.key) : collection, + dependencies: [renderDropIndicator], + children(node) { + let rendered = node.render!(node); + if (!renderDropIndicator || node.type !== 'item') { + return rendered; + } + + let key = node.key; + return ( + <> + {renderDropIndicator({type: 'item', key, dropPosition: 'before'})} + {rendered} + {collection.getKeyAfter(key) == null && renderDropIndicator({type: 'item', key, dropPosition: 'after'})} + + ); + } + }); +} + export const CollectionRendererContext = createContext(DefaultCollectionRenderer); diff --git a/packages/react-aria-components/src/DragAndDrop.tsx b/packages/react-aria-components/src/DragAndDrop.tsx new file mode 100644 index 00000000000..d83303e9ea9 --- /dev/null +++ b/packages/react-aria-components/src/DragAndDrop.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2024 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 type {DropIndicatorProps as AriaDropIndicatorProps, ItemDropTarget} from 'react-aria'; +import type {DragAndDropHooks} from './useDragAndDrop'; +import type {DraggableCollectionState, DroppableCollectionState, MultipleSelectionManager} from 'react-stately'; +import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useCallback, useContext} from 'react'; +import type {RenderProps} from './utils'; + +export interface DragAndDropContextValue { + dragAndDropHooks?: DragAndDropHooks, + dragState?: DraggableCollectionState, + dropState?: DroppableCollectionState +} + +export const DragAndDropContext = createContext({}); +export const DropIndicatorContext = createContext(null); + +export interface DropIndicatorRenderProps { + /** + * Whether the drop indicator is currently the active drop target. + * @selector [data-drop-target] + */ + isDropTarget: boolean +} + +export interface DropIndicatorProps extends AriaDropIndicatorProps, RenderProps { } +interface DropIndicatorContextValue { + render: (props: DropIndicatorProps, ref: ForwardedRef) => ReactNode +} + +function DropIndicator(props: DropIndicatorProps, ref: ForwardedRef): JSX.Element { + let {render} = useContext(DropIndicatorContext)!; + return <>{render(props, ref)}; +} + +/** + * A DropIndicator is rendered between items in a collection to indicate where dropped data will be inserted. + */ +const _DropIndicator = forwardRef(DropIndicator); +export {_DropIndicator as DropIndicator}; + +export function useRenderDropIndicator(dragAndDropHooks?: DragAndDropHooks, dropState?: DroppableCollectionState) { + let renderDropIndicator = dragAndDropHooks?.renderDropIndicator; + let isVirtualDragging = dragAndDropHooks?.isVirtualDragging?.(); + let fn = useCallback((target: ItemDropTarget) => { + // Only show drop indicators when virtual dragging or this is the current drop target. + if (isVirtualDragging || dropState?.isDropTarget(target)) { + return renderDropIndicator ? renderDropIndicator(target) : <_DropIndicator target={target} />; + } + // We invalidate whenever the target changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dropState?.target, isVirtualDragging, renderDropIndicator]); + return dragAndDropHooks?.useDropIndicator ? fn : undefined; +} + +export function useDndAwareFocusedKey(selectionManager: MultipleSelectionManager, dragAndDropHooks?: DragAndDropHooks, dropState?: DroppableCollectionState) { + // Use drop target key during drag sessions so virtualizer persisted keys enable keyboard navigation to work correctly. + return dragAndDropHooks?.isVirtualDragging?.() && dropState?.target?.type === 'item' + ? dropState.target.key + : selectionManager.focusedKey; +} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 862eb82a15b..d638a7a0eed 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -14,7 +14,8 @@ import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import {Collection, CollectionBuilder, CollectionProps, CollectionRendererContext, createLeafComponent, ItemRenderProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, forwardRefType, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; -import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContext, DropIndicatorProps} from './useDragAndDrop'; +import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndAwareFocusedKey, useRenderDropIndicator} from './DragAndDrop'; +import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; import {HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; @@ -227,7 +228,11 @@ function GridListInner({props, collection, gridListRef: ref}: [DropIndicatorContext, {render: GridListDropIndicatorWrapper}] ]}> {isListDroppable && } - + {emptyState} {dragPreview} @@ -326,7 +331,6 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function G } }); - let renderDropIndicator = dragAndDropHooks?.renderDropIndicator || (target => ); let dragButtonRef = useRef(null); useEffect(() => { if (dragState && !dragButtonRef.current) { @@ -343,9 +347,6 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function G return ( <> - {dragAndDropHooks?.useDropIndicator && - renderDropIndicator({type: 'item', key: item.key, dropPosition: 'before'}) - } {dropIndicator && !dropIndicator.isHidden &&
@@ -397,9 +398,6 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function G
- {dragAndDropHooks?.useDropIndicator && state.collection.getKeyAfter(item.key) == null && - renderDropIndicator({type: 'item', key: item.key, dropPosition: 'after'}) - } ); }); @@ -447,7 +445,7 @@ function GridListDropIndicator(props: GridListDropIndicatorProps, ref: Forwarded }); return ( - (
} @@ -456,7 +454,7 @@ function GridListDropIndicator(props: GridListDropIndicatorProps, ref: Forwarded
{renderProps.children}
-
) +
); } diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 835d50e34e1..2c8f9dc1dc4 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -13,7 +13,8 @@ import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria'; import {Collection, CollectionBuilder, CollectionProps, CollectionRendererContext, createLeafComponent, ItemRenderProps, SectionContext, SectionProps} from './Collection'; import {ContextValue, forwardRefType, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; -import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContext, DropIndicatorProps} from './useDragAndDrop'; +import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndAwareFocusedKey, useRenderDropIndicator} from './DragAndDrop'; +import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, useListState} from 'react-stately'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; import {HeaderContext} from './Header'; @@ -245,7 +246,11 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner [DropIndicatorContext, {render: ListBoxDropIndicatorWrapper}], [SectionContext, {render: ListBoxSection}] ]}> - + {emptyState} {dragPreview} @@ -256,6 +261,7 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner function ListBoxSection(props: SectionProps, ref: ForwardedRef, section: Node) { let state = useContext(ListStateContext)!; + let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; let {CollectionBranch} = useContext(CollectionRendererContext); let [headingRef, heading] = useSlot(); let {headingProps, groupProps} = useListBoxSection({ @@ -276,7 +282,10 @@ function ListBoxSection(props: SectionProps, ref: Forwarded {...renderProps} ref={ref}> - + ); @@ -351,8 +360,6 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent('item', function Li } }); - let renderDropIndicator = dragAndDropHooks?.renderDropIndicator || (target => ); - useEffect(() => { if (!item.textValue) { console.warn('A `textValue` prop is required for elements with non-plain text children in order to support accessibility features such as type to select.'); @@ -362,40 +369,32 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent('item', function Li let ElementType: React.ElementType = props.href ? 'a' : 'div'; return ( - <> - {dragAndDropHooks?.useDropIndicator && - renderDropIndicator({type: 'item', key: item.key, dropPosition: 'before'}) - } - - - {renderProps.children} - - - {dragAndDropHooks?.useDropIndicator && state.collection.getKeyAfter(item.key) == null && - renderDropIndicator({type: 'item', key: item.key, dropPosition: 'after'}) - } - + + + {renderProps.children} + + ); }); @@ -438,13 +437,13 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe }); return ( - (
} - data-drop-target={isDropTarget || undefined} />) + data-drop-target={isDropTarget || undefined} /> ); } diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 053cc40b83a..8a54f3dddd3 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -6,7 +6,8 @@ import {CheckboxContext} from './RSPContexts'; import {ColumnSize, ColumnStaticSize, TableCollection as ITableCollection, TableProps as SharedTableProps} from '@react-types/table'; import {ContextValue, DEFAULT_SLOT, DOMProps, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, MultipleSelectionState, Node, SelectionBehavior, SelectionMode, SortDirection, TableState, useMultipleSelectionState, useTableColumnResizeState, useTableState} from 'react-stately'; -import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContext, DropIndicatorProps} from './useDragAndDrop'; +import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndAwareFocusedKey, 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 {filterDOMProps, isScrollable, mergeRefs, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; @@ -478,7 +479,10 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl data-drop-target={isRootDropTarget || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined}> - + {dragPreview} @@ -928,7 +932,10 @@ export const TableBody = /*#__PURE__*/ createBranchComponent('tablebody', {isDroppable && } - + {emptyState} ); @@ -1011,7 +1018,6 @@ export const Row = /*#__PURE__*/ createBranchComponent( }, dropState, dropIndicatorRef); } - let renderDropIndicator = dragAndDropHooks?.renderDropIndicator || (target => ); let dragButtonRef = useRef(null); useEffect(() => { if (dragState && !dragButtonRef.current) { @@ -1045,9 +1051,6 @@ export const Row = /*#__PURE__*/ createBranchComponent( return ( <> - {dragAndDropHooks?.useDropIndicator && - renderDropIndicator({type: 'item', key: item.key, dropPosition: 'before'}) - } {dropIndicator && !dropIndicator.isHidden && ( @@ -1092,9 +1095,6 @@ export const Row = /*#__PURE__*/ createBranchComponent( - {dragAndDropHooks?.useDropIndicator && state.collection.getKeyAfter(item.key) == null && - renderDropIndicator({type: 'item', key: item.key, dropPosition: 'after'}) - } ); }, diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 0e0a1e35b79..38383e7539e 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -11,10 +11,12 @@ */ import {CollectionRenderer, CollectionRendererContext} from './Collection'; -import {DropTargetDelegate, Node} from '@react-types/shared'; -import {Layout, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; -import React, {ReactNode, useContext, useMemo} from 'react'; -import {useScrollView, VirtualizerContext, VirtualizerItem} from '@react-aria/virtualizer'; +import {DropPosition, DropTarget, DropTargetDelegate, ItemDropTarget, Node} from '@react-types/shared'; +import {Layout, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; +import React, {createContext, ReactElement, ReactNode, useContext, useMemo} from 'react'; +import {useScrollView, VirtualizerItem} from '@react-aria/virtualizer'; + +type View = ReusableView, ReactNode>; export interface LayoutOptionsDelegate { useLayoutOptions?(): O @@ -27,13 +29,15 @@ export interface VirtualizerProps { layout: ILayout } +const VirtualizerContext = createContext | null>(null); + export function Virtualizer(props: VirtualizerProps) { let {children, layout} = props; let renderer: CollectionRenderer = useMemo(() => ({ isVirtualized: true, layoutDelegate: layout, dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined, - CollectionRoot({collection, focusedKey, scrollRef}) { + CollectionRoot({collection, focusedKey, scrollRef, renderDropIndicator}) { let layoutOptions = layout.useLayoutOptions?.(); let state = useVirtualizerState({ layout, @@ -41,10 +45,12 @@ export function Virtualizer(props: VirtualizerProps) { renderView: (type, item) => { return item?.render?.(item); }, - renderWrapper, onVisibleRectChange(rect) { - scrollRef!.current!.scrollLeft = rect.x; - scrollRef!.current!.scrollTop = rect.y; + let element = scrollRef?.current; + if (element) { + element.scrollLeft = rect.x; + element.scrollTop = rect.y; + } }, persistedKeys: useMemo(() => focusedKey != null ? new Set([focusedKey]) : new Set(), [focusedKey]), layoutOptions @@ -64,16 +70,17 @@ export function Virtualizer(props: VirtualizerProps) { return (
- {state.visibleViews} + {renderChildren(null, state.visibleViews, renderDropIndicator)}
); }, - CollectionBranch({parent}) { + CollectionBranch({parent, renderDropIndicator}) { let virtualizer = useContext(VirtualizerContext); - return virtualizer!.virtualizer.getChildren(parent.key); + let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; + return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); } - }), [layout]); + }), [layout]); return ( @@ -82,11 +89,16 @@ export function Virtualizer(props: VirtualizerProps) { ); } -function renderWrapper( - parent: ReusableView | null, - reusableView: ReusableView -) { - return ( +function renderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget) => ReactNode) { + return children.map(view => renderWrapper(parent, view, renderDropIndicator)); +} + +function renderWrapper( + parent: View | null, + reusableView: View, + renderDropIndicator?: (target: ItemDropTarget) => ReactNode +): ReactElement { + let rendered = ( ( {reusableView.rendered} ); + + let {collection, layout} = reusableView.virtualizer; + let {key, type} = reusableView.content; + if (type === 'item' && renderDropIndicator && layout.getDropTargetLayoutInfo) { + rendered = ( + + {renderDropIndicatorWrapper(parent, reusableView, 'before', renderDropIndicator)} + {rendered} + {collection.getKeyAfter(key) == null && renderDropIndicatorWrapper(parent, reusableView, 'after', renderDropIndicator)} + + ); + } + + return rendered; +} + +function renderDropIndicatorWrapper( + parent: View | null, + reusableView: View, + dropPosition: DropPosition, + renderDropIndicator: (target: ItemDropTarget) => ReactNode +) { + let target: DropTarget = {type: 'item', key: reusableView.layoutInfo!.key, dropPosition}; + let indicator = renderDropIndicator(target); + if (indicator) { + let layoutInfo = reusableView.virtualizer.layout.getDropTargetLayoutInfo!(target); + indicator = ( + + {indicator} + + ); + } + + return indicator; } diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 9a5b058f10d..a02d3972a6f 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -71,12 +71,13 @@ export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {Toolbar, ToolbarContext} from './Toolbar'; export {TooltipTrigger, Tooltip, TooltipTriggerStateContext, TooltipContext} from './Tooltip'; export {UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeContext, UNSTABLE_TreeItemContent, UNSTABLE_TreeStateContext} from './Tree'; -export {useDragAndDrop, DropIndicator, DropIndicatorContext, DragAndDropContext} from './useDragAndDrop'; +export {useDragAndDrop} from './useDragAndDrop'; +export {DropIndicator, DropIndicatorContext, DragAndDropContext} from './DragAndDrop'; export {Virtualizer as UNSTABLE_Virtualizer} from './Virtualizer'; export {DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem, SSRProvider, RouterProvider, I18nProvider, useLocale} from 'react-aria'; export {FormValidationContext} from 'react-stately'; export {parseColor, getColorChannels} from '@react-stately/color'; -export {ListLayout as UNSTABLE_ListLayout} from '@react-stately/layout'; +export {ListLayout as UNSTABLE_ListLayout, GridLayout as UNSTABLE_GridLayout} from '@react-stately/layout'; export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbRenderProps} from './Breadcrumbs'; export type {ButtonProps, ButtonRenderProps} from './Button'; @@ -130,7 +131,8 @@ export type {ToggleButtonProps, ToggleButtonRenderProps} from './ToggleButton'; export type {ToolbarProps, ToolbarRenderProps} from './Toolbar'; export type {TooltipProps, TooltipRenderProps, TooltipTriggerComponentProps} from './Tooltip'; export type {TreeProps, TreeRenderProps, TreeItemProps, TreeItemRenderProps, TreeItemContentProps, TreeItemContentRenderProps} from './Tree'; -export type {DragAndDropHooks, DragAndDropOptions, DropIndicatorProps} from './useDragAndDrop'; +export type {DragAndDropHooks, DragAndDropOptions} from './useDragAndDrop'; +export type {DropIndicatorProps} from './DragAndDrop'; export type {ContextValue, SlotProps} from './utils'; export type {VirtualizerProps} from './Virtualizer'; @@ -139,4 +141,4 @@ export type {DirectoryDropItem, DraggableCollectionEndEvent, DraggableCollection export type {Key, Selection, SortDescriptor, SortDirection, SelectionMode} from 'react-stately'; export type {ValidationResult, RouterConfig} from '@react-types/shared'; export type {Color, ColorSpace, ColorFormat} from '@react-types/color'; -export type {ListLayoutOptions} from '@react-stately/layout'; +export type {ListLayoutOptions, GridLayoutOptions} from '@react-stately/layout'; diff --git a/packages/react-aria-components/src/useDragAndDrop.tsx b/packages/react-aria-components/src/useDragAndDrop.tsx index e30eb51e332..0feb19f1edd 100644 --- a/packages/react-aria-components/src/useDragAndDrop.tsx +++ b/packages/react-aria-components/src/useDragAndDrop.tsx @@ -40,8 +40,8 @@ import { useDraggableCollectionState, useDroppableCollectionState } from 'react-stately'; -import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, RefObject, useContext, useMemo} from 'react'; -import {RenderProps} from './utils'; +import {isVirtualDragging} from '@react-aria/dnd'; +import {JSX, RefObject, useMemo} from 'react'; interface DraggableCollectionStateOpts extends Omit {} @@ -50,7 +50,8 @@ interface DragHooks { useDraggableCollection?: (props: DraggableCollectionOptions, state: DraggableCollectionState, ref: RefObject) => void, useDraggableItem?: (props: DraggableItemProps, state: DraggableCollectionState) => DraggableItemResult, DragPreview?: typeof DragPreview, - renderDragPreview?: (items: DragItem[]) => JSX.Element + renderDragPreview?: (items: DragItem[]) => JSX.Element, + isVirtualDragging?: () => boolean } interface DropHooks { @@ -122,6 +123,7 @@ export function useDragAndDrop(options: DragAndDropOptions): DragAndDrop { hooks.useDraggableItem = useDraggableItem; hooks.DragPreview = DragPreview; hooks.renderDragPreview = renderDragPreview; + hooks.isVirtualDragging = isVirtualDragging; } if (isDroppable) { @@ -145,38 +147,3 @@ export function useDragAndDrop(options: DragAndDropOptions): DragAndDrop { dragAndDropHooks }; } - -export const DropIndicatorContext = createContext(null); - -export interface DropIndicatorRenderProps { - /** - * Whether the drop indicator is currently the active drop target. - * @selector [data-drop-target] - */ - isDropTarget: boolean -} - -export interface DropIndicatorProps extends AriaDropIndicatorProps, RenderProps {} - -interface DropIndicatorContextValue { - render: (props: DropIndicatorProps, ref: ForwardedRef) => ReactNode -} - -function DropIndicator(props: DropIndicatorProps, ref: ForwardedRef): JSX.Element { - let {render} = useContext(DropIndicatorContext)!; - return <>{render(props, ref)}; -} - -/** - * A DropIndicator is rendered between items in a collection to indicate where dropped data will be inserted. - */ -const _DropIndicator = forwardRef(DropIndicator); -export {_DropIndicator as DropIndicator}; - -export interface DragAndDropContextValue { - dragAndDropHooks?: DragAndDropHooks, - dragState?: DraggableCollectionState, - dropState?: DroppableCollectionState -} - -export const DragAndDropContext = createContext({}); diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 7c10dc07dcb..e120109c725 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -10,12 +10,12 @@ * governing permissions and limitations under the License. */ -import {Button, Checkbox, CheckboxProps, GridList, GridListItem, GridListItemProps, UNSTABLE_ListLayout as ListLayout, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components'; +import {Button, Checkbox, CheckboxProps, DropIndicator, UNSTABLE_GridLayout as GridLayout, GridList, GridListItem, GridListItemProps, UNSTABLE_ListLayout as ListLayout, useDragAndDrop, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; -import {GridLayout} from '@react-spectrum/card'; import React, {useMemo} from 'react'; import {Size} from '@react-stately/virtualizer'; import styles from '../example/index.css'; +import {useListData} from 'react-stately'; export default { title: 'React Aria Components' @@ -55,7 +55,8 @@ const MyGridListItem = (props: GridListItemProps) => { selected: isSelected, hovered: isHovered })}> - {({selectionMode}) => (<> + {({selectionMode, allowsDragging}) => (<> + {allowsDragging && } {selectionMode !== 'none' ? : null} {props.children as any} )} @@ -118,9 +119,35 @@ export function VirtualizedGridList() { }); }, []); + let list = useListData({ + initialItems: items + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + return [...keys].map(key => ({'text/plain': list.getItem(key).name})); + }, + onReorder(e) { + if (e.target.dropPosition === 'before') { + list.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + list.moveAfter(e.target.key, e.keys); + } + }, + renderDropIndicator(target) { + return ({width: '100%', height: '100%', background: isDropTarget ? 'blue' : 'transparent'})} />; + } + }); + return ( - + {item => {item.name}} diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index b5fe4ec14a0..9c8a79ab915 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -12,8 +12,7 @@ import {action} from '@storybook/addon-actions'; import {Collection} from '../src/Collection'; -import {GridLayout} from '@react-spectrum/card'; -import {Header, ListBox, ListBoxItem, ListBoxProps, UNSTABLE_ListLayout as ListLayout, Section, Separator, Text, useDragAndDrop, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components'; +import {DropIndicator, UNSTABLE_GridLayout as GridLayout, Header, ListBox, ListBoxItem, ListBoxProps, UNSTABLE_ListLayout as ListLayout, Section, Separator, Text, useDragAndDrop, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components'; import {MyListBoxItem} from './utils'; import React, {useMemo} from 'react'; import {Size} from '@react-stately/virtualizer'; @@ -296,6 +295,56 @@ export function VirtualizedListBoxEmpty() { ); } +export function VirtualizedListBoxDnd() { + let items: {id: number, name: string}[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i, name: `Item ${i}`}); + } + + let layout = useMemo(() => { + return new ListLayout({ + rowHeight: 25 + }); + }, []); + + let list = useListData({ + initialItems: items + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + return [...keys].map(key => ({'text/plain': list.getItem(key).name})); + }, + onReorder(e) { + if (e.target.dropPosition === 'before') { + list.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + list.moveAfter(e.target.key, e.keys); + } + }, + renderDropIndicator(target) { + return ({width: '100%', height: '100%', background: isDropTarget ? 'blue' : 'transparent'})} />; + } + }); + + return ( +
+ + + {item => {item.name}} + + +
+ ); +} + export function VirtualizedListBoxGrid() { let items: {id: number, name: string}[] = []; for (let i = 0; i < 10000; i++) { @@ -304,15 +353,46 @@ export function VirtualizedListBoxGrid() { let layout = useMemo(() => { return new GridLayout({ - minItemSize: new Size(40, 40) + minItemSize: new Size(80, 80), + maxItemSize: new Size(100, 100) }); }, []); + let list = useListData({ + initialItems: items + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + return [...keys].map(key => ({'text/plain': list.getItem(key).name})); + }, + onReorder(e) { + if (e.target.dropPosition === 'before') { + list.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + list.moveAfter(e.target.key, e.keys); + } + }, + renderDropIndicator(target) { + return ({width: '100%', height: '100%', background: isDropTarget ? 'blue' : 'transparent'})} />; + } + }); + return ( - - - {item => {item.name}} - - +
+ + + {item => {item.name}} + + +
); } diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 24b1f4baf27..82df148fd77 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Cell, Checkbox, CheckboxProps, Column, ColumnProps, ColumnResizer, Dialog, DialogTrigger, Heading, Menu, MenuTrigger, Modal, ModalOverlay, Popover, ResizableTableContainer, Row, Table, TableBody, TableHeader, UNSTABLE_TableLayout as TableLayout, useDragAndDrop, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components'; +import {Button, Cell, Checkbox, CheckboxProps, Column, ColumnProps, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Heading, Menu, MenuTrigger, Modal, ModalOverlay, Popover, ResizableTableContainer, Row, Table, TableBody, TableHeader, UNSTABLE_TableLayout as TableLayout, useDragAndDrop, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components'; import {isTextDropItem} from 'react-aria'; import {MyMenuItem} from './utils'; import React, {useMemo} from 'react'; @@ -406,17 +406,41 @@ export function VirtualizedTable() { }); }, []); + let list = useListData({ + initialItems: items + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + return [...keys].map(key => ({'text/plain': list.getItem(key).foo})); + }, + onReorder(e) { + if (e.target.dropPosition === 'before') { + list.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + list.moveAfter(e.target.key, e.keys); + } + }, + renderDropIndicator(target) { + return ({width: '100%', height: '100%', background: isDropTarget ? 'blue' : 'transparent'})} />; + } + }); + return ( - +
+ + Foo Bar Baz - + {item => ( + + {item.foo} {item.bar} {item.baz} diff --git a/packages/react-aria-components/stories/utils.tsx b/packages/react-aria-components/stories/utils.tsx index 04941fac6fc..95b1c6b57b7 100644 --- a/packages/react-aria-components/stories/utils.tsx +++ b/packages/react-aria-components/stories/utils.tsx @@ -7,7 +7,7 @@ export const MyListBoxItem = (props: ListBoxItemProps) => { return ( classNames(styles, 'item', { focused: isFocused, selected: isSelected,