Skip to content
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

[WIP] Tree drag and drop #7692

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
9 changes: 6 additions & 3 deletions packages/@react-spectrum/tree/src/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<T> extends Omit<AriaTreeGridListProps<T>, 'children'>, StyleProps, SpectrumSelectionProps, Expandable {
export interface SpectrumTreeViewProps<T> extends Omit<AriaTreeGridListProps<T>, 'children'>, StyleProps, SpectrumSelectionProps, Expandable {
/** Provides content to display when there are no items in the tree. */
renderEmptyState?: () => JSX.Element,
/**
Expand All @@ -35,7 +35,10 @@ export interface SpectrumTreeViewProps<T> extends Omit<AriaTreeGridListProps<T>,
/**
* 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<T extends object = object> extends Omit<TreeItemProps, 'className' | 'style' | 'value' | 'onHoverStart' | 'onHoverEnd' | 'onHoverChange'> {
Expand Down
131 changes: 129 additions & 2 deletions packages/@react-spectrum/tree/stories/TreeView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: <Folder />, childItems: [
{id: 'project-1', name: 'Project 1', icon: <FileTxt />},
{id: 'project-2', name: 'Project 2', icon: <Folder />, childItems: [
Expand Down Expand Up @@ -221,6 +233,105 @@ TreeExampleDynamic.story = {
parameters: null
};

export const TreeExampleDynamicDragNDrop = (
args: SpectrumTreeViewProps<unknown>
) => {
const list = useTreeData<Node>({
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 <InsertionIndicator />;
},
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 (
<div
style={{
width: '330px',
padding: '0 15px',
resize: 'both',
height: '90vh',
overflow: 'auto'
}}>
<TreeView
disabledKeys={['reports-1AB']}
aria-label="test dynamic tree"
items={list.items}
onExpandedChange={action('onExpandedChange')}
onSelectionChange={action('onSelectionChange')}
{...args}
dragAndDropHooks={dragAndDropHooks}>
{(item: any) => {
if (!item.value) {
return;
}
return (
<TreeViewItem
childItems={item.children ?? []}
textValue={item.value.name}>
<Text>{item.value.name}</Text>
{item.value.icon}
<ActionGroup onAction={action('onActionGroup action')}>
<Item key="edit">
<Edit />
<Text>Edit</Text>
</Item>
<Item key="delete">
<Delete />
<Text>Delete</Text>
</Item>
</ActionGroup>
</TreeViewItem>
);
}}
</TreeView>
</div>
);
};

TreeExampleDynamic.story = {
...TreeExampleStatic.story,
parameters: null
};

export const WithActions = {
render: TreeExampleDynamic,
Expand Down Expand Up @@ -310,3 +421,19 @@ export const WithActionMenu = (args: SpectrumTreeViewProps<unknown>) => (
</TreeView>
</div>
);


function InsertionIndicator() {
return (
<div
role="option"
aria-selected="false"
className={classNames(dropIndicatorStyles, 'spectrum-DropIndicator', 'spectrum-DropIndicator--horizontal')}
style={{
width: '100%',
margin: '-5px 0',
height: 2,
outline: 'none'
}} />
);
}
20 changes: 20 additions & 0 deletions packages/@react-stately/data/src/useTreeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface TreeData<T extends object> {
*/
getItem(key: Key): TreeNode<T> | undefined,

getDescendantKeys(node?: TreeNode<T>): 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.
Expand Down Expand Up @@ -250,10 +251,29 @@ export function useTreeData<T extends object>(options: TreeOptions<T>): TreeData
}
}

function getDescendantKeys(node?: TreeNode<T>): Key[] {
let descendantKeys: Key[] = [];
if (!node) {
return descendantKeys;
}
function recurse(currentNode: TreeNode<T>) {
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);
},
Expand Down
53 changes: 52 additions & 1 deletion packages/@react-stately/data/test/useTreeData.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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})
);
Expand All @@ -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([]);
});
});
Loading