Kanban Board (Tailwind)

PreviousNext

A full-featured kanban board with drag-and-drop functionality.

Docs
roiuiitem

Preview

Loading preview…
registry/brook/tailwind/blocks/kanban-board/components/kanban.tsx
"use client";

import {
  type CollisionDetection,
  closestCorners,
  DndContext,
  type DragEndEvent,
  type DragOverEvent,
  DragOverlay,
  type DragStartEvent,
  pointerWithin,
  useDroppable,
} from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Pencil, Trash2 } from "lucide-react";
import { createContext, useContext, useMemo } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { Card } from "@/registry/brook/tailwind/ui/card";
import {
  ContextMenu,
  ContextMenuItem,
  ContextMenuPopup,
  ContextMenuPortal,
  ContextMenuPositioner,
  ContextMenuSeparator,
  ContextMenuSpacer,
  ContextMenuTrigger,
} from "@/registry/brook/tailwind/ui/context-menu";
import { type KanbanColumnDataProps, type KanbanItemProps, useKanbanDnd } from "../hooks/use-kanban-dnd";
import { getTasksForColumn } from "../lib/project";

const kanbanCollisionDetection: CollisionDetection = (args) => {
  const pointerCollisions = pointerWithin(args);
  if (pointerCollisions.length > 0) {
    return pointerCollisions;
  }
  return closestCorners(args);
};

type KanbanContextValue<T extends KanbanItemProps = KanbanItemProps> = {
  getItemsForColumn: (columnId: string) => T[];
  onEditItem: (item: T) => void;
  onDeleteItem: (item: T) => void;
};

const KanbanContext = createContext<KanbanContextValue<KanbanItemProps> | null>(null);

export function useKanbanContext<T extends KanbanItemProps = KanbanItemProps>() {
  const context = useContext(KanbanContext) as KanbanContextValue<T> | null;
  if (!context) {
    throw new Error("useKanbanContext must be used within a KanbanProvider");
  }
  return context;
}

export type KanbanProviderProps<
  T extends KanbanItemProps = KanbanItemProps,
  C extends KanbanColumnDataProps = KanbanColumnDataProps,
> = {
  children: (column: C) => React.ReactNode;
  columns: C[];
  data: T[];
  className?: string;
  onDataChange: (data: T[]) => void;
  onDragStart?: (event: DragStartEvent) => void;
  onDragEnd?: (event: DragEndEvent) => void;
  onDragOver?: (event: DragOverEvent) => void;
  onEditItem: (item: T) => void;
  onDeleteItem: (item: T) => void;
  renderOverlay?: (item: T) => React.ReactNode;
};

export function KanbanProvider<
  T extends KanbanItemProps = KanbanItemProps,
  C extends KanbanColumnDataProps = KanbanColumnDataProps,
>({
  children,
  columns,
  data,
  className,
  onDataChange,
  onDragStart,
  onDragEnd,
  onDragOver,
  onEditItem,
  onDeleteItem,
  renderOverlay,
}: KanbanProviderProps<T, C>) {
  const { activeItem, sensors, announcements, handleDragStart, handleDragOver, handleDragEnd } = useKanbanDnd({
    data,
    columns,
    enabled: true,
    onDataChange,
    onDragStart,
    onDragEnd,
    onDragOver,
  });

  const getItemsForColumn = useMemo(() => (columnId: string) => getTasksForColumn(data, columnId) as T[], [data]);

  const contextValue = useMemo<KanbanContextValue<T>>(
    () => ({
      getItemsForColumn,
      onEditItem,
      onDeleteItem,
    }),
    [getItemsForColumn, onEditItem, onDeleteItem]
  );

  return (
    <KanbanContext.Provider value={contextValue as KanbanContextValue<KanbanItemProps>}>
      <DndContext
        accessibility={{ announcements }}
        collisionDetection={kanbanCollisionDetection}
        id="kanban-dnd"
        onDragEnd={handleDragEnd}
        onDragOver={handleDragOver}
        onDragStart={handleDragStart}
        sensors={sensors}
      >
        <div className={cn("flex min-h-0 flex-1 flex-col", className)} data-slot="kanban-root">
          <div
            className="flex min-h-0 flex-1 items-stretch gap-4 overflow-x-auto overflow-y-hidden p-4"
            data-slot="kanban-board"
          >
            {columns.map((column) => children(column))}
          </div>
        </div>
        {typeof window !== "undefined" &&
          createPortal(
            <DragOverlay>{activeItem && renderOverlay ? renderOverlay(activeItem as T) : null}</DragOverlay>,
            document.body
          )}
      </DndContext>
    </KanbanContext.Provider>
  );
}

export type KanbanColumnProps = React.ComponentProps<"div"> & {
  id: string;
};

export function KanbanColumn({ id, className, children, ...props }: KanbanColumnProps) {
  const { setNodeRef } = useDroppable({ id });

  return (
    <div
      className={cn("flex w-80 shrink-0 flex-col rounded-[var(--radius)] bg-[var(--col-background)]", className)}
      data-slot="kanban-column"
      ref={setNodeRef}
      {...props}
    >
      {children}
    </div>
  );
}

export type KanbanColumnHeaderProps = React.ComponentProps<"div">;

export function KanbanColumnHeader({ className, ...props }: KanbanColumnHeaderProps) {
  return (
    <div
      className={cn("flex items-center justify-between px-4 py-3", className)}
      data-slot="kanban-column-header"
      {...props}
    />
  );
}

export type KanbanCardListProps<T extends KanbanItemProps = KanbanItemProps> = Omit<
  React.ComponentProps<"div">,
  "children" | "id"
> & {
  id: string;
  children: (item: T) => React.ReactNode;
};

export function KanbanCardList<T extends KanbanItemProps = KanbanItemProps>({
  id,
  children,
  className,
  ...props
}: KanbanCardListProps<T>) {
  const { getItemsForColumn } = useKanbanContext<T>();
  const items = getItemsForColumn(id);
  const itemIds = items.map((item) => item.id);

  return (
    <SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
      <div
        className={cn("flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2", className)}
        data-slot="kanban-card-list"
        {...props}
      >
        {items.map(children)}
      </div>
    </SortableContext>
  );
}

export type KanbanCardProps<T extends KanbanItemProps = KanbanItemProps> = {
  id: string;
  item: T;
  children: React.ReactNode;
  isOverlay?: boolean;
  className?: string;
};

export function KanbanCard<T extends KanbanItemProps = KanbanItemProps>({
  id,
  item,
  children,
  isOverlay,
  className,
}: KanbanCardProps<T>) {
  const { onEditItem, onDeleteItem } = useKanbanContext<T>();

  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id,
    disabled: isOverlay,
  });

  const style = {
    transition,
    transform: CSS.Transform.toString(transform),
  };

  const cardContent = (
    <Card
      className={cn(
        "cursor-grab touch-manipulation bg-background p-4 transition-[box-shadow,border-color] duration-150 ease-out",
        "hover:bg-[oklch(from_var(--accent)_l_c_h_/_0.15)] hover:shadow-[var(--shadow-border-stack)]",
        "focus-visible:outline-2 focus-visible:outline-[var(--ring)] focus-visible:outline-offset-2",
        "[&[data-slot=card]]:gap-2 [&[data-slot=card]]:border-[0.5px] [&[data-slot=card]]:border-[oklch(from_var(--border)_l_c_h_/_0.3)] [&[data-slot=card]]:p-4",
        "[&_[data-slot=card-header]]:flex [&_[data-slot=card-header]]:items-start [&_[data-slot=card-header]]:justify-between [&_[data-slot=card-header]]:gap-2 [&_[data-slot=card-header]]:p-0",
        "[&_[data-slot=card-title]]:font-medium [&_[data-slot=card-title]]:text-foreground [&_[data-slot=card-title]]:text-sm [&_[data-slot=card-title]]:leading-[1.4]",
        "[&_[data-slot=card-content]]:flex [&_[data-slot=card-content]]:flex-col [&_[data-slot=card-content]]:gap-2 [&_[data-slot=card-content]]:p-0",
        "[&_[data-slot=card-description]]:line-clamp-1 [&_[data-slot=card-description]]:text-[0.8125rem] [&_[data-slot=card-description]]:text-muted-foreground [&_[data-slot=card-description]]:leading-[1.5]",
        isDragging === true && !isOverlay && "cursor-grabbing opacity-50",
        isOverlay === true && "cursor-grabbing shadow-lg",
        className
      )}
    >
      {children}
    </Card>
  );

  if (isOverlay) {
    return cardContent;
  }

  return (
    <div className="relative block" ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <ContextMenu>
        <ContextMenuTrigger className="block">{cardContent}</ContextMenuTrigger>
        <ContextMenuPortal>
          <ContextMenuPositioner>
            <ContextMenuPopup>
              <ContextMenuSpacer />
              <ContextMenuItem icon={<Pencil size={14} />} onClick={() => onEditItem(item)}>
                Edit
              </ContextMenuItem>
              <ContextMenuSeparator />
              <ContextMenuItem icon={<Trash2 size={14} />} onClick={() => onDeleteItem(item)} variant="destructive">
                Delete
              </ContextMenuItem>
              <ContextMenuSpacer />
            </ContextMenuPopup>
          </ContextMenuPositioner>
        </ContextMenuPortal>
      </ContextMenu>
    </div>
  );
}

Installation

npx shadcn@latest add @roiui/kanban-board-tailwind

Usage

import { KanbanBoardTailwind } from "@/components/kanban-board-tailwind"
<KanbanBoardTailwind />