Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

like image 1000
Alexiz Hernandez Avatar asked Nov 14 '25 11:11

Alexiz Hernandez


1 Answers

  1. You should add modifiers={[restrictToHorizontalAxis, restrictToParentElement]} to your DndContext

  2. not sure what you mean

  3. 1 will fix that

  4. 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}
like image 147
Werthis Avatar answered Nov 17 '25 07:11

Werthis



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!