Skip to content

Commit e4ec6b4

Browse files
rob-clayburnsnowystingerreidbarber
authored
feat(RAC): Tree drag and drop (#7692)
* [5574] - add moveBefore and moveAfter to useTreeData * add docs * remove onlys * remove console logs * [wip] add intial tree drag and drop * useTreeData - add getDescendantKeys method which is used to determine if a parent node can be dropped into its children * revert packlog json change * feat: Add moveBefore and moveAfter to useTreeData * updates + story * remove RSP TreeView dnd for now * cleanup * fix story * lint * lint * lint * ts * fix listMapData destructure * allow expanding during dragging * lint * review comments * fixes * lint * update story to use useTreeData * handle dropPosition === 'on' case * set shouldSelectOnPressUp on item if dragging enabled * update useTreeData move/moveBefore/moveAfter implementations * fix stories * typescript * add drag button * fix nested item drop button in story * prevent dropping items onto themselves or their descendants * add tests and fix broken ones * remove dead code * fix lost items when moving to root * fix tests --------- Co-authored-by: GitHub <[email protected]> Co-authored-by: Reid Barber <[email protected]> Co-authored-by: Robert Snow <[email protected]>
1 parent 92b96b7 commit e4ec6b4

File tree

6 files changed

+902
-80
lines changed

6 files changed

+902
-80
lines changed

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

-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export interface DropOptions {
3636
/**
3737
* Handler that is called after a valid drag is held over the drop target for a period of time.
3838
* This typically opens the item so that the user can drop within it.
39-
* @private
4039
*/
4140
onDropActivate?: (e: DropActivateEvent) => void,
4241
/** Handler that is called when a valid drag exits the drop target. */

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

+5-2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
DropTargetDelegate,
3131
Key,
3232
KeyboardDelegate,
33+
KeyboardEvents,
3334
Node,
3435
RefObject
3536
} from '@react-types/shared';
@@ -46,7 +47,8 @@ export interface DroppableCollectionOptions extends DroppableCollectionProps {
4647
/** A delegate object that implements behavior for keyboard focus movement. */
4748
keyboardDelegate: KeyboardDelegate,
4849
/** A delegate object that provides drop targets for pointer coordinates within the collection. */
49-
dropTargetDelegate: DropTargetDelegate
50+
dropTargetDelegate: DropTargetDelegate,
51+
onKeyDown?: KeyboardEvents['onKeyDown']
5052
}
5153

5254
export interface DroppableCollectionResult {
@@ -201,7 +203,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
201203
autoScroll.stop();
202204
},
203205
onDropActivate(e) {
204-
if (state.target?.type === 'item' && state.target?.dropPosition === 'on' && typeof props.onDropActivate === 'function') {
206+
if (state.target?.type === 'item' && typeof props.onDropActivate === 'function') {
205207
props.onDropActivate({
206208
type: 'dropactivate',
207209
x: e.x, // todo
@@ -741,6 +743,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state:
741743
break;
742744
}
743745
}
746+
localState.props.onKeyDown?.(e as any);
744747
}
745748
});
746749
}, [localState, ref, onDrop, direction]);

packages/@react-stately/data/src/useTreeData.ts

+153-6
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ export interface TreeData<T extends object> {
107107
*/
108108
move(key: Key, toParentKey: Key | null, index: number): void,
109109

110+
/**
111+
* Moves one or more items before a given key.
112+
* @param key - The key of the item to move the items before.
113+
* @param keys - The keys of the items to move.
114+
*/
115+
moveBefore(key: Key, keys: Iterable<Key>): void,
116+
117+
/**
118+
* Moves one or more items after a given key.
119+
* @param key - The key of the item to move the items after.
120+
* @param keys - The keys of the items to move.
121+
*/
122+
moveAfter(key: Key, keys: Iterable<Key>): void,
123+
110124
/**
111125
* Updates an item in the tree.
112126
* @param key - The key of the item to update.
@@ -115,6 +129,11 @@ export interface TreeData<T extends object> {
115129
update(key: Key, newValue: T): void
116130
}
117131

132+
interface TreeDataState<T extends object> {
133+
items: TreeNode<T>[],
134+
nodeMap: Map<Key, TreeNode<T>>
135+
}
136+
118137
/**
119138
* Manages state for an immutable tree data structure, and provides convenience methods to
120139
* update the data over time.
@@ -128,7 +147,7 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
128147
} = options;
129148

130149
// We only want to compute this on initial render.
131-
let [tree, setItems] = useState<{items: TreeNode<T>[], nodeMap: Map<Key, TreeNode<T>>}>(() => buildTree(initialItems, new Map()));
150+
let [tree, setItems] = useState<TreeDataState<T>>(() => buildTree(initialItems, new Map()));
132151
let {items, nodeMap} = tree;
133152

134153
let [selectedKeys, setSelectedKeys] = useState(new Set<Key>(initialSelectedKeys || []));
@@ -141,7 +160,7 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
141160
items: initialItems.map(item => {
142161
let node: TreeNode<T> = {
143162
key: getKey(item),
144-
parentKey: parentKey,
163+
parentKey: parentKey ?? null,
145164
value: item,
146165
children: null
147166
};
@@ -154,9 +173,9 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
154173
};
155174
}
156175

157-
function updateTree(items: TreeNode<T>[], key: Key, update: (node: TreeNode<T>) => TreeNode<T> | null, originalMap: Map<Key, TreeNode<T>>) {
158-
let node = originalMap.get(key);
159-
if (!node) {
176+
function updateTree(items: TreeNode<T>[], key: Key | null, update: (node: TreeNode<T>) => TreeNode<T> | null, originalMap: Map<Key, TreeNode<T>>) {
177+
let node = key == null ? null : originalMap.get(key);
178+
if (node == null) {
160179
return {items, nodeMap: originalMap};
161180
}
162181
let map = new Map<Key, TreeNode<T>>(originalMap);
@@ -233,7 +252,6 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
233252
}
234253
}
235254
}
236-
237255
return {
238256
items,
239257
selectedKeys,
@@ -352,6 +370,8 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
352370

353371
// If parentKey is null, insert into the root.
354372
if (toParentKey == null) {
373+
// safe to reuse the original map since no node was actually removed, so we just need to update the one moved node
374+
newMap = new Map(originalMap);
355375
newMap.set(movedNode.key, movedNode);
356376
return {items: [
357377
...newItems.slice(0, index),
@@ -373,6 +393,39 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
373393
}), newMap);
374394
});
375395
},
396+
moveBefore(key: Key, keys: Iterable<Key>) {
397+
setItems((prevState) => {
398+
let {items, nodeMap} = prevState;
399+
let node = nodeMap.get(key);
400+
if (!node) {
401+
return prevState;
402+
}
403+
let toParentKey = node.parentKey ?? null;
404+
let parent: null | TreeNode<T> = null;
405+
if (toParentKey != null) {
406+
parent = nodeMap.get(toParentKey) ?? null;
407+
}
408+
let toIndex = parent?.children ? parent.children.indexOf(node) : items.indexOf(node);
409+
return moveItems(prevState, keys, parent, toIndex, updateTree);
410+
});
411+
},
412+
moveAfter(key: Key, keys: Iterable<Key>) {
413+
setItems((prevState) => {
414+
let {items, nodeMap} = prevState;
415+
let node = nodeMap.get(key);
416+
if (!node) {
417+
return prevState;
418+
}
419+
let toParentKey = node.parentKey ?? null;
420+
let parent: null | TreeNode<T> = null;
421+
if (toParentKey != null) {
422+
parent = nodeMap.get(toParentKey) ?? null;
423+
}
424+
let toIndex = parent?.children ? parent.children.indexOf(node) : items.indexOf(node);
425+
toIndex++;
426+
return moveItems(prevState, keys, parent, toIndex, updateTree);
427+
});
428+
},
376429
update(oldKey: Key, newValue: T) {
377430
setItems(({items, nodeMap: originalMap}) => updateTree(items, oldKey, oldNode => {
378431
let node: TreeNode<T> = {
@@ -389,3 +442,97 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
389442
}
390443
};
391444
}
445+
446+
function moveItems<T extends object>(
447+
state: TreeDataState<T>,
448+
keys: Iterable<Key>,
449+
toParent: TreeNode<T> | null,
450+
toIndex: number,
451+
updateTree: (
452+
items: TreeNode<T>[],
453+
key: Key,
454+
update: (node: TreeNode<T>) => TreeNode<T> | null,
455+
originalMap: Map<Key, TreeNode<T>>
456+
) => TreeDataState<T>
457+
): TreeDataState<T> {
458+
let {items, nodeMap} = state;
459+
460+
let parent = toParent;
461+
let removeKeys = new Set(keys);
462+
while (parent?.parentKey != null) {
463+
if (removeKeys.has(parent.key)) {
464+
throw new Error('Cannot move an item to be a child of itself.');
465+
}
466+
parent = nodeMap.get(parent.parentKey!) ?? null;
467+
}
468+
469+
let originalToIndex = toIndex;
470+
471+
let keyArray = Array.isArray(keys) ? keys : [...keys];
472+
// depth first search to put keys in order
473+
let inOrderKeys: Map<Key, number> = new Map();
474+
let removedItems: Array<TreeNode<T>> = [];
475+
let newItems = items;
476+
let newMap = nodeMap;
477+
let i = 0;
478+
479+
function traversal(node, {inorder, postorder}) {
480+
inorder?.(node);
481+
if (node != null) {
482+
for (let child of node.children ?? []) {
483+
traversal(child, {inorder, postorder});
484+
postorder?.(child);
485+
}
486+
}
487+
}
488+
489+
function inorder(child) {
490+
// in-order so we add items as we encounter them in the tree, then we can insert them in expected order later
491+
if (keyArray.includes(child.key)) {
492+
inOrderKeys.set(child.key, i++);
493+
}
494+
}
495+
496+
function postorder(child) {
497+
// remove items and update the tree from the leaves and work upwards toward the root, this way
498+
// we don't copy child node references from parents inadvertently
499+
if (keyArray.includes(child.key)) {
500+
removedItems.push({...newMap.get(child.key)!, parentKey: toParent?.key ?? null});
501+
let {items: nextItems, nodeMap: nextMap} = updateTree(newItems, child.key, () => null, newMap);
502+
newItems = nextItems;
503+
newMap = nextMap;
504+
}
505+
// decrement the index if the child being removed is in the target parent and before the target index
506+
if (child.parentKey === toParent?.key
507+
&& keyArray.includes(child.key)
508+
&& (toParent?.children ? toParent.children.indexOf(child) : items.indexOf(child)) < originalToIndex) {
509+
toIndex--;
510+
}
511+
}
512+
513+
traversal({children: items}, {inorder, postorder});
514+
515+
let inOrderItems = removedItems.sort((a, b) => inOrderKeys.get(a.key)! > inOrderKeys.get(b.key)! ? 1 : -1);
516+
// If parentKey is null, insert into the root.
517+
if (!toParent || toParent.key == null) {
518+
newMap = new Map(nodeMap);
519+
inOrderItems.forEach(movedNode => newMap.set(movedNode.key, movedNode));
520+
return {items: [
521+
...newItems.slice(0, toIndex),
522+
...inOrderItems,
523+
...newItems.slice(toIndex)
524+
], nodeMap: newMap};
525+
}
526+
527+
// Otherwise, update the parent node and its ancestors.
528+
return updateTree(newItems, toParent.key, parentNode => ({
529+
key: parentNode.key,
530+
parentKey: parentNode.parentKey,
531+
value: parentNode.value,
532+
children: [
533+
...parentNode.children!.slice(0, toIndex),
534+
...inOrderItems,
535+
...parentNode.children!.slice(toIndex)
536+
]
537+
}), newMap);
538+
}

0 commit comments

Comments
 (0)