diff --git a/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts b/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts index 428b8ceaa83..54227fc6f3e 100644 --- a/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts +++ b/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts @@ -33,7 +33,7 @@ export class ListDropTargetDelegate implements DropTargetDelegate { private ref: RefObject; private layout: 'stack' | 'grid'; private orientation: Orientation; - private direction: Direction; + protected direction: Direction; constructor(collection: Iterable>, ref: RefObject, options?: ListDropTargetDelegateOptions) { this.collection = collection; diff --git a/packages/@react-stately/dnd/src/useDraggableCollectionState.ts b/packages/@react-stately/dnd/src/useDraggableCollectionState.ts index c0ba9e798ae..5114fbe22a6 100644 --- a/packages/@react-stately/dnd/src/useDraggableCollectionState.ts +++ b/packages/@react-stately/dnd/src/useDraggableCollectionState.ts @@ -72,15 +72,35 @@ export function useDraggableCollectionState(props: DraggableCollectionStateOptio let draggedKey = useRef(null); let getKeys = (key: Key) => { // The clicked item is always added to the drag. If it is selected, then all of the - // other selected items are also dragged. If it is not selected, the only the clicked + // other selected items are also dragged. If it is not selected, then only the clicked // item is dragged. This matches native macOS behavior. - let keys = new Set( - selectionManager.isSelected(key) - ? new Set([...selectionManager.selectedKeys].filter(key => !!collection.getItem(key))) - : [] - ); + // Additionally, we filter out any keys that are children of any of the other selected keys + let keys = new Set(); + if (selectionManager.isSelected(key)) { + for (let currentKey of selectionManager.selectedKeys) { + let node = collection.getItem(currentKey); + if (node) { + let isChild = false; + let parentKey = node.parentKey; + while (parentKey != null) { + // eslint-disable-next-line max-depth + if (selectionManager.selectedKeys.has(parentKey)) { + isChild = true; + break; + } + let parentNode = collection.getItem(parentKey); + parentKey = parentNode ? parentNode.parentKey : null; + } + + if (!isChild) { + keys.add(currentKey); + } + } + } + } else { + keys.add(key); + } - keys.add(key); return keys; }; diff --git a/packages/@react-stately/dnd/src/useDroppableCollectionState.ts b/packages/@react-stately/dnd/src/useDroppableCollectionState.ts index 3e8c5b58e23..01036c91688 100644 --- a/packages/@react-stately/dnd/src/useDroppableCollectionState.ts +++ b/packages/@react-stately/dnd/src/useDroppableCollectionState.ts @@ -72,14 +72,14 @@ export function useDroppableCollectionState(props: DroppableCollectionStateOptio let getOppositeTarget = (target: ItemDropTarget): ItemDropTarget | null => { if (target.dropPosition === 'before') { - let key = collection.getKeyBefore(target.key); - return key != null && collection.getItem(key)?.level === collection.getItem(target.key)?.level - ? {type: 'item', key, dropPosition: 'after'} + let node = collection.getItem(target.key); + return node && node.prevKey != null + ? {type: 'item', key: node.prevKey, dropPosition: 'after'} : null; } else if (target.dropPosition === 'after') { - let key = collection.getKeyAfter(target.key); - return key != null && collection.getItem(key)?.level === collection.getItem(target.key)?.level - ? {type: 'item', key, dropPosition: 'before'} + let node = collection.getItem(target.key); + return node && node.nextKey != null + ? {type: 'item', key: node.nextKey, dropPosition: 'before'} : null; } return null; diff --git a/packages/react-aria-components/docs/Tree.mdx b/packages/react-aria-components/docs/Tree.mdx index c93d508afc0..1eb52d17db7 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,6 +23,7 @@ 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'; @@ -134,7 +136,7 @@ import { gap: 0.571rem; min-height: 28px; padding: 0.286rem 0.286rem 0.286rem 0.571rem; - --padding: 8px; + --padding: 16px; border-radius: 6px; outline: none; cursor: default; @@ -259,6 +261,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 +282,7 @@ import {Tree, TreeItem, TreeItemContent, Button, Checkbox} from 'react-aria-comp } {selectionBehavior === 'toggle' && selectionMode !== 'none' && } + )} {hasChildItems && } + {props.supportsDragging && } {props.title || props.children} @@ -116,7 +123,7 @@ let DynamicTreeItem = (props) => { {(item: any) => ( - + {item.name} )} @@ -135,6 +142,24 @@ let DynamicTree = ({treeProps = {}, rowProps = {}}) => ( ); +let DraggableTree = (props) => { + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), + ...props + }); + + return ; +}; + +let DraggableTreeWithSelection = (props) => { + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), + ...props + }); + + return ; +}; + describe('Tree', () => { let user; let testUtilUser = new User(); @@ -1655,8 +1680,198 @@ describe('Tree', () => { expect(onSelectionChange).toBeCalledTimes(1); }); }); -}); + describe('drag and drop', () => { + let getItems = jest.fn(); + function DnDTree(props) { + let treeData = useTreeData({ + initialItems: rows, + getKey: item => item.id, + getChildren: item => item.childItems + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + getItems(keys); + return [...keys].map((key) => ({ + 'text/plain': treeData.getItem(key)?.value.name + })); + }, + getAllowedDropOperations: () => ['move'] + }); + + return ( + + {(item: any) => ( + + {item.value.name} + + )} + + ); + } + + afterEach(() => { + act(() => {jest.runAllTimers();}); + jest.clearAllMocks(); + }); + + it('should support drag button slot', () => { + let {getAllByRole} = render(); + let button = getAllByRole('button')[0]; + expect(button).toHaveAttribute('aria-label', 'Drag Projects'); + }); + + it('should render drop indicators', async () => { + let onReorder = jest.fn(); + let {getAllByRole} = render( Test} />); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(4); + expect(rows[0]).toHaveAttribute('class', 'react-aria-DropIndicator'); + expect(rows[0]).not.toHaveAttribute('data-drop-target', 'true'); + expect(rows[0]).toHaveTextContent('Test'); + expect(within(rows[0]).getByRole('button')).toHaveAttribute('aria-label', 'Insert before Projects'); + expect(rows[2]).toHaveAttribute('class', 'react-aria-DropIndicator'); + expect(rows[2]).toHaveAttribute('data-drop-target'); + expect(within(rows[2]).getByRole('button')).toHaveAttribute('aria-label', 'Insert between Projects and Reports'); + expect(rows[3]).toHaveAttribute('class', 'react-aria-DropIndicator'); + expect(rows[3]).not.toHaveAttribute('data-drop-target'); + expect(within(rows[3]).getByRole('button')).toHaveAttribute('aria-label', 'Insert after Reports'); + + await user.keyboard('{ArrowDown}'); + + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert after Reports'); + expect(rows[0]).not.toHaveAttribute('data-drop-target', 'true'); + expect(rows[2]).not.toHaveAttribute('data-drop-target', 'true'); + expect(rows[3]).toHaveAttribute('data-drop-target', 'true'); + + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + expect(onReorder).toHaveBeenCalledTimes(1); + }); + + it('should support dropping on items', async () => { + let onItemDrop = jest.fn(); + let {getAllByRole} = render(<> + + + ); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + let tree = getAllByRole('treegrid')[1]; + let rows = within(tree).getAllByRole('row'); + expect(rows).toHaveLength(20); + expect(within(rows[0]).getAllByRole('button')[0]).toHaveAttribute('aria-label', 'Drop on Projects'); + expect(rows[0].nextElementSibling).toHaveAttribute('data-drop-target', 'true'); + expect(within(rows[1]).getAllByRole('button')[0]).toHaveAttribute('aria-label', 'Drop on Project 1'); + expect(rows[1].nextElementSibling).not.toHaveAttribute('data-drop-target'); + expect(within(rows[2]).getAllByRole('button')[0]).toHaveAttribute('aria-label', 'Drop on Project 2'); + expect(rows[2].nextElementSibling).not.toHaveAttribute('data-drop-target'); + + expect(document.activeElement).toBe(within(rows[0]).getAllByRole('button')[0]); + + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + expect(onItemDrop).toHaveBeenCalledTimes(1); + }); + + it('should support dropping on the root', async () => { + let onRootDrop = jest.fn(); + let {getAllByRole} = render(<> + + + ); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + let tree = getAllByRole('treegrid')[1]; + let rows = within(tree).getAllByRole('row'); + expect(rows).toHaveLength(1); + expect(within(rows[0]).getAllByRole('button')[0]).toHaveAttribute('aria-label', 'Drop on'); + expect(document.activeElement).toBe(within(rows[0]).getAllByRole('button')[0]); + expect(tree).toHaveAttribute('data-drop-target', 'true'); + + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + expect(onRootDrop).toHaveBeenCalledTimes(1); + }); + + it('should support disabled drag and drop', async () => { + let {getByRole, queryAllByRole} = render( + + ); + + let dragButtons = queryAllByRole('button').filter(button => button.getAttribute('slot') === 'drag'); + dragButtons.forEach(button => { + expect(button).toBeDisabled(); + }); + + let tree = getByRole('treegrid'); + expect(tree).not.toHaveAttribute('data-allows-dragging', 'true'); + expect(tree).not.toHaveAttribute('draggable', 'true'); + + let rows = within(tree).getAllByRole('row'); + rows.forEach(row => { + expect(row).not.toHaveAttribute('draggable', 'true'); + }); + }); + + it('should allow selection even when drag and drop is disabled', async () => { + let {getByRole, getAllByRole} = render( + + ); + + for (let row of getAllByRole('row')) { + let checkbox = within(row).getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + } + + let checkbox = getAllByRole('checkbox')[0]; + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + + await user.click(checkbox); + + let tree = getByRole('treegrid'); + let rows = within(tree).getAllByRole('row'); + expect(rows[0]).toHaveAttribute('data-selected', 'true'); + expect(checkbox).toBeChecked(); + }); + + it('should filter out selected child keys in getItems if a parent is also selected', async () => { + let {getAllByRole} = render( + + ); + + let rows = getAllByRole('row'); + let projectsRow = rows[0]; + expect(projectsRow).toHaveAttribute('aria-selected', 'true'); + + let dataTransfer = new DataTransfer(); + + fireEvent.pointerDown(projectsRow, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 5, clientY: 5}); + fireEvent(projectsRow, new DragEvent('dragstart', {dataTransfer, clientX: 5, clientY: 5})); + fireEvent.pointerUp(projectsRow, {button: 0, pointerId: 1, clientX: 5, clientY: 5}); + fireEvent(projectsRow, new DragEvent('dragend', {dataTransfer, clientX: 5, clientY: 5})); + expect(getItems).toHaveBeenCalledTimes(1); + expect(getItems).toHaveBeenCalledWith(new Set(['projects', 'reports'])); + }); + }); +}); AriaTreeTests({ prefix: 'rac-static',