Skip to content

Commit 21d1679

Browse files
committed
feat: add foreign drag object checks
1 parent e8ddbb0 commit 21d1679

File tree

5 files changed

+211
-4
lines changed

5 files changed

+211
-4
lines changed

.changeset/new-taxis-guess.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@headless-tree/core": minor
3+
---
4+
5+
add `canDragForeignDragObjectOver` to allow customizing whether a draggable visualization should be shown when dragging foreign data. This allows differentiating logic between drag-over and drop (via the existing `canDropForeignDataObject`), since for the latter `dataTransfer.getData` is not available by default in browsers.

packages/core/src/features/drag-and-drop/drag-and-drop.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,9 @@ describe("core-feature/drag-and-drop", () => {
300300

301301
it("drags foreign object inside tree, on folder", () => {
302302
tree.mockedHandler("canDropForeignDragObject").mockReturnValue(true);
303+
tree
304+
.mockedHandler("canDragForeignDragObjectOver")
305+
.mockReturnValue(true);
303306
const onDropForeignDragObject = tree.mockedHandler(
304307
"onDropForeignDragObject",
305308
);
@@ -318,6 +321,9 @@ describe("core-feature/drag-and-drop", () => {
318321
tree
319322
.mockedHandler("canDropForeignDragObject")
320323
.mockImplementation((_, target) => target.item.isFolder());
324+
tree
325+
.mockedHandler("canDragForeignDragObjectOver")
326+
.mockImplementation((_, target) => target.item.isFolder());
321327
const onDropForeignDragObject = tree.mockedHandler(
322328
"onDropForeignDragObject",
323329
);
@@ -340,6 +346,9 @@ describe("core-feature/drag-and-drop", () => {
340346

341347
it("doesnt drag foreign object inside tree if not allowed", () => {
342348
tree.mockedHandler("canDropForeignDragObject").mockReturnValue(false);
349+
tree
350+
.mockedHandler("canDragForeignDragObjectOver")
351+
.mockReturnValue(false);
343352
const onDropForeignDragObject = tree.mockedHandler(
344353
"onDropForeignDragObject",
345354
);

packages/core/src/features/drag-and-drop/feature.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export const dragAndDropFeature: FeatureImplementation = {
1515
getDefaultConfig: (defaultConfig, tree) => ({
1616
canDrop: (_, target) => target.item.isFolder(),
1717
canDropForeignDragObject: () => false,
18+
canDragForeignDragObjectOver: defaultConfig.canDropForeignDragObject
19+
? (dataTransfer) => dataTransfer.effectAllowed !== "none"
20+
: () => false,
1821
setDndState: makeStateUpdater("dnd", tree),
1922
canReorder: true,
2023
...defaultConfig,
@@ -162,9 +165,13 @@ export const dragAndDropFeature: FeatureImplementation = {
162165
e.dataTransfer?.setDragImage(imgElement, xOffset ?? 0, yOffset ?? 0);
163166
}
164167

165-
if (config.createForeignDragObject) {
166-
const { format, data } = config.createForeignDragObject(items);
167-
e.dataTransfer?.setData(format, data);
168+
if (config.createForeignDragObject && e.dataTransfer) {
169+
const { format, data, dropEffect, effectAllowed } =
170+
config.createForeignDragObject(items);
171+
e.dataTransfer.setData(format, data);
172+
173+
if (dropEffect) e.dataTransfer.dropEffect = dropEffect;
174+
if (effectAllowed) e.dataTransfer.effectAllowed = effectAllowed;
168175
}
169176

170177
tree.applySubStateUpdate("dnd", {
@@ -174,6 +181,7 @@ export const dragAndDropFeature: FeatureImplementation = {
174181
},
175182

176183
onDragOver: (e: DragEvent) => {
184+
e.stopPropagation(); // don't bubble up to container dragover
177185
const dataRef = tree.getDataRef<DndDataRef>();
178186
const nextDragCode = getDragCode(e, item, tree);
179187
if (nextDragCode === dataRef.current.lastDragCode) {
@@ -191,7 +199,7 @@ export const dragAndDropFeature: FeatureImplementation = {
191199
(!e.dataTransfer ||
192200
!tree
193201
.getConfig()
194-
.canDropForeignDragObject?.(e.dataTransfer, target))
202+
.canDragForeignDragObjectOver?.(e.dataTransfer, target))
195203
) {
196204
dataRef.current.lastAllowDrop = false;
197205
return;

packages/core/src/features/drag-and-drop/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,31 @@ export type DragAndDropFeatureDef<T> = {
5858
createForeignDragObject?: (items: ItemInstance<T>[]) => {
5959
format: string;
6060
data: any;
61+
dropEffect?: DataTransfer["dropEffect"];
62+
effectAllowed?: DataTransfer["effectAllowed"];
6163
};
6264
setDragImage?: (items: ItemInstance<T>[]) => {
6365
imgElement: Element;
6466
xOffset?: number;
6567
yOffset?: number;
6668
};
69+
70+
/** Checks if a foreign drag object can be dropped on a target, validating that an actual drop can commence based on
71+
* the data in the DataTransfer object. */
6772
canDropForeignDragObject?: (
6873
dataTransfer: DataTransfer,
6974
target: DragTarget<T>,
7075
) => boolean;
76+
77+
/** Checks if a droppable visualization should be displayed when dragging a foreign object over a target. Since this
78+
* is executed on a dragover event, `dataTransfer.getData()` is not available, so `dataTransfer.effectAllowed` or
79+
* `dataTransfer.types` should be used instead. Before actually completing the drag, @{link canDropForeignDragObject}
80+
* will be called by HT before applying the drop. */
81+
canDragForeignDragObjectOver?: (
82+
dataTransfer: DataTransfer,
83+
target: DragTarget<T>,
84+
) => boolean;
85+
7186
onDrop?: (
7287
items: ItemInstance<T>[],
7388
target: DragTarget<T>,
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { Meta } from "@storybook/react";
2+
import React from "react";
3+
import {
4+
dragAndDropFeature,
5+
hotkeysCoreFeature,
6+
insertItemsAtTarget,
7+
keyboardDragAndDropFeature,
8+
removeItemsFromParents,
9+
selectionFeature,
10+
syncDataLoaderFeature,
11+
} from "@headless-tree/core";
12+
import { useTree } from "@headless-tree/react";
13+
import cn from "classnames";
14+
15+
const meta = {
16+
title: "React/Guides/Multiple Trees Drop Restriction",
17+
tags: ["feature/dnd"],
18+
} satisfies Meta;
19+
20+
export default meta;
21+
22+
// story-start
23+
type Item = {
24+
name: string;
25+
children?: string[];
26+
};
27+
28+
const data: Record<string, Item> = {
29+
root1: { name: "Root", children: ["lunch", "dessert"] },
30+
root2: {
31+
name: "Root",
32+
children: ["solar", "centauri", "cannotdrop1", "cannotdrop2"],
33+
},
34+
lunch: { name: "Lunch", children: ["sandwich", "salad", "soup"] },
35+
sandwich: { name: "Sandwich" },
36+
salad: { name: "Salad" },
37+
soup: { name: "Soup", children: ["tomato", "chicken"] },
38+
tomato: { name: "Tomato" },
39+
chicken: { name: "Chicken" },
40+
dessert: { name: "Dessert", children: ["icecream", "cake"] },
41+
icecream: { name: "Icecream" },
42+
cake: { name: "Cake" },
43+
solar: {
44+
name: "Solar System",
45+
children: ["jupiter", "earth", "mars", "venus"],
46+
},
47+
jupiter: { name: "Jupiter", children: ["io", "europa", "ganymede"] },
48+
io: { name: "Io" },
49+
europa: { name: "Europa" },
50+
ganymede: { name: "Ganymede" },
51+
earth: { name: "Earth", children: ["moon"] },
52+
moon: { name: "Moon" },
53+
mars: { name: "Mars" },
54+
venus: { name: "Venus" },
55+
centauri: {
56+
name: "Alpha Centauri",
57+
children: ["rigilkent", "toliman", "proxima"],
58+
},
59+
rigilkent: { name: "Rigel Kentaurus" },
60+
toliman: { name: "Toliman" },
61+
proxima: { name: "Proxima Centauri" },
62+
cannotdrop1: { name: "Cannot Drop in other Tree 1" },
63+
cannotdrop2: { name: "Cannot Drop in other Tree 2" },
64+
};
65+
66+
const Tree = (props: { root: string; prefix: string }) => {
67+
const tree = useTree<Item>({
68+
rootItemId: props.root,
69+
dataLoader: {
70+
getItem: (id) => data[id],
71+
getChildren: (id) => data[id]?.children ?? [],
72+
},
73+
getItemName: (item) => item.getItemData().name,
74+
isItemFolder: (item) => item.getItemData().children !== undefined,
75+
canReorder: true,
76+
indent: 20,
77+
78+
// When moving items out of the tree, this is used to serialize the
79+
// dragged items as foreign drag object
80+
createForeignDragObject: (items) => ({
81+
format: "text/plain",
82+
data: JSON.stringify(items.map((item) => item.getId())),
83+
effectAllowed: items.some((item) => item.getId().includes("cannotdrop"))
84+
? "none"
85+
: "copy",
86+
}),
87+
88+
// Called before dropping to check if foreign drag object can be dropped
89+
canDropForeignDragObject: (dataTransfer) =>
90+
!dataTransfer.getData("text/plain").includes("cannotdrop"),
91+
92+
// Called on every drag over to determine if a draggable visualization should be shown
93+
canDragForeignDragObjectOver: (dataTransfer) =>
94+
dataTransfer.effectAllowed !== "none",
95+
96+
// Remaining drop logic. This is the same as in "Multiple Trees", see this example for more
97+
// details on that logic.
98+
onDrop: async (items, target) => {
99+
const itemIds = items.map((item) => item.getId());
100+
await removeItemsFromParents(items, (item, newChildren) => {
101+
item.getItemData().children = newChildren;
102+
});
103+
await insertItemsAtTarget(itemIds, target, (item, newChildren) => {
104+
item.getItemData().children = newChildren;
105+
});
106+
},
107+
onDropForeignDragObject: (dataTransfer, target) => {
108+
const newChildrenIds = JSON.parse(dataTransfer.getData("text/plain"));
109+
insertItemsAtTarget(newChildrenIds, target, (item, newChildren) => {
110+
item.getItemData().children = newChildren;
111+
});
112+
},
113+
onCompleteForeignDrop: (items) => {
114+
removeItemsFromParents(items, (item, newChildren) => {
115+
item.getItemData().children = newChildren;
116+
});
117+
},
118+
features: [
119+
syncDataLoaderFeature,
120+
selectionFeature,
121+
hotkeysCoreFeature,
122+
dragAndDropFeature,
123+
keyboardDragAndDropFeature,
124+
],
125+
});
126+
127+
return (
128+
<div {...tree.getContainerProps()} className="tree">
129+
{tree.getItems().map((item) => (
130+
<button
131+
{...item.getProps()}
132+
key={item.getId()}
133+
style={{ paddingLeft: `${item.getItemMeta().level * 20}px` }}
134+
>
135+
<div
136+
className={cn("treeitem", {
137+
focused: item.isFocused(),
138+
expanded: item.isExpanded(),
139+
selected: item.isSelected(),
140+
folder: item.isFolder(),
141+
drop: item.isDragTarget(),
142+
})}
143+
>
144+
{item.getItemName()}
145+
</div>
146+
</button>
147+
))}
148+
<div style={tree.getDragLineStyle()} className="dragline" />
149+
</div>
150+
);
151+
};
152+
153+
export const MultipleTreesDropRestriction = () => {
154+
return (
155+
<>
156+
<p className="description">
157+
In this case, "canDragForeignDragObjectOver" is used to determine if a
158+
foreign drag object can be dragged over a target.
159+
</p>
160+
<div style={{ display: "flex" }}>
161+
<div style={{ width: "200px", marginRight: "20px" }}>
162+
<Tree root="root1" prefix="a" />
163+
</div>
164+
<div style={{ width: "200px", marginRight: "20px" }}>
165+
<Tree root="root2" prefix="b" />
166+
</div>
167+
</div>
168+
</>
169+
);
170+
};

0 commit comments

Comments
 (0)