From 903ba603425f1a2bbc66c4a1b3065a08dce812f2 Mon Sep 17 00:00:00 2001 From: Rob Date: Thu, 30 Jan 2025 17:29:24 +0000 Subject: [PATCH 1/7] [5574] - add moveBefore and moveAfter to useTreeData --- .../@react-stately/data/src/useTreeData.ts | 61 ++++++++++++++++ .../data/test/useTreeData.test.js | 70 +++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/packages/@react-stately/data/src/useTreeData.ts b/packages/@react-stately/data/src/useTreeData.ts index 580279b8e4c..170ba439ec2 100644 --- a/packages/@react-stately/data/src/useTreeData.ts +++ b/packages/@react-stately/data/src/useTreeData.ts @@ -107,6 +107,22 @@ export interface TreeData { */ move(key: Key, toParentKey: Key | null, index: number): void, + /** + * Moves an item before a node within the tree. + * @param key - The key of the item to move. + * @param toParentKey - The key of the new parent to insert into. `null` for the root. + * @param index - The index within the new parent to insert before. + */ + moveBefore(key: Key, toParentKey: Key | null, index: number): void, + + /** + * Moves an item aftera node within the tree. + * @param key - The key of the item to move. + * @param toParentKey - The key of the new parent to insert into. `null` for the root. + * @param index - The index within the new parent to insert before. + */ + moveAfter(key: Key, toParentKey: Key | null, index: number): void, + /** * Updates an item in the tree. * @param key - The key of the item to update. @@ -373,6 +389,51 @@ export function useTreeData(options: TreeOptions): TreeData }), newMap); }); }, + moveBefore(key: Key, toParentKey: Key | null, index: number) { + this.move(key, toParentKey, index); + }, + moveAfter(key: Key, toParentKey: Key | null, index: number) { + setItems(({items, nodeMap: originalMap}) => { + let node = originalMap.get(key); + if (!node) { + return {items, nodeMap: originalMap}; + } + + let {items: newItems, nodeMap: newMap} = updateTree(items, key, () => null, originalMap); + + const movedNode = { + ...node, + parentKey: toParentKey + }; + + const afterIndex = items.length === index ? index : index + 1; + // If parentKey is null, insert into the root. + if (toParentKey == null) { + newMap.set(movedNode.key, movedNode); + return {items: [ + ...newItems.slice(0, afterIndex), + movedNode, + ...newItems.slice(afterIndex) + ], nodeMap: newMap}; + } + + // Otherwise, update the parent node and its ancestors. + return updateTree(newItems, toParentKey, parentNode => { + console.log('parent node children ', parentNode.children); + const c = [ + ...parentNode.children!.slice(0, afterIndex), + movedNode, + ...parentNode.children!.slice(afterIndex) + ]; + return { + key: parentNode.key, + parentKey: parentNode.parentKey, + value: parentNode.value, + children: c + }; + }, newMap); + }); + }, update(oldKey: Key, newValue: T) { setItems(({items, nodeMap: originalMap}) => updateTree(items, oldKey, oldNode => { let node: TreeNode = { diff --git a/packages/@react-stately/data/test/useTreeData.test.js b/packages/@react-stately/data/test/useTreeData.test.js index 033303b5077..c47d54916aa 100644 --- a/packages/@react-stately/data/test/useTreeData.test.js +++ b/packages/@react-stately/data/test/useTreeData.test.js @@ -676,4 +676,74 @@ describe('useTreeData', function () { expect(result.current.items[1].value).toEqual(initialResult.items[2].value); expect(result.current.items[2]).toEqual(initialResult.items[1]); }); + + + it('should move an item within its same level before the target', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveBefore('Eli', null, 0); + }); + expect(result.current.items[0].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('David'); + expect(result.current.items[2].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(initialItems.length); + }); + + it('should move an item to a different level before the target', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + act(() => { + result.current.moveBefore('Eli', 'David', 1); + }); + expect(result.current.items[0].key).toEqual('David'); + expect(result.current.items[0].children[0].key).toEqual('John'); + expect(result.current.items[0].children[1].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(2); + }); + + it.only('should move an item to a different level after the target', function () { + const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; + let {result} = renderHook(() => + useTreeData({initialItems, getChildren, getKey}) + ); + + act(() => { + result.current.moveAfter('Eli', 'David', 1); + }); + expect(result.current.items[0].key).toEqual('David'); + + expect(result.current.items[0].children[0].key).toEqual('John'); + expect(result.current.items[0].children[1].key).toEqual('Sam'); + expect(result.current.items[0].children[2].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(2); + }); + + it.only('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}) + ); + + act(() => { + result.current.moveAfter('Eli', 'David', 100); + }); + expect(result.current.items[0].key).toEqual('David'); + + expect(result.current.items[0].children[0].key).toEqual('John'); + expect(result.current.items[0].children[1].key).toEqual('Sam'); + expect(result.current.items[0].children[2].key).toEqual('Jane'); + expect(result.current.items[0].children[3].key).toEqual('Eli'); + expect(result.current.items[1].key).toEqual('Emily'); + expect(result.current.items.length).toEqual(2); + }); }); From 8f14593534cfff36864a3f1cec32465514db6d4f Mon Sep 17 00:00:00 2001 From: Rob Date: Thu, 30 Jan 2025 17:52:03 +0000 Subject: [PATCH 2/7] add docs --- .../@react-stately/data/docs/useTreeData.mdx | 27 +++++++++++++++++++ .../@react-stately/data/src/useTreeData.ts | 4 +-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/data/docs/useTreeData.mdx b/packages/@react-stately/data/docs/useTreeData.mdx index 9ec0c4c3ceb..024c9872d7a 100644 --- a/packages/@react-stately/data/docs/useTreeData.mdx +++ b/packages/@react-stately/data/docs/useTreeData.mdx @@ -182,6 +182,33 @@ tree.move('Sam', 'Animals', 1); tree.move('Sam', null, 1); ``` +### Move before +An alias to move + +```tsx +// Move an item within the same parent +tree.moveBefore('Sam', 'People', 0); + +// Move an item to a different parent +tree.moveBefore('Sam', 'Animals', 1); + +// Move an item to the root +tree.moveBefore('Sam', null, 1); +``` + +### Move after + +```tsx +// Move an item within the same parent +tree.moveAfter('Sam', 'People', 0); + +// Move an item to a different parent +tree.moveAfter('Sam', 'Animals', 1); + +// Move an item to the root +tree.moveAfter('Sam', null, 1); +``` + ### Updating items ```tsx diff --git a/packages/@react-stately/data/src/useTreeData.ts b/packages/@react-stately/data/src/useTreeData.ts index 170ba439ec2..4fe47dd59eb 100644 --- a/packages/@react-stately/data/src/useTreeData.ts +++ b/packages/@react-stately/data/src/useTreeData.ts @@ -116,10 +116,10 @@ export interface TreeData { moveBefore(key: Key, toParentKey: Key | null, index: number): void, /** - * Moves an item aftera node within the tree. + * Moves an item after ia node within the tree. * @param key - The key of the item to move. * @param toParentKey - The key of the new parent to insert into. `null` for the root. - * @param index - The index within the new parent to insert before. + * @param index - The index within the new parent to insert after. */ moveAfter(key: Key, toParentKey: Key | null, index: number): void, From 768e2530fff8677e451c6d59df3cd8bb607040cc Mon Sep 17 00:00:00 2001 From: Rob Date: Thu, 30 Jan 2025 17:58:58 +0000 Subject: [PATCH 3/7] remove onlys --- packages/@react-stately/data/test/useTreeData.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/data/test/useTreeData.test.js b/packages/@react-stately/data/test/useTreeData.test.js index c47d54916aa..ae685f66c98 100644 --- a/packages/@react-stately/data/test/useTreeData.test.js +++ b/packages/@react-stately/data/test/useTreeData.test.js @@ -709,7 +709,7 @@ describe('useTreeData', function () { expect(result.current.items.length).toEqual(2); }); - it.only('should move an item to a different level after the target', function () { + it('should move an item to a different level after the target', function () { const initialItems = [...initial, {name: 'Emily'}, {name: 'Eli'}]; let {result} = renderHook(() => useTreeData({initialItems, getChildren, getKey}) @@ -727,7 +727,7 @@ describe('useTreeData', function () { expect(result.current.items.length).toEqual(2); }); - it.only('should move an item to a different level at the end when the index is greater than the node list length', 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(() => From aa2d3622753a8116eb23a8040bac49603f4640bb Mon Sep 17 00:00:00 2001 From: Rob Date: Thu, 30 Jan 2025 18:00:19 +0000 Subject: [PATCH 4/7] remove console logs --- packages/@react-stately/data/src/useTreeData.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@react-stately/data/src/useTreeData.ts b/packages/@react-stately/data/src/useTreeData.ts index 4fe47dd59eb..5bcaa98b9f3 100644 --- a/packages/@react-stately/data/src/useTreeData.ts +++ b/packages/@react-stately/data/src/useTreeData.ts @@ -116,7 +116,7 @@ export interface TreeData { moveBefore(key: Key, toParentKey: Key | null, index: number): void, /** - * Moves an item after ia node within the tree. + * Moves an item after a node within the tree. * @param key - The key of the item to move. * @param toParentKey - The key of the new parent to insert into. `null` for the root. * @param index - The index within the new parent to insert after. @@ -419,7 +419,6 @@ export function useTreeData(options: TreeOptions): TreeData // Otherwise, update the parent node and its ancestors. return updateTree(newItems, toParentKey, parentNode => { - console.log('parent node children ', parentNode.children); const c = [ ...parentNode.children!.slice(0, afterIndex), movedNode, From d9b4d571a042542702489816bdc1682af172959d Mon Sep 17 00:00:00 2001 From: Rob Date: Thu, 30 Jan 2025 22:44:32 +0000 Subject: [PATCH 5/7] [wip] add intial tree drag and drop --- .../@react-spectrum/tree/src/TreeView.tsx | 9 +- .../tree/stories/TreeView.stories.tsx | 125 +++++++++++++- .../data/test/useTreeData.test.js | 1 - packages/react-aria-components/package.json | 3 + packages/react-aria-components/src/Tree.tsx | 153 +++++++++++++++++- 5 files changed, 277 insertions(+), 14 deletions(-) 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..5eef11aef3f 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,99 @@ 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; + } + + // 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 +415,19 @@ export const WithActionMenu = (args: SpectrumTreeViewProps) => ( ); + + +function InsertionIndicator() { + return ( +
+ ); +} diff --git a/packages/@react-stately/data/test/useTreeData.test.js b/packages/@react-stately/data/test/useTreeData.test.js index ae685f66c98..eaffc8ad200 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}) ); diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index f29b0767168..6481c6bbdd8 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -37,6 +37,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@adobe/spectrum-css-temp": "3.0.0-alpha.1", "@internationalized/date": "^3.7.0", "@internationalized/string": "^3.2.5", "@react-aria/autocomplete": "3.0.0-alpha.37", @@ -52,6 +53,8 @@ "@react-aria/tree": "3.0.0-beta.3", "@react-aria/utils": "^3.27.0", "@react-aria/virtualizer": "^4.1.1", + "@react-spectrum/dnd": "^3.5.1", + "@react-spectrum/utils": "^3.12.1", "@react-stately/autocomplete": "3.0.0-alpha.0", "@react-stately/color": "^3.8.2", "@react-stately/disclosure": "^3.0.1", diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 053dfaa0b95..4da990a6b71 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 'selection' */ - 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}
@@ -341,6 +410,18 @@ export const UNSTABLE_TreeItem = /*#__PURE__*/ createBranchComponent('item', (() => ({ ...states, isHovered, @@ -400,7 +481,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); From 22b88d50edc0d59b95fed670bfcc407da450aac9 Mon Sep 17 00:00:00 2001 From: Rob Date: Fri, 31 Jan 2025 12:27:48 +0000 Subject: [PATCH 6/7] useTreeData - add getDescendantKeys method which is used to determine if a parent node can be dropped into its children --- .../tree/stories/TreeView.stories.tsx | 6 +++ .../@react-stately/data/src/useTreeData.ts | 20 +++++++ .../data/test/useTreeData.test.js | 54 +++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx index 5eef11aef3f..024f83ca78c 100644 --- a/packages/@react-spectrum/tree/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/tree/stories/TreeView.stories.tsx @@ -262,6 +262,12 @@ export const TreeExampleDynamicDragNDrop = ( 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) { 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 eaffc8ad200..563410bd6c3 100644 --- a/packages/@react-stately/data/test/useTreeData.test.js +++ b/packages/@react-stately/data/test/useTreeData.test.js @@ -745,4 +745,58 @@ 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([]); + }); + + }); From 121712a8a0e78615a916648b01ed0d83d6501011 Mon Sep 17 00:00:00 2001 From: Rob Date: Fri, 31 Jan 2025 12:33:16 +0000 Subject: [PATCH 7/7] revert packlog json change --- packages/react-aria-components/package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 596a4de3f8f..a6ba52df7d3 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -37,7 +37,6 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@adobe/spectrum-css-temp": "3.0.0-alpha.1", "@internationalized/date": "^3.7.0", "@internationalized/string": "^3.2.5", "@react-aria/autocomplete": "3.0.0-alpha.37", @@ -53,8 +52,6 @@ "@react-aria/tree": "3.0.0-beta.3", "@react-aria/utils": "^3.27.0", "@react-aria/virtualizer": "^4.1.1", - "@react-spectrum/dnd": "^3.5.1", - "@react-spectrum/utils": "^3.12.1", "@react-stately/autocomplete": "3.0.0-alpha.0", "@react-stately/color": "^3.8.2", "@react-stately/disclosure": "^3.0.1",