diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 851466dd273..0e818675496 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -13,7 +13,6 @@ import {announce} from '@react-aria/live-announcer'; import {ariaHideOutside} from '@react-aria/overlays'; import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared'; -import {flushSync} from 'react-dom'; import {getDragModality, getTypes} from './utils'; import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; @@ -26,6 +25,7 @@ let subscriptions = new Set<() => void>(); interface DropTarget { element: FocusableElement, + preventFocusOnDrop?: boolean, getDropOperation?: (types: Set, allowedOperations: DropOperation[]) => DropOperation, onDropEnter?: (e: DropEnterEvent, dragTarget: DragTarget) => void, onDropExit?: (e: DropExitEvent) => void, @@ -513,19 +513,11 @@ class DragSession { }); } - // Blur and re-focus the drop target so that the focus ring appears. - if (this.currentDropTarget) { - // Since we cancel all focus events in drag sessions, refire blur to make sure state gets updated so drag target doesn't think it's still focused - // i.e. When you from one list to another during a drag session, we need the blur to fire on the first list after the drag. - if (!this.dragTarget.element.contains(this.currentDropTarget.element)) { - this.dragTarget.element.dispatchEvent(new FocusEvent('blur')); - this.dragTarget.element.dispatchEvent(new FocusEvent('focusout', {bubbles: true})); - } - // Re-focus the focusedKey upon reorder. This requires a React rerender between blurring and focusing. - flushSync(() => { - this.currentDropTarget.element.blur(); - }); - this.currentDropTarget.element.focus(); + if (this.currentDropTarget && !this.currentDropTarget.preventFocusOnDrop) { + // Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent). + // This corrects state such as whether focus ring should appear. + // useDroppableCollection handles this itself, so this is only for standalone drop zones. + document.activeElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); } this.setCurrentDropTarget(null); diff --git a/packages/@react-aria/dnd/src/useDroppableCollection.ts b/packages/@react-aria/dnd/src/useDroppableCollection.ts index 6a03bd6fad1..3a19b073b75 100644 --- a/packages/@react-aria/dnd/src/useDroppableCollection.ts +++ b/packages/@react-aria/dnd/src/useDroppableCollection.ts @@ -57,6 +57,9 @@ interface DroppingState { collection: Collection>, focusedKey: Key, selectedKeys: Set, + target: DropTarget, + draggingKeys: Set, + isInternal: boolean, timeout: ReturnType } @@ -213,26 +216,93 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: }); let droppingState = useRef(null); - let onDrop = useCallback((e: DropEvent, target: DropTarget) => { + let updateFocusAfterDrop = useCallback(() => { let {state} = localState; + if (droppingState.current) { + let { + target, + collection: prevCollection, + selectedKeys: prevSelectedKeys, + focusedKey: prevFocusedKey, + isInternal, + draggingKeys + } = droppingState.current; + + // If an insert occurs during a drop, we want to immediately select these items to give + // feedback to the user that a drop occurred. Only do this if the selection didn't change + // since the drop started so we don't override if the user or application did something. + if ( + state.collection.size > prevCollection.size && + state.selectionManager.isSelectionEqual(prevSelectedKeys) + ) { + let newKeys = new Set(); + for (let key of state.collection.getKeys()) { + if (!prevCollection.getItem(key)) { + newKeys.add(key); + } + } - // Focus the collection. - state.selectionManager.setFocused(true); + state.selectionManager.setSelectedKeys(newKeys); - // Save some state of the collection/selection before the drop occurs so we can compare later. - let focusedKey = state.selectionManager.focusedKey; + // If the focused item didn't change since the drop occurred, also focus the first + // inserted item. If selection is disabled, then also show the focus ring so there + // is some indication that items were added. + if (state.selectionManager.focusedKey === prevFocusedKey) { + let first = newKeys.keys().next().value; + let item = state.collection.getItem(first); + + // If this is a cell, focus the parent row. + if (item?.type === 'cell') { + first = item.parentKey; + } + + state.selectionManager.setFocusedKey(first); - // If parent key was dragged, we want to use it instead (i.e. focus row instead of cell after dropping) - if (globalDndState.draggingKeys.has(state.collection.getItem(focusedKey)?.parentKey)) { - focusedKey = state.collection.getItem(focusedKey).parentKey; - state.selectionManager.setFocusedKey(focusedKey); + if (state.selectionManager.selectionMode === 'none') { + setInteractionModality('keyboard'); + } + } + } else if ( + state.selectionManager.focusedKey === prevFocusedKey && + isInternal && + target.type === 'item' && + target.dropPosition !== 'on' && + draggingKeys.has(state.collection.getItem(prevFocusedKey)?.parentKey) + ) { + // Focus row instead of cell when reordering. + state.selectionManager.setFocusedKey(state.collection.getItem(prevFocusedKey).parentKey); + setInteractionModality('keyboard'); + } else if ( + state.selectionManager.focusedKey === prevFocusedKey && + target.type === 'item' && + target.dropPosition === 'on' && + state.collection.getItem(target.key) != null + ) { + // If focus didn't move already (e.g. due to an insert), and the user dropped on an item, + // focus that item and show the focus ring to give the user feedback that the drop occurred. + // Also show the focus ring if the focused key is not selected, e.g. in case of a reorder. + state.selectionManager.setFocusedKey(target.key); + setInteractionModality('keyboard'); + } else if (!state.selectionManager.isSelected(state.selectionManager.focusedKey)) { + setInteractionModality('keyboard'); + } + + state.selectionManager.setFocused(true); } + }, [localState]); + let onDrop = useCallback((e: DropEvent, target: DropTarget) => { + let {state} = localState; + + // Save some state of the collection/selection before the drop occurs so we can compare later. droppingState.current = { timeout: null, - focusedKey, + focusedKey: state.selectionManager.focusedKey, collection: state.collection, - selectedKeys: state.selectionManager.selectedKeys + selectedKeys: state.selectionManager.selectedKeys, + draggingKeys: globalDndState.draggingKeys, + isInternal: isInternalDropOperation(ref), + target }; let onDropFn = localState.props.onDrop || defaultOnDrop; @@ -246,26 +316,13 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: }); // Wait for a short time period after the onDrop is called to allow the data to be read asynchronously - // and for React to re-render. If an insert occurs during this time, it will be selected/focused below. - // If items are not "immediately" inserted by the onDrop handler, the application will need to handle - // selecting and focusing those items themselves. + // and for React to re-render. If the collection didn't already change during this time (handled below), + // update the focused key here. droppingState.current.timeout = setTimeout(() => { - // If focus didn't move already (e.g. due to an insert), and the user dropped on an item, - // focus that item and show the focus ring to give the user feedback that the drop occurred. - // Also show the focus ring if the focused key is not selected, e.g. in case of a reorder. - let {state} = localState; - - if (target.type === 'item' && target.dropPosition === 'on' && state.collection.getItem(target.key) != null) { - state.selectionManager.setFocusedKey(target.key); - state.selectionManager.setFocused(true); - setInteractionModality('keyboard'); - } else if (!state.selectionManager.isSelected(focusedKey)) { - setInteractionModality('keyboard'); - } - + updateFocusAfterDrop(); droppingState.current = null; }, 50); - }, [localState, defaultOnDrop]); + }, [localState, defaultOnDrop, ref, updateFocusAfterDrop]); // eslint-disable-next-line arrow-body-style useEffect(() => { @@ -277,44 +334,9 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: }, []); useLayoutEffect(() => { - // If an insert occurs during a drop, we want to immediately select these items to give - // feedback to the user that a drop occurred. Only do this if the selection didn't change - // since the drop started so we don't override if the user or application did something. - if ( - droppingState.current && - state.selectionManager.isFocused && - state.collection.size > droppingState.current.collection.size && - state.selectionManager.isSelectionEqual(droppingState.current.selectedKeys) - ) { - let newKeys = new Set(); - for (let key of state.collection.getKeys()) { - if (!droppingState.current.collection.getItem(key)) { - newKeys.add(key); - } - } - - state.selectionManager.setSelectedKeys(newKeys); - - // If the focused item didn't change since the drop occurred, also focus the first - // inserted item. If selection is disabled, then also show the focus ring so there - // is some indication that items were added. - if (state.selectionManager.focusedKey === droppingState.current.focusedKey) { - let first = newKeys.keys().next().value; - let item = state.collection.getItem(first); - - // If this is a cell, focus the parent row. - if (item?.type === 'cell') { - first = item.parentKey; - } - - state.selectionManager.setFocusedKey(first); - - if (state.selectionManager.selectionMode === 'none') { - setInteractionModality('keyboard'); - } - } - - droppingState.current = null; + // If the collection changed after a drop, update the focused key. + if (droppingState.current && state.collection !== droppingState.current.collection) { + updateFocusAfterDrop(); } }); @@ -470,6 +492,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: return DragManager.registerDropTarget({ element: ref.current, + preventFocusOnDrop: true, getDropOperation(types, allowedOperations) { if (localState.state.target) { let {draggingKeys} = globalDndState; diff --git a/packages/@react-aria/dnd/stories/DraggableCollection.tsx b/packages/@react-aria/dnd/stories/DraggableCollection.tsx index 8476f15814d..8f871d81a36 100644 --- a/packages/@react-aria/dnd/stories/DraggableCollection.tsx +++ b/packages/@react-aria/dnd/stories/DraggableCollection.tsx @@ -58,7 +58,7 @@ function DraggableCollection(props) { let state = useListState(props); let gridState = useGridState({ selectionMode: 'multiple', - collection: new GridCollection({ + collection: React.useMemo(() => new GridCollection({ columnCount: 1, items: [...state.collection].map(item => ({ ...item, @@ -74,7 +74,7 @@ function DraggableCollection(props) { childNodes: [] }] })) - }) + }), [state.collection]) }); let preview = useRef(null); diff --git a/packages/@react-aria/dnd/stories/DroppableGrid.tsx b/packages/@react-aria/dnd/stories/DroppableGrid.tsx index 17c7edbedf6..68f23dfd195 100644 --- a/packages/@react-aria/dnd/stories/DroppableGrid.tsx +++ b/packages/@react-aria/dnd/stories/DroppableGrid.tsx @@ -122,7 +122,7 @@ const DroppableGrid = React.forwardRef(function (props: any, ref) { focusMode: 'cell', selectedKeys: props.selectedKeys, onSelectionChange: props.onSelectionChange, - collection: new GridCollection({ + collection: React.useMemo(() => new GridCollection({ columnCount: 1, items: [...state.collection].map(item => ({ ...item, @@ -138,7 +138,7 @@ const DroppableGrid = React.forwardRef(function (props: any, ref) { childNodes: [] }] })) - }) + }), [state.collection]) }); React.useImperativeHandle(ref, () => ({ diff --git a/packages/@react-aria/dnd/stories/Reorderable.tsx b/packages/@react-aria/dnd/stories/Reorderable.tsx index ee09cb6b8c9..564995bde88 100644 --- a/packages/@react-aria/dnd/stories/Reorderable.tsx +++ b/packages/@react-aria/dnd/stories/Reorderable.tsx @@ -71,7 +71,7 @@ function ReorderableGrid(props) { let keyboardDelegate = new ListKeyboardDelegate(state.collection, new Set(), ref); let gridState = useGridState({ selectionMode: 'multiple', - collection: new GridCollection({ + collection: React.useMemo(() => new GridCollection({ columnCount: 1, items: [...state.collection].map(item => ({ ...item, @@ -87,7 +87,7 @@ function ReorderableGrid(props) { childNodes: [] }] })) - }) + }), [state.collection]) }); // Use a random drag type so the items can only be reordered within this list and not dragged elsewhere. diff --git a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx index d0bc2c9687a..9fefd5ce50c 100644 --- a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx +++ b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx @@ -21,7 +21,7 @@ import Folder from '@spectrum-icons/workflow/Folder'; import {Item} from '@react-stately/collections'; import {ListKeyboardDelegate} from '@react-aria/selection'; import {ListLayout} from '@react-stately/layout'; -import React from 'react'; +import React, {useMemo} from 'react'; import {useDropIndicator, useDroppableCollection, useDroppableItem} from '..'; import {useDroppableCollectionState} from '@react-stately/dnd'; import {useListBox, useOption} from '@react-aria/listbox'; @@ -159,6 +159,8 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) { isVirtualized: true }, state, domRef); let isDropTarget = dropState.isDropTarget({type: 'root'}); + let focusedKey = dropState.target?.type === 'item' ? dropState.target.key : state.selectionManager.focusedKey; + let persistedKeys = useMemo(() => focusedKey != null ? new Set([focusedKey]) : null, [focusedKey]); return ( @@ -170,7 +172,7 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) { scrollDirection="vertical" layout={layout} collection={state.collection} - focusedKey={dropState.target?.type === 'item' ? dropState.target.key : state.selectionManager.focusedKey}> + persistedKeys={persistedKeys}> {(type, item) => ( <> {state.collection.getKeyBefore(item.key) == null && diff --git a/packages/@react-aria/dnd/stories/dnd.stories.tsx b/packages/@react-aria/dnd/stories/dnd.stories.tsx index 20d26c103c8..f89e66225b6 100644 --- a/packages/@react-aria/dnd/stories/dnd.stories.tsx +++ b/packages/@react-aria/dnd/stories/dnd.stories.tsx @@ -386,7 +386,7 @@ function DraggableCollection(props) { let gridState = useGridState({ ...props, selectionMode: 'multiple', - collection: new GridCollection({ + collection: React.useMemo(() => new GridCollection({ columnCount: 1, items: [...state.collection].map(item => ({ ...item, @@ -402,7 +402,7 @@ function DraggableCollection(props) { childNodes: [] }] })) - }) + }), [state.collection]) }); let preview = useRef(null); diff --git a/packages/@react-aria/grid/stories/example.tsx b/packages/@react-aria/grid/stories/example.tsx index c7603f31512..e1e84ed3e9a 100644 --- a/packages/@react-aria/grid/stories/example.tsx +++ b/packages/@react-aria/grid/stories/example.tsx @@ -11,7 +11,7 @@ export function Grid(props) { let gridState = useGridState({ ...props, selectionMode: 'multiple', - collection: new GridCollection({ + collection: React.useMemo(() => new GridCollection({ columnCount: 1, items: [...state.collection].map(item => ({ type: 'item', @@ -21,7 +21,7 @@ export function Grid(props) { type: 'cell' }] })) - }) + }), [state.collection]) }); let ref = React.useRef(undefined); diff --git a/packages/@react-aria/virtualizer/src/Virtualizer.tsx b/packages/@react-aria/virtualizer/src/Virtualizer.tsx index a08ae1a08d4..e098a0ed1ac 100644 --- a/packages/@react-aria/virtualizer/src/Virtualizer.tsx +++ b/packages/@react-aria/virtualizer/src/Virtualizer.tsx @@ -13,7 +13,7 @@ import {Collection, Key} from '@react-types/shared'; import {Layout, Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer'; import {mergeProps, useLayoutEffect} from '@react-aria/utils'; -import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useMemo, useRef} from 'react'; +import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useRef} from 'react'; import {ScrollView} from './ScrollView'; import {VirtualizerItem} from './VirtualizerItem'; @@ -29,7 +29,7 @@ interface VirtualizerProps extends Omit, layout: Layout, collection: Collection, - focusedKey?: Key, + persistedKeys?: Set | null, sizeToFit?: 'width' | 'height', scrollDirection?: 'horizontal' | 'vertical' | 'both', isLoading?: boolean, @@ -49,7 +49,7 @@ function Virtualizer(props: Virtualize isLoading, // eslint-disable-next-line @typescript-eslint/no-unused-vars onLoadMore, - focusedKey, + persistedKeys, layoutOptions, ...otherProps } = props; @@ -65,7 +65,7 @@ function Virtualizer(props: Virtualize ref.current.scrollLeft = rect.x; ref.current.scrollTop = rect.y; }, - persistedKeys: useMemo(() => focusedKey != null ? new Set([focusedKey]) : new Set(), [focusedKey]), + persistedKeys, layoutOptions }); diff --git a/packages/@react-spectrum/card/src/BaseLayout.tsx b/packages/@react-spectrum/card/src/BaseLayout.tsx index a2bc1a0c11b..f62c7fc9a4c 100644 --- a/packages/@react-spectrum/card/src/BaseLayout.tsx +++ b/packages/@react-spectrum/card/src/BaseLayout.tsx @@ -53,7 +53,7 @@ export class BaseLayout extends Layout, CardViewLayoutOptions> implem this.margin = options.margin || 24; } - validate(invalidationContext: InvalidationContext) { + update(invalidationContext: InvalidationContext) { this.collection = this.virtualizer.collection as GridCollection; this.isLoading = invalidationContext.layoutOptions?.isLoading || false; this.direction = invalidationContext.layoutOptions?.direction || 'ltr'; diff --git a/packages/@react-spectrum/card/src/CardView.tsx b/packages/@react-spectrum/card/src/CardView.tsx index 8b166fa2397..97341ade178 100644 --- a/packages/@react-spectrum/card/src/CardView.tsx +++ b/packages/@react-spectrum/card/src/CardView.tsx @@ -102,6 +102,8 @@ function CardView(props: SpectrumCardViewProps, ref: DOMRef focusedKey = focusedItem.parentKey; } + let persistedKeys = useMemo(() => focusedKey != null ? new Set([focusedKey]) : null, [focusedKey]); + // TODO: does aria-row count and aria-col count need to be modified? Perhaps aria-col count needs to be omitted return ( @@ -110,7 +112,7 @@ function CardView(props: SpectrumCardViewProps, ref: DOMRef {...styleProps} className={classNames(styles, 'spectrum-CardView')} ref={domRef} - focusedKey={focusedKey} + persistedKeys={persistedKeys} scrollDirection="vertical" layout={cardViewLayout} collection={gridCollection} diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index a776c299e7f..e9aa0040cb1 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -184,10 +184,19 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef }, state, domRef); let focusedKey = selectionManager.focusedKey; + let dropTargetKey: Key | null = null; if (dropState?.target?.type === 'item') { - focusedKey = dropState.target.key; + dropTargetKey = dropState.target.key; + if (dropState.target.dropPosition === 'after') { + // Normalize to the "before" drop position since we only render those in the DOM. + dropTargetKey = state.collection.getKeyAfter(dropTargetKey) ?? dropTargetKey; + } } + let persistedKeys = useMemo(() => { + return new Set([focusedKey, dropTargetKey].filter(k => k !== null)); + }, [focusedKey, dropTargetKey]); + // wait for layout to get accurate measurements let [isVerticalScrollbarVisible, setVerticalScollbarVisible] = useState(false); let [isHorizontalScrollbarVisible, setHorizontalScollbarVisible] = useState(false); @@ -214,7 +223,7 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef isLoading={isLoading} onLoadMore={onLoadMore} ref={domRef} - focusedKey={focusedKey} + persistedKeys={persistedKeys} scrollDirection="vertical" className={ classNames( diff --git a/packages/@react-spectrum/list/src/ListViewLayout.ts b/packages/@react-spectrum/list/src/ListViewLayout.ts index 2044b803bd6..8f8d251eefb 100644 --- a/packages/@react-spectrum/list/src/ListViewLayout.ts +++ b/packages/@react-spectrum/list/src/ListViewLayout.ts @@ -20,9 +20,9 @@ interface ListViewLayoutProps { export class ListViewLayout extends ListLayout { private isLoading: boolean = false; - validate(invalidationContext: InvalidationContext): void { + update(invalidationContext: InvalidationContext): void { this.isLoading = invalidationContext.layoutOptions?.isLoading || false; - super.validate(invalidationContext); + super.update(invalidationContext); } protected buildCollection(): LayoutNode[] { diff --git a/packages/@react-spectrum/list/stories/ListViewDnD.stories.tsx b/packages/@react-spectrum/list/stories/ListViewDnD.stories.tsx index 87ae808d981..632d02b0f78 100644 --- a/packages/@react-spectrum/list/stories/ListViewDnD.stories.tsx +++ b/packages/@react-spectrum/list/stories/ListViewDnD.stories.tsx @@ -111,6 +111,20 @@ export const DragWithinScroll: ListViewStory = { name: 'Drag within list scrolling (Reorder)' }; +let manyItems = []; +for (let i = 0; i < 100; i++) { + manyItems.push({id: 'item' + i, type: 'item', textValue: 'Item ' + i}); +} + +export const DragWithinMany: ListViewStory = { + render: (args) => ( + + + + ), + name: 'Drag within list with many items' +}; + export const DragIntoFolder: ListViewStory = { render: (args) => ( diff --git a/packages/@react-spectrum/list/stories/ListViewDnDExamples.tsx b/packages/@react-spectrum/list/stories/ListViewDnDExamples.tsx index 2b6d2a54f07..a23a3c81df4 100644 --- a/packages/@react-spectrum/list/stories/ListViewDnDExamples.tsx +++ b/packages/@react-spectrum/list/stories/ListViewDnDExamples.tsx @@ -85,9 +85,9 @@ let itemList2 = [ ]; export function ReorderExample(props) { - let {onDrop, onDragStart, onDragEnd, disabledKeys = ['2'], ...otherprops} = props; + let {items, onDrop, onDragStart, onDragEnd, disabledKeys = ['2'], ...otherprops} = props; let list = useListData({ - initialItems: props.items || itemList1 + initialItems: items || itemList1 }); // Use a random drag type so the items can only be reordered within this list and not dragged elsewhere. diff --git a/packages/@react-spectrum/list/stories/ListViewDnDUtil.stories.tsx b/packages/@react-spectrum/list/stories/ListViewDnDUtil.stories.tsx index 86e75233219..6da108bb537 100644 --- a/packages/@react-spectrum/list/stories/ListViewDnDUtil.stories.tsx +++ b/packages/@react-spectrum/list/stories/ListViewDnDUtil.stories.tsx @@ -74,6 +74,18 @@ export const DragWithin: ListViewStory = { name: 'Drag within list (Reorder}' }; +let manyItems = []; +for (let i = 0; i < 100; i++) { + manyItems.push({identifier: 'item' + i, type: 'item', name: 'Item ' + i}); +} + +export const DragWithinMany: ListViewStory = { + render: (args) => ( + + ), + name: 'Drag within list with many items' +}; + export const DropOntoItem: ListViewStory = { render: (args) => ( diff --git a/packages/@react-spectrum/list/stories/ListViewDnDUtilExamples.tsx b/packages/@react-spectrum/list/stories/ListViewDnDUtilExamples.tsx index 6427ac8aa87..0ecf61a4c33 100644 --- a/packages/@react-spectrum/list/stories/ListViewDnDUtilExamples.tsx +++ b/packages/@react-spectrum/list/stories/ListViewDnDUtilExamples.tsx @@ -93,9 +93,9 @@ export function DragExampleUtilHandlers(props) { } export function ReorderExampleUtilHandlers(props) { - let {listViewProps, dndOptions} = props; + let {listViewProps, dndOptions, items} = props; let list = useListData({ - initialItems: folderList1, + initialItems: (items as typeof folderList1) || folderList1, getKey: (item) => item.identifier }); diff --git a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx index b89ecb13b8f..3b32b9e3450 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx +++ b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx @@ -100,6 +100,9 @@ function ListBoxBase(props: ListBoxBaseProps, ref: RefObject focusedKey != null ? new Set([focusedKey]) : null, [focusedKey]); + return ( @@ -107,7 +110,7 @@ function ListBoxBase(props: ListBoxBaseProps, ref: RefObject extends ListLayout { this.padding = opts.padding; } - validate(invalidationContext: InvalidationContext): void { + update(invalidationContext: InvalidationContext): void { this.isLoading = invalidationContext.layoutOptions?.isLoading || false; - super.validate(invalidationContext); + super.update(invalidationContext); } protected buildCollection(): LayoutNode[] { diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 0e4de9e3eaa..86e2da13a96 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -404,10 +404,20 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef { + return new Set([focusedKey, dropTargetKey].filter(k => k !== null)); + }, [focusedKey, dropTargetKey]); + let mergedProps = mergeProps( isTableDroppable && droppableCollection?.collectionProps, gridProps, @@ -462,7 +472,7 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef extends HTMLAttributes { tableState: TableState, layout: TableViewLayout, collection: TableCollection, - focusedKey: Key | null, + persistedKeys: Set | null, renderView: (type: string, content: GridNode) => ReactElement, renderWrapper?: ( parent: View | null, @@ -513,7 +523,7 @@ interface TableVirtualizerProps extends HTMLAttributes { // This is a custom Virtualizer that also has a header that syncs its scroll position with the body. function TableVirtualizer(props: TableVirtualizerProps) { - let {tableState, layout, collection, focusedKey, renderView, renderWrapper, domRef, bodyRef, headerRef, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, isVirtualDragging, isRootDropTarget, ...otherProps} = props; + let {tableState, layout, collection, persistedKeys, renderView, renderWrapper, domRef, bodyRef, headerRef, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, isVirtualDragging, isRootDropTarget, ...otherProps} = props; let {direction} = useLocale(); let loadingState = collection.body.props.loadingState; let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; @@ -558,7 +568,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { bodyRef.current.scrollTop = rect.y; setScrollLeft(bodyRef.current, direction, rect.x); }, - persistedKeys: useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]), + persistedKeys, layoutOptions: useMemo(() => ({ columnWidths: columnResizeState.columnWidths }), [columnResizeState.columnWidths]) @@ -566,10 +576,10 @@ function TableVirtualizer(props: TableVirtualizerProps) { let memoedVirtualizerProps = useMemo(() => ({ tabIndex: otherProps.tabIndex, - focusedKey, + persistedKeys, isLoading, onLoadMore - }), [otherProps.tabIndex, focusedKey, isLoading, onLoadMore]); + }), [otherProps.tabIndex, persistedKeys, isLoading, onLoadMore]); let {virtualizerProps, scrollViewProps: {onVisibleRectChange}} = useVirtualizer(memoedVirtualizerProps, state, domRef); let onVisibleRectChangeMemo = useCallback(rect => { diff --git a/packages/@react-spectrum/table/src/TableViewLayout.ts b/packages/@react-spectrum/table/src/TableViewLayout.ts index 365bb65b763..483d39a0b42 100644 --- a/packages/@react-spectrum/table/src/TableViewLayout.ts +++ b/packages/@react-spectrum/table/src/TableViewLayout.ts @@ -36,7 +36,7 @@ export class TableViewLayout extends TableLayout { if (this.isLoading) { // Add some margin around the loader to ensure that scrollbars don't flicker in and out. - let rect = new Rect(40, 40, (width || this.virtualizer.visibleRect.width) - 80, children.length === 0 ? this.virtualizer.visibleRect.height - 80 : 60); + let rect = new Rect(40, Math.max(layoutInfo.rect.maxY, 40), (width || this.virtualizer.visibleRect.width) - 80, children.length === 0 ? this.virtualizer.visibleRect.height - 80 : 60); let loader = new LayoutInfo('loader', 'loader', rect); loader.parentKey = layoutInfo.key; loader.isSticky = children.length === 0; diff --git a/packages/@react-spectrum/table/stories/TableDnD.stories.tsx b/packages/@react-spectrum/table/stories/TableDnD.stories.tsx index 3f006d7d7cf..4ff47820ac7 100644 --- a/packages/@react-spectrum/table/stories/TableDnD.stories.tsx +++ b/packages/@react-spectrum/table/stories/TableDnD.stories.tsx @@ -14,7 +14,7 @@ import {action} from '@storybook/addon-actions'; import {ComponentMeta} from '@storybook/react'; import defaultConfig, {TableStory} from './Table.stories'; import {Divider} from '@react-spectrum/divider'; -import {DragBetweenTablesExample, DragBetweenTablesRootOnlyExample, DragExample, DragOntoRowExample, DragWithoutRowHeaderExample, ReorderExample} from './TableDnDExamples'; +import {DragBetweenTablesExample, DragBetweenTablesRootOnlyExample, DragExample, DragOntoRowExample, DragWithoutRowHeaderExample, items, ReorderExample} from './TableDnDExamples'; import {Droppable} from '../../../@react-aria/dnd/stories/dnd.stories'; import {Flex} from '@react-spectrum/layout'; import React from 'react'; @@ -91,6 +91,23 @@ export const DragWithinTable: TableStory = { name: 'Drag within table (Reorder)' }; +let manyItems = []; +for (let i = 0; i < 100; i++) { + manyItems.push({...items[i % 10], id: `${i}`}); +} + +export const DragWithinTableManyItems: TableStory = { + args: { + disabledKeys: ['Foo 2'] + }, + render: (args) => ( + + + + ), + name: 'Drag within table many items' +}; + export const DragOntoRow: TableStory = { args: { disabledKeys: ['1'] diff --git a/packages/@react-spectrum/table/stories/TableDnDExamples.tsx b/packages/@react-spectrum/table/stories/TableDnDExamples.tsx index 1fa2ac205cc..5cf41de71b9 100644 --- a/packages/@react-spectrum/table/stories/TableDnDExamples.tsx +++ b/packages/@react-spectrum/table/stories/TableDnDExamples.tsx @@ -29,7 +29,7 @@ let columnsWithOutRowHeader = [ {name: 'IP Address', key: 'ip_address'} ]; -let items = [ +export let items = [ {id: 'a', first_name: 'Vin', last_name: 'Charlet', email: 'vcharlet0@123-reg.co.uk', ip_address: '18.45.175.130', department: 'Services', job_title: 'Analog Circuit Design manager'}, {id: 'b', first_name: 'Lexy', last_name: 'Maddison', email: 'lmaddison1@xinhuanet.com', ip_address: '238.210.151.48', department: 'Research and Development', job_title: 'Analog Circuit Design manager'}, {id: 'c', first_name: 'Robbi', last_name: 'Persence', email: 'rpersence2@hud.gov', ip_address: '130.2.120.99', department: 'Business Development', job_title: 'Analog Circuit Design manager'}, @@ -115,7 +115,7 @@ export function DragWithoutRowHeaderExample(props?) { export function ReorderExample(props) { let {onDrop, onDragStart, onDragEnd, tableViewProps} = props; let list = useListData({ - initialItems: items, + initialItems: (props.items as typeof items) || items, getKey: item => item.id }); diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index bfbcceed306..3cf3607a99e 100644 --- a/packages/@react-stately/layout/src/GridLayout.ts +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -61,7 +61,7 @@ export class GridLayout extends Layout, O> implements DropTa this.dropIndicatorThickness = options.dropIndicatorThickness || 2; } - validate(): void { + update(): void { let visibleWidth = this.virtualizer.visibleRect.width; // The max item width is always the entire viewport. diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index e3f452b0a91..dd105cf24e3 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -162,7 +162,7 @@ export class ListLayout extends Layout, O> implements DropTa return invalidationContext.sizeChanged; } - validate(invalidationContext: InvalidationContext) { + update(invalidationContext: InvalidationContext) { this.collection = this.virtualizer.collection; // Reset valid rect if we will have to invalidate everything. diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 8087889dad4..489b51cd16a 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -46,7 +46,7 @@ export class TableLayout exten ); } - validate(invalidationContext: InvalidationContext): void { + update(invalidationContext: InvalidationContext): void { let newCollection = this.virtualizer.collection as TableCollection; // If columnWidths were provided via layoutOptions, update those. @@ -62,7 +62,7 @@ export class TableLayout exten invalidationContext.sizeChanged = true; } - super.validate(invalidationContext); + super.update(invalidationContext); } protected buildCollection(): LayoutNode[] { @@ -227,7 +227,7 @@ export class TableLayout exten let width = 0; let children: LayoutNode[] = []; let rowHeight = this.getEstimatedRowHeight(); - for (let [i, node] of [...getChildNodes(this.collection.body, this.collection)].entries()) { + for (let node of getChildNodes(this.collection.body, this.collection)) { // Skip rows before the valid rectangle unless they are already cached. if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { y += rowHeight; @@ -237,7 +237,7 @@ export class TableLayout exten let layoutNode = this.buildChild(node, 0, y, layoutInfo.key); layoutNode.layoutInfo.parentKey = layoutInfo.key; - layoutNode.index = i; + layoutNode.index = children.length; y = layoutNode.layoutInfo.rect.maxY; width = Math.max(width, layoutNode.layoutInfo.rect.width); children.push(layoutNode); @@ -285,7 +285,7 @@ export class TableLayout exten let children: LayoutNode[] = []; let height = 0; - for (let [i, child] of [...getChildNodes(node, this.collection)].entries()) { + for (let child of getChildNodes(node, this.collection)) { if (child.type === 'cell') { if (x > this.requestedRect.maxX) { // Adjust existing cached layoutInfo to ensure that it is out of view. @@ -299,7 +299,7 @@ export class TableLayout exten let layoutNode = this.buildChild(child, x, y, layoutInfo.key); x = layoutNode.layoutInfo.rect.maxX; height = Math.max(height, layoutNode.layoutInfo.rect.height); - layoutNode.index = i; + layoutNode.index = children.length; children.push(layoutNode); } } diff --git a/packages/@react-stately/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index 806a9b0becb..1cb27f9b865 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -52,7 +52,7 @@ export abstract class Layout implements LayoutDelegat * Called by the virtualizer before {@link getVisibleLayoutInfos} * or {@link getLayoutInfo} are called. */ - validate(invalidationContext: InvalidationContext) {} // eslint-disable-line @typescript-eslint/no-unused-vars + update(invalidationContext: InvalidationContext) {} // eslint-disable-line @typescript-eslint/no-unused-vars /** * Returns an array of {@link LayoutInfo} objects which are inside the given rectangle. diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index 4626c0e482a..c77aa305e5c 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -147,8 +147,8 @@ export class Virtualizer { } private relayout(context: InvalidationContext = {}) { - // Validate the layout - this.layout.validate(context); + // Update the layout + this.layout.update(context); (this as Mutable).contentSize = this.layout.getContentSize(); // Constrain scroll position. diff --git a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts index 3a5485e362e..dff243c91a2 100644 --- a/packages/@react-stately/virtualizer/src/useVirtualizerState.ts +++ b/packages/@react-stately/virtualizer/src/useVirtualizerState.ts @@ -25,7 +25,7 @@ interface VirtualizerProps { layout: Layout, collection: Collection, onVisibleRectChange(rect: Rect): void, - persistedKeys?: Set, + persistedKeys?: Set | null, layoutOptions?: O } diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 7e4837ea1ad..638ff819529 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -12,7 +12,7 @@ import {CollectionBase, DropTargetDelegate, ItemDropTarget, Key, LayoutDelegate} from '@react-types/shared'; import {createBranchComponent, useCachedChildren} from '@react-aria/collections'; import {Collection as ICollection, Node, SelectionBehavior, SelectionMode, SectionProps as SharedSectionProps} from 'react-stately'; -import React, {createContext, ForwardedRef, HTMLAttributes, JSX, ReactElement, ReactNode, RefObject, useContext} from 'react'; +import React, {createContext, ForwardedRef, HTMLAttributes, JSX, ReactElement, ReactNode, RefObject, useContext, useMemo} from 'react'; import {StyleProps} from './utils'; export interface CollectionProps extends Omit, 'children'> { @@ -104,23 +104,35 @@ export const Section = /*#__PURE__*/ createBranchComponent('section', >, + /** The parent node of the items to render. */ parent: Node, + /** A function that renders a drop indicator between items. */ renderDropIndicator?: (target: ItemDropTarget) => ReactNode } export interface CollectionRootProps extends HTMLAttributes { + /** The collection of items to render. */ collection: ICollection>, - focusedKey?: Key | null, + /** A set of keys for items that should always be persisted in the DOM. */ + persistedKeys?: Set | null, + /** A ref to the scroll container for the collection. */ scrollRef?: RefObject, + /** A function that renders a drop indicator between items. */ renderDropIndicator?: (target: ItemDropTarget) => ReactNode } export interface CollectionRenderer { + /** Whether this is a virtualized collection. */ isVirtualized?: boolean, + /** A delegate object that provides layout information for items in the collection. */ layoutDelegate?: LayoutDelegate, + /** A delegate object that provides drop targets for pointer coordinates within the collection. */ dropTargetDelegate?: DropTargetDelegate, + /** A component that renders the root collection items. */ CollectionRoot: React.ComponentType, + /** A component that renders the child collection items. */ CollectionBranch: React.ComponentType } @@ -160,3 +172,7 @@ function useCollectionRender( } export const CollectionRendererContext = createContext(DefaultCollectionRenderer); + +export function usePersistedKeys(focusedKey: Key) { + return useMemo(() => focusedKey != null ? new Set([focusedKey]) : null, [focusedKey]); +} diff --git a/packages/react-aria-components/src/DragAndDrop.tsx b/packages/react-aria-components/src/DragAndDrop.tsx index d83303e9ea9..f11546ebde9 100644 --- a/packages/react-aria-components/src/DragAndDrop.tsx +++ b/packages/react-aria-components/src/DragAndDrop.tsx @@ -9,10 +9,10 @@ * 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 {DropIndicatorProps as AriaDropIndicatorProps, ItemDropTarget, Key} 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 React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useCallback, useContext, useMemo} from 'react'; import type {RenderProps} from './utils'; export interface DragAndDropContextValue { @@ -62,9 +62,19 @@ export function useRenderDropIndicator(dragAndDropHooks?: DragAndDropHooks, drop 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; +export function useDndPersistedKeys(selectionManager: MultipleSelectionManager, dragAndDropHooks?: DragAndDropHooks, dropState?: DroppableCollectionState) { + // Persist the focused key and the drop target key. + let focusedKey = selectionManager.focusedKey; + let dropTargetKey: Key | null = null; + if (dragAndDropHooks?.isVirtualDragging?.() && dropState?.target?.type === 'item') { + dropTargetKey = dropState.target.key; + if (dropState.target.dropPosition === 'after') { + // Normalize to the "before" drop position since we only render those to the DOM. + dropTargetKey = dropState.collection.getKeyAfter(dropTargetKey) ?? dropTargetKey; + } + } + + return useMemo(() => { + return new Set([focusedKey, dropTargetKey].filter(k => k !== null)); + }, [focusedKey, dropTargetKey]); } diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 261c5aa49db..376a48b09e4 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -15,7 +15,7 @@ import {CheckboxContext} from './RSPContexts'; import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; -import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndAwareFocusedKey, useRenderDropIndicator} from './DragAndDrop'; +import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; @@ -232,7 +232,7 @@ function GridListInner({props, collection, gridListRef: ref}: {emptyState} diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 16f19cb60a4..c48a2d15cfd 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -14,7 +14,7 @@ import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRe import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection'; import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; -import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndAwareFocusedKey, useRenderDropIndicator} from './DragAndDrop'; +import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, 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'; @@ -250,7 +250,7 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner {emptyState} diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 5d2b9d8ca22..88656a91ec3 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -14,7 +14,7 @@ import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria'; import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; -import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection'; +import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; @@ -222,7 +222,10 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [SubmenuTriggerContext, {parentMenuRef: ref}], [MenuItemContext, null] ]}> - +
diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 73c75053cdb..b10662ea109 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -7,7 +7,7 @@ import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, I 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, DropIndicatorContext, DropIndicatorProps, useDndAwareFocusedKey, useRenderDropIndicator} from './DragAndDrop'; +import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useFocusRing, useHover, useLocale, useLocalizedStringFormatter, useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox, useVisuallyHidden} from 'react-aria'; import {filterDOMProps, isScrollable, mergeRefs, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; @@ -483,7 +483,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl + persistedKeys={useDndPersistedKeys(selectionManager, dragAndDropHooks, dropState)} /> {dragPreview} diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 28083387547..cfa70926594 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -13,7 +13,7 @@ import {AriaLabelingProps, forwardRefType, Key, LinkDOMProps} from '@react-types/shared'; import {AriaTabListProps, AriaTabPanelProps, mergeProps, Orientation, useFocusRing, useHover, useTab, useTabList, useTabPanel} from 'react-aria'; import {Collection, CollectionBuilder, createHideableComponent, createLeafComponent} from '@react-aria/collections'; -import {CollectionProps, CollectionRendererContext} from './Collection'; +import {CollectionProps, CollectionRendererContext, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; import {Collection as ICollection, Node, TabListState, useTabListState} from 'react-stately'; @@ -231,7 +231,7 @@ function TabListInner({props, forwardedRef: ref}: TabListInner ref={objectRef} {...renderProps} data-orientation={orientation || undefined}> - +
); } diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 9cad5d01edc..6cfaf56fe5e 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -13,7 +13,7 @@ import {AriaTagGroupProps, useFocusRing, useHover, useTag, useTagGroup} from 'react-aria'; import {ButtonContext} from './Button'; import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; -import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; +import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, usePersistedKeys} from './Collection'; import {ContextValue, DOMProps, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {filterDOMProps, mergeProps, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; @@ -157,6 +157,8 @@ function TagListInner({props, forwardedRef}: TagListInnerProps values: renderValues }); + let persistedKeys = usePersistedKeys(state.selectionManager.focusedKey); + return (
({props, forwardedRef}: TagListInnerProps data-focus-visible={isFocusVisible || undefined}> {state.collection.size === 0 && props.renderEmptyState ? props.renderEmptyState(renderValues) - : } + : }
); } diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index c8ee3a1b0b0..4eccc34c930 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -14,7 +14,7 @@ import {AriaTreeGridListProps, useTreeGridList, useTreeGridListItem} from '@reac import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent, NodeValue, useCachedChildren} from '@react-aria/collections'; -import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; +import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, usePersistedKeys} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DisabledBehavior, Expandable, forwardRefType, HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; @@ -241,7 +241,10 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne values={[ [UNSTABLE_TreeStateContext, state] ]}> - + {emptyState} diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 536b7eb6d3d..37f8a281752 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -25,7 +25,9 @@ export interface LayoutOptionsDelegate { interface ILayout extends Layout, O>, Partial, LayoutOptionsDelegate {} export interface VirtualizerProps { + /** The child collection to virtualize (e.g. ListBox, GridList, or Table). */ children: ReactNode, + /** The layout object that determines the position and size of the visible elements. */ layout: ILayout } @@ -37,7 +39,7 @@ export function Virtualizer(props: VirtualizerProps) { isVirtualized: true, layoutDelegate: layout, dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined, - CollectionRoot({collection, focusedKey, scrollRef, renderDropIndicator}) { + CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator}) { let layoutOptions = layout.useLayoutOptions?.(); let state = useVirtualizerState({ layout, @@ -52,7 +54,7 @@ export function Virtualizer(props: VirtualizerProps) { element.scrollTop = rect.y; } }, - persistedKeys: useMemo(() => focusedKey != null ? new Set([focusedKey]) : new Set(), [focusedKey]), + persistedKeys, layoutOptions });