370 lines
8.8 KiB
TypeScript
370 lines
8.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import type {
|
||
|
|
Announcements,
|
||
|
|
DndContextProps,
|
||
|
|
DragEndEvent,
|
||
|
|
DragOverEvent,
|
||
|
|
DragStartEvent,
|
||
|
|
} from "@dnd-kit/core";
|
||
|
|
import {
|
||
|
|
closestCenter,
|
||
|
|
DndContext,
|
||
|
|
DragOverlay,
|
||
|
|
KeyboardSensor,
|
||
|
|
MouseSensor,
|
||
|
|
TouchSensor,
|
||
|
|
useDroppable,
|
||
|
|
useSensor,
|
||
|
|
useSensors,
|
||
|
|
} from "@dnd-kit/core";
|
||
|
|
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
|
||
|
|
import { CSS } from "@dnd-kit/utilities";
|
||
|
|
import { GripHorizontal } from "lucide-react";
|
||
|
|
import {
|
||
|
|
createContext,
|
||
|
|
type HTMLAttributes,
|
||
|
|
type ReactNode,
|
||
|
|
useContext,
|
||
|
|
useState,
|
||
|
|
} from "react";
|
||
|
|
import { createPortal } from "react-dom";
|
||
|
|
import tunnel from "tunnel-rat";
|
||
|
|
import { Card } from "~/components/ui/card";
|
||
|
|
import { ScrollArea, ScrollBar } from "~/components/ui/scroll-area";
|
||
|
|
import { cn } from "~/lib/utils";
|
||
|
|
|
||
|
|
//https://www.shadcn.io/components/data/kanban#installation
|
||
|
|
const t = tunnel();
|
||
|
|
|
||
|
|
export type { DragEndEvent } from "@dnd-kit/core";
|
||
|
|
|
||
|
|
type KanbanItemProps = {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
column: string;
|
||
|
|
} & Record<string, unknown>;
|
||
|
|
|
||
|
|
type KanbanColumnProps = {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
} & Record<string, unknown>;
|
||
|
|
|
||
|
|
type KanbanContextProps<
|
||
|
|
T extends KanbanItemProps = KanbanItemProps,
|
||
|
|
C extends KanbanColumnProps = KanbanColumnProps,
|
||
|
|
> = {
|
||
|
|
columns: C[];
|
||
|
|
data: T[];
|
||
|
|
activeCardId: string | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
const KanbanContext = createContext<KanbanContextProps>({
|
||
|
|
columns: [],
|
||
|
|
data: [],
|
||
|
|
activeCardId: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
export type KanbanBoardProps = {
|
||
|
|
id: string;
|
||
|
|
children: ReactNode;
|
||
|
|
className?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
|
||
|
|
const { isOver, setNodeRef } = useDroppable({
|
||
|
|
id,
|
||
|
|
});
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex size-full min-h-40 flex-col divide-y overflow-hidden rounded-md border bg-secondary text-xs shadow-sm ring-2 transition-all",
|
||
|
|
isOver ? "ring-primary" : "ring-transparent",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
ref={setNodeRef}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export type KanbanCardProps<T extends KanbanItemProps = KanbanItemProps> = T & {
|
||
|
|
children?: ReactNode;
|
||
|
|
className?: string;
|
||
|
|
cardOnClick?: () => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const KanbanCard = <T extends KanbanItemProps = KanbanItemProps>({
|
||
|
|
id,
|
||
|
|
name,
|
||
|
|
children,
|
||
|
|
className,
|
||
|
|
cardOnClick,
|
||
|
|
}: KanbanCardProps<T>) => {
|
||
|
|
const {
|
||
|
|
attributes,
|
||
|
|
listeners,
|
||
|
|
setNodeRef,
|
||
|
|
setActivatorNodeRef,
|
||
|
|
transition,
|
||
|
|
transform,
|
||
|
|
isDragging,
|
||
|
|
} = useSortable({
|
||
|
|
id,
|
||
|
|
});
|
||
|
|
const { activeCardId } = useContext(KanbanContext) as KanbanContextProps;
|
||
|
|
|
||
|
|
const style = {
|
||
|
|
transition,
|
||
|
|
transform: CSS.Transform.toString(transform),
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div style={style} {...attributes} ref={setNodeRef}>
|
||
|
|
<Card
|
||
|
|
className={cn(
|
||
|
|
"relative flex w-full cursor-pointer flex-col gap-0 rounded-md py-0 shadow-sm", // Removed default padding to handle it manually
|
||
|
|
isDragging && "opacity-30",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
onClick={cardOnClick}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
{...listeners}
|
||
|
|
className={cn(
|
||
|
|
"flex h-6 w-full cursor-grab items-center justify-center rounded-t-md bg-muted/50 transition-colors hover:bg-muted",
|
||
|
|
isDragging && "cursor-grabbing",
|
||
|
|
)}
|
||
|
|
ref={setActivatorNodeRef}
|
||
|
|
>
|
||
|
|
<GripHorizontal className="text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="p-3">
|
||
|
|
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{activeCardId === id && (
|
||
|
|
<t.In>
|
||
|
|
<Card
|
||
|
|
className={cn(
|
||
|
|
"relative flex w-full flex-col gap-0 rounded-md py-0 shadow-sm ring-2 ring-primary",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="flex h-6 w-full cursor-grabbing items-center justify-center rounded-t-md bg-muted/50">
|
||
|
|
<GripHorizontal className="text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
<div className="p-3">
|
||
|
|
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
</t.In>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export type KanbanCardsProps<T extends KanbanItemProps = KanbanItemProps> =
|
||
|
|
Omit<HTMLAttributes<HTMLDivElement>, "children" | "id"> & {
|
||
|
|
children: (item: T) => ReactNode;
|
||
|
|
id: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const KanbanCards = <T extends KanbanItemProps = KanbanItemProps>({
|
||
|
|
children,
|
||
|
|
className,
|
||
|
|
...props
|
||
|
|
}: KanbanCardsProps<T>) => {
|
||
|
|
const { data } = useContext(KanbanContext) as KanbanContextProps<T>;
|
||
|
|
const filteredData = data.filter((item) => item.column === props.id);
|
||
|
|
const items = filteredData.map((item) => item.id);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ScrollArea className="overflow-hidden">
|
||
|
|
<SortableContext items={items}>
|
||
|
|
<div
|
||
|
|
className={cn("flex flex-grow flex-col gap-2 p-2", className)}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{filteredData.map(children)}
|
||
|
|
</div>
|
||
|
|
</SortableContext>
|
||
|
|
<ScrollBar orientation="vertical" />
|
||
|
|
</ScrollArea>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export type KanbanHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||
|
|
|
||
|
|
export const KanbanHeader = ({ className, ...props }: KanbanHeaderProps) => (
|
||
|
|
<div className={cn("m-0 p-2 font-semibold text-sm", className)} {...props} />
|
||
|
|
);
|
||
|
|
|
||
|
|
export type KanbanProviderProps<
|
||
|
|
T extends KanbanItemProps = KanbanItemProps,
|
||
|
|
C extends KanbanColumnProps = KanbanColumnProps,
|
||
|
|
> = Omit<DndContextProps, "children"> & {
|
||
|
|
children: (column: C) => ReactNode;
|
||
|
|
className?: string;
|
||
|
|
columns: C[];
|
||
|
|
data: T[];
|
||
|
|
onDataChange?: (data: T[]) => void;
|
||
|
|
onDragStart?: (event: DragStartEvent) => void;
|
||
|
|
onDragEnd?: (event: DragEndEvent) => void;
|
||
|
|
onDragOver?: (event: DragOverEvent) => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const KanbanProvider = <
|
||
|
|
T extends KanbanItemProps = KanbanItemProps,
|
||
|
|
C extends KanbanColumnProps = KanbanColumnProps,
|
||
|
|
>({
|
||
|
|
children,
|
||
|
|
onDragStart,
|
||
|
|
onDragEnd,
|
||
|
|
onDragOver,
|
||
|
|
className,
|
||
|
|
columns,
|
||
|
|
data,
|
||
|
|
onDataChange,
|
||
|
|
...props
|
||
|
|
}: KanbanProviderProps<T, C>) => {
|
||
|
|
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const sensors = useSensors(
|
||
|
|
useSensor(MouseSensor),
|
||
|
|
useSensor(TouchSensor),
|
||
|
|
useSensor(KeyboardSensor),
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleDragStart = (event: DragStartEvent) => {
|
||
|
|
const card = data.find((item) => item.id === event.active.id);
|
||
|
|
if (card) {
|
||
|
|
setActiveCardId(event.active.id as string);
|
||
|
|
}
|
||
|
|
onDragStart?.(event);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragOver = (event: DragOverEvent) => {
|
||
|
|
const { active, over } = event;
|
||
|
|
|
||
|
|
if (!over) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const activeItem = data.find((item) => item.id === active.id);
|
||
|
|
const overItem = data.find((item) => item.id === over.id);
|
||
|
|
|
||
|
|
if (!activeItem) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const activeColumn = activeItem.column;
|
||
|
|
const overColumn =
|
||
|
|
overItem?.column ||
|
||
|
|
columns.find((col) => col.id === over.id)?.id ||
|
||
|
|
columns[0]?.id;
|
||
|
|
|
||
|
|
if (!overColumn) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (activeColumn !== overColumn) {
|
||
|
|
let newData = [...data];
|
||
|
|
const activeIndex = newData.findIndex((item) => item.id === active.id);
|
||
|
|
const overIndex = newData.findIndex((item) => item.id === over.id);
|
||
|
|
|
||
|
|
if (newData[activeIndex] === undefined) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
newData[activeIndex].column = overColumn; // Now TypeScript knows this is a string
|
||
|
|
newData = arrayMove(newData, activeIndex, overIndex);
|
||
|
|
|
||
|
|
onDataChange?.(newData);
|
||
|
|
}
|
||
|
|
|
||
|
|
onDragOver?.(event);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
||
|
|
setActiveCardId(null);
|
||
|
|
|
||
|
|
onDragEnd?.(event);
|
||
|
|
|
||
|
|
const { active, over } = event;
|
||
|
|
|
||
|
|
if (!over || active.id === over.id) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let newData = [...data];
|
||
|
|
|
||
|
|
const oldIndex = newData.findIndex((item) => item.id === active.id);
|
||
|
|
const newIndex = newData.findIndex((item) => item.id === over.id);
|
||
|
|
|
||
|
|
newData = arrayMove(newData, oldIndex, newIndex);
|
||
|
|
|
||
|
|
onDataChange?.(newData);
|
||
|
|
};
|
||
|
|
|
||
|
|
const announcements: Announcements = {
|
||
|
|
onDragStart({ active }) {
|
||
|
|
const { name, column } = data.find((item) => item.id === active.id) ?? {};
|
||
|
|
|
||
|
|
return `Picked up the card "${name}" from the "${column}" column`;
|
||
|
|
},
|
||
|
|
onDragOver({ active, over }) {
|
||
|
|
const { name } = data.find((item) => item.id === active.id) ?? {};
|
||
|
|
const newColumn = columns.find((column) => column.id === over?.id)?.name;
|
||
|
|
|
||
|
|
return `Dragged the card "${name}" over the "${newColumn}" column`;
|
||
|
|
},
|
||
|
|
onDragEnd({ active, over }) {
|
||
|
|
const { name } = data.find((item) => item.id === active.id) ?? {};
|
||
|
|
const newColumn = columns.find((column) => column.id === over?.id)?.name;
|
||
|
|
|
||
|
|
return `Dropped the card "${name}" into the "${newColumn}" column`;
|
||
|
|
},
|
||
|
|
onDragCancel({ active }) {
|
||
|
|
const { name } = data.find((item) => item.id === active.id) ?? {};
|
||
|
|
|
||
|
|
return `Cancelled dragging the card "${name}"`;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<KanbanContext.Provider value={{ columns, data, activeCardId }}>
|
||
|
|
<DndContext
|
||
|
|
accessibility={{ announcements }}
|
||
|
|
collisionDetection={closestCenter}
|
||
|
|
onDragEnd={handleDragEnd}
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
onDragStart={handleDragStart}
|
||
|
|
sensors={sensors}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"grid size-full auto-cols-fr grid-flow-col gap-3",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{columns.map((column) => children(column))}
|
||
|
|
</div>
|
||
|
|
{typeof window !== "undefined" &&
|
||
|
|
createPortal(
|
||
|
|
<DragOverlay>
|
||
|
|
<t.Out />
|
||
|
|
</DragOverlay>,
|
||
|
|
document.body,
|
||
|
|
)}
|
||
|
|
</DndContext>
|
||
|
|
</KanbanContext.Provider>
|
||
|
|
);
|
||
|
|
};
|