feat(bases): card-to-card kanban drag with edge slotting

This commit is contained in:
Philipinho
2026-05-24 15:34:40 +01:00
parent a9c6051d12
commit bdfd0413b4
4 changed files with 191 additions and 7 deletions
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import {
IBase,
IBaseRow,
@@ -7,7 +7,15 @@ import {
} from "@/features/base/types/base.types";
import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups";
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
import {
useCreateRowMutation,
useReorderRowMutation,
useUpdateRowMutation,
} from "@/features/base/queries/base-row-query";
import { resolveCardDrop } from "@/features/base/hooks/resolve-card-drop";
import type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { KanbanColumn } from "./kanban-column";
import { KanbanEmptyState } from "./kanban-empty-state";
import classes from "@/features/base/styles/kanban.module.css";
@@ -40,6 +48,9 @@ export function BaseKanban({
const isGroupable = property?.type === "select" || property?.type === "status";
const updateViewMutation = useUpdateViewMutation();
const createRowMutation = useCreateRowMutation();
const updateRowMutation = useUpdateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const sortsActive = (effectiveView?.config?.sorts?.length ?? 0) > 0;
// Rules of Hooks: call useKanbanGroups unconditionally with `undefined`
// when not groupable; switch the render path on isGroupable below.
@@ -59,6 +70,60 @@ export function BaseKanban({
});
};
const handleCardDrop = useCallback(
(payload: CardDropPayload) => {
if (!groupByPropertyId) return;
const targetColumn = columns.find((c) => c.key === payload.targetColumnKey);
// The drop target restricts allowedEdges to ["top","bottom"], so the
// runtime value is always assignable; narrow the broader Edge union.
const edge =
payload.edge === "top" || payload.edge === "bottom"
? payload.edge
: null;
const result = resolveCardDrop({
draggedCardId: payload.draggedCardId,
targetCardId: payload.targetCardId,
edge,
sourceColumnKey: payload.sourceColumnKey,
targetColumnKey: payload.targetColumnKey,
groupByPropertyId,
columnRows: targetColumn?.rows ?? [],
sortsActive,
});
if (result.cells !== undefined) {
updateRowMutation.mutate({
rowId: payload.draggedCardId,
pageId: base.id,
cells: result.cells,
...(result.position !== undefined && { position: result.position }),
});
} else if (result.position !== undefined) {
reorderRowMutation.mutate({
rowId: payload.draggedCardId,
pageId: base.id,
position: result.position,
});
}
// a11y + post-move flash on the dropped card (if still in DOM).
const el = document.querySelector(
`[data-row-id="${payload.draggedCardId}"]`,
);
if (el instanceof HTMLElement) triggerPostMoveFlash(el);
const colName = targetColumn?.name ?? "column";
liveRegion.announce(`Moved card to ${colName}`);
},
[
base.id,
columns,
groupByPropertyId,
reorderRowMutation,
sortsActive,
updateRowMutation,
],
);
const handleAddCard = (columnKey: string) => {
if (!groupByPropertyId) return;
const cells =
@@ -87,6 +152,7 @@ export function BaseKanban({
primaryProperty={primaryProperty}
onCardClick={onCardClick}
onAddCard={handleAddCard}
onCardDrop={handleCardDrop}
/>
))}
</div>
@@ -2,25 +2,46 @@ import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
import classes from "@/features/base/styles/kanban.module.css";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import {
useKanbanCardDrag,
type CardDropPayload,
} from "@/features/base/hooks/use-kanban-card-drag";
import { BaseDropEdgeIndicator } from "@/features/base/components/grid/base-drop-edge-indicator";
type KanbanCardProps = {
row: IBaseRow;
columnKey: string;
primaryProperty: IBaseProperty | undefined;
onClick: (rowId: string) => void;
onDrop: (payload: CardDropPayload) => void;
};
export function KanbanCard({ row, primaryProperty, onClick }: KanbanCardProps) {
export function KanbanCard({
row,
columnKey,
primaryProperty,
onClick,
onDrop,
}: KanbanCardProps) {
const { t } = useTranslation();
const titleValue =
primaryProperty
? ((row.cells ?? {})[primaryProperty.id] as string | undefined)
: undefined;
const { ref, isDragging, closestEdge } = useKanbanCardDrag({
cardId: row.id,
columnKey,
onDrop,
});
const titleValue = primaryProperty
? ((row.cells ?? {})[primaryProperty.id] as string | undefined)
: undefined;
const titleText = titleValue?.trim().length ? titleValue : t("Untitled");
const isEmpty = !titleValue?.trim().length;
return (
<div
ref={ref}
className={classes.card}
data-row-id={row.id}
data-dragging={isDragging || undefined}
role="button"
tabIndex={0}
onClick={() => onClick(row.id)}
@@ -34,6 +55,7 @@ export function KanbanCard({ row, primaryProperty, onClick }: KanbanCardProps) {
<div className={clsx(classes.cardTitle, isEmpty && classes.cardTitleEmpty)}>
{titleText}
</div>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -3,6 +3,7 @@ import { IBaseProperty } from "@/features/base/types/base.types";
import { KanbanCard } from "./kanban-card";
import { KanbanColumnHeader } from "./kanban-column-header";
import { KanbanAddCardButton } from "./kanban-add-card-button";
import type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag";
import classes from "@/features/base/styles/kanban.module.css";
type KanbanColumnProps = {
@@ -10,6 +11,7 @@ type KanbanColumnProps = {
primaryProperty: IBaseProperty | undefined;
onCardClick: (rowId: string) => void;
onAddCard: (columnKey: string) => void;
onCardDrop: (payload: CardDropPayload) => void;
};
export function KanbanColumn({
@@ -17,6 +19,7 @@ export function KanbanColumn({
primaryProperty,
onCardClick,
onAddCard,
onCardDrop,
}: KanbanColumnProps) {
return (
<div className={classes.column} data-column-key={column.key}>
@@ -30,8 +33,10 @@ export function KanbanColumn({
<KanbanCard
key={row.id}
row={row}
columnKey={column.key}
primaryProperty={primaryProperty}
onClick={onCardClick}
onDrop={onCardDrop}
/>
))}
<KanbanAddCardButton onClick={() => onAddCard(column.key)} />
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
export type CardDragData = {
type: "base-kanban-card";
cardId: string;
columnKey: string;
};
export type CardDropPayload = {
draggedCardId: string;
targetCardId: string;
edge: Edge;
sourceColumnKey: string;
targetColumnKey: string;
};
export function useKanbanCardDrag({
cardId,
columnKey,
onDrop,
disabled,
}: {
cardId: string;
columnKey: string;
onDrop: (payload: CardDropPayload) => void;
disabled?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
// Keep onDrop fresh without re-registering the effect each render.
const onDropRef = useRef(onDrop);
onDropRef.current = onDrop;
useEffect(() => {
const el = ref.current;
if (!el || disabled) return;
const data: CardDragData = {
type: "base-kanban-card",
cardId,
columnKey,
};
return combine(
draggable({
element: el,
getInitialData: () => data,
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: el,
canDrop: ({ source }) => {
if (source.data.type !== "base-kanban-card") return false;
return source.data.cardId !== cardId;
},
getData: ({ input, element }) =>
attachClosestEdge(
{ type: "base-kanban-card-target", cardId, columnKey },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
if (source.data.type !== "base-kanban-card") return;
const edge = extractClosestEdge(self.data);
if (!edge) return;
onDropRef.current({
draggedCardId: source.data.cardId as string,
targetCardId: cardId,
edge,
sourceColumnKey: source.data.columnKey as string,
targetColumnKey: columnKey,
});
},
}),
);
}, [cardId, columnKey, disabled]);
return { ref, isDragging, closestEdge };
}