Concept project · runs in your browserTypeScript · React.js · Next.js
Kanban Board with Keyboard Drag and Drop Demo
A multi-board kanban you can actually use. Drag cards between columns with the mouse, or focus a card and press Space to lift it and move it with the arrow keys. Columns enforce WIP limits, cards open in an edit drawer, search and label filters dim what does not match, and every change can be undone.
Interactive demo
## How it works
All board changes go through one pure reducer in board.ts. Every action that changes data pushes the previous state onto a capped history stack, so undo is just popping the stack. The UI never mutates the board directly.
Mouse drag uses plain pointer events. On every move the demo hit-tests column rectangles and the midpoints of the cards inside, which gives the drop index. On touch you drag from the grip, so the rest of the card still scrolls the page.
Keyboard drag renders a preview: while a card is lifted, the board is drawn as if the move had already happened, but nothing is committed until you press Space. Escape throws the preview away. A live region announces every position change.
WIP limits are a rule in canMove(), checked by all three input methods. Filters dim cards rather than hiding them, so drop positions always match the real order.
## View source
The code running in the window above, exactly as deployed.
app/lab/kanban-board/board.ts335 lines
/**
* Board model and reducer for the kanban demo. No React in here: every change to the board is a plain
* action run through `reduce`, which makes undo, persistence and tests straightforward.
* In a production build the same actions would be sent to an API (PATCH /cards/:id/position) and applied
* optimistically, then rolled back if the server refuses them.
*/
export type LabelId = "bug" | "feature" | "design" | "chore" | "urgent";
export const LABELS: Record<LabelId, { name: string; className: string }> = {
bug: { name: "Bug", className: "bg-[#f87171]/15 text-danger border-danger/40" },
feature: { name: "Feature", className: "bg-accent-2/15 text-accent-2 border-accent-2/40" },
design: { name: "Design", className: "bg-accent-3/15 text-accent-3 border-accent-3/40" },
chore: { name: "Chore", className: "bg-bg-muted text-fg-muted border-border" },
urgent: { name: "Urgent", className: "bg-warn-bg text-warn-fg border-warn-fg/40" },
};
export const LABEL_IDS = Object.keys(LABELS) as LabelId[];
export const PEOPLE = [
{ id: "ab", name: "Alex Brook" },
{ id: "jd", name: "Jamie Doe" },
{ id: "rk", name: "Riley Khan" },
{ id: "ms", name: "Morgan Shaw" },
] as const;
export type Card = {
id: string;
title: string;
description: string;
labels: LabelId[];
assignee: string | null;
/** ISO date, yyyy-mm-dd */
due: string | null;
};
export type Column = { id: string; title: string; wip: number | null; cardIds: string[] };
export type Board = { id: string; name: string; columns: Column[]; cards: Record<string, Card> };
export type State = {
boards: Board[];
activeId: string;
/** Previous `boards` snapshots, newest last. Undo pops one. */
past: { boards: Board[]; label: string }[];
/** Monotonic counter used to make ids without Math.random during render. */
seq: number;
};
export type Action =
| { type: "board/add"; name: string }
| { type: "board/rename"; name: string }
| { type: "board/delete" }
| { type: "board/switch"; id: string }
| { type: "column/add"; title: string }
| { type: "column/rename"; columnId: string; title: string }
| { type: "column/wip"; columnId: string; wip: number | null }
| { type: "column/move"; columnId: string; delta: -1 | 1 }
| { type: "column/delete"; columnId: string }
| { type: "card/add"; columnId: string; title: string }
| { type: "card/update"; cardId: string; patch: Partial<Omit<Card, "id">> }
| { type: "card/delete"; cardId: string }
| { type: "card/move"; cardId: string; toColumn: string; toIndex: number }
| { type: "undo" };
export type MoveCheck = { ok: true } | { ok: false; reason: string };
const HISTORY_LIMIT = 40;
export const activeBoard = (s: State): Board => s.boards.find((b) => b.id === s.activeId) ?? s.boards[0]!;
export function findCard(board: Board, cardId: string): { column: Column; index: number } | null {
for (const column of board.columns) {
const index = column.cardIds.indexOf(cardId);
if (index !== -1) return { column, index };
}
return null;
}
/** WIP rule: a column with a limit refuses cards coming from another column once it is full. */
export function canMove(board: Board, cardId: string, toColumn: string): MoveCheck {
const from = findCard(board, cardId);
const target = board.columns.find((c) => c.id === toColumn);
if (!from || !target) return { ok: false, reason: "Card or column no longer exists." };
if (from.column.id === target.id) return { ok: true };
if (target.wip !== null && target.cardIds.length >= target.wip) {
return { ok: false, reason: `“${target.title}” is at its WIP limit (${target.wip}). Finish something first.` };
}
return { ok: true };
}
/** Pure move used both by the reducer and by the keyboard preview. Index is clamped. */
export function moveCard(board: Board, cardId: string, toColumn: string, toIndex: number): Board {
const from = findCard(board, cardId);
if (!from) return board;
const columns = board.columns.map((c) => ({ ...c, cardIds: c.cardIds.filter((id) => id !== cardId) }));
const target = columns.find((c) => c.id === toColumn);
if (!target) return board;
const i = Math.max(0, Math.min(toIndex, target.cardIds.length));
target.cardIds.splice(i, 0, cardId);
return { ...board, columns };
}
function updateActive(s: State, fn: (b: Board) => Board): Board[] {
return s.boards.map((b) => (b.id === s.activeId ? fn(b) : b));
}
/** Push the current boards onto the undo stack, then apply the change. */
function commit(s: State, label: string, boards: Board[], extra: Partial<State> = {}): State {
const past = [...s.past, { boards: s.boards, label }].slice(-HISTORY_LIMIT);
return { ...s, ...extra, boards, past, seq: s.seq + 1 };
}
export function reduce(s: State, a: Action): State {
const id = (prefix: string) => `${prefix}${s.seq + 1}`;
switch (a.type) {
case "undo": {
const last = s.past.at(-1);
if (!last) return s;
const activeId = last.boards.some((b) => b.id === s.activeId) ? s.activeId : last.boards[0]!.id;
return { ...s, boards: last.boards, past: s.past.slice(0, -1), activeId };
}
case "board/switch":
return s.boards.some((b) => b.id === a.id) ? { ...s, activeId: a.id } : s;
case "board/add": {
const bid = id("b");
const board: Board = {
id: bid,
name: a.name.trim() || "Untitled board",
cards: {},
columns: ["To do", "In progress", "Done"].map((title, i) => ({ id: `${bid}-c${i}`, title, wip: null, cardIds: [] })),
};
return commit(s, "add board", [...s.boards, board], { activeId: bid });
}
case "board/rename":
return commit(s, "rename board", updateActive(s, (b) => ({ ...b, name: a.name.trim() || b.name })));
case "board/delete": {
if (s.boards.length <= 1) return s;
const boards = s.boards.filter((b) => b.id !== s.activeId);
return commit(s, "delete board", boards, { activeId: boards[0]!.id });
}
case "column/add":
return commit(
s,
"add column",
updateActive(s, (b) => ({ ...b, columns: [...b.columns, { id: id("c"), title: a.title.trim() || "New column", wip: null, cardIds: [] }] })),
);
case "column/rename":
return commit(
s,
"rename column",
updateActive(s, (b) => ({ ...b, columns: b.columns.map((c) => (c.id === a.columnId ? { ...c, title: a.title.trim() || c.title } : c)) })),
);
case "column/wip":
return commit(
s,
"change WIP limit",
updateActive(s, (b) => ({
...b,
columns: b.columns.map((c) => (c.id === a.columnId ? { ...c, wip: a.wip && a.wip > 0 ? Math.floor(a.wip) : null } : c)),
})),
);
case "column/move":
return commit(
s,
"move column",
updateActive(s, (b) => {
const i = b.columns.findIndex((c) => c.id === a.columnId);
const j = i + a.delta;
if (i < 0 || j < 0 || j >= b.columns.length) return b;
const columns = [...b.columns];
[columns[i], columns[j]] = [columns[j]!, columns[i]!];
return { ...b, columns };
}),
);
case "column/delete":
return commit(
s,
"delete column",
updateActive(s, (b) => {
const col = b.columns.find((c) => c.id === a.columnId);
if (!col) return b;
const cards = { ...b.cards };
for (const cid of col.cardIds) delete cards[cid];
return { ...b, cards, columns: b.columns.filter((c) => c.id !== a.columnId) };
}),
);
case "card/add": {
const title = a.title.trim();
if (!title) return s;
const cid = id("k");
return commit(
s,
"add card",
updateActive(s, (b) => ({
...b,
cards: { ...b.cards, [cid]: { id: cid, title, description: "", labels: [], assignee: null, due: null } },
columns: b.columns.map((c) => (c.id === a.columnId ? { ...c, cardIds: [...c.cardIds, cid] } : c)),
})),
);
}
case "card/update":
return commit(
s,
"edit card",
updateActive(s, (b) => {
const card = b.cards[a.cardId];
if (!card) return b;
return { ...b, cards: { ...b.cards, [a.cardId]: { ...card, ...a.patch } } };
}),
);
case "card/delete":
return commit(
s,
"delete card",
updateActive(s, (b) => {
const cards = { ...b.cards };
delete cards[a.cardId];
return { ...b, cards, columns: b.columns.map((c) => ({ ...c, cardIds: c.cardIds.filter((x) => x !== a.cardId) })) };
}),
);
case "card/move": {
const board = activeBoard(s);
const from = findCard(board, a.cardId);
if (!from) return s;
if (from.column.id === a.toColumn && from.index === a.toIndex) return s;
if (!canMove(board, a.cardId, a.toColumn).ok) return s; // the UI shows the reason before dispatching
return commit(s, "move card", updateActive(s, (b) => moveCard(b, a.cardId, a.toColumn, a.toIndex)));
}
}
}
/** Search + label + assignee filter. Non-matching cards are dimmed, not removed, so drop indexes stay stable. */
export type Filter = { query: string; labels: LabelId[]; assignee: string | null };
export function matches(card: Card, f: Filter): boolean {
const q = f.query.trim().toLowerCase();
if (q && !`${card.title} ${card.description}`.toLowerCase().includes(q)) return false;
if (f.labels.length && !f.labels.some((l) => card.labels.includes(l))) return false;
if (f.assignee && card.assignee !== f.assignee) return false;
return true;
}
const addDays = (today: Date, n: number) => new Date(today.getTime() + n * 86_400_000).toISOString().slice(0, 10);
/** Seed data: a fictional website launch for "Acme Dental" plus a smaller second board. */
export function seed(today: Date): State {
type Row = [title: string, labels: LabelId[], assignee: string | null, dueIn: number | null, description?: string];
let n = 0;
const build = (bid: string, name: string, cols: [string, number | null, Row[]][]): Board => {
const cards: Record<string, Card> = {};
const columns = cols.map(([title, wip, rows], ci) => ({
id: `${bid}-c${ci}`,
title,
wip,
cardIds: rows.map(([t, labels, assignee, dueIn, description]) => {
const id = `s${++n}`;
cards[id] = { id, title: t, labels, assignee, due: dueIn === null ? null : addDays(today, dueIn), description: description ?? "" };
return id;
}),
}));
return { id: bid, name, columns, cards };
};
const launch = build("b1", "Acme Dental website launch", [
[
"Backlog",
null,
[
["Write privacy policy page", ["chore"], null, 12],
["Add online booking widget", ["feature"], "jd", 18, "Patients pick a dentist, a treatment and a free slot. Needs the clinic calendar API."],
["Blog: 5 tips for sensitive teeth", ["chore"], "ms", null],
["Cookie banner with consent log", ["feature", "urgent"], null, 6],
],
],
[
"To do",
null,
[
["Treatment price list page", ["feature"], "rk", 5],
["Hero photo is 2.4 MB, compress it", ["bug"], "ab", 2, "Export as AVIF + WebP at 1x/2x and serve with explicit width and height."],
["Footer opening hours per clinic", ["design"], "ms", 9],
["Set up uptime monitoring", ["chore"], null, null],
],
],
[
"In progress",
3,
[
["Contact form sends twice on double click", ["bug", "urgent"], "jd", 1, "Disable the button while the request is pending and add an idempotency key."],
["Team page with dentist profiles", ["feature", "design"], "ab", 4],
["Mobile nav focus trap", ["bug"], "rk", 3],
],
],
[
"Review",
2,
[
["Schema.org Dentist markup", ["feature"], "ms", 2],
["Colour contrast pass on buttons", ["design"], "ab", -1, "Primary teal on white was 3.1:1. Darkened to pass AA (4.6:1)."],
],
],
[
"Done",
null,
[
["Choose hosting and domain", ["chore"], "jd", null],
["Design tokens from brand guide", ["design"], "ms", null],
["Homepage wireframe sign-off", ["design"], "ab", null],
],
],
]);
const app = build("b2", "Order tracker v2", [
[
"To do",
null,
[
["Show order #1042 timeline", ["feature"], "rk", 7],
["Empty state for no orders", ["design"], null, null],
],
],
[
"Doing",
2,
[["Push notification on dispatch", ["feature"], "jd", 3]],
],
[
"Done",
null,
[["Fix totals rounding on refunds", ["bug"], "ab", null]],
],
]);
return { boards: [launch, app], activeId: "b1", past: [], seq: 100 };
}app/lab/kanban-board/Demo.tsx833 lines
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LABELS,
LABEL_IDS,
PEOPLE,
activeBoard,
canMove,
findCard,
matches,
moveCard,
reduce,
seed,
type Action,
type Board,
type Card,
type Filter,
type State,
} from "./board";
import { CardDrawer } from "./CardDrawer";
const KEY = "lab:kanban-board:v1";
type Press = { cardId: string; startX: number; startY: number; offX: number; offY: number; width: number };
type Drag = Press & { x: number; y: number; toColumn: string | null; toIndex: number };
type Lift = { cardId: string; fromColumn: string; fromIndex: number; toColumn: string; toIndex: number };
const personName = (id: string | null) => PEOPLE.find((p) => p.id === id)?.name ?? "Unassigned";
const initials = (id: string | null) => (id ? id.toUpperCase() : "");
export default function Demo() {
const [state, setState] = useState<State | null>(null);
const [today, setToday] = useState<string>("");
const [filter, setFilter] = useState<Filter>({ query: "", labels: [], assignee: null });
const [status, setStatus] = useState("");
const [error, setError] = useState<string | null>(null);
const [drag, setDrag] = useState<Drag | null>(null);
const [lift, setLift] = useState<Lift | null>(null);
const [openCard, setOpenCard] = useState<string | null>(null);
const [settingsFor, setSettingsFor] = useState<string | null>(null);
const [showHelp, setShowHelp] = useState(false);
const press = useRef<Press | null>(null);
const suppressClick = useRef(false);
const columnEls = useRef(new Map<string, HTMLElement>());
const searchRef = useRef<HTMLInputElement>(null);
const boardRef = useRef<HTMLDivElement>(null);
const returnFocus = useRef<string | null>(null);
/* ---------- load / persist ---------- */
useEffect(() => {
const now = new Date();
setToday(now.toISOString().slice(0, 10));
let loaded: State | null = null;
try {
const raw = localStorage.getItem(KEY);
if (raw) loaded = JSON.parse(raw) as State;
if (loaded && !Array.isArray(loaded.boards)) loaded = null;
} catch {
/* blocked or corrupted storage: fall back to the seed */
}
setState(loaded ?? seed(now));
}, []);
useEffect(() => {
if (!state) return;
try {
// History is not persisted: undo is for this session only.
localStorage.setItem(KEY, JSON.stringify({ ...state, past: [] }));
} catch {
/* ignore */
}
}, [state]);
const dispatch = useCallback((a: Action) => setState((s) => (s ? reduce(s, a) : s)), []);
const announce = (msg: string) => setStatus(msg);
const board = state ? activeBoard(state) : null;
/** While a card is lifted by keyboard, render the board as if it were already dropped. */
const shown: Board | null = useMemo(() => {
if (!board || !lift) return board;
return moveCard(board, lift.cardId, lift.toColumn, lift.toIndex);
}, [board, lift]);
const matchCount = useMemo(() => {
if (!board) return 0;
return Object.values(board.cards).filter((c) => matches(c, filter)).length;
}, [board, filter]);
const filtering = filter.query.trim() !== "" || filter.labels.length > 0 || filter.assignee !== null;
/* ---------- moving (shared by mouse, touch and keyboard) ---------- */
function tryMove(cardId: string, toColumn: string, toIndex: number): boolean {
if (!board) return false;
const check = canMove(board, cardId, toColumn);
if (!check.ok) {
setError(check.reason);
announce(check.reason);
return false;
}
const col = board.columns.find((c) => c.id === toColumn);
dispatch({ type: "card/move", cardId, toColumn, toIndex });
setError(null);
announce(`Moved “${board.cards[cardId]?.title}” to ${col?.title}, position ${toIndex + 1}.`);
return true;
}
/* ---------- pointer drag ---------- */
function onCardPointerDown(e: React.PointerEvent<HTMLElement>, cardId: string) {
if (e.button !== 0 || lift) return;
const fromHandle = (e.target as HTMLElement).closest("[data-drag-handle]");
if (e.pointerType !== "mouse" && !fromHandle) return; // touch drags from the grip so the page can still scroll
const el = e.currentTarget.closest<HTMLElement>("[data-card-id]")!;
const r = el.getBoundingClientRect();
press.current = { cardId, startX: e.clientX, startY: e.clientY, offX: e.clientX - r.left, offY: e.clientY - r.top, width: r.width };
}
const hitTest = useCallback(
(x: number, y: number, cardId: string): { toColumn: string | null; toIndex: number } => {
for (const [colId, el] of columnEls.current) {
const r = el.getBoundingClientRect();
if (x < r.left || x > r.right || y < r.top - 40 || y > r.bottom + 80) continue;
const cards = [...el.querySelectorAll<HTMLElement>("[data-card-id]")].filter((c) => c.dataset.cardId !== cardId);
let index = 0;
for (const c of cards) {
const cr = c.getBoundingClientRect();
if (y > cr.top + cr.height / 2) index++;
}
return { toColumn: colId, toIndex: index };
}
return { toColumn: null, toIndex: 0 };
},
[],
);
useEffect(() => {
function move(e: PointerEvent) {
const p = press.current;
if (!p) return;
if (!drag && Math.hypot(e.clientX - p.startX, e.clientY - p.startY) < 5) return;
e.preventDefault();
const hit = hitTest(e.clientX, e.clientY, p.cardId);
setDrag({ ...p, x: e.clientX, y: e.clientY, ...hit });
}
function up() {
const p = press.current;
press.current = null;
if (!p || !drag) return;
suppressClick.current = true;
setTimeout(() => (suppressClick.current = false), 0);
if (drag.toColumn) tryMove(drag.cardId, drag.toColumn, drag.toIndex);
setDrag(null);
}
function cancel(e: KeyboardEvent) {
if (e.key === "Escape" && drag) {
press.current = null;
setDrag(null);
announce("Drag cancelled.");
}
}
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
window.addEventListener("keydown", cancel);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
window.removeEventListener("keydown", cancel);
};
});
/* ---------- keyboard lift / move / drop ---------- */
function onCardKeyDown(e: React.KeyboardEvent<HTMLButtonElement>, cardId: string) {
if (!board || !shown) return;
const card = board.cards[cardId];
if (!card) return;
if (!lift) {
if (e.key === " ") {
e.preventDefault();
suppressClick.current = true;
const at = findCard(board, cardId)!;
setLift({ cardId, fromColumn: at.column.id, fromIndex: at.index, toColumn: at.column.id, toIndex: at.index });
announce(`Picked up “${card.title}” in ${at.column.title}, position ${at.index + 1}. Use arrow keys to move, Space to drop, Escape to cancel.`);
}
return;
}
if (lift.cardId !== cardId) return;
const cols = shown.columns;
const ci = cols.findIndex((c) => c.id === lift.toColumn);
const here = cols[ci]!;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const toIndex = Math.max(0, Math.min(here.cardIds.length - 1, lift.toIndex + (e.key === "ArrowUp" ? -1 : 1)));
setLift({ ...lift, toIndex });
announce(`${here.title}, position ${toIndex + 1} of ${here.cardIds.length}.`);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const next = cols[ci + (e.key === "ArrowLeft" ? -1 : 1)];
if (!next) return;
if (next.id !== lift.fromColumn) {
const check = canMove(board, cardId, next.id);
if (!check.ok) {
announce(check.reason);
setError(check.reason);
return;
}
}
const toIndex = Math.min(lift.toIndex, next.cardIds.length);
setLift({ ...lift, toColumn: next.id, toIndex });
announce(`${next.title}, position ${toIndex + 1} of ${next.cardIds.length + 1}.`);
} else if (e.key === " " || e.key === "Enter") {
e.preventDefault();
suppressClick.current = true;
setLift(null);
if (lift.toColumn === lift.fromColumn && lift.toIndex === lift.fromIndex) announce(`Dropped “${card.title}” where it was.`);
else tryMove(cardId, lift.toColumn, lift.toIndex);
} else if (e.key === "Escape") {
e.preventDefault();
setLift(null);
announce(`Move cancelled. “${card.title}” is back in its original place.`);
} else if (e.key === "Tab") {
e.preventDefault(); // keep focus on the lifted card until it is dropped
}
}
// A keyboard move re-parents the card element, so put focus back on it after each render.
useEffect(() => {
const id = lift?.cardId ?? returnFocus.current;
if (!id) return;
returnFocus.current = null;
boardRef.current?.querySelector<HTMLButtonElement>(`[data-card-id="${id}"] button[data-card-main]`)?.focus();
}, [lift, state]);
/* ---------- global shortcuts ---------- */
useEffect(() => {
function onKey(e: KeyboardEvent) {
// keydown can target the document itself (e.g. synthetic events); fall back to <body>
const t = e.target instanceof HTMLElement ? e.target : document.body;
const typing = t.closest("input, textarea, select, [contenteditable]");
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !typing) {
e.preventDefault();
dispatch({ type: "undo" });
announce("Undone.");
return;
}
if (typing || e.ctrlKey || e.metaKey || e.altKey || openCard) return;
if (!boardRef.current?.closest("section")?.contains(t) && t !== document.body) return;
if (e.key === "/") {
e.preventDefault();
searchRef.current?.focus();
} else if (e.key === "u") {
dispatch({ type: "undo" });
announce("Undone.");
} else if (e.key === "n") {
e.preventDefault();
boardRef.current?.querySelector<HTMLInputElement>("input[data-quick-add]")?.focus();
} else if (e.key === "?") {
setShowHelp((v) => !v);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [dispatch, openCard]);
if (!state || !board || !shown) {
return (
<div className="grid gap-3 p-6 sm:grid-cols-3" aria-busy="true" aria-label="Loading board">
{[0, 1, 2].map((i) => (
<div key={i} className="h-64 animate-pulse rounded-lg bg-bg-muted" />
))}
</div>
);
}
const lastChange = state.past.at(-1)?.label;
const drawerCard = openCard ? board.cards[openCard] : undefined;
function reset() {
const s = seed(new Date());
setState(s);
setFilter({ query: "", labels: [], assignee: null });
setLift(null);
setError(null);
announce("Demo reset to the sample boards.");
}
return (
<div className="relative" ref={boardRef}>
{/* ---------- toolbar ---------- */}
<div className="flex flex-wrap items-end gap-3 border-b border-border p-3 sm:p-4">
<label className="block min-w-[12rem] flex-1 sm:flex-none">
<span className="text-xs font-medium text-fg-muted">Board</span>
<select
value={state.activeId}
onChange={(e) => {
dispatch({ type: "board/switch", id: e.target.value });
setLift(null);
}}
className="mt-1 w-full rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm"
>
{state.boards.map((b) => (
<option key={b.id} value={b.id}>
{b.name} ({Object.keys(b.cards).length})
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => {
const name = window.prompt("New board name", "Sprint 14");
if (name !== null) dispatch({ type: "board/add", name });
}}
className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:border-accent hover:text-accent"
>
+ Board
</button>
<button
type="button"
onClick={() => {
const name = window.prompt("Rename board", board.name);
if (name !== null) dispatch({ type: "board/rename", name });
}}
className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:border-accent hover:text-accent"
>
Rename
</button>
<button
type="button"
disabled={state.boards.length <= 1}
onClick={() => {
dispatch({ type: "board/delete" });
announce(`Deleted board “${board.name}”. Press Undo to restore it.`);
}}
className="rounded-md border border-border px-2.5 py-1.5 text-sm hover:border-danger hover:text-danger disabled:opacity-40"
>
Delete board
</button>
<div className="ml-auto flex flex-wrap items-end gap-2">
<label className="block">
<span className="text-xs font-medium text-fg-muted">
Search <kbd className="rounded border border-border px-1 font-mono text-[10px]">/</kbd>
</span>
<input
ref={searchRef}
type="search"
value={filter.query}
onChange={(e) => setFilter({ ...filter, query: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Escape") setFilter({ ...filter, query: "" });
}}
placeholder="Filter cards…"
className="mt-1 w-44 rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-fg-muted">Assignee</span>
<select
value={filter.assignee ?? ""}
onChange={(e) => setFilter({ ...filter, assignee: e.target.value || null })}
className="mt-1 rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm"
>
<option value="">Anyone</option>
{PEOPLE.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
<span className="text-xs text-fg-muted">Labels:</span>
{LABEL_IDS.map((l) => {
const on = filter.labels.includes(l);
return (
<button
key={l}
type="button"
aria-pressed={on}
onClick={() => setFilter({ ...filter, labels: on ? filter.labels.filter((x) => x !== l) : [...filter.labels, l] })}
className={`rounded-full border px-2 py-0.5 text-xs ${on ? LABELS[l].className + " font-semibold" : "border-border text-fg-muted hover:text-fg"}`}
>
{LABELS[l].name}
</button>
);
})}
{filtering && (
<>
<span className="font-mono text-xs text-fg-muted" aria-live="polite">
{matchCount} match{matchCount === 1 ? "" : "es"}
</span>
<button type="button" onClick={() => setFilter({ query: "", labels: [], assignee: null })} className="text-xs text-accent underline">
clear
</button>
</>
)}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
disabled={!lastChange}
onClick={() => {
dispatch({ type: "undo" });
announce(`Undid: ${lastChange}.`);
}}
className="rounded-md border border-border px-2.5 py-1 font-mono text-xs hover:border-accent hover:text-accent disabled:opacity-40"
title="Undo (u or Ctrl+Z)"
>
↶ undo{lastChange ? `: ${lastChange}` : ""}
</button>
<button
type="button"
onClick={() => setShowHelp((v) => !v)}
aria-expanded={showHelp}
className="rounded-md border border-border px-2.5 py-1 font-mono text-xs hover:border-accent"
>
? keys
</button>
<button type="button" onClick={reset} className="rounded-md border border-border px-2.5 py-1 font-mono text-xs hover:border-danger hover:text-danger">
reset demo
</button>
</div>
</div>
{showHelp && (
<div className="border-b border-border bg-bg-elev px-4 py-3 text-sm">
<h3 className="font-mono text-xs font-semibold text-fg-muted">Keyboard</h3>
<ul className="mt-2 grid gap-x-6 gap-y-1 sm:grid-cols-2">
{[
["Space", "lift the focused card, then drop it"],
["← → ↑ ↓", "move a lifted card between columns and positions"],
["Esc", "cancel the move (or a mouse drag)"],
["Enter", "open card details"],
["/", "search"],
["n", "quick-add a card"],
["u / Ctrl+Z", "undo"],
["?", "toggle this help"],
].map(([k, d]) => (
<li key={k} className="flex gap-2">
<kbd className="min-w-[5.5rem] font-mono text-xs text-accent">{k}</kbd>
<span className="text-fg-muted">{d}</span>
</li>
))}
</ul>
</div>
)}
{error && (
<p role="alert" className="flex items-center justify-between gap-3 border-b border-border bg-warn-bg px-4 py-2 text-sm text-warn-fg">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="font-mono text-xs underline">
dismiss
</button>
</p>
)}
{/* ---------- columns ---------- */}
<div className="flex snap-x gap-3 overflow-x-auto p-3 sm:p-4" style={{ minHeight: "28rem" }}>
{shown.columns.map((col, ci) => {
const count = col.cardIds.length;
const atLimit = col.wip !== null && count >= col.wip;
const over = col.wip !== null && count > col.wip;
const visible = drag ? col.cardIds.filter((id) => id !== drag.cardId) : col.cardIds;
const isTarget = drag?.toColumn === col.id;
const blocked = isTarget && drag && !canMove(board, drag.cardId, col.id).ok;
return (
<section
key={col.id}
aria-labelledby={`col-${col.id}`}
className={`flex w-[16.5rem] shrink-0 snap-start flex-col rounded-lg border bg-bg-elev ${
blocked ? "border-danger" : isTarget ? "border-accent" : "border-border"
}`}
>
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<h3 id={`col-${col.id}`} className="truncate text-sm font-semibold">
{col.title}
</h3>
<span
className={`rounded-full px-1.5 font-mono text-[11px] ${over ? "bg-[#f87171]/15 text-danger" : atLimit ? "bg-warn-bg text-warn-fg" : "bg-bg-muted text-fg-muted"}`}
title={col.wip ? `WIP limit ${col.wip}` : "No WIP limit"}
>
{count}
{col.wip !== null && `/${col.wip}`}
</span>
<button
type="button"
aria-expanded={settingsFor === col.id}
aria-label={`Settings for column ${col.title}`}
onClick={() => setSettingsFor(settingsFor === col.id ? null : col.id)}
className="ml-auto rounded px-1.5 font-mono text-fg-muted hover:bg-bg-muted hover:text-fg"
>
⋯
</button>
</header>
{settingsFor === col.id && (
<ColumnSettings
key={col.id + col.title + col.wip}
title={col.title}
wip={col.wip}
first={ci === 0}
last={ci === shown.columns.length - 1}
cardCount={count}
onRename={(t) => dispatch({ type: "column/rename", columnId: col.id, title: t })}
onWip={(w) => {
dispatch({ type: "column/wip", columnId: col.id, wip: w });
announce(w ? `WIP limit for ${col.title} set to ${w}.` : `WIP limit removed from ${col.title}.`);
}}
onMove={(d) => dispatch({ type: "column/move", columnId: col.id, delta: d })}
onDelete={() => {
dispatch({ type: "column/delete", columnId: col.id });
setSettingsFor(null);
announce(`Deleted column ${col.title} and its ${count} cards. Undo is available.`);
}}
/>
)}
<ol
ref={(el) => {
if (el) columnEls.current.set(col.id, el);
else columnEls.current.delete(col.id);
}}
className="flex min-h-16 flex-1 flex-col gap-2 p-2"
aria-label={`${col.title} cards`}
>
{visible.map((id, i) => {
const card = board.cards[id];
if (!card) return null;
return (
<li key={id} data-card-id={id} className="relative">
{isTarget && drag.toIndex === i && <DropLine blocked={!!blocked} />}
<CardTile
card={card}
today={today}
dim={filtering && !matches(card, filter)}
lifted={lift?.cardId === id}
onPointerDown={(e) => onCardPointerDown(e, id)}
onKeyDown={(e) => onCardKeyDown(e, id)}
onKeyUp={(e) => e.key === " " && e.preventDefault()}
onClick={() => {
if (suppressClick.current) return;
returnFocus.current = null;
setOpenCard(id);
}}
/>
</li>
);
})}
{isTarget && drag.toIndex >= visible.length && <DropLine blocked={!!blocked} />}
{visible.length === 0 && !isTarget && <li className="py-6 text-center text-xs text-fg-muted">No cards. Drop one here.</li>}
</ol>
<QuickAdd column={col.title} onAdd={(title) => dispatch({ type: "card/add", columnId: col.id, title })} />
</section>
);
})}
<AddColumn onAdd={(t) => dispatch({ type: "column/add", title: t })} />
</div>
<p className="sr-only" role="status" aria-live="assertive">
{status}
</p>
<p className="border-t border-border px-4 py-2 text-xs text-fg-muted">
Drag with the mouse, the <span aria-hidden="true">⠿</span> grip on touch, or focus a card and press{" "}
<kbd className="font-mono">Space</kbd>. {status && <span className="text-fg">{status}</span>}
</p>
{/* ---------- floating drag ghost ---------- */}
{drag && board.cards[drag.cardId] && (
<div
className="pointer-events-none fixed z-50 rotate-2 opacity-95 shadow-2xl"
style={{ left: drag.x - drag.offX, top: drag.y - drag.offY, width: drag.width }}
aria-hidden="true"
>
<CardTile card={board.cards[drag.cardId]!} today={today} dim={false} lifted />
</div>
)}
{drawerCard && (
<CardDrawer
card={drawerCard}
board={board}
onClose={() => {
returnFocus.current = drawerCard.id;
setOpenCard(null);
}}
onSave={(patch, toColumn) => {
dispatch({ type: "card/update", cardId: drawerCard.id, patch });
const at = findCard(board, drawerCard.id);
if (toColumn && at && at.column.id !== toColumn) {
const target = board.columns.find((c) => c.id === toColumn)!;
if (!tryMove(drawerCard.id, toColumn, target.cardIds.length)) return false;
}
announce(`Saved “${patch.title ?? drawerCard.title}”.`);
return true;
}}
onDelete={() => {
dispatch({ type: "card/delete", cardId: drawerCard.id });
setOpenCard(null);
announce(`Deleted “${drawerCard.title}”. Press Undo to bring it back.`);
}}
/>
)}
</div>
);
}
function DropLine({ blocked }: { blocked: boolean }) {
return <div className={`mb-2 h-1 rounded-full ${blocked ? "bg-danger" : "bg-accent"}`} aria-hidden="true" />;
}
function CardTile({
card,
today,
dim,
lifted,
onPointerDown,
onKeyDown,
onKeyUp,
onClick,
}: {
card: Card;
today: string;
dim: boolean;
lifted: boolean;
onPointerDown?: (e: React.PointerEvent<HTMLElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onKeyUp?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onClick?: () => void;
}) {
const overdue = !!card.due && !!today && card.due < today;
const daysLeft = card.due && today ? (Date.parse(card.due) - Date.parse(today)) / 86_400_000 : null;
const dueSoon = !overdue && daysLeft !== null && daysLeft <= 2;
return (
<div
onPointerDown={onPointerDown}
className={`group relative rounded-md border bg-bg text-left transition-opacity ${lifted ? "border-accent ring-2 ring-accent" : "border-border hover:border-fg-muted"} ${dim ? "opacity-30" : ""}`}
>
<button
type="button"
data-card-main
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onClick={onClick}
aria-roledescription="draggable card"
aria-describedby="kanban-card-help"
className="block w-full cursor-grab select-none p-2.5 pr-7 text-left active:cursor-grabbing"
>
{card.labels.length > 0 && (
<span className="mb-1.5 flex flex-wrap gap-1">
{card.labels.map((l) => (
<span key={l} className={`rounded border px-1.5 text-[10px] font-medium ${LABELS[l].className}`}>
{LABELS[l].name}
</span>
))}
</span>
)}
<span className="block text-sm leading-snug">{card.title}</span>
<span className="mt-2 flex items-center gap-2 text-[11px] text-fg-muted">
{card.due && (
<span className={`font-mono ${overdue ? "text-danger" : dueSoon ? "text-warn-fg" : ""}`}>
{overdue ? "overdue " : "due "}
{new Date(card.due + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
</span>
)}
{card.description && <span title="Has description">≡</span>}
{card.assignee && (
<span
className="ml-auto grid size-6 place-items-center rounded-full bg-accent-soft font-mono text-[10px] font-semibold text-accent"
title={personName(card.assignee)}
>
{initials(card.assignee)}
</span>
)}
</span>
{lifted && <span className="mt-1 block font-mono text-[10px] text-accent">lifted · arrows to move · space to drop</span>}
</button>
<span
data-drag-handle
className="absolute top-1.5 right-1.5 cursor-grab touch-none rounded px-1 text-fg-muted opacity-60 group-hover:opacity-100"
aria-hidden="true"
>
⠿
</span>
<span id="kanban-card-help" className="sr-only">
Press Space to lift and move with arrow keys. Press Enter to open details.
</span>
</div>
);
}
function QuickAdd({ column, onAdd }: { column: string; onAdd: (title: string) => void }) {
const [v, setV] = useState("");
const [err, setErr] = useState(false);
return (
<form
className="border-t border-border p-2"
onSubmit={(e) => {
e.preventDefault();
if (!v.trim()) return setErr(true);
onAdd(v);
setV("");
setErr(false);
}}
>
<label className="sr-only" htmlFor={`qa-${column}`}>
Add a card to {column}
</label>
<input
id={`qa-${column}`}
data-quick-add
value={v}
onChange={(e) => {
setV(e.target.value);
setErr(false);
}}
placeholder="+ Add a card"
maxLength={120}
aria-invalid={err}
className={`w-full rounded-md border bg-bg px-2 py-1.5 text-sm outline-none focus:border-accent ${err ? "border-danger" : "border-transparent hover:border-border"}`}
/>
{err && <p className="mt-1 text-xs text-danger">Type a title first.</p>}
</form>
);
}
function AddColumn({ onAdd }: { onAdd: (t: string) => void }) {
const [v, setV] = useState("");
return (
<form
className="w-[14rem] shrink-0 self-start rounded-lg border border-dashed border-border p-2"
onSubmit={(e) => {
e.preventDefault();
if (!v.trim()) return;
onAdd(v);
setV("");
}}
>
<label className="text-xs text-fg-muted" htmlFor="kanban-add-col">
New column
</label>
<div className="mt-1 flex gap-1">
<input
id="kanban-add-col"
value={v}
onChange={(e) => setV(e.target.value)}
placeholder="Blocked"
maxLength={40}
className="w-full rounded-md border border-border bg-bg px-2 py-1 text-sm outline-none focus:border-accent"
/>
<button type="submit" className="rounded-md border border-border px-2 text-sm hover:border-accent hover:text-accent">
Add
</button>
</div>
</form>
);
}
function ColumnSettings({
title,
wip,
first,
last,
cardCount,
onRename,
onWip,
onMove,
onDelete,
}: {
title: string;
wip: number | null;
first: boolean;
last: boolean;
cardCount: number;
onRename: (t: string) => void;
onWip: (w: number | null) => void;
onMove: (d: -1 | 1) => void;
onDelete: () => void;
}) {
const [name, setName] = useState(title);
const [limit, setLimit] = useState(wip === null ? "" : String(wip));
const btn = "rounded border border-border px-2 py-0.5 text-xs hover:border-accent hover:text-accent disabled:opacity-40";
return (
<div className="space-y-2 border-b border-border bg-bg-muted p-2 text-xs">
<form
className="flex items-end gap-1"
onSubmit={(e) => {
e.preventDefault();
if (name.trim() && name !== title) onRename(name);
const n = limit.trim() === "" ? null : Number(limit);
if (n !== wip) onWip(n !== null && Number.isFinite(n) ? n : null);
}}
>
<label className="flex-1">
<span className="text-fg-muted">Name</span>
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={40} className="mt-0.5 w-full rounded border border-border bg-bg px-1.5 py-1" />
</label>
<label className="w-14">
<span className="text-fg-muted">WIP</span>
<input
type="number"
min={1}
max={50}
value={limit}
onChange={(e) => setLimit(e.target.value)}
placeholder="∞"
className="mt-0.5 w-full rounded border border-border bg-bg px-1.5 py-1"
/>
</label>
<button type="submit" className={btn + " py-1"}>
Save
</button>
</form>
<div className="flex flex-wrap gap-1">
<button type="button" className={btn} disabled={first} onClick={() => onMove(-1)}>
← Move left
</button>
<button type="button" className={btn} disabled={last} onClick={() => onMove(1)}>
Move right →
</button>
<button type="button" className={btn + " ml-auto hover:border-danger hover:text-danger"} onClick={onDelete}>
Delete{cardCount ? ` (${cardCount})` : ""}
</button>
</div>
</div>
);
}app/lab/kanban-board/CardDrawer.tsx177 lines
"use client";
import { useEffect, useRef, useState } from "react";
import { LABELS, LABEL_IDS, PEOPLE, findCard, type Board, type Card, type LabelId } from "./board";
type Props = {
card: Card;
board: Board;
onClose: () => void;
/** Returns false when the save was refused (for example a WIP limit), so the drawer stays open. */
onSave: (patch: Partial<Omit<Card, "id">>, toColumn: string | null) => boolean;
onDelete: () => void;
};
/** Side drawer for editing one card. Modal: focus is trapped inside and returns to the card on close. */
export function CardDrawer({ card, board, onClose, onSave, onDelete }: Props) {
const [title, setTitle] = useState(card.title);
const [description, setDescription] = useState(card.description);
const [labels, setLabels] = useState<LabelId[]>(card.labels);
const [assignee, setAssignee] = useState(card.assignee ?? "");
const [due, setDue] = useState(card.due ?? "");
const [column, setColumn] = useState(findCard(board, card.id)?.column.id ?? "");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const panel = useRef<HTMLDivElement>(null);
const first = useRef<HTMLInputElement>(null);
useEffect(() => {
first.current?.focus();
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab" || !panel.current) return;
const items = [...panel.current.querySelectorAll<HTMLElement>("button, input, textarea, select")].filter((el) => !el.hasAttribute("disabled"));
const firstEl = items[0];
const lastEl = items.at(-1);
if (!firstEl || !lastEl) return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
function save(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) {
setError("Title is required.");
first.current?.focus();
return;
}
const ok = onSave({ title: title.trim(), description, labels, assignee: assignee || null, due: due || null }, column || null);
if (ok) onClose();
else setError("Saved the edits, but the card could not move: that column is at its WIP limit.");
}
const field = "mt-1 w-full rounded-md border border-border bg-bg px-2 py-1.5 text-sm outline-none focus:border-accent";
return (
<div className="fixed inset-0 z-40 flex justify-end bg-black/50" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
onKeyDown={onKeyDown}
className="flex h-full w-full max-w-md flex-col overflow-y-auto border-l border-border bg-bg-elev shadow-2xl"
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 id="drawer-title" className="font-mono text-sm font-semibold">
card <span className="text-fg-muted">#{card.id}</span>
</h2>
<button type="button" onClick={onClose} aria-label="Close card details" className="rounded px-2 text-lg text-fg-muted hover:text-fg">
×
</button>
</div>
<form onSubmit={save} className="flex-1 space-y-4 p-4" noValidate>
<label className="block">
<span className="text-sm font-medium">Title</span>
<input
ref={first}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
aria-invalid={!title.trim()}
aria-describedby={error ? "drawer-error" : undefined}
className={field}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Description</span>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} className={field} />
</label>
<fieldset>
<legend className="text-sm font-medium">Labels</legend>
<div className="mt-1 flex flex-wrap gap-1.5">
{LABEL_IDS.map((l) => (
<label key={l} className={`flex cursor-pointer items-center gap-1 rounded border px-2 py-0.5 text-xs ${labels.includes(l) ? LABELS[l].className : "border-border text-fg-muted"}`}>
<input
type="checkbox"
checked={labels.includes(l)}
onChange={(e) => setLabels(e.target.checked ? [...labels, l] : labels.filter((x) => x !== l))}
className="accent-current"
/>
{LABELS[l].name}
</label>
))}
</div>
</fieldset>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="text-sm font-medium">Assignee</span>
<select value={assignee} onChange={(e) => setAssignee(e.target.value)} className={field}>
<option value="">Unassigned</option>
{PEOPLE.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm font-medium">Due date</span>
<input type="date" value={due} onChange={(e) => setDue(e.target.value)} className={field} />
</label>
</div>
<label className="block">
<span className="text-sm font-medium">Column</span>
<select value={column} onChange={(e) => setColumn(e.target.value)} className={field}>
{board.columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title} ({c.cardIds.length}
{c.wip !== null ? `/${c.wip}` : ""})
</option>
))}
</select>
</label>
{error && (
<p id="drawer-error" role="alert" className="text-sm text-danger">
{error}
</p>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<button type="submit" className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90">
Save changes
</button>
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-2 text-sm hover:border-fg-muted">
Cancel
</button>
{confirmDelete ? (
<button type="button" onClick={onDelete} className="ml-auto rounded-md bg-danger px-3 py-2 text-sm font-medium text-white">
Confirm delete
</button>
) : (
<button type="button" onClick={() => setConfirmDelete(true)} className="ml-auto rounded-md border border-border px-3 py-2 text-sm text-danger hover:border-danger">
Delete card
</button>
)}
</div>
</form>
</div>
</div>
);
}