TaskKanbanColumn.tsx10.5 KBView on GitHub 'use client';
/**
* A single task-board column, styled after a Linear board: a light rounded backdrop, a header of
* [icon · name · count] with hover-revealed add / options buttons, and a card list that scrolls
* internally (invisible scrollbar) so the board itself never grows.
*
* Purpose-built rather than reusing CanvasKanbanColumn: that one is the conversation kanban's own
* look (no backdrop, an eye-toggle, a summary line), and this needs a distinct chrome. Both wrap
* `useDroppable`; there's no shared state to lose by keeping them separate.
*/
import { SortableContext, type SortingStrategy } from '@dnd-kit/sortable';
import { MoreHorizontal, Plus } from 'lucide-react';
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
interface TaskKanbanColumnProps {
/** dnd droppable id — the target group id, or the Misc/Upcoming sentinels. */
id: string;
label: string;
icon: React.ReactNode;
count: number;
/** The cards for this column — the only thing that scrolls. */
children: React.ReactNode;
/** Pinned below the scroll area, always visible (e.g. the overflow-cleanup button). */
footer?: React.ReactNode;
/** Whether a card can be dropped here. */
canDrop?: boolean;
/**
* The cursor is over this lane mid-drag. Passed down rather than read from `useDroppable`'s own
* `isOver`, because a drop onto a CARD in this lane reports over-the-card, not over-the-column —
* so the lane would go dark exactly when you were aiming at it most precisely.
*/
isOver?: boolean;
/**
* The ids of the cards this column renders, top to bottom — the sortable list a vertical drag
* reorders within. Omit for a column whose cards aren't reorderable (any non-manual ordering
* mode); the column still accepts whole-column drops via its own droppable.
*/
sortableIds?: string[];
/**
* The reorder modifier is held, so the cards should part to show where this one would land.
* Without it the lane takes the drop as a whole and nothing inside it moves — see the overlay
* below, and `useMetaKeyHeld`.
*/
reordering?: boolean;
/** What the whole-lane drop cover says: the ordering in force, and the modifier that overrides it. */
dropHint?: { title: string; hint: string };
/** Clicking the header "+" — starts inline task creation in this column. Omit to hide it. */
onAdd?: () => void;
/** Clicking the header's [icon · name · count] — opens this column's group. Omit to leave it inert. */
onOpen?: () => void;
/** Cursor entered/left the column — lets `c` target the hovered column. */
onHover?: (hovered: boolean) => void;
}
/**
* A lane NEVER displaces its own cards. The board is the only thing that moves them.
*
* `SortableContext` requires a strategy, and returning null for every item is how you decline one.
* This looks like giving something up and is the opposite: the board already arranges the lane
* itself, by moving the dragged card to its resolved slot in the array it renders, so the cards
* part through plain DOM order. Letting dnd-kit displace them TOO is a second, independent answer
* layered on the first.
*
* The two answers disagree, because they are computed from different things.
* `verticalListSortingStrategy` shifts by `activeIndex` vs `overIndex` — indices into the list the
* board has ALREADY rearranged, against an `over` that dnd-kit resolves with its own collision
* detection over rects measured before the drag. The board resolves the slot from the pointer
* against midpoints captured on entering the lane. Crossing a card's midpoint moves the board's
* gap one way and dnd-kit's displacement the other, so the card visibly settles into a slot the
* drop was never going to write: "it moves out of the way, and then it goes back". Reported by a
* user as the hitbox not matching the animation, which is exactly what it was.
*
* Cost of declining: cards jump between slots rather than sliding. Worth it — an animation that
* lies about where the card will land is worse than no animation.
*
* A MODULE-LEVEL constant, never a fresh closure: `SortableContext` re-registers when this
* identity changes, dnd-kit then re-measures its droppables, and `measureRect` calls setState — so
* a new function each render is an infinite render loop, not a wasted allocation.
*/
export const noSortingStrategy: SortingStrategy = () => null;
export function TaskKanbanColumn({
id,
label,
icon,
count,
children,
footer,
canDrop = true,
isOver: isOverProp,
sortableIds,
reordering = false,
dropHint,
onAdd,
onOpen,
onHover,
}: TaskKanbanColumnProps) {
const { setNodeRef, isOver: isOverSelf } = useDroppable({ id, disabled: !canDrop });
const isOver = isOverProp ?? isOverSelf;
// The lane is covered as a whole unless you are actively placing a card inside it. Sorting is a
// strategy rather than a conditional SortableContext because the modifier can be pressed and
// released MID-drag: unmounting the context under dnd-kit's feet loses the drag, whereas
// swapping the strategy just stops it computing displacements.
const showColumnTarget = isOver && canDrop && !reordering;
const headerLabel = (
<>
<span className="flex size-4 shrink-0 items-center justify-center">{icon}</span>
<span className="min-w-0 truncate text-sm font-medium">{label}</span>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">{count}</span>
</>
);
return (
<div
ref={setNodeRef}
data-task-lane={id}
onMouseEnter={() => onHover?.(true)}
onMouseLeave={() => onHover?.(false)}
className={cn(
'group/col bg-muted/50 relative flex min-h-0 w-[320px] shrink-0 flex-col rounded-xl transition-colors',
// Only while placing by hand. The unmodified drag gets the full-lane cover below instead,
// which is a much louder signal and does not need a tint underneath it as well.
isOver && canDrop && reordering && 'bg-primary/5',
)}
>
{/* The whole lane as one drop target.
An unmodified cross-lane drop files the card here and lets the board's ordering place it,
so the honest target is the COLUMN, not a gap between two cards. Darkening it whole says
that: there is no insertion point to aim at, and nothing inside needs to move. It covers
the header too, because the header is part of what you are dropping onto.
Dark rather than tinted. This sits ON TOP of a column of cards, and a translucent accent
left all of them legible underneath — which reads as "these are still in play" at the
exact moment the point is that they are not. */}
{showColumnTarget && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-xl bg-black/70 px-6 text-center backdrop-blur-[2px]">
{dropHint && (
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-white">{dropHint.title}</span>
<span className="text-xs text-white/60">{dropHint.hint}</span>
</div>
)}
</div>
)}
{/* Header — [icon] name count ····· + ⋯ (buttons reveal on column hover, Linear-style).
The label block itself opens the column's group when `onOpen` is given. */}
<div className="flex items-center gap-2 px-3 pb-1.5 pt-2.5">
{onOpen ? (
<button
type="button"
onClick={onOpen}
aria-label={`Open ${label}`}
className="hover:bg-subtleWhite -mx-1 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-left transition-colors dark:hover:bg-[#202020]"
>
{headerLabel}
</button>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2">{headerLabel}</div>
)}
<span className="ml-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/col:opacity-100">
{onAdd && (
<button
type="button"
aria-label={`Add a task to ${label}`}
onClick={onAdd}
className="hover:bg-subtleWhite text-muted-foreground hover:text-foreground flex size-5 cursor-pointer items-center justify-center rounded-md dark:hover:bg-[#202020]"
>
<Plus className="size-3.5" />
</button>
)}
<button
type="button"
aria-label={`${label} options`}
className="hover:bg-subtleWhite text-muted-foreground hover:text-foreground flex size-5 items-center justify-center rounded-md dark:hover:bg-[#202020]"
>
<MoreHorizontal className="size-3.5" />
</button>
</span>
</div>
{/* Cards — the only scrolling region; invisible scrollbar.
Wrapped in a SortableContext when the cards are reorderable, so dnd-kit knows this
column's vertical order and can open a gap as a card is dragged through it. The
column's own `useDroppable` above stays either way: it is what catches a drop onto
empty space, and an empty column has no card to drop onto at all. */}
<div className="scrollbar-none flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
{sortableIds ? (
<SortableContext items={sortableIds} strategy={noSortingStrategy}>
{children}
</SortableContext>
) : (
children
)}
</div>
{/* Pinned footer — always visible, so it just shortens the scroll area above it rather than
scrolling away with the cards. */}
{footer && <div className="shrink-0 px-2 pb-2 pt-1">{footer}</div>}
</div>
);
}
/**
* A collapsed, empty column in the right-hand "Hidden columns" rail. Still a drop target, so a
* task can be filed into an empty group by dragging it onto the row.
*/
export function HiddenColumnRow({
id,
label,
icon,
}: {
id: string;
label: string;
icon: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors',
isOver ? 'bg-primary/10' : 'hover:bg-muted/50',
)}
>
<span className="flex size-4 shrink-0 items-center justify-center">{icon}</span>
<span className="min-w-0 flex-1 truncate text-sm">{label}</span>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">0</span>
</div>
);
}