@dnd-kit nested SortableContext components overlapping context

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.

  1. 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.
    Item outside of Context, not swapping
    Image: This one works if I drag directly inside the context, but not over other types of items
    Working Activity drag

  2. 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.

  3. 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
    Non working Activity Drag

  4. 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
    Working drag over Trash
    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).
    Non working drag over Trash

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.

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật