I am currently attempting to create a Story Map grid using @dnd-kit. A Story Map board contains Activities, which contain Steps, which contain Items. So my data structure looks like this
export interface Item {
id: string;
stepId: string;
name: string;
index: number;
}
export interface Step {
id: string;
activityId: string;
name: string;
index: number;
items: Array<Item>;
}
export interface Activity {
id: string;
name: string;
index: number;
steps: Array<Step>;
}
Now, I basically want to be able to sort my activities between them, sort steps between them only inside of their activity and the same for items. This means you can't change the parent (look at the provided Story Map article link for a better visual representation). When I initially created the board, I not only had nested <SortableContext> components, but I also had nested <DndContext> components.
<DndContext ...>
<SortableContext ...> {/* horizontal */}
<DndContext ...>
<SortableContext> {/* horizontal */}
<DndContext ...>
<SortableContext ...> {/* vertical */}
</SortableContext>
</DndContext>
</SortableContext>
</DndContext>
</SortableContext />
</DndContext>
This was working fine and I had the results that I expected. Basically, I could drag the items anywhere on the screen, even if the Draggable item wasn't over the context container and it would swap the items. I also had nice animations when swapping and there was no overlap between the draggables from separate contexts.
The only issue was that I wanted to add a Droppable Trash container, so that when an item was being dragged, I would show the Trash and you could drop it in to delete an item. Unfortunately, since I was using separate DndContexts for the three types of items, it wasn't working as expected. I had to go from 3 DndContext wrappers, to just one and use the 3 SortableContext wrappers like before. Now, the code looks like this:
<DndContext ...>
<SortableContext ...> {/* horizontal */}
<SortableContext> {/* horizontal */}
<SortableContext ...> {/* vertical */}
</SortableContext>
</SortableContext>
</SortableContext />
</DndContext>
Now that I have it setup this way, I have multiple issues.
When swapping any type of item, I have to stay within that item's SortableContext for the items to swap (before I could drag anywhere as long as the swapping axis was overlapping). Image: Item outside of context, not swapping.
Image: This one works if I drag directly inside the context, but not over other types of items

The animations aren't really working for the Step or Item items, only for the Activity items. They do swap, they just sort of snap when they do and the Activity ones will smoothly swap.
When dragging a parent item (Step or Activity), I can't go over a child item or it will act as if I am outside of the context. Basically, if I drag an Activity horizontally, as long as I am over another Activity, it will swap. If I move the item down and over a Step or Item, it will shift back as if the swap is not working. If I move the Activity in between the gaps of those children elements, it will swap fine.
Image: This image shows what I mean about dragging over a child item when swapping

If I drag an item over the Trash droppable, I have it so that the border turns red when isOver, but if there is another Draggable item under the Trash, and my mouse overlaps that item's space, it will act as if I'm not over the Trash.
Image: This image shows how it should look when dragging over the Trash component
Image: This image shows how dragging over trash won't work if there is another item behind the cursor (I'm using pointerWithin collision detection).

Here is how my code looks
MainContext.tsx
import React, { useState } from "react";
import { createPortal } from "react-dom";
import {
Active,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
pointerWithin,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import ActivityContainer from "./activity/ActivityContainer";
import ActivityItem from "./activity/ActivityItem";
import DetailItem from "./detail-item/DetailItem";
import StepItem from "./step/StepItem";
import Trash from "./Trash";
import { Activity, Item, Step } from "./types";
interface Props {
activities: Array<Activity>;
onMutateActivity: (activities: Array<Activity>) => void;
onMutateStep: (steps: Array<Step>) => void;
onMutateItem: (items: Array<Item>) => void;
}
const MainContext: React.FC<Props> = ({
activities,
onMutateActivity,
onMutateStep,
onMutateItem,
}) => {
const [activeItem, setActiveItem] = useState<
Activity | Step | Item | undefined
>(undefined);
// for input methods detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 250,
distance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = (event: DragStartEvent) => {
setActiveItem(
event.active.data.current?.current as
| Activity
| Step
| Item
);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveItem(undefined);
if (!over) return;
if (over.id === "trash") {
handleDelete(active);
} else {
if ("steps" in active.data.current?.current) {
handleActivityDragEnd(event);
} else if ("items" in active.data.current?.current) {
handleStepDragEnd(event);
} else {
handleItemDragEnd(event);
}
}
};
const handleDelete = (active: Active) => {
// handle delete logic
console.log(`${active.id} is being deleted...`);
};
const handleActivityDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const oldIndex = activities.findIndex(
activity => activity.id === active.id
);
const newIndex = activities.findIndex(activity => activity.id === over.id);
if (oldIndex < 0 || newIndex < 0 || newIndex === oldIndex) return;
const newActivities = arrayMove(activities, oldIndex, newIndex);
newActivities.forEach((item, index) => {
item.index = index + 1;
});
onMutateActivity(newActivities);
};
const handleStepDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const activityId = (active.data.current?.current as Step)
.activityId;
const activity = activities.find(a => a.id === activityId);
if (!activity) return;
const steps = activity.steps;
const oldIndex = steps.findIndex(step => step.id === active.id);
const newIndex = steps.findIndex(step => step.id === over.id);
const newSteps = arrayMove(steps, oldIndex, newIndex);
newSteps.forEach((item, index) => {
item.index = index + 1;
});
onMutateStep(newSteps);
};
const handleItemDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const stepId = (active.data.current?.current as Item).stepId;
const activity = activities.find(a =>
a.steps.some(s => s.id === stepId)
);
if (!activity) return;
const step = activity.steps.find(s => s.id === stepId);
if (!step) return;
const items = step.items;
const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex(item => item.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
newItems.forEach((item, index) => {
item.index = index + 1;
});
onMutateItem(newItems);
};
const handleDragCancel = () => {
setActiveItem(undefined);
};
const renderOverlay = () => {
if (!activeItem) return null;
if ("steps" in activeItem) {
return (
<ActivityItem activity={activeItem as Activity} isDragging />
);
} else if ("items" in activeItem) {
return <StepItem step={activeItem as Step} isDragging />;
} else {
return <Item item={activeItem as Item} isDragging />;
}
};
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<ActivityContainer activities={activities} />
{activeItem ? <Trash /> : null}
{createPortal(
<DragOverlay>{renderOverlay()}</DragOverlay>,
document.body
)}
</DndContext>
);
};
export default MainContext;
Trash.tsx
import React from "react";
import { useDroppable } from "@dnd-kit/core";
const Trash: React.FC = () => {
const { isOver, setNodeRef } = useDroppable({ id: "trash" });
return (
<div
ref={setNodeRef}
style={{
border: isOver ? "3px solid red" : "1px dashed gray",
width: "200px",
height: "200px",
position: "fixed",
bottom: "10px",
right: "calc(50% - 100px)",
}}
>
Trash
</div>
);
};
export default Trash;
ActivityContainer.tsx
import React from "react";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { Activity } from "../types";
import SortableActivity from "./SortableActivity";
interface Props {
activities: Array<Activity>;
}
const ActivityContainer: React.FC<Props> = ({ activities }) => {
return (
<SortableContext
items={activities.map(activity => activity.id)}
strategy={horizontalListSortingStrategy}
>
{activities.map(activity => (
<SortableActivity key={activity.id} activity={activity} />
))}
</SortableContext>
);
};
export default ActivityContainer;
SortableActivity.tsx
import React, { HTMLAttributes } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import ActivityItem from "./ActivityItem";
import { Activity } from "../types";
type Props = {
activity: Activity;
} & HTMLAttributes<HTMLDivElement>;
const SortableActivity: React.FC<Props> = ({ activity, ...props }) => {
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: activity.id,
data: { type: "activity", current: activity },
});
const styles = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<ActivityItem
activity={activity}
ref={setNodeRef}
isOpacityEnabled={isDragging}
isDragging={isDragging}
style={styles}
attributes={attributes}
listeners={listeners}
{...props}
/>
);
};
export default SortableActivity;
ActivityItem.tsx
import React, { forwardRef, HTMLAttributes } from "react";
import {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import { Activity } from "../types";
import StepContainer from "../step/StepContainer";
type Props = {
activity: Activity;
isOpacityEnabled?: boolean;
isDragging: boolean;
attributes?: DraggableAttributes;
listeners?: DraggableSyntheticListeners;
} & HTMLAttributes<HTMLDivElement>;
const ActivityItem = forwardRef<HTMLDivElement, Props>(
(
{ activity, isOpacityEnabled, isDragging, attributes, listeners, ...props },
ref
) => {
return (
<div
ref={ref}
className={`flex flex-col rounded-lg gap-2 ${
isOpacityEnabled ? "opacity-40" : "opacity-100"
}`}
{...props}
>
<div
className={`flex justify-center items-center h-14 bg-[#92a4efb2] rounded-lg px-[1.375rem] py-[.3125rem] ${
isDragging ? "cursor-grabbing shadow-xl" : "cursor-grab shadow-sm"
} ${isOpacityEnabled ? "shadow-none" : ""}`}
{...attributes}
{...listeners}
>
<h3
className={`text-[#23262f] text-lg text-center font-medium w-full line-clamp-1`}
>
{activity.name}
</h3>
</div>
<StepContainer steps={activity.steps} />
</div>
);
}
);
export default ActivityItem;
StepContainer.tsx
import React from "react";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { Step } from "../types";
import SortableStep from "./SortableStep";
type Props = {
steps: Array<Step>;
};
const StepContainer: React.FC<Props> = ({ steps }) => {
return (
<SortableContext
items={steps.map(step => step.id)}
strategy={horizontalListSortingStrategy}
>
<div className={`grid grid-flow-col min-w-max gap-2`}>
{steps.map(step => {
return <SortableStep key={step.id} step={step} />;
})}
</div>
</SortableContext>
);
};
export default StepContainer;
SortableStep.tsx
import React, { HTMLAttributes } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import StepItem from "./StepItem";
import { Step } from "../types";
type Props = {
step: Step;
} & HTMLAttributes<HTMLDivElement>;
const SortableStep: React.FC<Props> = ({ step, ...props }) => {
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: step.id, data: { type: "step", current: step } });
const styles = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<StepItem
step={step}
ref={setNodeRef}
isOpacityEnabled={isDragging}
isDragging={isDragging}
style={styles}
attributes={attributes}
listeners={listeners}
{...props}
/>
);
};
export default SortableStep;
StepItem.tsx
import React, { forwardRef, HTMLAttributes } from "react";
import {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import { Step } from "../types";
import ItemContainer from "../item/ItemContainer";
type Props = {
step: Step;
isOpacityEnabled?: boolean;
isDragging: boolean;
attributes?: DraggableAttributes;
listeners?: DraggableSyntheticListeners;
} & HTMLAttributes<HTMLDivElement>;
const StepItem = forwardRef<HTMLDivElement, Props>(
(
{ step, isOpacityEnabled, isDragging, attributes, listeners, ...props },
ref
) => {
return (
<div
ref={ref}
className={`flex flex-col rounded-lg gap-2 ${
isOpacityEnabled ? "opacity-40" : "opacity-100"
}`}
{...props}
>
<div
className={`flex justify-center items-center h-20 w-[8.25rem] p-2 bg-orange-200 rounded-lg mb-2 ${
isDragging ? "cursor-grabbing shadow-xl" : "cursor-grab shadow-sm"
} ${isOpacityEnabled ? "shadow-none" : ""}`}
{...attributes}
{...listeners}
>
<h4 className={`text-center w-full line-clamp-2`}>{step.name}</h4>
</div>
<ItemContainer items={step.items} />
<div
className={`flex justify-center cursor-pointer items-center h-20 w-[8.25rem] p-2 bg-[#dddee2] text-white rounded-lg text-3xl`}
>
+
</div>
</div>
);
}
);
export default StepItem;
There is also the ItemContainer, SortableItem and Item files but they are very similar to the Step files, it just doesn't include another SortableContext within it, if it is needed, I can edit and add those files, but I think the provided files kind of explain my issue and I don't want to make this question any longer than it already is.
You should add modifiers={[restrictToHorizontalAxis, restrictToParentElement]} to your DndContext
not sure what you mean
1 will fix that
Trash is complex, you need to use 2 different types of collisionDetection. You can find in dnd-kit doc
And btw you have great sandbox example here, it fits to your example, there is trash too (hidden)
hint: remove condition to show trash:
{trashable && activeId && !containers.includes(activeId) ? (
<Trash id={TRASH_ID} />
) : null}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With