Skip to content

Commit 99c3106

Browse files
authored
Fix virtualizer persisted keys with drag and drop (#6644)
1 parent b940126 commit 99c3106

39 files changed

+277
-157
lines changed

packages/@react-aria/dnd/src/DragManager.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import {announce} from '@react-aria/live-announcer';
1414
import {ariaHideOutside} from '@react-aria/overlays';
1515
import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared';
16-
import {flushSync} from 'react-dom';
1716
import {getDragModality, getTypes} from './utils';
1817
import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils';
1918
import type {LocalizedStringFormatter} from '@internationalized/string';
@@ -26,6 +25,7 @@ let subscriptions = new Set<() => void>();
2625

2726
interface DropTarget {
2827
element: FocusableElement,
28+
preventFocusOnDrop?: boolean,
2929
getDropOperation?: (types: Set<string>, allowedOperations: DropOperation[]) => DropOperation,
3030
onDropEnter?: (e: DropEnterEvent, dragTarget: DragTarget) => void,
3131
onDropExit?: (e: DropExitEvent) => void,
@@ -513,19 +513,11 @@ class DragSession {
513513
});
514514
}
515515

516-
// Blur and re-focus the drop target so that the focus ring appears.
517-
if (this.currentDropTarget) {
518-
// 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
519-
// 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.
520-
if (!this.dragTarget.element.contains(this.currentDropTarget.element)) {
521-
this.dragTarget.element.dispatchEvent(new FocusEvent('blur'));
522-
this.dragTarget.element.dispatchEvent(new FocusEvent('focusout', {bubbles: true}));
523-
}
524-
// Re-focus the focusedKey upon reorder. This requires a React rerender between blurring and focusing.
525-
flushSync(() => {
526-
this.currentDropTarget.element.blur();
527-
});
528-
this.currentDropTarget.element.focus();
516+
if (this.currentDropTarget && !this.currentDropTarget.preventFocusOnDrop) {
517+
// Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent).
518+
// This corrects state such as whether focus ring should appear.
519+
// useDroppableCollection handles this itself, so this is only for standalone drop zones.
520+
document.activeElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
529521
}
530522

531523
this.setCurrentDropTarget(null);

packages/@react-aria/dnd/src/useDroppableCollection.ts

Lines changed: 89 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ interface DroppingState {
5757
collection: Collection<Node<unknown>>,
5858
focusedKey: Key,
5959
selectedKeys: Set<Key>,
60+
target: DropTarget,
61+
draggingKeys: Set<Key>,
62+
isInternal: boolean,
6063
timeout: ReturnType<typeof setTimeout>
6164
}
6265

@@ -213,26 +216,93 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
213216
});
214217

215218
let droppingState = useRef<DroppingState>(null);
216-
let onDrop = useCallback((e: DropEvent, target: DropTarget) => {
219+
let updateFocusAfterDrop = useCallback(() => {
217220
let {state} = localState;
221+
if (droppingState.current) {
222+
let {
223+
target,
224+
collection: prevCollection,
225+
selectedKeys: prevSelectedKeys,
226+
focusedKey: prevFocusedKey,
227+
isInternal,
228+
draggingKeys
229+
} = droppingState.current;
230+
231+
// If an insert occurs during a drop, we want to immediately select these items to give
232+
// feedback to the user that a drop occurred. Only do this if the selection didn't change
233+
// since the drop started so we don't override if the user or application did something.
234+
if (
235+
state.collection.size > prevCollection.size &&
236+
state.selectionManager.isSelectionEqual(prevSelectedKeys)
237+
) {
238+
let newKeys = new Set<Key>();
239+
for (let key of state.collection.getKeys()) {
240+
if (!prevCollection.getItem(key)) {
241+
newKeys.add(key);
242+
}
243+
}
218244

219-
// Focus the collection.
220-
state.selectionManager.setFocused(true);
245+
state.selectionManager.setSelectedKeys(newKeys);
221246

222-
// Save some state of the collection/selection before the drop occurs so we can compare later.
223-
let focusedKey = state.selectionManager.focusedKey;
247+
// If the focused item didn't change since the drop occurred, also focus the first
248+
// inserted item. If selection is disabled, then also show the focus ring so there
249+
// is some indication that items were added.
250+
if (state.selectionManager.focusedKey === prevFocusedKey) {
251+
let first = newKeys.keys().next().value;
252+
let item = state.collection.getItem(first);
253+
254+
// If this is a cell, focus the parent row.
255+
if (item?.type === 'cell') {
256+
first = item.parentKey;
257+
}
258+
259+
state.selectionManager.setFocusedKey(first);
224260

225-
// If parent key was dragged, we want to use it instead (i.e. focus row instead of cell after dropping)
226-
if (globalDndState.draggingKeys.has(state.collection.getItem(focusedKey)?.parentKey)) {
227-
focusedKey = state.collection.getItem(focusedKey).parentKey;
228-
state.selectionManager.setFocusedKey(focusedKey);
261+
if (state.selectionManager.selectionMode === 'none') {
262+
setInteractionModality('keyboard');
263+
}
264+
}
265+
} else if (
266+
state.selectionManager.focusedKey === prevFocusedKey &&
267+
isInternal &&
268+
target.type === 'item' &&
269+
target.dropPosition !== 'on' &&
270+
draggingKeys.has(state.collection.getItem(prevFocusedKey)?.parentKey)
271+
) {
272+
// Focus row instead of cell when reordering.
273+
state.selectionManager.setFocusedKey(state.collection.getItem(prevFocusedKey).parentKey);
274+
setInteractionModality('keyboard');
275+
} else if (
276+
state.selectionManager.focusedKey === prevFocusedKey &&
277+
target.type === 'item' &&
278+
target.dropPosition === 'on' &&
279+
state.collection.getItem(target.key) != null
280+
) {
281+
// If focus didn't move already (e.g. due to an insert), and the user dropped on an item,
282+
// focus that item and show the focus ring to give the user feedback that the drop occurred.
283+
// Also show the focus ring if the focused key is not selected, e.g. in case of a reorder.
284+
state.selectionManager.setFocusedKey(target.key);
285+
setInteractionModality('keyboard');
286+
} else if (!state.selectionManager.isSelected(state.selectionManager.focusedKey)) {
287+
setInteractionModality('keyboard');
288+
}
289+
290+
state.selectionManager.setFocused(true);
229291
}
292+
}, [localState]);
230293

294+
let onDrop = useCallback((e: DropEvent, target: DropTarget) => {
295+
let {state} = localState;
296+
297+
// Save some state of the collection/selection before the drop occurs so we can compare later.
231298
droppingState.current = {
232299
timeout: null,
233-
focusedKey,
300+
focusedKey: state.selectionManager.focusedKey,
234301
collection: state.collection,
235-
selectedKeys: state.selectionManager.selectedKeys
302+
selectedKeys: state.selectionManager.selectedKeys,
303+
draggingKeys: globalDndState.draggingKeys,
304+
isInternal: isInternalDropOperation(ref),
305+
target
236306
};
237307

238308
let onDropFn = localState.props.onDrop || defaultOnDrop;
@@ -246,26 +316,13 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
246316
});
247317

248318
// Wait for a short time period after the onDrop is called to allow the data to be read asynchronously
249-
// and for React to re-render. If an insert occurs during this time, it will be selected/focused below.
250-
// If items are not "immediately" inserted by the onDrop handler, the application will need to handle
251-
// selecting and focusing those items themselves.
319+
// and for React to re-render. If the collection didn't already change during this time (handled below),
320+
// update the focused key here.
252321
droppingState.current.timeout = setTimeout(() => {
253-
// If focus didn't move already (e.g. due to an insert), and the user dropped on an item,
254-
// focus that item and show the focus ring to give the user feedback that the drop occurred.
255-
// Also show the focus ring if the focused key is not selected, e.g. in case of a reorder.
256-
let {state} = localState;
257-
258-
if (target.type === 'item' && target.dropPosition === 'on' && state.collection.getItem(target.key) != null) {
259-
state.selectionManager.setFocusedKey(target.key);
260-
state.selectionManager.setFocused(true);
261-
setInteractionModality('keyboard');
262-
} else if (!state.selectionManager.isSelected(focusedKey)) {
263-
setInteractionModality('keyboard');
264-
}
265-
322+
updateFocusAfterDrop();
266323
droppingState.current = null;
267324
}, 50);
268-
}, [localState, defaultOnDrop]);
325+
}, [localState, defaultOnDrop, ref, updateFocusAfterDrop]);
269326

270327
// eslint-disable-next-line arrow-body-style
271328
useEffect(() => {
@@ -277,44 +334,9 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
277334
}, []);
278335

279336
useLayoutEffect(() => {
280-
// If an insert occurs during a drop, we want to immediately select these items to give
281-
// feedback to the user that a drop occurred. Only do this if the selection didn't change
282-
// since the drop started so we don't override if the user or application did something.
283-
if (
284-
droppingState.current &&
285-
state.selectionManager.isFocused &&
286-
state.collection.size > droppingState.current.collection.size &&
287-
state.selectionManager.isSelectionEqual(droppingState.current.selectedKeys)
288-
) {
289-
let newKeys = new Set<Key>();
290-
for (let key of state.collection.getKeys()) {
291-
if (!droppingState.current.collection.getItem(key)) {
292-
newKeys.add(key);
293-
}
294-
}
295-
296-
state.selectionManager.setSelectedKeys(newKeys);
297-
298-
// If the focused item didn't change since the drop occurred, also focus the first
299-
// inserted item. If selection is disabled, then also show the focus ring so there
300-
// is some indication that items were added.
301-
if (state.selectionManager.focusedKey === droppingState.current.focusedKey) {
302-
let first = newKeys.keys().next().value;
303-
let item = state.collection.getItem(first);
304-
305-
// If this is a cell, focus the parent row.
306-
if (item?.type === 'cell') {
307-
first = item.parentKey;
308-
}
309-
310-
state.selectionManager.setFocusedKey(first);
311-
312-
if (state.selectionManager.selectionMode === 'none') {
313-
setInteractionModality('keyboard');
314-
}
315-
}
316-
317-
droppingState.current = null;
337+
// If the collection changed after a drop, update the focused key.
338+
if (droppingState.current && state.collection !== droppingState.current.collection) {
339+
updateFocusAfterDrop();
318340
}
319341
});
320342

@@ -470,6 +492,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
470492

471493
return DragManager.registerDropTarget({
472494
element: ref.current,
495+
preventFocusOnDrop: true,
473496
getDropOperation(types, allowedOperations) {
474497
if (localState.state.target) {
475498
let {draggingKeys} = globalDndState;

packages/@react-aria/dnd/stories/DraggableCollection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function DraggableCollection(props) {
5858
let state = useListState(props);
5959
let gridState = useGridState({
6060
selectionMode: 'multiple',
61-
collection: new GridCollection({
61+
collection: React.useMemo(() => new GridCollection({
6262
columnCount: 1,
6363
items: [...state.collection].map(item => ({
6464
...item,
@@ -74,7 +74,7 @@ function DraggableCollection(props) {
7474
childNodes: []
7575
}]
7676
}))
77-
})
77+
}), [state.collection])
7878
});
7979

8080
let preview = useRef(null);

packages/@react-aria/dnd/stories/DroppableGrid.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ const DroppableGrid = React.forwardRef(function (props: any, ref) {
122122
focusMode: 'cell',
123123
selectedKeys: props.selectedKeys,
124124
onSelectionChange: props.onSelectionChange,
125-
collection: new GridCollection({
125+
collection: React.useMemo(() => new GridCollection({
126126
columnCount: 1,
127127
items: [...state.collection].map(item => ({
128128
...item,
@@ -138,7 +138,7 @@ const DroppableGrid = React.forwardRef(function (props: any, ref) {
138138
childNodes: []
139139
}]
140140
}))
141-
})
141+
}), [state.collection])
142142
});
143143

144144
React.useImperativeHandle(ref, () => ({

packages/@react-aria/dnd/stories/Reorderable.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function ReorderableGrid(props) {
7171
let keyboardDelegate = new ListKeyboardDelegate(state.collection, new Set(), ref);
7272
let gridState = useGridState({
7373
selectionMode: 'multiple',
74-
collection: new GridCollection({
74+
collection: React.useMemo(() => new GridCollection({
7575
columnCount: 1,
7676
items: [...state.collection].map(item => ({
7777
...item,
@@ -87,7 +87,7 @@ function ReorderableGrid(props) {
8787
childNodes: []
8888
}]
8989
}))
90-
})
90+
}), [state.collection])
9191
});
9292

9393
// Use a random drag type so the items can only be reordered within this list and not dragged elsewhere.

packages/@react-aria/dnd/stories/VirtualizedListBox.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import Folder from '@spectrum-icons/workflow/Folder';
2121
import {Item} from '@react-stately/collections';
2222
import {ListKeyboardDelegate} from '@react-aria/selection';
2323
import {ListLayout} from '@react-stately/layout';
24-
import React from 'react';
24+
import React, {useMemo} from 'react';
2525
import {useDropIndicator, useDroppableCollection, useDroppableItem} from '..';
2626
import {useDroppableCollectionState} from '@react-stately/dnd';
2727
import {useListBox, useOption} from '@react-aria/listbox';
@@ -159,6 +159,8 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) {
159159
isVirtualized: true
160160
}, state, domRef);
161161
let isDropTarget = dropState.isDropTarget({type: 'root'});
162+
let focusedKey = dropState.target?.type === 'item' ? dropState.target.key : state.selectionManager.focusedKey;
163+
let persistedKeys = useMemo(() => focusedKey != null ? new Set([focusedKey]) : null, [focusedKey]);
162164

163165
return (
164166
<Context.Provider value={{state, dropState}}>
@@ -170,7 +172,7 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) {
170172
scrollDirection="vertical"
171173
layout={layout}
172174
collection={state.collection}
173-
focusedKey={dropState.target?.type === 'item' ? dropState.target.key : state.selectionManager.focusedKey}>
175+
persistedKeys={persistedKeys}>
174176
{(type, item) => (
175177
<>
176178
{state.collection.getKeyBefore(item.key) == null &&

packages/@react-aria/dnd/stories/dnd.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ function DraggableCollection(props) {
386386
let gridState = useGridState({
387387
...props,
388388
selectionMode: 'multiple',
389-
collection: new GridCollection({
389+
collection: React.useMemo(() => new GridCollection({
390390
columnCount: 1,
391391
items: [...state.collection].map(item => ({
392392
...item,
@@ -402,7 +402,7 @@ function DraggableCollection(props) {
402402
childNodes: []
403403
}]
404404
}))
405-
})
405+
}), [state.collection])
406406
});
407407

408408
let preview = useRef(null);

packages/@react-aria/grid/stories/example.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function Grid(props) {
1111
let gridState = useGridState({
1212
...props,
1313
selectionMode: 'multiple',
14-
collection: new GridCollection({
14+
collection: React.useMemo(() => new GridCollection({
1515
columnCount: 1,
1616
items: [...state.collection].map(item => ({
1717
type: 'item',
@@ -21,7 +21,7 @@ export function Grid(props) {
2121
type: 'cell'
2222
}]
2323
}))
24-
})
24+
}), [state.collection])
2525
});
2626

2727
let ref = React.useRef(undefined);

packages/@react-aria/virtualizer/src/Virtualizer.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {Collection, Key} from '@react-types/shared';
1414
import {Layout, Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer';
1515
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
16-
import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useMemo, useRef} from 'react';
16+
import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useRef} from 'react';
1717
import {ScrollView} from './ScrollView';
1818
import {VirtualizerItem} from './VirtualizerItem';
1919

@@ -29,7 +29,7 @@ interface VirtualizerProps<T extends object, V, O> extends Omit<HTMLAttributes<H
2929
renderWrapper?: RenderWrapper<T, V>,
3030
layout: Layout<T, O>,
3131
collection: Collection<T>,
32-
focusedKey?: Key,
32+
persistedKeys?: Set<Key> | null,
3333
sizeToFit?: 'width' | 'height',
3434
scrollDirection?: 'horizontal' | 'vertical' | 'both',
3535
isLoading?: boolean,
@@ -49,7 +49,7 @@ function Virtualizer<T extends object, V extends ReactNode, O>(props: Virtualize
4949
isLoading,
5050
// eslint-disable-next-line @typescript-eslint/no-unused-vars
5151
onLoadMore,
52-
focusedKey,
52+
persistedKeys,
5353
layoutOptions,
5454
...otherProps
5555
} = props;
@@ -65,7 +65,7 @@ function Virtualizer<T extends object, V extends ReactNode, O>(props: Virtualize
6565
ref.current.scrollLeft = rect.x;
6666
ref.current.scrollTop = rect.y;
6767
},
68-
persistedKeys: useMemo(() => focusedKey != null ? new Set([focusedKey]) : new Set(), [focusedKey]),
68+
persistedKeys,
6969
layoutOptions
7070
});
7171

0 commit comments

Comments
 (0)