diff --git a/packages/@react-spectrum/tree/src/TreeView.tsx b/packages/@react-spectrum/tree/src/TreeView.tsx index ccf90601ec0..3dc58c29e08 100644 --- a/packages/@react-spectrum/tree/src/TreeView.tsx +++ b/packages/@react-spectrum/tree/src/TreeView.tsx @@ -11,7 +11,7 @@ */ import {AriaTreeGridListProps} from '@react-aria/tree'; -import {ButtonContext, Collection, TreeItemContentRenderProps, TreeItemProps, TreeItemRenderProps, TreeRenderProps, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, useContextProps} from 'react-aria-components'; +import {ButtonContext, Collection, DragAndDropHooks, TreeItemContentRenderProps, TreeItemProps, TreeItemRenderProps, TreeRenderProps, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, useContextProps} from 'react-aria-components'; import {Checkbox} from '@react-spectrum/checkbox'; import ChevronLeftMedium from '@spectrum-icons/ui/ChevronLeftMedium'; import ChevronRightMedium from '@spectrum-icons/ui/ChevronRightMedium'; @@ -24,7 +24,7 @@ import {Text} from '@react-spectrum/text'; import {useButton} from '@react-aria/button'; import {useLocale} from '@react-aria/i18n'; -export interface SpectrumTreeViewProps extends Omit, 'children'>, StyleProps, SpectrumSelectionProps, Expandable { +export interface SpectrumTreeViewProps extends Omit, 'children'>, StyleProps, SpectrumSelectionProps, Expandable { /** Provides content to display when there are no items in the tree. */ renderEmptyState?: () => JSX.Element, /** @@ -35,7 +35,10 @@ export interface SpectrumTreeViewProps extends Omit, /** * The contents of the tree. */ - children?: ReactNode | ((item: T) => ReactNode) + children?: ReactNode | ((item: T) => ReactNode), + + /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the ListBox. */ + dragAndDropHooks?: DragAndDropHooks } export interface SpectrumTreeViewItemProps extends Omit { diff --git a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx index 9f1b3b10473..024f83ca78c 100644 --- a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx @@ -14,16 +14,22 @@ import {action} from '@storybook/addon-actions'; import {ActionGroup, Item} from '@react-spectrum/actiongroup'; import {ActionMenu} from '@react-spectrum/menu'; import Add from '@spectrum-icons/workflow/Add'; +import {classNames} from '@react-spectrum/utils'; import {Content} from '@react-spectrum/view'; import Delete from '@spectrum-icons/workflow/Delete'; +import {DragItem} from '@react-types/shared'; +import dropIndicatorStyles from '@adobe/spectrum-css-temp/components/dropindicator/vars.css'; import Edit from '@spectrum-icons/workflow/Edit'; import FileTxt from '@spectrum-icons/workflow/FileTxt'; import Folder from '@spectrum-icons/workflow/Folder'; import {Heading, Text} from '@react-spectrum/text'; import {IllustratedMessage} from '@react-spectrum/illustratedmessage'; import {Link} from '@react-spectrum/link'; -import React from 'react'; +import React, {JSX} from 'react'; import {SpectrumTreeViewProps, TreeView, TreeViewItem} from '../src'; +import {useDragAndDrop} from 'react-aria-components'; +import {TreeData, useTreeData} from 'react-stately'; + export default { title: 'TreeView', @@ -163,7 +169,13 @@ TreeExampleStatic.story = { } }; -let rows = [ +type Node = { + id: string, + name: string, + icon: JSX.Element, + childItems?: Node[] +}; +let rows: Node[] = [ {id: 'projects', name: 'Projects', icon: , childItems: [ {id: 'project-1', name: 'Project 1', icon: }, {id: 'project-2', name: 'Project 2', icon: , childItems: [ @@ -221,6 +233,105 @@ TreeExampleDynamic.story = { parameters: null }; +export const TreeExampleDynamicDragNDrop = ( + args: SpectrumTreeViewProps +) => { + const list = useTreeData({ + initialItems: rows, + getChildren: (item) => { + return item.childItems ?? []; + } + }); + // @TODO internalise inside Tree ? + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + return [...keys].map((key) => { + return { + 'text/plain': list.getItem(key)?.key ?? '' + } as DragItem; + }); + + }, + renderDropIndicator() { + return ; + }, + onReorder(e) { + const k = e.keys.values().next().value; + const parent = list.getItem(e.target.key)?.parentKey ?? null; + if (!k) { + return; + } + + // you shouldn't be able to drop a parent into a child + const dragNode = list.getItem(k); + const childTreeKeys = list.getDescendantKeys(dragNode); + if (childTreeKeys.includes(e.target.key)) { + return null; + } + // node list index... + let i = 0; + if (parent) { + const parentNode = list.getItem(parent); + i = (parentNode?.children ?? []).findIndex( + (c) => c.key === e.target.key + ); + } + if (e.target.dropPosition === 'before') { + list.moveBefore(k, parent, i); + } else if (e.target.dropPosition === 'after') { + list.moveAfter(k, parent, i); + } + } + }); + return ( +
+ + {(item: any) => { + if (!item.value) { + return; + } + return ( + + {item.value.name} + {item.value.icon} + + + + Edit + + + + Delete + + + + ); + }} + +
+ ); +}; + +TreeExampleDynamic.story = { + ...TreeExampleStatic.story, + parameters: null +}; export const WithActions = { render: TreeExampleDynamic, @@ -310,3 +421,19 @@ export const WithActionMenu = (args: SpectrumTreeViewProps) => ( ); + + +function InsertionIndicator() { + return ( +
+ ); +} diff --git a/packages/@react-stately/data/src/useTreeData.ts b/packages/@react-stately/data/src/useTreeData.ts index 5bcaa98b9f3..5c3f42bbafa 100644 --- a/packages/@react-stately/data/src/useTreeData.ts +++ b/packages/@react-stately/data/src/useTreeData.ts @@ -51,6 +51,7 @@ export interface TreeData { */ getItem(key: Key): TreeNode | undefined, + getDescendantKeys(node?: TreeNode): Key[], /** * Inserts an item into a parent node as a child. * @param parentKey - The key of the parent item to insert into. `null` for the root. @@ -250,10 +251,29 @@ export function useTreeData(options: TreeOptions): TreeData } } + function getDescendantKeys(node?: TreeNode): Key[] { + let descendantKeys: Key[] = []; + if (!node) { + return descendantKeys; + } + function recurse(currentNode: TreeNode) { + if (currentNode.children) { + for (let child of currentNode.children) { + descendantKeys.push(child.key); + recurse(child); + } + } + } + + recurse(node); + return descendantKeys; + } + return { items, selectedKeys, setSelectedKeys, + getDescendantKeys, getItem(key: Key) { return nodeMap.get(key); }, diff --git a/packages/@react-stately/data/test/useTreeData.test.js b/packages/@react-stately/data/test/useTreeData.test.js index ae685f66c98..6a944002e6d 100644 --- a/packages/@react-stately/data/test/useTreeData.test.js +++ b/packages/@react-stately/data/test/useTreeData.test.js @@ -729,7 +729,6 @@ describe('useTreeData', function () { it('should move an item to a different level at the end when the index is greater than the node list length', function () { const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; - console.log('initialItems', initialItems[0]); let {result} = renderHook(() => useTreeData({initialItems, getChildren, getKey}) ); @@ -746,4 +745,56 @@ describe('useTreeData', function () { expect(result.current.items[1].key).toEqual('Emily'); expect(result.current.items.length).toEqual(2); }); + + it('gets the decentants of a node', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + let decendants; + act(() => { + const top = result.current.getItem('David'); + decendants = result.current.getDescendantKeys(top); + }); + expect(decendants).toEqual(['John', 'Suzie', 'Sam', 'Stacy', 'Brad', 'Jane']); + }); + + + it('gets the decentants of a child node', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + let descendants; + act(() => { + const top = result.current.getItem('Sam'); + descendants = result.current.getDescendantKeys(top); + }); + expect(descendants).toEqual(['Stacy', 'Brad']); + }); + + it('returns an empty array when getting the decendant keys for a leaf node', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + let descendants; + act(() => { + const top = result.current.getItem('Eli'); + descendants = result.current.getDescendantKeys(top); + }); + expect(descendants).toEqual([]); + }); + + it('returns an empty array when an undefined key is supplied', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + let descendants; + act(() => { + descendants = result.current.getDescendantKeys(undefined); + }); + expect(descendants).toEqual([]); + }); }); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 072d041d489..42f4696e6c2 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -16,10 +16,12 @@ import {CheckboxContext} from './RSPContexts'; import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, useCachedChildren} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, usePersistedKeys} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; -import {DisabledBehavior, Expandable, forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; +import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, HoverEvents, Key, KeyboardDelegate, LinkDOMProps, RefObject} from '@react-types/shared'; +import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useRenderDropIndicator} from './DragAndDrop'; +import {DragAndDropHooks} from './useDragAndDrop'; +import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; +import {DraggableItemResult, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSelectionCheckbox, useHover, useLocale} from 'react-aria'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; -import {FocusScope, mergeProps, useFocusRing, useGridListSelectionCheckbox, useHover} from 'react-aria'; -import {Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {useControlledState} from '@react-stately/utils'; @@ -129,7 +131,14 @@ export interface TreeProps extends Omit, 'children'> * Whether `disabledKeys` applies to all interactions, or only selection. * @default 'all' */ - disabledBehavior?: DisabledBehavior + disabledBehavior?: DisabledBehavior, + dragAndDropHooks?: DragAndDropHooks, + + /** + * An optional keyboard delegate implementation for type to select, + * to override the default. + */ + keyboardDelegate?: KeyboardDelegate } @@ -158,6 +167,11 @@ interface TreeInnerProps { } function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { + const {dragAndDropHooks} = props; + let {direction} = useLocale(); + let collator = useCollator({usage: 'search', sensitivity: 'base'}); + let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState; + let isListDroppable = !!dragAndDropHooks?.useDroppableCollectionState; let { selectionMode = 'none', expandedKeys: propExpandedKeys, @@ -189,6 +203,22 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne disabledBehavior }); + let keyboardDelegate = useMemo( + () => + props.keyboardDelegate || + new ListKeyboardDelegate({ + collection: state.collection, + collator, + ref, + disabledKeys: state.selectionManager.disabledKeys, + disabledBehavior: state.selectionManager.disabledBehavior, + layout: 'stack', + direction, + layoutDelegate + }), + [collator, ref, state.selectionManager.disabledKeys, direction, state.collection, state.selectionManager.disabledBehavior, props.keyboardDelegate, layoutDelegate] + ); + let {gridProps} = useTreeGridList({ ...props, isVirtualized, @@ -230,13 +260,49 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne
); } + let dragState: DraggableCollectionState | undefined = undefined; + let dropState: DroppableCollectionState | undefined = undefined; + let droppableCollection: DroppableCollectionResult | undefined = undefined; + let preview = useRef(null); + + if (isListDraggable && dragAndDropHooks) { + dragState = dragAndDropHooks.useDraggableCollectionState!({ + collection: state.collection, + selectionManager: state.selectionManager, + preview: dragAndDropHooks.renderDragPreview ? preview : undefined + }); + dragAndDropHooks.useDraggableCollection!({}, dragState, ref); + } + + if (isListDroppable && dragAndDropHooks) { + dropState = dragAndDropHooks.useDroppableCollectionState!({ + collection: state.collection, + selectionManager: state.selectionManager + }); + + let dropTargetDelegate = new dragAndDropHooks.ListDropTargetDelegate( + state.collection, + ref, + {layout: 'stack', direction} + ); + droppableCollection = dragAndDropHooks.useDroppableCollection!( + {keyboardDelegate, dropTargetDelegate}, + dropState, + ref + ); + } return (
({props, collection, treeRef: ref}: TreeInne data-focus-visible={isFocusVisible || undefined}> + scrollRef={ref} + renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} /> {emptyState}
@@ -345,6 +414,18 @@ export const UNSTABLE_TreeItem = /*#__PURE__*/ createBranchComponent('item', (() => ({ ...states, isHovered, @@ -406,7 +487,15 @@ export const UNSTABLE_TreeItem = /*#__PURE__*/ createBranchComponent('item',
(collection: TreeCollection, opts: TreeGridCollectionO keyMap }; } + +export function TreeDropIndicatorWrapper(props: DropIndicatorProps, ref: ForwardedRef) { + ref = useObjectRef(ref); + let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; + let {dropIndicatorProps, isHidden, isDropTarget} = dragAndDropHooks!.useDropIndicator!( + props, + dropState!, + ref + ); + + if (isHidden) { + return null; + } + return ( + + ); +} + +interface TreeDropIndicatorProps extends DropIndicatorProps { + dropIndicatorProps: React.HTMLAttributes, + isDropTarget: boolean +} + +function TreeDropIndicator(props: TreeDropIndicatorProps, ref: ForwardedRef) { + let { + dropIndicatorProps, + isDropTarget, + ...otherProps + } = props; + let renderProps = useRenderProps({ + ...otherProps, + defaultClassName: 'react-aria-DropIndicator', + values: { + isDropTarget + } + }); + return ( +
} + data-drop-target={isDropTarget || undefined} /> + ); +} + +const TreeDropIndicatorForwardRef = forwardRef(TreeDropIndicator);