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>
  );
}