"use client";
import * as React from "react";
import { LayoutGroup, motion, MotionConfig, useReducedMotion } from "motion/react";
import {
ArrowDown,
ArrowRight,
ArrowUp,
ChevronRight,
Filter,
Maximize2,
Pencil,
Plus,
Search,
Star,
Trash2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { BoardColumn, type ColumnEntry } from "./board-column";
import { LABEL_STYLES, SEED_BOARD, uid } from "./data";
import { Avatar, MenuProvider, useMenu, type MenuItem } from "./kanban-ui";
import { TaskCardBody } from "./task-card";
import { TaskPanel } from "./task-panel";
import type { BoardColumn as Column, BoardData, DropTarget, Task } from "./types";
import { useBoardDrag } from "./use-board-drag";
export type { BoardData, Task, Column as BoardColumnData };
export interface KanbanAppProps {
/** Initial board (project, members, labels, columns, tasks). Defaults to a seeded demo board. */
initialData?: BoardData;
/** Called with the full board after every change — persist it to your backend here. */
onChange?: (board: BoardData) => void;
/** Called when a task is created, with the column it was created in. */
onTaskCreate?: (task: Task, columnId: string) => void;
/** "Today" as an ISO date (YYYY-MM-DD) for due-date highlighting. Defaults to the client's date. */
today?: string;
className?: string;
}
export function KanbanApp(props: KanbanAppProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
return (
<MotionConfig reducedMotion="user">
<div
ref={rootRef}
className={cn(
"relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased",
props.className,
)}
>
<MenuProvider rootRef={rootRef}>
<Board {...props} rootRef={rootRef} />
</MenuProvider>
</div>
</MotionConfig>
);
}
function Board({ initialData, onChange, onTaskCreate, today: todayProp, rootRef }: KanbanAppProps & { rootRef: React.RefObject<HTMLDivElement | null> }) {
const [board, setBoard] = React.useState<BoardData>(initialData ?? SEED_BOARD);
const [query, setQuery] = React.useState("");
const [assignees, setAssignees] = React.useState<string[]>([]);
const [labelFilter, setLabelFilter] = React.useState<string[]>([]);
const [openId, setOpenId] = React.useState<string | null>(null);
const [focusTitle, setFocusTitle] = React.useState(false);
const [renaming, setRenaming] = React.useState<string | null>(null);
const [addingColumn, setAddingColumn] = React.useState(false);
const [starred, setStarred] = React.useState(true);
const [clientToday, setClientToday] = React.useState<string | null>(null);
const scrollerRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const returnFocus = React.useRef<string | null>(null);
const pendingFocus = React.useRef<string | null>(null);
const reduced = useReducedMotion() ?? false;
const menu = useMenu();
const today = todayProp ?? clientToday;
React.useEffect(() => {
if (todayProp) return;
const d = new Date();
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
setClientToday(iso);
}, [todayProp]);
// Notify the host after every change (not on mount).
const first = React.useRef(true);
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(board);
}, [board]);
// Move keyboard focus to a card after a keyboard move / panel close.
React.useEffect(() => {
const id = pendingFocus.current;
if (!id) return;
pendingFocus.current = null;
requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`[data-task-button="${CSS.escape(id)}"]`)?.focus());
});
const labelsById = React.useMemo(() => Object.fromEntries(board.labels.map((l) => [l.id, l])), [board.labels]);
const membersById = React.useMemo(() => Object.fromEntries(board.members.map((m) => [m.id, m])), [board.members]);
const q = query.trim().toLowerCase();
const filtering = q.length > 0 || assignees.length > 0 || labelFilter.length > 0;
const isVisible = React.useCallback(
(t: Task) =>
(!q || t.title.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)) &&
(assignees.length === 0 || (t.assigneeId ? assignees.includes(t.assigneeId) : assignees.includes("none"))) &&
(labelFilter.length === 0 || t.labelIds.some((l) => labelFilter.includes(l))),
[q, assignees, labelFilter],
);
const columnOf = React.useCallback((b: BoardData, taskId: string) => b.columns.find((c) => c.taskIds.includes(taskId)) ?? null, []);
/* ---------------------------- mutations ---------------------------- */
const moveTask = React.useCallback((taskId: string, toColumnId: string, toIndex: number) => {
setBoard((b) => {
const columns = b.columns.map((c) => ({ ...c, taskIds: c.taskIds.filter((id) => id !== taskId) }));
const target = columns.find((c) => c.id === toColumnId);
if (!target) return b;
target.taskIds.splice(Math.max(0, Math.min(toIndex, target.taskIds.length)), 0, taskId);
return { ...b, columns };
});
}, []);
const updateTask = React.useCallback((task: Task) => setBoard((b) => ({ ...b, tasks: { ...b.tasks, [task.id]: task } })), []);
const deleteTask = React.useCallback((taskId: string) => {
setBoard((b) => {
const tasks = { ...b.tasks };
delete tasks[taskId];
return { ...b, tasks, columns: b.columns.map((c) => ({ ...c, taskIds: c.taskIds.filter((id) => id !== taskId) })) };
});
setOpenId((o) => (o === taskId ? null : o));
}, []);
const createTask = (columnId: string, title: string, open: boolean) => {
const task: Task = { id: uid("task"), title, description: "", labelIds: [], priority: "none", checklist: [] };
setBoard((b) => ({
...b,
tasks: { ...b.tasks, [task.id]: task },
columns: b.columns.map((c) => (c.id === columnId ? { ...c, taskIds: open ? [task.id, ...c.taskIds] : [...c.taskIds, task.id] } : c)),
}));
onTaskCreate?.(task, columnId);
if (open) {
returnFocus.current = task.id;
setFocusTitle(true);
setOpenId(task.id);
}
};
/** Converts a visible-list index (filters applied, dragged card removed) into a real array index. */
const toRealIndex = (b: BoardData, taskId: string, target: DropTarget) => {
const col = b.columns.find((c) => c.id === target.columnId);
if (!col) return 0;
const ids = col.taskIds.filter((id) => id !== taskId);
const visible = ids.filter((id) => b.tasks[id] && isVisible(b.tasks[id]));
if (target.index < visible.length) return ids.indexOf(visible[target.index]);
return visible.length ? ids.indexOf(visible[visible.length - 1]) + 1 : ids.length;
};
/* ------------------------------ drag ------------------------------- */
const boardRef = React.useRef(board);
React.useLayoutEffect(() => {
boardRef.current = board;
});
const { drag, overlayX, overlayY, onCardPointerDown, registerList, consumeClick } = useBoardDrag({
rootRef,
scrollerRef,
reducedMotion: reduced,
locate: (taskId) => {
const b = boardRef.current;
const col = columnOf(b, taskId);
if (!col) return null;
const before = col.taskIds.slice(0, col.taskIds.indexOf(taskId)).filter((id) => b.tasks[id] && isVisible(b.tasks[id]));
return { columnId: col.id, index: before.length };
},
onDrop: (taskId, target) => {
const b = boardRef.current;
moveTask(taskId, target.columnId, toRealIndex(b, taskId, target));
},
});
const entriesFor = (col: Column): ColumnEntry[] => {
const list: ColumnEntry[] = col.taskIds
.filter((id) => id !== drag?.taskId)
.map((id) => board.tasks[id])
.filter((t): t is Task => Boolean(t) && isVisible(t))
.map((task) => ({ task, mode: "card" }));
const dragged = drag ? board.tasks[drag.taskId] : undefined;
if (drag && dragged) {
if (drag.over.columnId === col.id) list.splice(Math.min(drag.over.index, list.length), 0, { task: dragged, mode: "placeholder" });
if (col.taskIds.includes(drag.taskId)) list.push({ task: dragged, mode: "hidden" });
}
return list;
};
/* ------------------------------ UI actions ------------------------- */
const openTask = React.useCallback(
(id: string) => {
if (consumeClick()) return;
returnFocus.current = id;
setFocusTitle(false);
setOpenId(id);
},
[consumeClick],
);
const closePanel = React.useCallback(() => {
setOpenId(null);
pendingFocus.current = returnFocus.current;
}, []);
const keyboardMove = (taskId: string, columnId: string, index: number) => {
moveTask(taskId, columnId, index);
pendingFocus.current = taskId;
};
const onCardMenu = (anchor: HTMLElement, taskId: string) => {
const b = boardRef.current;
const col = columnOf(b, taskId);
if (!col) return;
const idx = col.taskIds.indexOf(taskId);
const items: MenuItem[] = [
{ id: "open", label: "Open task", icon: <Maximize2 className="size-3.5" />, hint: "↵", onSelect: () => openTask(taskId) },
{ kind: "separator", id: "s0" },
{ kind: "heading", id: "h", label: "Move to" },
...b.columns.map<MenuItem>((c) => ({
id: `to-${c.id}`,
label: c.title,
icon: <ArrowRight className="size-3.5" />,
hint: c.id === col.id ? "Current" : undefined,
disabled: c.id === col.id,
onSelect: () => keyboardMove(taskId, c.id, 0),
})),
{ kind: "separator", id: "s1" },
{ id: "up", label: "Move up", icon: <ArrowUp className="size-3.5" />, disabled: idx <= 0, onSelect: () => keyboardMove(taskId, col.id, idx - 1) },
{
id: "down",
label: "Move down",
icon: <ArrowDown className="size-3.5" />,
disabled: idx >= col.taskIds.length - 1,
onSelect: () => keyboardMove(taskId, col.id, idx + 1),
},
{ kind: "separator", id: "s2" },
{ id: "del", label: "Delete task", danger: true, icon: <Trash2 className="size-3.5 text-destructive" />, onSelect: () => deleteTask(taskId) },
];
menu.open({ anchor, items, label: "Task actions" });
};
const onColumnMenu = (anchor: HTMLElement, columnId: string) => {
const b = boardRef.current;
const col = b.columns.find((c) => c.id === columnId);
if (!col) return;
menu.open({
anchor,
label: `${col.title} options`,
items: [
{ id: "add", label: "Add task", icon: <Plus className="size-3.5" />, onSelect: () => createTask(columnId, "Untitled task", true) },
{ id: "rename", label: "Rename column", icon: <Pencil className="size-3.5" />, onSelect: () => setRenaming(columnId) },
{ kind: "separator", id: "s" },
{
id: "delete",
label: col.taskIds.length ? `Delete column and ${col.taskIds.length} tasks` : "Delete column",
danger: true,
disabled: b.columns.length <= 1,
icon: <Trash2 className="size-3.5 text-destructive" />,
onSelect: () =>
setBoard((prev) => {
const tasks = { ...prev.tasks };
for (const id of col.taskIds) delete tasks[id];
return { ...prev, tasks, columns: prev.columns.filter((c) => c.id !== columnId) };
}),
},
],
});
};
const renameColumn = React.useCallback((id: string, title: string | null) => {
setRenaming(null);
const t = title?.trim();
if (t) setBoard((b) => ({ ...b, columns: b.columns.map((c) => (c.id === id ? { ...c, title: t } : c)) }));
}, []);
const labelMenuItems = React.useRef<() => MenuItem[]>(() => []);
React.useLayoutEffect(() => {
labelMenuItems.current = () => [
{ kind: "heading", id: "h", label: "Filter by label" },
...board.labels.map<MenuItem>((l) => ({
id: l.id,
label: l.name,
keepOpen: true,
checked: labelFilter.includes(l.id),
icon: <span className={cn("size-2 rounded-full", LABEL_STYLES[l.color].dot)} />,
onSelect: () => setLabelFilter((f) => (f.includes(l.id) ? f.filter((x) => x !== l.id) : [...f, l.id])),
})),
...(labelFilter.length
? [{ kind: "separator", id: "s" } as MenuItem, { id: "clear", label: "Clear label filter", onSelect: () => setLabelFilter([]) } as MenuItem]
: []),
];
});
// "/" focuses search, "n" creates a task (when not typing).
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (t.closest("input,textarea,select,[contenteditable]") || e.metaKey || e.ctrlKey || e.altKey) return;
if (!rootRef.current?.contains(t) && t !== document.body) return;
if (e.key === "/") {
e.preventDefault();
searchRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [rootRef]);
const allTasks = Object.values(board.tasks);
const doneCol = board.columns[board.columns.length - 1];
const doneCount = doneCol?.taskIds.length ?? 0;
const openTaskObj = openId ? board.tasks[openId] ?? null : null;
const draggedTask = drag ? board.tasks[drag.taskId] : null;
return (
<>
<div inert={openTaskObj ? true : undefined} className={cn("flex min-h-0 flex-1 flex-col", drag && "cursor-grabbing [&_*]:cursor-grabbing")}>
{/* Header */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-4 sm:px-5">
<span
aria-hidden
className="grid size-7 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-violet-500 to-sky-500 text-white shadow-sm shadow-violet-500/30"
>
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6">
<circle cx="8" cy="8" r="2.4" />
<ellipse cx="8" cy="8" rx="6.2" ry="2.8" transform="rotate(-30 8 8)" />
</svg>
</span>
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1 text-sm">
<span className="hidden text-muted-foreground sm:inline">Projects</span>
<ChevronRight className="hidden size-3.5 text-muted-foreground/60 sm:inline" aria-hidden />
<h1 className="truncate font-semibold tracking-tight">{board.projectName}</h1>
</nav>
<button
type="button"
aria-pressed={starred}
aria-label={starred ? "Unstar project" : "Star project"}
onClick={() => setStarred((s) => !s)}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<motion.span className="grid" initial={false} animate={{ scale: starred ? [1, 1.35, 1] : 1 }} transition={{ duration: 0.3, ease: "easeOut" }}>
<Star className={cn("size-4", starred && "fill-amber-400 text-amber-400")} />
</motion.span>
</button>
<div className="ml-auto flex items-center gap-3">
<div className="hidden items-center md:flex" aria-label={`${board.members.length} members`}>
{board.members.map((m, i) => (
<Avatar key={m.id} member={m} size="md" className={cn("ring-2 ring-background", i > 0 && "-ml-2")} />
))}
</div>
<span className="hidden text-xs tabular-nums text-muted-foreground lg:inline">
{doneCount}/{allTasks.length} done
</span>
<button
type="button"
onClick={() => createTask(board.columns[0]?.id ?? "", "Untitled task", true)}
disabled={!board.columns.length}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary pl-2.5 pr-3 text-[13px] font-medium text-primary-foreground shadow-sm outline-none transition hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.98] disabled:opacity-50"
>
<Plus className="size-4" aria-hidden />
New task
</button>
</div>
</header>
{/* Toolbar */}
<div className="flex shrink-0 items-center gap-2 overflow-x-auto border-b px-4 py-2 [scrollbar-width:none] sm:px-5">
<div className="relative w-40 shrink-0 sm:w-60">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
<label htmlFor="kanban-search" className="sr-only">
Search tasks
</label>
<input
id="kanban-search"
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
placeholder="Search tasks"
className="h-8 w-full rounded-lg border bg-background pl-8 pr-8 text-[13px] outline-none transition placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring dark:bg-muted/40"
/>
<kbd className="pointer-events-none absolute right-2 top-1/2 hidden -translate-y-1/2 rounded border bg-muted px-1 font-mono text-[10px] text-muted-foreground sm:block">
/
</kbd>
</div>
<div role="group" aria-label="Filter by assignee" className="flex shrink-0 items-center gap-0.5 rounded-lg border p-0.5">
{board.members.map((m) => {
const on = assignees.includes(m.id);
return (
<button
key={m.id}
type="button"
aria-pressed={on}
aria-label={`Show tasks for ${m.name}`}
title={m.name}
onClick={() => setAssignees((a) => (on ? a.filter((x) => x !== m.id) : [...a, m.id]))}
className={cn(
"grid size-7 place-items-center rounded-md outline-none transition focus-visible:ring-2 focus-visible:ring-ring",
on ? "bg-accent" : "hover:bg-muted",
)}
>
<Avatar
member={m}
size="xs"
className={cn("transition", assignees.length > 0 && !on && "opacity-40 grayscale", on && "ring-2 ring-primary ring-offset-1 ring-offset-accent")}
/>
</button>
);
})}
</div>
<button
type="button"
aria-haspopup="menu"
onClick={(e) => menu.open({ anchor: e.currentTarget, label: "Label filter", align: "start", items: () => labelMenuItems.current() })}
className={cn(
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] outline-none transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
labelFilter.length > 0 && "border-primary/40 bg-primary/5 text-foreground",
)}
>
<Filter className="size-3.5 text-muted-foreground" aria-hidden />
Labels
{labelFilter.length > 0 && (
<span className="grid h-4 min-w-4 place-items-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground">
{labelFilter.length}
</span>
)}
</button>
{filtering && (
<motion.button
type="button"
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
onClick={() => {
setQuery("");
setAssignees([]);
setLabelFilter([]);
}}
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2 text-[13px] text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3.5" aria-hidden />
Clear
</motion.button>
)}
</div>
{/* Board */}
<motion.div
ref={scrollerRef}
layoutScroll
className="flex min-h-0 flex-1 snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain px-4 py-3 sm:snap-none sm:px-5 [scrollbar-width:thin]"
>
<LayoutGroup>
{board.columns.map((col) => (
<BoardColumn
key={col.id}
column={col}
entries={entriesFor(col)}
total={col.taskIds.length}
filtered={filtering}
isDropTarget={Boolean(drag && !drag.settling && drag.over.columnId === col.id)}
labels={labelsById}
members={membersById}
today={today}
registerList={registerList}
onOpenTask={openTask}
onCardPointerDown={onCardPointerDown}
onCardMenu={onCardMenu}
onColumnMenu={onColumnMenu}
onQuickAdd={(id, title) => createTask(id, title, false)}
renaming={renaming === col.id}
onRename={renameColumn}
/>
))}
</LayoutGroup>
<div className="shrink-0">
{addingColumn ? (
<form
className="w-64 rounded-2xl bg-muted/50 p-2 dark:bg-muted/40"
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
const title = input.value.trim();
if (title) {
const tones: Column["tone"][] = ["sky", "rose", "violet", "amber", "slate"];
setBoard((b) => ({
...b,
columns: [...b.columns, { id: uid("col"), title, tone: tones[b.columns.length % tones.length], taskIds: [] }],
}));
}
setAddingColumn(false);
}}
>
<label htmlFor="new-column" className="sr-only">
Column name
</label>
<input
id="new-column"
name="title"
autoFocus
placeholder="Column name"
onKeyDown={(e) => e.key === "Escape" && setAddingColumn(false)}
onBlur={(e) => !e.currentTarget.value && setAddingColumn(false)}
className="h-8 w-full rounded-lg border bg-background px-2.5 text-[13px] outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<div className="mt-2 flex justify-end">
<button
type="submit"
className="h-7 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
>
Add column
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setAddingColumn(true)}
aria-label="Add column"
title="Add column"
className="grid h-11 w-10 place-items-center rounded-2xl border border-dashed text-muted-foreground outline-none transition hover:border-foreground/25 hover:bg-muted/50 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Plus className="size-4" />
</button>
)}
</div>
<div aria-hidden className="w-1 shrink-0 sm:hidden" />
</motion.div>
</div>
{/* Drag overlay */}
{drag && draggedTask && (
<motion.div
aria-hidden
className="pointer-events-none absolute left-0 top-0 z-50"
style={{ x: overlayX, y: overlayY, width: drag.width }}
>
<motion.div
initial={{ rotate: 0, scale: 1 }}
animate={drag.settling ? { rotate: 0, scale: 1 } : { rotate: 2.5, scale: 1.03 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={cn(
"rounded-xl border bg-card p-3 ring-1 ring-primary/20",
drag.settling ? "shadow-sm" : "shadow-2xl shadow-black/15 dark:shadow-black/50",
)}
>
<TaskCardBody
task={draggedTask}
labels={labelsById}
member={draggedTask.assigneeId ? membersById[draggedTask.assigneeId] : undefined}
today={today}
/>
</motion.div>
</motion.div>
)}
<p aria-live="polite" className="sr-only">
{drag && !drag.settling
? `Dragging ${draggedTask?.title}. Over ${board.columns.find((c) => c.id === drag.over.columnId)?.title}, position ${drag.over.index + 1}.`
: ""}
</p>
<TaskPanel
task={openTaskObj}
board={board}
columnId={openId ? columnOf(board, openId)?.id ?? null : null}
autoFocusTitle={focusTitle}
onClose={closePanel}
onUpdate={updateTask}
onMove={(taskId, columnId) => moveTask(taskId, columnId, 0)}
onDelete={(id) => {
deleteTask(id);
returnFocus.current = null;
}}
/>
</>
);
}
export default KanbanApp;