Skip to content

Commit b1c3b3b

Browse files
authored
Update dnd examples to focus inserted items on drop (#1721)
* Update dnd examples to focus inserted items on drop * Automatically focus and select items on drop * Review changes
1 parent faa9348 commit b1c3b3b

File tree

11 files changed

+350
-104
lines changed

11 files changed

+350
-104
lines changed

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

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {Collection, DropEvent, DropOperation, DroppableCollectionProps, DropPosition, DropTarget, KeyboardDelegate, Node} from '@react-types/shared';
1314
import * as DragManager from './DragManager';
14-
import {DropOperation, DroppableCollectionProps, DropPosition, DropTarget, KeyboardDelegate} from '@react-types/shared';
1515
import {DroppableCollectionState} from '@react-stately/dnd';
1616
import {getTypes} from './utils';
17-
import {HTMLAttributes, RefObject, useEffect, useRef} from 'react';
17+
import {HTMLAttributes, Key, RefObject, useCallback, useEffect, useLayoutEffect, useRef} from 'react';
1818
import {mergeProps} from '@react-aria/utils';
19+
import {setInteractionModality} from '@react-aria/interactions';
1920
import {useAutoScroll} from './useAutoScroll';
2021
import {useDrop} from './useDrop';
2122
import {useDroppableCollectionId} from './utils';
@@ -29,6 +30,13 @@ interface DroppableCollectionResult {
2930
collectionProps: HTMLAttributes<HTMLElement>
3031
}
3132

33+
interface DroppingState {
34+
collection: Collection<Node<unknown>>,
35+
focusedKey: Key,
36+
selectedKeys: Set<Key>,
37+
timeout: NodeJS.Timeout
38+
}
39+
3240
const DROP_POSITIONS: DropPosition[] = ['before', 'on', 'after'];
3341

3442
export function useDroppableCollection(props: DroppableCollectionOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement>): DroppableCollectionResult {
@@ -96,15 +104,100 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
96104
},
97105
onDrop(e) {
98106
if (state.target && typeof props.onDrop === 'function') {
99-
props.onDrop({
100-
type: 'drop',
101-
x: e.x, // todo
102-
y: e.y,
103-
target: state.target,
104-
items: e.items,
105-
dropOperation: e.dropOperation
106-
});
107+
onDrop(e, state.target);
108+
}
109+
}
110+
});
111+
112+
let droppingState = useRef<DroppingState>(null);
113+
let onDrop = useCallback((e: DropEvent, target: DropTarget) => {
114+
let {state} = localState;
115+
116+
// Focus the collection.
117+
state.selectionManager.setFocused(true);
118+
119+
// Save some state of the collection/selection before the drop occurs so we can compare later.
120+
let focusedKey = state.selectionManager.focusedKey;
121+
droppingState.current = {
122+
timeout: null,
123+
focusedKey,
124+
collection: state.collection,
125+
selectedKeys: state.selectionManager.selectedKeys
126+
};
127+
128+
localState.props.onDrop({
129+
type: 'drop',
130+
x: e.x, // todo
131+
y: e.y,
132+
target,
133+
items: e.items,
134+
dropOperation: e.dropOperation
135+
});
136+
137+
// Wait for a short time period after the onDrop is called to allow the data to be read asynchronously
138+
// and for React to re-render. If an insert occurs during this time, it will be selected/focused below.
139+
// If items are not "immediately" inserted by the onDrop handler, the application will need to handle
140+
// selecting and focusing those items themselves.
141+
droppingState.current.timeout = setTimeout(() => {
142+
// If focus didn't move already (e.g. due to an insert), and the user dropped on an item,
143+
// focus that item and show the focus ring to give the user feedback that the drop occurred.
144+
// Also show the focus ring if the focused key is not selected, e.g. in case of a reorder.
145+
let {state} = localState;
146+
if (state.selectionManager.focusedKey === focusedKey) {
147+
if (target.type === 'item' && target.dropPosition === 'on' && state.collection.getItem(target.key) != null) {
148+
state.selectionManager.setFocusedKey(target.key);
149+
state.selectionManager.setFocused(true);
150+
setInteractionModality('keyboard');
151+
} else if (!state.selectionManager.isSelected(focusedKey)) {
152+
setInteractionModality('keyboard');
153+
}
107154
}
155+
156+
droppingState.current = null;
157+
}, 50);
158+
}, [localState]);
159+
160+
// eslint-disable-next-line arrow-body-style
161+
useEffect(() => {
162+
return () => {
163+
if (droppingState.current) {
164+
clearTimeout(droppingState.current.timeout);
165+
}
166+
};
167+
}, []);
168+
169+
useLayoutEffect(() => {
170+
// If an insert occurs during a drop, we want to immediately select these items to give
171+
// feedback to the user that a drop occurred. Only do this if the selection didn't change
172+
// since the drop started so we don't override if the user or application did something.
173+
if (
174+
droppingState.current &&
175+
state.selectionManager.isFocused &&
176+
state.collection.size > droppingState.current.collection.size &&
177+
state.selectionManager.isSelectionEqual(droppingState.current.selectedKeys)
178+
) {
179+
let newKeys = new Set<Key>();
180+
for (let key of state.collection.getKeys()) {
181+
if (!droppingState.current.collection.getItem(key)) {
182+
newKeys.add(key);
183+
}
184+
}
185+
186+
state.selectionManager.setSelectedKeys(newKeys);
187+
188+
// If the focused item didn't change since the drop occurred, also focus the first
189+
// inserted item. If selection is disabled, then also show the focus ring so there
190+
// is some indication that items were added.
191+
if (state.selectionManager.focusedKey === droppingState.current.focusedKey) {
192+
let first = newKeys.keys().next().value;
193+
state.selectionManager.setFocusedKey(first);
194+
195+
if (state.selectionManager.selectionMode === 'none') {
196+
setInteractionModality('keyboard');
197+
}
198+
}
199+
200+
droppingState.current = null;
108201
}
109202
});
110203

@@ -315,14 +408,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
315408
},
316409
onDrop(e, target) {
317410
if (localState.state.target && typeof localState.props.onDrop === 'function') {
318-
localState.props.onDrop({
319-
type: 'drop',
320-
x: e.x, // todo
321-
y: e.y,
322-
target: target || localState.state.target,
323-
items: e.items,
324-
dropOperation: e.dropOperation
325-
});
411+
onDrop(e, target || localState.state.target);
326412
}
327413
},
328414
onKeyDown(e, drag) {
@@ -440,7 +526,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
440526
}
441527
}
442528
});
443-
}, [localState, ref]);
529+
}, [localState, ref, onDrop]);
444530

445531
let id = useDroppableCollectionId(state);
446532
return {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export class DragTypes implements IDragTypes {
154154

155155
// In Safari, when dragging files, the dataTransfer.items list is empty, but dataTransfer.types contains "Files".
156156
// Unfortunately, this doesn't tell us what types of files the user is dragging, so we need to assume that any
157-
// type the user checks for is included.
157+
// type the user checks for is included. See https://bugs.webkit.org/show_bug.cgi?id=223517.
158158
this.includesUnknownTypes = !hasFiles && dataTransfer.types.includes('Files');
159159
}
160160

@@ -208,10 +208,11 @@ export function readFromDataTransfer(dataTransfer: DataTransfer) {
208208
if (typeof item.webkitGetAsEntry === 'function') {
209209
let entry: FileSystemEntry = item.webkitGetAsEntry();
210210
if (!entry) {
211-
// For some reason, Chrome and Firefox include an item with type image/png when copy
212-
// and pasting any file or directory (no matter the type), but return `null` for both
211+
// For some reason, Firefox includes an item with type image/png when copy
212+
// and pasting any file or directory (no matter the type), but returns `null` for both
213213
// item.getAsFile() and item.webkitGetAsEntry(). Safari works as expected. Ignore this
214-
// item if this happens.
214+
// item if this happens. See https://bugzilla.mozilla.org/show_bug.cgi?id=1699743.
215+
// This was recently fixed in Chrome Canary: https://bugs.chromium.org/p/chromium/issues/detail?id=1175483.
215216
continue;
216217
}
217218

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

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export function DroppableGridExample(props) {
4444
]
4545
});
4646

47+
let ref = React.useRef(null);
48+
4749
let onDrop = async (e: DroppableCollectionDropEvent) => {
4850
if (props.onDrop) {
4951
props.onDrop(e);
@@ -99,7 +101,7 @@ export function DroppableGridExample(props) {
99101
};
100102

101103
return (
102-
<DroppableGrid {...props} items={list.items} onDrop={onDrop}>
104+
<DroppableGrid {...props} items={list.items} onDrop={onDrop} ref={ref}>
103105
{item => (
104106
<Item textValue={item.text}>
105107
{item.type === 'folder' && <Folder size="S" />}
@@ -110,12 +112,15 @@ export function DroppableGridExample(props) {
110112
);
111113
}
112114

113-
function DroppableGrid(props) {
114-
let ref = React.useRef<HTMLDivElement>(null);
115+
const DroppableGrid = React.forwardRef(function (props: any, ref) {
116+
let domRef = React.useRef<HTMLDivElement>(null);
115117
let state = useListState(props);
116-
let keyboardDelegate = new ListKeyboardDelegate(state.collection, new Set(), ref);
118+
let keyboardDelegate = new ListKeyboardDelegate(state.collection, new Set(), domRef);
117119
let gridState = useGridState({
118120
selectionMode: 'multiple',
121+
focusMode: 'cell',
122+
selectedKeys: props.selectedKeys,
123+
onSelectionChange: props.onSelectionChange,
119124
collection: new GridCollection({
120125
columnCount: 1,
121126
items: [...state.collection].map(item => ({
@@ -135,6 +140,13 @@ function DroppableGrid(props) {
135140
})
136141
});
137142

143+
React.useImperativeHandle(ref, () => ({
144+
focusItem(key) {
145+
gridState.selectionManager.setFocusedKey(`cell-${key}`);
146+
gridState.selectionManager.setFocused(true);
147+
}
148+
}));
149+
138150
let defaultGetDropOperation = (target, items, allowedOperations) => {
139151
if (target.type === 'root') {
140152
return 'move';
@@ -165,14 +177,14 @@ function DroppableGrid(props) {
165177
onDropActivate: props.onDropActivate,
166178
onDrop: props.onDrop,
167179
getDropTargetFromPoint(x, y) {
168-
let rect = ref.current.getBoundingClientRect();
180+
let rect = domRef.current.getBoundingClientRect();
169181
x += rect.x;
170182
y += rect.y;
171183
let closest = null;
172184
let closestDistance = Infinity;
173185
let closestDir = null;
174186

175-
for (let child of ref.current.children) {
187+
for (let child of domRef.current.children) {
176188
if (!(child as HTMLElement).dataset.key) {
177189
continue;
178190
}
@@ -210,11 +222,11 @@ function DroppableGrid(props) {
210222
};
211223
}
212224
}
213-
}, dropState, ref);
225+
}, dropState, domRef);
214226

215227
let {gridProps} = useGrid({
216228
...props,
217-
ref,
229+
ref: domRef,
218230
'aria-label': 'List',
219231
focusMode: 'cell'
220232
}, gridState);
@@ -229,7 +241,7 @@ function DroppableGrid(props) {
229241
return (
230242
<div
231243
{...mergeProps(collectionProps, gridProps)}
232-
ref={ref}
244+
ref={domRef}
233245
className={classNames(dndStyles, 'droppable-collection', {'is-drop-target': isDropTarget})}
234246
style={props.style}
235247
data-droptarget={isDropTarget}
@@ -247,7 +259,7 @@ function DroppableGrid(props) {
247259
<React.Fragment key={item.key}>
248260
<InsertionIndicator
249261
key={item.key + '-before'}
250-
collectionRef={ref}
262+
collectionRef={domRef}
251263
target={{type: 'item', key: item.key, dropPosition: 'before'}}
252264
dropState={dropState} />
253265
<CollectionItem
@@ -260,14 +272,14 @@ function DroppableGrid(props) {
260272
<InsertionIndicator
261273
key={item.key + '-after'}
262274
target={{type: 'item', key: item.key, dropPosition: 'after'}}
263-
collectionRef={ref}
275+
collectionRef={domRef}
264276
dropState={dropState} />
265277
}
266278
</React.Fragment>
267279
))}
268280
</div>
269281
);
270-
}
282+
});
271283

272284
function CollectionItem({item, state, dropState, onPaste}) {
273285
let rowRef = React.useRef();

0 commit comments

Comments
 (0)