diff --git a/packages/react-aria-components/docs/Tree.mdx b/packages/react-aria-components/docs/Tree.mdx index c93d508afc0..905c45c3468 100644 --- a/packages/react-aria-components/docs/Tree.mdx +++ b/packages/react-aria-components/docs/Tree.mdx @@ -11,6 +11,7 @@ import {Layout} from '@react-spectrum/docs'; export default Layout; import docs from 'docs:react-aria-components'; +import sharedDocs from 'docs:@react-types/shared'; import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI, VersionBadge} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import packageData from 'react-aria-components/package.json'; @@ -22,10 +23,12 @@ import {ExampleList} from '@react-spectrum/docs/src/ExampleList'; import {Keyboard} from '@react-spectrum/text'; import Collections from '@react-spectrum/docs/pages/assets/component-illustrations/Collections.svg'; import Selection from '@react-spectrum/docs/pages/assets/component-illustrations/Selection.svg'; +import DragAndDrop from '@react-spectrum/docs/pages/assets/component-illustrations/DragAndDrop.svg'; import Checkbox from '@react-spectrum/docs/pages/assets/component-illustrations/Checkbox.svg'; import Button from '@react-spectrum/docs/pages/assets/component-illustrations/Button.svg'; import treeUtils from 'docs:@react-aria/test-utils/src/tree.ts'; import {StarterKits} from '@react-spectrum/docs/src/StarterKits'; +import {InlineAlert, Content, Heading} from '@adobe/react-spectrum'; --- category: Collections @@ -259,6 +262,7 @@ HTML lists are meant for static content, rather than hierarchies with rich inter * **Interactive children** – Tree items may include interactive elements such as buttons, menus, etc. * **Actions** – Items support optional actions such as navigation via click, tap, double click, or Enter key. * **Keyboard navigation** – Tree items and focusable children can be navigated using the arrow keys, along with page up/down, home/end, etc. Typeahead, auto scrolling, and selection modifier keys are supported as well. +* **Drag and drop** – Tree supports drag and drop to reorder, move, insert, or update items via mouse, touch, keyboard, and screen reader interactions. * **Virtualized scrolling** – Use [Virtualizer](Virtualizer.html) to improve performance of large lists by rendering only the visible items. * **Touch friendly** – Selection and actions adapt their behavior depending on the device. For example, selection is activated via long press on touch when item actions are present. * **Accessible** – Follows the [ARIA treegrid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/), with additional selection announcements via an ARIA live region. Extensively tested across many devices and [assistive technologies](accessibility.html#testing) to ensure announcements and behaviors are consistent. @@ -279,6 +283,7 @@ import {Tree, TreeItem, TreeItemContent, Button, Checkbox} from 'react-aria-comp } {selectionBehavior === 'toggle' && selectionMode !== 'none' && } + + {item.value.name} + + + {(item) => ( + + + + + + {item.value.name} + + + )} + + + )} + + ); +} +``` + +
+ Show CSS + +```css +.drag-preview { + width: 150px; + padding: 4px 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + background: var(--highlight-background); + color: var(--highlight-foreground); + border-radius: 4px; + + .badge { + background: var(--highlight-foreground); + color: var(--highlight-background); + padding: 0 8px; + border-radius: 4px; + } +} +``` + +
+ +### Drag data + +Data for draggable items can be provided in multiple formats at once. This allows drop targets to choose data in a format that they understand. For example, you could serialize a complex object as JSON in a custom format for use within your own application, and also provide plain text and/or rich HTML fallbacks that can be used when a user drops data in an external application (e.g. an email message). + +This can be done by returning multiple keys for an item from the `getItems` function. Types can either be a standard [mime type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) for interoperability with external applications, or a custom string for use within your own app. + +This example provides representations of each item as plain text, HTML, and a custom app-specific data format. Dropping on the drop targets in this page will use the custom data format to render formatted items. If you drop in an external application supporting rich text, the HTML representation will be used. Dropping in a text editor will use the plain text format. + +```tsx example export=true +function DraggableTree() { + let tree = useTreeData({ + initialItems: [ + {id: 'documents', name: 'Documents', type: 'folder', children: [ + {id: 'project', name: 'Project', type: 'folder', children: [ + {id: 'report', name: 'Weekly Report', type: 'document'} + ]} + ]}, + {id: 'photos', name: 'Photos', type: 'folder', children: [ + {id: 'image1', name: 'Image 1', type: 'image'}, + {id: 'image2', name: 'Image 2', type: 'image'} + ]}, + {id: 'videos', name: 'Videos', type: 'folder', children: []} + ], + getKey: item => item.id, + getChildren: item => item.children || [] + }); + + let {dragAndDropHooks} = useDragAndDrop({ + ///- begin highlight -/// + getItems(keys) { + return [...keys].map(key => { + let item = tree.getItem(key).value; + return { + 'text/plain': `${item.name} (${item.type})`, + 'text/html': `${item.name} (${item.type})`, + 'custom-app-type': JSON.stringify(item) + }; + }); + }, + ///- end highlight -/// + onMove(e) { + if (e.target.dropPosition === 'before') { + tree.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + tree.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + let targetNode = tree.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++) { + tree.move(keyArray[i], e.target.key, targetIndex + i); + } + } + } + }, + renderDragPreview(items) { + return ( +
+ {items[0]['text/plain']} + {items.length} +
+ ); + } + }); + + return ( + + {(item) => ( + + + + + + {item.value.name} + + + {(item) => ( + + + + + + {item.value.name} + + + )} + + + )} + + ); +} + +
+ + {/* see below */} + +
+``` + +### Dropping on the collection + +Dropping on the Tree as a whole can be enabled using the `onRootDrop` event. When a valid drag hovers over the Tree, it receives the `isDropTarget` state and can be styled using the `[data-drop-target]` CSS selector. + +```tsx example +import {isTextDropItem} from 'react-aria-components'; + +function Example() { + let [items, setItems] = React.useState([]); + + let {dragAndDropHooks} = useDragAndDrop({ + /*- begin highlight -*/ + async onRootDrop(e) { + let items = await Promise.all( + e.items + .filter(isTextDropItem) + .map(async item => JSON.parse(await item.getText('custom-app-type'))) + ); + setItems(items); + } + /*- end highlight -*/ + }); + + return ( +
+ + 'Drop items here'} + > + {(item) => ( + + + {item.name} + + + {(child) => ( + + + {child.name} + + + )} + + + )} + +
+ ); +} +``` + +
+ Show CSS + +```css +.react-aria-Tree[data-drop-target] { + outline: 2px solid var(--highlight-background); + outline-offset: -1px; + background: var(--highlight-overlay); +} +``` + +
+ +### Dropping on items + +Dropping on items can be enabled using the `onItemDrop` event. When a valid drag hovers over an item, it receives the `isDropTarget` state and can be styled using the `[data-drop-target]` CSS selector. + +```tsx example +function Example() { + let {dragAndDropHooks} = useDragAndDrop({ + ///- begin highlight -/// + onItemDrop(e) { + alert(`Dropped on ${e.target.key}`); + } + ///- end highlight -/// + }); + + return ( +
+ + +
+ ); +} +``` + +### Dropping between items + +Dropping between items can be enabled using the `onInsert` event. Tree renders a between items to indicate the insertion position, which can be styled using the `.react-aria-DropIndicator` selector. When it is active, it receives the `[data-drop-target]` state. + +```tsx example +import {isTextDropItem} from 'react-aria-components'; + +function Example() { + let tree = useTreeData({ + initialItems: [ + {id: 'folder1', name: 'Folder 1', children: [ + {id: 'file1', name: 'File 1', children: []}, + {id: 'file2', name: 'File 2', children: []} + ]}, + {id: 'folder2', name: 'Folder 2', children: []} + ], + getKey: item => item.id, + getChildren: item => item.children || [] + }); + + let {dragAndDropHooks} = useDragAndDrop({ + ///- begin highlight -/// + async onInsert(e) { + let items = await Promise.all( + e.items + .filter(isTextDropItem) + .map(async item => JSON.parse(await item.getText('custom-app-type'))) + ); + + if (e.target.dropPosition === 'before') { + tree.insertBefore(e.target.key, ...items); + } else if (e.target.dropPosition === 'after') { + tree.insertAfter(e.target.key, ...items); + } + } + ///- end highlight -/// + }); + + return ( +
+ + + {(item) => ( + + + + {item.value.name} + + + {(item) => ( + + + {item.value.name} + + + )} + + + )} + +
+ ); +} +``` + +### Drop data + +`Tree` allows users to drop one or more **drag items**, each of which contains data to be transferred from the drag source to drop target. There are three kinds of drag items: + +* `text` – represents data inline as a string in one or more formats +* `file` – references a file on the user's device +* `directory` – references the contents of a directory + +#### Text + +A represents textual data in one or more different formats. These may be either standard [mime types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) or custom app-specific formats. Representing data in multiple formats allows drop targets both within and outside an application to choose data in a format that they understand. For example, a complex object may be serialized in a custom format for use within an application, with fallbacks in plain text and/or rich HTML that can be used when a user drops data from an external application. + +The example below uses the `acceptedDragTypes` prop to accept items that include a custom app-specific type, which is retrieved using the item's `getText` method. The same draggable component as used in the above example is used here, but rather than displaying the plain text representation, the custom format is used instead. When `acceptedDragTypes` is specified, the dropped items are filtered to include only items that include the accepted types. + +```tsx example export=true +import {isTextDropItem} from 'react-aria-components'; + +function DroppableTree() { + let [items, setItems] = React.useState([]); + + let {dragAndDropHooks} = useDragAndDrop({ + /*- begin highlight -*/ + acceptedDragTypes: ['custom-app-type'], + async onRootDrop(e) { + let items = await Promise.all( + e.items + .filter(isTextDropItem) + .map(async item => JSON.parse(await item.getText('custom-app-type'))) + ); + setItems(items); + } + /*- end highlight -*/ + }); + + return ( + 'Drop items here'} + > + {(item) => ( + + + + {item.name} + + + {(child) => ( + + + {child.name} + + + )} + + + )} + + ); +} + +
+ + +
+``` + +#### Files + +A references a file on the user's device. It includes the name and mime type of the file, and methods to read the contents as plain text, or retrieve a native [File](https://developer.mozilla.org/en-US/docs/Web/API/File) object which can be attached to form data for uploading. + +This example accepts JPEG and PNG image files, and renders them in a tree structure by creating a local [object URL](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL). When the tree is empty, you can drop on the whole collection, and otherwise items can be inserted. + +```tsx example +import {isFileDropItem} from 'react-aria-components'; + +interface ImageItem { + id: number, + url: string, + name: string, + type: string, + lastModified: number +} + +function Example() { + let [items, setItems] = React.useState([]); + + let {dragAndDropHooks} = useDragAndDrop({ + /*- begin highlight -*/ + acceptedDragTypes: ['image/jpeg', 'image/png'], + async onRootDrop(e) { + let items = await Promise.all( + e.items.filter(isFileDropItem).map(async item => { + let file = await item.getFile(); + return { + id: Math.random(), + url: URL.createObjectURL(file), + name: item.name, + type: file.type, + lastModified: file.lastModified + }; + }) + ); + setItems(items); + } + /*- end highlight -*/ + }); + + return ( + 'Drop images here'} + > + {(item) => ( + + + + {item.name} + + + )} + + ); +} +``` + +#### Directories + +A references the contents of a directory on the user's device. It includes the name of the directory, as well as a method to iterate through the files and folders within the directory. The contents of any folders within the directory can be accessed recursively. + +The `getEntries` method returns an [async iterable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of) object, which can be used in a `for await...of` loop. This provides each item in the directory as either a or , and you can access the contents of each file as discussed above. + +This example accepts directory drops over the whole collection, and renders the contents as items in the tree. `DIRECTORY_DRAG_TYPE` is imported from `react-aria-components` and included in the `acceptedDragTypes` prop to limit the accepted items to only directories. + +```tsx example +import {DIRECTORY_DRAG_TYPE, isDirectoryDropItem} from 'react-aria-components'; + +interface DirItem { + name: string, + kind: string, + type: string, + children?: DirItem[] +} + +function Example() { + let [files, setFiles] = React.useState([]); + + let {dragAndDropHooks} = useDragAndDrop({ + /*- begin highlight -*/ + acceptedDragTypes: [DIRECTORY_DRAG_TYPE], + async onRootDrop(e) { + // Read entries in directory and update state with relevant info. + let dir = e.items.find(isDirectoryDropItem)!; + let files = []; + for await (let entry of dir.getEntries()) { + files.push({ + name: entry.name, + kind: entry.kind, + type: entry.kind === 'directory' ? 'Directory' : entry.type + }); + } + setFiles(files); + } + /*- end highlight -*/ + }); + + return ( + 'Drop directory here'} + > + {(item) => ( + + + {item.kind === 'directory' ? '📁' : '📄'} {item.name} + + + )} + + ); +} +``` + +### Drop operations + +A is an indication of what will happen when dragged data is dropped on a particular drop target. These are: + +* `move` – indicates that the dragged data will be moved from its source location to the target location. +* `copy` – indicates that the dragged data will be copied to the target destination. +* `link` – indicates that there will be a relationship established between the source and target locations. +* `cancel` – indicates that the drag and drop operation will be canceled, resulting in no changes made to the source or target. + +Many operating systems display these in the form of a cursor change, e.g. a plus sign to indicate a copy operation. The user may also be able to use a modifier key to choose which drop operation to perform, such as Option or Alt to switch from move to copy. + +#### onDragEnd + +The `onDragEnd` event allows the drag source to respond when a drag that it initiated ends, either because it was dropped or because it was canceled by the user. The `dropOperation` property of the event object indicates the operation that was performed. For example, when data is moved, the UI could be updated to reflect this change by removing the original dragged items. + +This example removes the dragged items from the UI when a move operation is completed. Try holding the Option or Alt keys to change the operation to copy, and see how the behavior changes. + +```tsx example +function Example() { + let tree = useTreeData({ + initialItems: [ + {id: 1, name: 'Adobe Photoshop', children: []}, + {id: 2, name: 'Adobe XD', children: []}, + {id: 3, name: 'Adobe Dreamweaver', children: []}, + {id: 4, name: 'Adobe InDesign', children: []}, + {id: 5, name: 'Adobe Connect', children: []} + ], + getKey: item => item.id, + getChildren: item => item.children || [] + }); + + let { dragAndDropHooks } = useDragAndDrop({ + ///- begin collapse -/// + renderDragPreview(items) { + return ( +
+ {items[0]['text/plain']} + {items.length} +
+ ); + }, + getItems(keys) { + return [...keys].map(key => { + let item = tree.getItem(key).value; + return { + 'text/plain': item.name, + 'custom-app-type': JSON.stringify(item) + }; + }); + }, + ///- end collapse -/// + // ... + /*- begin highlight -*/ + onDragEnd(e) { + if (e.dropOperation === 'move') { + tree.remove(...e.keys); + } + } + /*- end highlight -*/ + }); + + return ( +
+ + {item => ( + + + + + + {item.value.name} + + + )} + + +
+ ); +} +``` + +#### getAllowedDropOperations + +The drag source can also control which drop operations are allowed for the data. For example, if moving data is not allowed, and only copying is supported, the `getAllowedDropOperations` function could be implemented to indicate this. When you drag the element below, the cursor now shows the copy affordance by default, and pressing a modifier to switch drop operations results in the drop being canceled. + +```tsx example +function Example() { + ///- begin collapse -/// + let tree = useTreeData({ + initialItems: [ + {id: 1, name: 'Adobe Photoshop', children: []}, + {id: 2, name: 'Adobe XD', children: []}, + {id: 3, name: 'Adobe Dreamweaver', children: []}, + {id: 4, name: 'Adobe InDesign', children: []}, + {id: 5, name: 'Adobe Connect', children: []} + ], + getKey: item => item.id, + getChildren: item => item.children || [] + }); + ///- end collapse -/// + // ... + + let { dragAndDropHooks } = useDragAndDrop({ + ///- begin collapse -/// + renderDragPreview(items) { + return ( +
+ {items[0]['text/plain']} + {items.length} +
+ ); + }, + getItems(keys) { + return [...keys].map(key => { + let item = tree.getItem(key).value; + return { + 'text/plain': item.name, + 'custom-app-type': JSON.stringify(item) + }; + }); + }, + ///- end collapse -/// + // ... + /*- begin highlight -*/ + getAllowedDropOperations: () => ['copy'] + /*- end highlight -*/ + }); + + return ( +
+ + {item => ( + + + + + + {item.value.name} + + + )} + + +
+ ); +} +``` + +#### getDropOperation + +The `getDropOperation` function passed to `useDragAndDrop` can be used to provide appropriate feedback to the user when a drag hovers over the drop target. This function receives the drop target, set of types contained in the drag, and a list of allowed drop operations as specified by the drag source. It should return one of the drop operations in `allowedOperations`, or a specific drop operation if only that drop operation is supported. It may also return `'cancel'` to reject the drop. If the returned operation is not in `allowedOperations`, then the drop target will act as if `'cancel'` was returned. + +In the below example, the drop target only supports dropping PNG images. If a PNG is dragged over the target, it will be highlighted and the operating system displays a copy cursor. If another type is dragged over the target, then there is no visual feedback, indicating that a drop is not accepted there. If the user holds a modifier key such as Control while dragging over the drop target in order to change the drop operation, then the drop target does not accept the drop. + +```tsx example +///- begin collapse -/// +interface ImageItem { + id: number, + url: string, + name: string +} +///- end collapse -/// +function Example() { + let [items, setItems] = React.useState([]); + + let { dragAndDropHooks } = useDragAndDrop({ + /*- begin highlight -*/ + getDropOperation: () => 'copy', + /*- end highlight -*/ + acceptedDragTypes: ['image/png'], + async onRootDrop(e) { + ///- begin collapse -/// + let items = await Promise.all( + e.items.filter(isFileDropItem).map(async item => ({ + id: Math.random(), + url: URL.createObjectURL(await item.getFile()), + name: item.name + })) + ); + setItems(items); + ///- end collapse -/// + // ... + } + }); + + ///- begin collapse -/// + return ( + "Drop PNGs here"}> + {item => ( + + +
+ + {item.name} +
+
+
+ )} +
+ ); + ///- end collapse -/// + // See "Files" example above... +} +``` + +#### Drop events + +Drop events such as `onInsert`, `onItemDrop`, etc. also include the `dropOperation`. This can be used to perform different actions accordingly, for example, when communicating with a backend API. + +```tsx +let onItemDrop = async (e) => { + let data = JSON.parse(await e.items[0].getText('my-app-file')); + /*- begin highlight -*/ + switch (e.dropOperation) { + case 'move': + MyAppFileService.move(data.filePath, props.filePath); + break; + case 'copy': + MyAppFileService.copy(data.filePath, props.filePath); + break; + case 'link': + MyAppFileService.link(data.filePath, props.filePath); + break; + } + /*- end highlight -*/ +}; +``` + +### Drag between trees + +This example puts together many of the concepts described above, allowing users to drag items between trees bidirectionally. It also supports moving items within the same tree hierarchy. When a tree is empty, it accepts drops on the whole collection. `getDropOperation` ensures that items are always moved rather than copied, which avoids duplicate items. + +```tsx example +import {isTextDropItem} from 'react-aria-components'; + +interface FileItem { + id: string, + name: string, + type: string, + children?: FileItem[] +} + +interface DndTreeProps { + initialItems: FileItem[], + 'aria-label': string +} + +function DndTree(props: DndTreeProps) { + let tree = useTreeData({ + initialItems: props.initialItems, + getKey: item => item.id, + getChildren: item => item.children || [] + }); + + let {dragAndDropHooks} = useDragAndDrop({ + // Provide drag data in a custom format as well as plain text. + getItems(keys) { + return [...keys].map((key) => { + let item = tree.getItem(key); + let serializeItem = (nodeItem) => ({ + ...nodeItem.value, + children: nodeItem.children ? [...nodeItem.children].map(serializeItem) : [] + }); + + return { + 'custom-app-type': JSON.stringify(serializeItem(item)), + 'text/plain': item.value.name + }; + }); + }, + + // Accept drops with the custom format. + acceptedDragTypes: ['custom-app-type'], + + // Ensure items are always moved rather than copied. + getDropOperation: () => 'move', + + // Handle drops between items from other trees. + async onInsert(e) { + let processedItems = await Promise.all( + e.items + .filter(isTextDropItem) + .map(async item => JSON.parse(await item.getText('custom-app-type'))) + ); + if (e.target.dropPosition === 'before') { + tree.insertBefore(e.target.key, ...processedItems); + } else if (e.target.dropPosition === 'after') { + tree.insertAfter(e.target.key, ...processedItems); + } + }, + + // Handle drops on the collection when empty. + async onRootDrop(e) { + let processedItems = await Promise.all( + e.items + .filter(isTextDropItem) + .map(async item => JSON.parse(await item.getText('custom-app-type'))) + ); + tree.insert(null, 0, ...processedItems); + }, + + // Handle moving items within the same tree or to different levels. + onMove(e) { + if (e.target.dropPosition === 'before') { + tree.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + tree.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + let targetNode = tree.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++) { + tree.move(keyArray[i], e.target.key, targetIndex + i); + } + } + } + }, + + // Remove the items from the source tree on drop + // if they were moved to a different tree. + onDragEnd(e) { + if (e.dropOperation === 'move' && !e.isInternal) { + tree.remove(...e.keys); + } + } + }); + + return ( + 'Drop items here'} + > + {(item) => ( + + + + + + {item.value.name} + + + {(item) => ( + + + + + + {item.value.name} + + + )} + + + )} + + ); +} + +
+ + +
+``` ## Props diff --git a/packages/react-aria-components/example/index.css b/packages/react-aria-components/example/index.css index d2c97174ef5..79f67756ed6 100644 --- a/packages/react-aria-components/example/index.css +++ b/packages/react-aria-components/example/index.css @@ -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; diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 58a507b1809..f6bd3fb005d 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -185,6 +185,24 @@ function TreeInner({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.'); @@ -273,7 +291,87 @@ function TreeInner({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 + // 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 = { @@ -282,11 +380,15 @@ function TreeInner({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; } }, @@ -822,6 +924,7 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO function TreeDropIndicatorWrapper(props: DropIndicatorProps, ref: ForwardedRef): JSX.Element | null { ref = useObjectRef(ref); let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; + let state = useContext(TreeStateContext)!; let buttonRef = useRef(null); let {dropIndicatorProps, isHidden, isDropTarget} = dragAndDropHooks!.useDropIndicator!( props, @@ -834,7 +937,7 @@ function TreeDropIndicatorWrapper(props: DropIndicatorProps, ref: ForwardedRef + level={level} + isExpanded={isExpanded} /> ); } @@ -850,7 +954,8 @@ interface TreeDropIndicatorProps extends DropIndicatorProps { dropIndicatorProps: React.HTMLAttributes, isDropTarget: boolean, buttonRef: RefObject, - level: number + level: number, + isExpanded: boolean } function TreeDropIndicator(props: TreeDropIndicatorProps, ref: ForwardedRef) { @@ -859,6 +964,7 @@ function TreeDropIndicator(props: TreeDropIndicatorProps, ref: ForwardedRef} data-drop-target={isDropTarget || undefined}>
diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index f089d0ec971..0f42ba4fbb3 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -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)) }; }); @@ -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 => ({ @@ -638,10 +644,26 @@ 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') { @@ -649,12 +671,35 @@ function SecondTree(args) { } }, 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); + } } });