Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save colmtuite/1dea505984ca04f0068e25a1d3692d4a to your computer and use it in GitHub Desktop.

Select an option

Save colmtuite/1dea505984ca04f0068e25a1d3692d4a to your computer and use it in GitHub Desktop.
'use client';
import * as React from 'react';
import clsx from 'clsx';
import {
dropTarget,
monitorForElements,
useDraggable,
useIsDragging,
} from '@base-ui/plus/drag-engine';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import styles from './kanban-snap.module.css';
// Demonstrates a Trello-style "snap to closest column" pattern using only
// `monitorForElements`. Columns are intentionally not registered as drop
// targets: the monitor reads the pointer position on every drag event and
// resolves the horizontally-closest column from a consumer-maintained map of
// column elements. Drops land in whichever column is closest at release time,
// even when the pointer is outside every column.
type ColumnId = string;
type CardId = string;
interface Card {
id: CardId;
title: string;
}
interface Column {
id: ColumnId;
title: string;
cardIds: CardId[];
}
interface Board {
columnOrder: ColumnId[];
columns: Record<ColumnId, Column>;
cards: Record<CardId, Card>;
}
const CARD_KIND = 'kanbanSnap:card';
interface CardDragData {
id: CardId;
fromColumn: ColumnId;
}
function buildInitialBoard(): Board {
const columns: Column[] = [
{ id: 'todo', title: 'Todo', cardIds: ['c1', 'c2', 'c3'] },
{ id: 'in-progress', title: 'In progress', cardIds: ['c4', 'c5'] },
{ id: 'review', title: 'Review', cardIds: ['c6'] },
{ id: 'done', title: 'Done', cardIds: ['c7', 'c8'] },
];
const cards: Card[] = [
{ id: 'c1', title: 'Write spec' },
{ id: 'c2', title: 'Sketch UI' },
{ id: 'c3', title: 'Set up project' },
{ id: 'c4', title: 'Wire the API' },
{ id: 'c5', title: 'Build the form' },
{ id: 'c6', title: 'Self review' },
{ id: 'c7', title: 'Ship v0' },
{ id: 'c8', title: 'Tell the team' },
];
return {
columnOrder: columns.map((c) => c.id),
columns: Object.fromEntries(columns.map((c) => [c.id, c])),
cards: Object.fromEntries(cards.map((c) => [c.id, c])),
};
}
function findClosestColumn(clientX: number, elements: Map<ColumnId, HTMLElement>): ColumnId | null {
let bestId: ColumnId | null = null;
let bestDx = Infinity;
for (const [id, el] of elements) {
const rect = el.getBoundingClientRect();
const center = rect.left + rect.width / 2;
const dx = Math.abs(clientX - center);
if (dx < bestDx) {
bestDx = dx;
bestId = id;
}
}
return bestId;
}
export default function KanbanSnap() {
const [board, setBoard] = React.useState<Board>(buildInitialBoard);
const [activeColumnId, setActiveColumnId] = React.useState<ColumnId | null>(null);
const columnElementsRef = React.useRef<Map<ColumnId, HTMLElement>>(new Map());
const registerColumnElement = useStableCallback((id: ColumnId, el: HTMLElement | null) => {
if (el) {
columnElementsRef.current.set(id, el);
} else {
columnElementsRef.current.delete(id);
}
});
const moveCard = useStableCallback((cardId: CardId, fromColumn: ColumnId, toColumn: ColumnId) => {
setBoard((prev) => {
const from = prev.columns[fromColumn];
const to = prev.columns[toColumn];
if (!from || !to) {
return prev;
}
if (fromColumn === toColumn) {
return prev;
}
return {
...prev,
columns: {
...prev.columns,
[fromColumn]: { ...from, cardIds: from.cardIds.filter((id) => id !== cardId) },
[toColumn]: { ...to, cardIds: [...to.cardIds, cardId] },
},
};
});
});
// Catch-all drop target on the document body. Two reasons it lives on
// `<body>` rather than the experiment root:
// 1. Native drag-and-drop animates the drag image back to the source
// ("snap-back") when releasing outside any registered drop target.
// Putting the catch-all on `<body>` guarantees every release on the
// page is a valid drop, suppressing the animation.
// 2. It lets the monitor distinguish a real release from an Escape
// cancel: the engine clears `dropTargets` on cancel, so a non-empty
// stack at drop time means the user actually released.
React.useEffect(() => {
return dropTarget<CardDragData>({
element: document.body,
acceptKinds: [CARD_KIND],
});
}, []);
React.useEffect(() => {
return monitorForElements<CardDragData>({
acceptKinds: [CARD_KIND],
onDragStart: ({ location }) => {
setActiveColumnId(
findClosestColumn(location.current.input.clientX, columnElementsRef.current),
);
},
onDrag: ({ location }) => {
setActiveColumnId(
findClosestColumn(location.current.input.clientX, columnElementsRef.current),
);
},
onDrop: ({ source, location }) => {
const cancelled = location.current.dropTargets.length === 0;
if (!cancelled) {
const target = findClosestColumn(
location.current.input.clientX,
columnElementsRef.current,
);
if (target) {
moveCard(source.data.id, source.data.fromColumn, target);
}
}
setActiveColumnId(null);
},
});
}, [moveCard]);
return (
<div className={styles.root}>
<header>
<h1 className={styles.title}>Snap-to-closest column</h1>
<p className={styles.subtitle}>
Drag a card anywhere on this page — including above, below, or to the side of the board.
The card always drops into the horizontally-closest column.
</p>
</header>
<div className={styles.board}>
{board.columnOrder.map((id) => {
const column = board.columns[id];
return (
<KanbanColumn
key={id}
column={column}
cards={column.cardIds.map((cardId) => board.cards[cardId])}
isActive={activeColumnId === id}
registerElement={registerColumnElement}
/>
);
})}
</div>
</div>
);
}
function KanbanColumn({
column,
cards,
isActive,
registerElement,
}: {
column: Column;
cards: Card[];
isActive: boolean;
registerElement: (id: ColumnId, el: HTMLElement | null) => void;
}) {
const setRef = React.useCallback(
(el: HTMLDivElement | null) => {
registerElement(column.id, el);
},
[column.id, registerElement],
);
return (
<div ref={setRef} className={clsx(styles.column, isActive && styles.columnActive)}>
<div className={styles.columnHeader}>{column.title}</div>
<div className={styles.columnBody}>
{cards.map((card) => (
<DraggableCard key={card.id} card={card} columnId={column.id} />
))}
{cards.length === 0 && <div className={styles.emptyHint}>(empty)</div>}
</div>
</div>
);
}
function DraggableCard({ card, columnId }: { card: Card; columnId: ColumnId }) {
const { ref, dragPreview } = useDraggable<CardDragData, HTMLDivElement>({
id: card.id,
kind: CARD_KIND,
getInitialData: () => ({ id: card.id, fromColumn: columnId }),
renderDragPreview: () => <div className={styles.preview}>{card.title}</div>,
});
const isDragging = useIsDragging({ id: card.id });
return (
<React.Fragment>
<div ref={ref} className={clsx(styles.card, isDragging && styles.cardDragging)}>
{card.title}
</div>
{dragPreview}
</React.Fragment>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment