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 DndContext
s 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
orItem
items, only for theActivity
items. They do swap, they just sort of snap when they do and theActivity
ones will smoothly swap. -
When dragging a parent item (
Step
orActivity
), I can’t go over a child item or it will act as if I am outside of the context. Basically, if I drag anActivity
horizontally, as long as I am over anotherActivity
, it will swap. If I move the item down and over aStep
orItem
, it will shift back as if the swap is not working. If I move theActivity
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 anotherDraggable
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 usingpointerWithin
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.