Fazekit

Code

"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;

More in Productivity

View all →