Skip to content

fix(RAC): Tree DnD followup #8302

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/react-aria-components/example/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ html {
margin-left: calc(var(--tree-item-level) * 15px);
}

:global(.react-aria-DropIndicator[data-drop-target]) {
outline: 1px solid slateblue;
}

&[data-drop-target] {
outline: 2px solid purple;
outline-offset: -2px;
Expand Down
118 changes: 113 additions & 5 deletions packages/react-aria-components/src/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,24 @@ function TreeInner<T extends object>({props, collection, treeRef: ref}: TreeInne
let hasDropHooks = !!dragAndDropHooks?.useDroppableCollectionState;
let dragHooksProvided = useRef(hasDragHooks);
let dropHooksProvided = useRef(hasDropHooks);

// Track pointer movement for handling ambiguous drop positions
let pointerTrackingRef = useRef<{
lastY: number,
movementDirection: 'up' | 'down' | null,
boundaryContext: {
parentKey: Key,
lastChildKey: Key,
preferredPosition: 'inside' | 'after',
lastSwitchY: number,
entryDirection: 'up' | 'down' | null
} | null
}>({
lastY: 0,
movementDirection: null,
boundaryContext: null
});

useEffect(() => {
if (dragHooksProvided.current !== hasDragHooks) {
console.warn('Drag hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.');
Expand Down Expand Up @@ -273,7 +291,87 @@ function TreeInner<T extends object>({props, collection, treeRef: ref}: TreeInne
dropTargetDelegate: {
getDropTargetFromPoint(x, y, isValidDropTarget) {
let target = dropTargetDelegate.getDropTargetFromPoint(x, y, isValidDropTarget);
let tracking = pointerTrackingRef.current;

// Calculate movement direction
let deltaY = y - tracking.lastY;
let currentMovement: 'up' | 'down' | null = null;

if (Math.abs(deltaY) > 3) {
currentMovement = deltaY > 0 ? 'down' : 'up';
tracking.movementDirection = currentMovement;
}

// Handle ambiguous drop position: 'after last child' or 'after parent'
if (target?.type === 'item' && target.dropPosition === 'after') {
let item = state.collection.getItem(target.key);
let parentKey = item?.parentKey;

if (parentKey) {
let parentItem = state.collection.getItem(parentKey);
let isParentExpanded = parentItem && state.expandedKeys.has(parentKey);

if (isParentExpanded) {
let nextKey = state.collection.getKeyAfter(target.key);
let nextItem = nextKey ? state.collection.getItem(nextKey) : null;
let isLastChild = !nextItem || nextItem.parentKey !== parentKey;

if (isLastChild) {
let afterParentTarget = {
type: 'item',
key: parentKey,
dropPosition: 'after'
} as const;

// eslint-disable-next-line max-depth
if (!tracking.boundaryContext || tracking.boundaryContext.parentKey !== parentKey) {
let initialPreference: 'inside' | 'after' = 'inside';
// eslint-disable-next-line max-depth
if (tracking.movementDirection === 'up') {
initialPreference = 'after';
} else if (tracking.movementDirection === 'down') {
initialPreference = 'inside';
}

tracking.boundaryContext = {
parentKey,
lastChildKey: target.key,
preferredPosition: initialPreference,
lastSwitchY: y,
entryDirection: tracking.movementDirection
};
}

let context = tracking.boundaryContext;
let distanceFromLastSwitch = Math.abs(y - context.lastSwitchY);

// Switch logic based on continued movement direction
if (distanceFromLastSwitch > 12) { // Threshold for smooth switching
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coded now, but maybe this should be relative to the item height? Or be dynamic based on input type (touch or mouse)

// eslint-disable-next-line max-depth
if (context.preferredPosition === 'inside' && currentMovement === 'down') {
context.preferredPosition = 'after';
context.lastSwitchY = y;
} else if (context.preferredPosition === 'after' && currentMovement === 'up') {
context.preferredPosition = 'inside';
context.lastSwitchY = y;
}
}

// Apply the preferred position
if (context.preferredPosition === 'after' && isValidDropTarget(afterParentTarget)) {
target = afterParentTarget;
}
// If preferredPosition is 'inside', keep original target (after last child)

tracking.lastY = y;
return target;
}
}
}

// Reset boundary context since we're not in a boundary case
tracking.boundaryContext = null;

let nextKey = state.collection.getKeyAfter(target.key);
if (nextKey != null) {
let beforeTarget = {
Expand All @@ -282,11 +380,15 @@ function TreeInner<T extends object>({props, collection, treeRef: ref}: TreeInne
dropPosition: 'before'
} as const;
if (isValidDropTarget(beforeTarget)) {
return beforeTarget;
target = beforeTarget;
}
}
} else {
// Reset boundary context if target is not 'after' an item
tracking.boundaryContext = null;
}

tracking.lastY = y;
return target;
}
},
Expand Down Expand Up @@ -822,6 +924,7 @@ function flattenTree<T>(collection: TreeCollection<T>, opts: TreeGridCollectionO
function TreeDropIndicatorWrapper(props: DropIndicatorProps, ref: ForwardedRef<HTMLElement>): JSX.Element | null {
ref = useObjectRef(ref);
let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!;
let state = useContext(TreeStateContext)!;
let buttonRef = useRef<HTMLDivElement>(null);
let {dropIndicatorProps, isHidden, isDropTarget} = dragAndDropHooks!.useDropIndicator!(
props,
Expand All @@ -834,23 +937,25 @@ function TreeDropIndicatorWrapper(props: DropIndicatorProps, ref: ForwardedRef<H
}

let level = dropState && props.target.type === 'item' ? (dropState.collection.getItem(props.target.key)?.level || 0) + 1 : 1;

let isExpanded = props.target.type === 'item' && state.expandedKeys.has(props.target.key);
return (
<TreeDropIndicatorForwardRef
{...props}
dropIndicatorProps={dropIndicatorProps}
isDropTarget={isDropTarget}
ref={ref}
buttonRef={buttonRef}
level={level} />
level={level}
isExpanded={isExpanded} />
);
}

interface TreeDropIndicatorProps extends DropIndicatorProps {
dropIndicatorProps: React.HTMLAttributes<HTMLElement>,
isDropTarget: boolean,
buttonRef: RefObject<HTMLDivElement | null>,
level: number
level: number,
isExpanded: boolean
}

function TreeDropIndicator(props: TreeDropIndicatorProps, ref: ForwardedRef<HTMLElement>) {
Expand All @@ -859,6 +964,7 @@ function TreeDropIndicator(props: TreeDropIndicatorProps, ref: ForwardedRef<HTML
isDropTarget,
buttonRef,
level,
isExpanded,
...otherProps
} = props;
let {visuallyHiddenProps} = useVisuallyHidden();
Expand All @@ -879,8 +985,10 @@ function TreeDropIndicator(props: TreeDropIndicatorProps, ref: ForwardedRef<HTML
{...renderProps}
role="row"
aria-level={level}
aria-expanded={isExpanded}
ref={ref as RefObject<HTMLDivElement | null>}
data-drop-target={isDropTarget || undefined}>
data-drop-target={isDropTarget || undefined}
style={{position: 'relative'}}>
<div role="gridcell">
<div {...visuallyHiddenProps} role="button" {...dropIndicatorProps} ref={buttonRef} />
{renderProps.children}
Expand Down
55 changes: 50 additions & 5 deletions packages/react-aria-components/stories/Tree.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -567,9 +567,15 @@ function TreeDragAndDropExample(args) {

let getItems = (keys) => [...keys].map(key => {
let item = treeData.getItem(key)!;

let serializeItem = (nodeItem) => ({
...nodeItem.value,
childItems: nodeItem.children ? [...nodeItem.children].map(serializeItem) : []
});

return {
'text/plain': item.value.name,
'tree-item': JSON.stringify(item.value)
'tree-item': JSON.stringify(serializeItem(item))
};
});

Expand Down Expand Up @@ -626,7 +632,7 @@ function SecondTree(args) {
getChildren: item => item.childItems
});

let getItems = async (e) => {
let processIncomingItems = async (e) => {
return await Promise.all(e.items.filter(isTextDropItem).map(async item => {
let parsed = JSON.parse(await item.getText('tree-item'));
let convertItem = item => ({
Expand All @@ -638,23 +644,62 @@ function SecondTree(args) {
}));
};

let getItems = (keys) => [...keys].map(key => {
let item = treeData.getItem(key)!;

let serializeItem = (nodeItem) => ({
...nodeItem.value,
childItems: nodeItem.children ? [...nodeItem.children].map(serializeItem) : []
});

return {
'text/plain': item.value.name,
'tree-item': JSON.stringify(serializeItem(item))
};
});

let {dragAndDropHooks} = useDragAndDrop({
getItems, // Enable dragging FROM this tree
getAllowedDropOperations: () => ['move'],
acceptedDragTypes: ['tree-item'],
async onInsert(e) {
let items = await getItems(e);
let items = await processIncomingItems(e);
if (e.target.dropPosition === 'before') {
treeData.insertBefore(e.target.key, ...items);
} else if (e.target.dropPosition === 'after') {
treeData.insertAfter(e.target.key, ...items);
}
},
async onItemDrop(e) {
let items = await getItems(e);
let items = await processIncomingItems(e);
treeData.insert(e.target.key, 0, ...items);
},
async onRootDrop(e) {
let items = await getItems(e);
let items = await processIncomingItems(e);
treeData.insert(null, 0, ...items);
},
[args.dropFunction]: (e: DroppableCollectionReorderEvent) => {
console.log(`moving [${[...e.keys].join(',')}] ${e.target.dropPosition} ${e.target.key} in SecondTree`);
try {
if (e.target.dropPosition === 'before') {
treeData.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
treeData.moveAfter(e.target.key, e.keys);
} else if (e.target.dropPosition === 'on') {
let targetNode = treeData.getItem(e.target.key);
if (targetNode) {
let targetIndex = targetNode.children ? targetNode.children.length : 0;
let keyArray = Array.from(e.keys);
for (let i = 0; i < keyArray.length; i++) {
treeData.move(keyArray[i], e.target.key, targetIndex + i);
}
} else {
console.error('Target node not found for drop on:', e.target.key);
}
}
} catch (error) {
console.error(error);
}
}
});

Expand Down