CanvasKanbanColumn.tsx4.0 KBView on GitHub /**
* CanvasKanbanColumn
*
* Droppable conversation-board column, styled to match the task board (`TaskKanbanColumn`): a light
* rounded backdrop, a header of [icon · name · count] with the summary and a hover-revealed hide
* button on the right, and a card list that scrolls internally (invisible scrollbar) so the board
* itself never grows.
*
* Header: [status icon / colour dot] [column name] [count] ····· [summary] [hide]
*/
import { useDroppable } from '@dnd-kit/core';
import { EyeOff } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CanvasKanbanColumnProps<T> {
id: string;
label: string;
/** Tailwind bg-* class for the dot indicator, e.g. 'bg-blue-500' */
dotClass: string;
/**
* Renders in place of the colour dot. Status columns pass their own tinted lucide icon; omit it
* and the dot is used, so callers without an icon are unaffected.
*/
headerIcon?: React.ReactNode;
items: T[];
activeItemId?: string | null;
getItemId: (item: T) => string;
renderItem: (item: T, isSelected: boolean) => React.ReactNode;
/** Called when the user clicks the hide icon to hide this column */
onHide?: () => void;
/** Pre-formatted summary string to show in the header, e.g. "SUM $1.2M" */
summaryLine?: string;
/** Extra classes on the column root. */
className?: string;
}
export function CanvasKanbanColumn<T>({
id,
label,
dotClass,
headerIcon,
items,
activeItemId,
getItemId,
renderItem,
onHide,
summaryLine,
className,
}: CanvasKanbanColumnProps<T>) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={cn(
'group/col bg-muted/50 flex min-h-0 w-[320px] shrink-0 flex-col rounded-xl transition-colors',
isOver && 'bg-primary/5 ring-primary/30 ring-1',
className,
)}
>
{/* Header — [icon] name count ····· summary (hide reveals on column hover) */}
<div className="flex items-center gap-2 px-3 pb-1.5 pt-2.5">
<span className="flex size-4 shrink-0 items-center justify-center">
{headerIcon ?? <span className={cn('size-2 shrink-0 rounded-full', dotClass)} />}
</span>
<span className="min-w-0 truncate text-sm font-medium">{label}</span>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">{items.length}</span>
<span className="ml-auto flex shrink-0 items-center gap-1">
{summaryLine && (
<span className="text-muted-foreground text-xs font-medium tabular-nums">
{summaryLine}
</span>
)}
{onHide && (
<button
type="button"
onClick={onHide}
aria-label={`Hide ${label} column`}
className="hover:bg-subtleWhite text-muted-foreground hover:text-foreground flex size-5 cursor-pointer items-center justify-center rounded-md opacity-0 transition-opacity group-hover/col:opacity-100 dark:hover:bg-[#202020]"
>
<EyeOff className="size-3.5" />
</button>
)}
</span>
</div>
{/* Cards — the only scrolling region; invisible scrollbar.
`min-h-0` lets this shrink below its content so `overflow-y-auto` actually scrolls when
the column is height-bounded. */}
<div className="scrollbar-none flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
{items.length === 0 ? (
<div
className={cn(
'text-muted-foreground flex h-16 items-center justify-center rounded-lg border border-dashed text-xs transition-colors',
isOver && 'border-primary/50 bg-primary/5 text-primary',
)}
>
{isOver ? 'Drop here' : 'Empty'}
</div>
) : (
items.map((item) => {
const itemId = getItemId(item);
return <div key=[redacted], itemId === activeItemId)}</div>;
})
)}
</div>
</div>
);
}