- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.0.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { createPortal } from "react-dom";
import { AddCardForm, CardContent, CardItem, type DragState, type KeyboardLift } from "./Board";
import { boardData, formatDue, members, TODAY } from "./data";
import { CardDialog, ConfirmDialog } from "./Dialogs";
import { columnById, positionOf, useBoard, wipBlockReason } from "./store";
import { Icon, Wordmark } from "./ui";
import type { Card, Column, Filters } from "./types";
const EMPTY_FILTERS: Filters = { assignee: "all", labels: [], query: "" };
const DRAG_THRESHOLD = 6;
interface Toast {
id: number;
text: string;
tone: "info" | "error";
}
export default function App() {
const board = useBoard();
const { state } = board;
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [drag, setDrag] = useState<DragState | null>(null);
const [lift, setLift] = useState<KeyboardLift | null>(null);
const [openCardId, setOpenCardId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [resetOpen, setResetOpen] = useState(false);
const [addingTo, setAddingTo] = useState<string | null>(null);
const [toast, setToast] = useState<Toast | null>(null);
const [announcement, setAnnouncement] = useState<{ text: string; n: number }>({ text: "", n: 0 });
const boardRef = useRef<HTMLDivElement>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const suppressClick = useRef(false);
const focusAfterRender = useRef<string | null>(null);
const undoBtnRef = useRef<HTMLButtonElement>(null);
const stateRef = useRef(state);
stateRef.current = state;
/* ---------- filtering ---------- */
const filtersActive =
filters.assignee !== "all" || filters.labels.length > 0 || filters.query.trim() !== "";
const matches = useCallback(
(card: Card) => {
if (filters.assignee === "unassigned" && card.assignee !== null) return false;
if (filters.assignee !== "all" && filters.assignee !== "unassigned" && card.assignee !== filters.assignee)
return false;
if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false;
const q = filters.query.trim().toLowerCase();
if (q && !card.title.toLowerCase().includes(q) && !card.description.toLowerCase().includes(q)) return false;
return true;
},
[filters],
);
const visibleByColumn = useMemo(() => {
const out: Record<string, Card[]> = {};
for (const col of state.columns) {
out[col.id] = col.card_ids.map((id) => state.cards[id]).filter((c): c is Card => !!c && matches(c));
}
return out;
}, [state, matches]);
const totalVisible = Object.values(visibleByColumn).reduce((n, list) => n + list.length, 0);
const totalCards = state.columns.reduce((n, c) => n + c.card_ids.length, 0);
/* ---------- messaging ---------- */
const announce = useCallback((text: string) => {
setAnnouncement((a) => ({ text, n: a.n + 1 }));
}, []);
const notify = useCallback((text: string, tone: Toast["tone"] = "info") => {
setToast({ id: Date.now(), text, tone });
}, []);
useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), toast.tone === "error" ? 7000 : 4500);
return () => window.clearTimeout(t);
}, [toast]);
/* ---------- focus management ---------- */
useEffect(() => {
if (focusAfterRender.current) {
const el = document.getElementById(`card-${focusAfterRender.current}`);
if (el) {
el.focus({ preventScroll: false });
focusAfterRender.current = null;
}
}
});
const focusCard = (id: string) => {
focusAfterRender.current = id;
};
/* ---------- helpers ---------- */
const wipReasonFor = useCallback(
(cardId: string) => (toColumnId: string) => {
const from = positionOf(state, cardId);
return wipBlockReason(state, toColumnId, from?.columnId ?? null);
},
[state],
);
const describePosition = (cardId: string) => {
const s = stateRef.current;
const pos = positionOf(s, cardId);
if (!pos) return "";
const col = columnById(s, pos.columnId)!;
return `position ${pos.index + 1} of ${col.card_ids.length} in ${col.title}`;
};
/* ---------- moves via menu / dialog ---------- */
const moveToColumn = (cardId: string, toColumnId: string) => {
const card = state.cards[cardId];
const to = columnById(state, toColumnId);
if (!card || !to) return;
const res = board.move(cardId, toColumnId, null);
if (!res.ok) {
notify(res.reason, "error");
announce(`Not moved. ${res.reason}`);
return;
}
announce(`Moved “${card.title}” to ${to.title}, position ${to.card_ids.length + 1} of ${to.card_ids.length + 1}.`);
notify(`Moved “${card.title}” to ${to.title}.`);
focusCard(cardId);
};
/* ---------- pointer drag ---------- */
const dragRef = useRef<{
cardId: string;
fromColumnId: string;
startX: number;
startY: number;
offsetX: number;
offsetY: number;
width: number;
height: number;
active: boolean;
over: { columnId: string; index: number; blocked: boolean } | null;
raf: number | null;
lastX: number;
lastY: number;
} | null>(null);
const onCardPointerDown = (e: ReactPointerEvent<HTMLElement>, cardId: string, columnId: string) => {
if (e.button !== 0 || e.pointerType === "touch") return;
if ((e.target as HTMLElement).closest("button, [role=menu], input, select, textarea")) return;
if (lift) return;
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
dragRef.current = {
cardId,
fromColumnId: columnId,
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
width: rect.width,
height: rect.height,
active: false,
over: null,
raf: null,
lastX: e.clientX,
lastY: e.clientY,
};
window.addEventListener("pointermove", stableHandlers.move);
window.addEventListener("pointerup", stableHandlers.up);
window.addEventListener("pointercancel", stableHandlers.cancel);
window.addEventListener("keydown", stableHandlers.key);
};
const computeOver = (x: number, y: number) => {
const d = dragRef.current;
if (!d) return null;
const target = document.elementFromPoint(x, y);
const colEl = target?.closest<HTMLElement>("[data-column-id]");
if (!colEl) return null;
const columnId = colEl.dataset.columnId!;
const list = colEl.querySelector<HTMLElement>("[data-card-list]");
const cards = list ? Array.from(list.querySelectorAll<HTMLElement>("[data-card-id]")) : [];
let index = cards.length;
for (let i = 0; i < cards.length; i++) {
const r = cards[i].getBoundingClientRect();
if (y < r.top + r.height / 2) {
index = i;
break;
}
}
const blocked = wipBlockReason(board.state, columnId, d.fromColumnId) !== null;
return { columnId, index, blocked };
};
const positionGhost = (x: number, y: number) => {
const d = dragRef.current;
const g = ghostRef.current;
if (!d || !g) return;
g.style.transform = `translate(${x - d.offsetX}px, ${y - d.offsetY}px)`;
};
const onWindowPointerMove = (e: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
d.lastX = e.clientX;
d.lastY = e.clientY;
if (!d.active) {
if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < DRAG_THRESHOLD) return;
d.active = true;
const visible = visibleByColumn[d.fromColumnId] ?? [];
const startIndex = Math.max(
0,
visible.findIndex((c) => c.id === d.cardId),
);
d.over = { columnId: d.fromColumnId, index: startIndex, blocked: false };
document.body.classList.add("is-dragging");
setDrag({
cardId: d.cardId,
fromColumnId: d.fromColumnId,
overColumnId: d.fromColumnId,
overIndex: startIndex,
blocked: false,
width: d.width,
height: d.height,
});
const card = board.state.cards[d.cardId];
if (card) announce(`Dragging “${card.title}”.`);
}
e.preventDefault();
if (d.raf !== null) return;
d.raf = requestAnimationFrame(() => {
d.raf = null;
positionGhost(d.lastX, d.lastY);
// auto-scroll the board horizontally near the edges
const b = boardRef.current;
if (b) {
const r = b.getBoundingClientRect();
if (d.lastX < r.left + 48) b.scrollLeft -= 12;
else if (d.lastX > r.right - 48) b.scrollLeft += 12;
}
const over = computeOver(d.lastX, d.lastY);
if (over) {
const prev = d.over;
if (!prev || prev.columnId !== over.columnId || prev.index !== over.index || prev.blocked !== over.blocked) {
d.over = over;
setDrag((s) =>
s ? { ...s, overColumnId: over.columnId, overIndex: over.index, blocked: over.blocked } : s,
);
}
}
});
};
const endDrag = () => {
const d = dragRef.current;
if (d?.raf !== null && d?.raf !== undefined) cancelAnimationFrame(d.raf);
dragRef.current = null;
document.body.classList.remove("is-dragging");
window.removeEventListener("pointermove", stableHandlers.move);
window.removeEventListener("pointerup", stableHandlers.up);
window.removeEventListener("pointercancel", stableHandlers.cancel);
window.removeEventListener("keydown", stableHandlers.key);
setDrag(null);
};
const onWindowPointerUp = () => {
const d = dragRef.current;
if (!d) return;
if (!d.active) {
endDrag();
return;
}
suppressClick.current = true;
window.setTimeout(() => (suppressClick.current = false), 0);
const card = board.state.cards[d.cardId];
const over = d.over;
endDrag();
if (!card || !over) return;
const to = columnById(board.state, over.columnId);
if (!to) return;
if (over.blocked) {
const reason = wipBlockReason(board.state, over.columnId, d.fromColumnId) ?? "That column is full.";
notify(reason, "error");
announce(`Not moved. ${reason}`);
focusCard(d.cardId);
return;
}
const visible = (visibleByColumn[over.columnId] ?? []).filter((c) => c.id !== d.cardId);
const beforeId = visible[over.index]?.id ?? null;
const res = board.move(d.cardId, over.columnId, beforeId);
if (!res.ok) {
notify(res.reason, "error");
announce(`Not moved. ${res.reason}`);
return;
}
focusCard(d.cardId);
window.setTimeout(() => announce(`Dropped “${card.title}” at ${describePosition(d.cardId)}.`), 0);
};
const onWindowPointerCancel = () => {
endDrag();
};
const onWindowKeyDownDuringDrag = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape" && dragRef.current?.active) {
e.preventDefault();
const id = dragRef.current.cardId;
endDrag();
announce("Drag cancelled.");
focusCard(id);
}
};
// Window listeners are registered once per drag; route them through a ref so
// they always run the handlers from the latest render (no stale state).
const latestHandlers = useRef({
move: onWindowPointerMove,
up: onWindowPointerUp,
cancel: onWindowPointerCancel,
key: onWindowKeyDownDuringDrag,
end: endDrag,
});
latestHandlers.current = {
move: onWindowPointerMove,
up: onWindowPointerUp,
cancel: onWindowPointerCancel,
key: onWindowKeyDownDuringDrag,
end: endDrag,
};
const stableHandlers = useRef({
move: (e: PointerEvent) => latestHandlers.current.move(e),
up: () => latestHandlers.current.up(),
cancel: () => latestHandlers.current.cancel(),
key: (e: globalThis.KeyboardEvent) => latestHandlers.current.key(e),
}).current;
useEffect(() => () => latestHandlers.current.end(), []);
// Place the ghost under the pointer as soon as it mounts.
useEffect(() => {
const d = dragRef.current;
if (drag && d) positionGhost(d.lastX, d.lastY);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drag?.cardId]);
/* ---------- keyboard moves ---------- */
const onCardKeyDown = (e: KeyboardEvent<HTMLElement>, cardId: string, columnId: string) => {
if (e.target !== e.currentTarget) return;
const card = state.cards[cardId];
if (!card) return;
const lifted = lift?.cardId === cardId;
if (e.key === "Enter" && !lifted) {
e.preventDefault();
setOpenCardId(cardId);
return;
}
if (e.key === " ") {
e.preventDefault();
if (!lifted) {
if (lift) return; // another card is already lifted
const pos = positionOf(state, cardId)!;
setLift({ cardId, origin: pos });
announce(
`Picked up “${card.title}”, ${describePosition(cardId)}. Use up and down arrows to reorder, left and right to change column, Space to drop, Escape to cancel.`,
);
} else {
board.recordMove(cardId, lift.origin);
setLift(null);
announce(`Dropped “${card.title}” at ${describePosition(cardId)}.`);
notify(`Moved “${card.title}”.`);
}
return;
}
if (!lifted) return;
if (e.key === "Escape") {
e.preventDefault();
board.moveToPosition(cardId, lift.origin.columnId, lift.origin.index, false);
setLift(null);
focusCard(cardId);
const col = columnById(state, lift.origin.columnId);
announce(`Move cancelled. “${card.title}” returned to ${col?.title ?? "its column"}, position ${lift.origin.index + 1}.`);
return;
}
const pos = positionOf(state, cardId)!;
const colIndex = state.columns.findIndex((c) => c.id === pos.columnId);
let target: { columnId: string; index: number } | null = null;
if (e.key === "ArrowUp") target = { columnId: pos.columnId, index: Math.max(0, pos.index - 1) };
else if (e.key === "ArrowDown") {
const len = columnById(state, pos.columnId)!.card_ids.length;
target = { columnId: pos.columnId, index: Math.min(len - 1, pos.index + 1) };
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
const next = state.columns[colIndex + (e.key === "ArrowLeft" ? -1 : 1)];
if (!next) {
e.preventDefault();
announce(e.key === "ArrowLeft" ? "Already in the first column." : "Already in the last column.");
return;
}
target = { columnId: next.id, index: Math.min(pos.index, next.card_ids.length) };
}
if (!target) return;
e.preventDefault();
if (target.columnId === pos.columnId && target.index === pos.index) {
announce(e.key === "ArrowUp" ? "Already at the top." : "Already at the bottom.");
return;
}
const res = board.moveToPosition(cardId, target.columnId, target.index, false);
if (!res.ok) {
notify(res.reason, "error");
announce(`Not moved. ${res.reason}`);
return;
}
focusCard(cardId);
const col = columnById(state, target.columnId)!;
const len = target.columnId === pos.columnId ? col.card_ids.length : col.card_ids.length + 1;
announce(`Moved to ${col.title}, position ${target.index + 1} of ${len}.`);
};
/* ---------- undo ---------- */
const doUndo = useCallback(() => {
const last = board.lastUndo;
if (!last) return;
const res = board.undo();
if (!res.ok) {
notify(res.reason, "error");
announce(res.reason);
return;
}
const text =
last.kind === "move"
? "Move undone."
: last.kind === "create"
? "New card removed."
: `“${last.card.title}” restored.`;
notify(text);
announce(text);
if (last.kind === "move") focusCard(last.cardId);
if (last.kind === "delete") focusCard(last.card.id);
}, [board, notify, announce]);
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "z") {
const t = e.target as HTMLElement | null;
if (t?.closest("textarea, [contenteditable=true], input:not([type=checkbox]):not([type=radio])")) return;
e.preventDefault();
doUndo();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [doUndo]);
/* ---------- create / delete ---------- */
const createIn = (columnId: string) => (title: string) => {
const res = board.create(columnId, title);
if (!res.ok) return res;
const col = columnById(state, columnId)!;
setAddingTo(null);
notify(`Added “${title.trim()}” to ${col.title}.`);
announce(`Added “${title.trim()}” to ${col.title}.`);
if (res.cardId) focusCard(res.cardId);
return { ok: true as const };
};
const confirmDelete = () => {
if (!deleteId) return;
const card = state.cards[deleteId];
const res = board.remove(deleteId);
setDeleteId(null);
if (openCardId === deleteId) setOpenCardId(null);
if (res.ok && card) {
notify(`Deleted “${card.title}”. Undo is available.`);
announce(`Deleted “${card.title}”.`);
window.setTimeout(() => undoBtnRef.current?.focus(), 0);
} else if (!res.ok) notify(res.reason, "error");
};
/* ---------- dialog ---------- */
const openCard = openCardId ? (state.cards[openCardId] ?? null) : null;
const openColumn = openCardId ? (state.columns.find((c) => c.card_ids.includes(openCardId)) ?? null) : null;
const closeDialog = useCallback(() => {
const id = openCardId;
setOpenCardId(null);
if (id) focusCard(id);
}, [openCardId]);
useEffect(() => {
if (openCardId && !state.cards[openCardId]) setOpenCardId(null);
}, [openCardId, state.cards]);
/* ---------- render ---------- */
const undoLabel = board.lastUndo
? board.lastUndo.kind === "move"
? "Undo move"
: board.lastUndo.kind === "create"
? "Undo new card"
: "Undo delete"
: "Undo";
const dragCard = drag ? state.cards[drag.cardId] : null;
return (
<div className="app">
<a className="skip-link" href="#board">
Skip to board
</a>
<header className="topbar">
<div className="topbar-left">
<Wordmark />
<nav className="crumbs" aria-label="Location">
<span className="crumb-workspace">{boardData.workspace}</span>
<span className="crumb-sep" aria-hidden="true">
/
</span>
<h1 className="crumb-board">{boardData.board}</h1>
</nav>
</div>
<div className="topbar-right">
<span className="today" title="The demo uses a fixed date">
<Icon.Calendar size={14} />
Today {formatDue(TODAY, true)}
</span>
<button
ref={undoBtnRef}
type="button"
className="btn btn-ghost"
onClick={doUndo}
disabled={!board.lastUndo}
aria-keyshortcuts="Control+Z Meta+Z"
title="Ctrl+Z / Cmd+Z"
>
<Icon.Undo size={15} />
<span>{undoLabel}</span>
</button>
<button type="button" className="btn btn-ghost" onClick={() => setResetOpen(true)}>
<Icon.Reset size={15} />
<span>Reset demo</span>
</button>
</div>
</header>
<section className="filters" aria-label="Filter cards">
<div className="filter-search">
<Icon.Search size={15} />
<label className="sr-only" htmlFor="filter-query">
Search cards by title or description
</label>
<input
id="filter-query"
type="search"
placeholder="Search cards"
value={filters.query}
onChange={(e) => setFilters((f) => ({ ...f, query: e.target.value }))}
/>
</div>
<div className="filter-assignee">
<label htmlFor="filter-assignee">Assignee</label>
<select
id="filter-assignee"
className="select"
value={filters.assignee}
onChange={(e) => setFilters((f) => ({ ...f, assignee: e.target.value }))}
>
<option value="all">Anyone</option>
<option value="unassigned">Unassigned</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div className="filter-labels" role="group" aria-label="Filter by label">
<span className="filter-labels-title">Labels</span>
{boardData.labels.map((l) => {
const on = filters.labels.includes(l.id);
return (
<button
key={l.id}
type="button"
className={`chip label-${l.id}${on ? " is-on" : ""}`}
aria-pressed={on}
onClick={() =>
setFilters((f) => ({
...f,
labels: on ? f.labels.filter((x) => x !== l.id) : [...f.labels, l.id],
}))
}
>
<span className="label-dot" aria-hidden="true" />
{l.name}
</button>
);
})}
</div>
<div className="filter-status" aria-live="polite">
{filtersActive && (
<>
<span className="filter-count">
{totalVisible} of {totalCards} cards
</span>
<button type="button" className="btn btn-link" onClick={() => setFilters(EMPTY_FILTERS)}>
<Icon.Close size={13} /> Clear filters
</button>
</>
)}
</div>
</section>
<main className="board-wrap" id="board">
{filtersActive && totalVisible === 0 && (
<div className="board-empty" role="status">
<svg viewBox="0 0 48 48" width="48" height="48" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="6" y="10" width="36" height="28" rx="4" />
<path d="M6 18h36M16 26h16M20 32h8" strokeLinecap="round" />
</svg>
<h2>No cards match these filters</h2>
<p>Try a different assignee, fewer labels, or a shorter search.</p>
<button type="button" className="btn btn-primary" onClick={() => setFilters(EMPTY_FILTERS)}>
Clear all filters
</button>
</div>
)}
<div className={`board${filtersActive && totalVisible === 0 ? " is-hidden" : ""}`} ref={boardRef}>
{state.columns.map((column) => (
<ColumnView
key={column.id}
column={column}
columns={state.columns}
visible={visibleByColumn[column.id] ?? []}
filtersActive={filtersActive}
drag={drag}
dragCard={dragCard}
lift={lift}
adding={addingTo === column.id}
onStartAdd={() => setAddingTo(column.id)}
onCancelAdd={() => {
setAddingTo(null);
window.setTimeout(() => document.getElementById(`add-btn-${column.id}`)?.focus(), 0);
}}
onCreate={createIn(column.id)}
wipReasonFor={wipReasonFor}
onOpen={setOpenCardId}
onMoveTo={moveToColumn}
onDeleteRequest={setDeleteId}
onPointerDown={onCardPointerDown}
onKeyDown={onCardKeyDown}
suppressClick={suppressClick}
/>
))}
</div>
</main>
<footer className="footer">
<p className="notice">{boardData.fictional_notice}</p>
<p className="footer-hint">
Drag cards with a pointer, or focus a card and press Space to move it with the arrow keys.
</p>
</footer>
{/* Live region for screen readers */}
<div className="sr-only" aria-live="assertive" aria-atomic="true">
{announcement.text}
{announcement.n % 2 === 0 ? " " : ""}
</div>
{/* Visible toast */}
<div className="toast-region" aria-live="polite">
{toast && (
<div key={toast.id} className={`toast toast-${toast.tone}`} role={toast.tone === "error" ? "alert" : "status"}>
{toast.tone === "error" ? <Icon.Alert size={16} /> : <Icon.Info size={16} />}
<span>{toast.text}</span>
{toast.tone !== "error" && board.lastUndo && (
<button type="button" className="btn btn-link toast-undo" onClick={doUndo}>
Undo
</button>
)}
<button type="button" className="icon-btn toast-close" aria-label="Dismiss message" onClick={() => setToast(null)}>
<Icon.Close size={14} />
</button>
</div>
)}
</div>
{/* Drag ghost */}
{drag &&
dragCard &&
createPortal(
<div
ref={ghostRef}
className={`drag-ghost${drag.blocked ? " is-blocked" : ""}`}
style={{ width: drag.width }}
aria-hidden="true"
>
<article className="card is-ghost">
<CardContent card={dragCard} columnId={drag.fromColumnId} />
</article>
</div>,
document.body,
)}
<CardDialog
card={openCard}
column={openColumn}
columns={state.columns}
onClose={closeDialog}
onUpdate={board.update}
onMoveTo={(id, to) => {
moveToColumn(id, to);
}}
onDelete={(id) => {
// The dialog already asked for confirmation inline.
setOpenCardId(null);
const card = state.cards[id];
const res = board.remove(id);
if (res.ok && card) {
notify(`Deleted “${card.title}”. Undo is available.`);
announce(`Deleted “${card.title}”.`);
window.setTimeout(() => undoBtnRef.current?.focus(), 0);
}
}}
wipReason={(to) => (openCardId ? wipReasonFor(openCardId)(to) : null)}
/>
<ConfirmDialog
open={deleteId !== null && openCardId === null}
title="Delete this card?"
body={
<p>
“{deleteId ? state.cards[deleteId]?.title : ""}” will be removed from {deleteId ? columnById(state, positionOf(state, deleteId)?.columnId ?? "")?.title : "the board"}. You can undo this afterwards.
</p>
}
confirmLabel="Delete card"
danger
onConfirm={confirmDelete}
onCancel={() => {
const id = deleteId;
setDeleteId(null);
if (id) focusCard(id);
}}
/>
<ConfirmDialog
open={resetOpen}
title="Reset the demo board?"
body={<p>All of your changes on this device are discarded and the original Website Relaunch board comes back.</p>}
confirmLabel="Reset board"
onConfirm={() => {
board.reset();
setFilters(EMPTY_FILTERS);
setLift(null);
setResetOpen(false);
notify("Board reset to the demo data.");
announce("Board reset to the demo data.");
}}
onCancel={() => setResetOpen(false)}
/>
</div>
);
}
/* ---------- Column ---------- */
interface ColumnViewProps {
column: Column;
columns: Column[];
visible: Card[];
filtersActive: boolean;
drag: DragState | null;
dragCard: Card | null;
lift: KeyboardLift | null;
adding: boolean;
onStartAdd: () => void;
onCancelAdd: () => void;
onCreate: (title: string) => { ok: true } | { ok: false; reason: string };
wipReasonFor: (cardId: string) => (toColumnId: string) => string | null;
onOpen: (cardId: string) => void;
onMoveTo: (cardId: string, toColumnId: string) => void;
onDeleteRequest: (cardId: string) => void;
onPointerDown: (e: ReactPointerEvent<HTMLElement>, cardId: string, columnId: string) => void;
onKeyDown: (e: KeyboardEvent<HTMLElement>, cardId: string, columnId: string) => void;
suppressClick: React.MutableRefObject<boolean>;
}
function ColumnView({
column,
columns,
visible,
filtersActive,
drag,
dragCard,
lift,
adding,
onStartAdd,
onCancelAdd,
onCreate,
wipReasonFor,
onOpen,
onMoveTo,
onDeleteRequest,
onPointerDown,
onKeyDown,
suppressClick,
}: ColumnViewProps) {
const total = column.card_ids.length;
const isFull = column.wip_limit !== null && total >= column.wip_limit;
const isOver = drag?.overColumnId === column.id;
const showPlaceholder = isOver && !drag.blocked;
const isBlockedTarget = isOver && drag.blocked;
// While dragging, the moving card is represented by the placeholder only.
const list = drag ? visible.filter((c) => c.id !== drag.cardId) : visible;
const items: Array<{ kind: "card"; card: Card } | { kind: "placeholder" }> = list.map((card) => ({ kind: "card", card }));
if (showPlaceholder) items.splice(Math.min(drag.overIndex, items.length), 0, { kind: "placeholder" });
const countText = filtersActive && visible.length !== total ? `${visible.length} of ${total}` : `${total}`;
return (
<section
className={`column${isOver ? " is-over" : ""}${isBlockedTarget ? " is-blocked" : ""}${isFull ? " is-full" : ""}`}
data-column-id={column.id}
aria-labelledby={`col-title-${column.id}`}
>
<header className="column-head">
<h2 className="column-title" id={`col-title-${column.id}`}>
{column.title}
<span className="column-count" aria-label={`${visible.length} card${visible.length === 1 ? "" : "s"} shown`}>
{countText}
</span>
</h2>
{column.wip_limit !== null && (
<span
className={`column-limit${isFull ? " is-full" : ""}`}
title={`Work-in-progress limit: at most ${column.wip_limit} cards in this column`}
>
<span className="sr-only">Limit </span>
<span aria-hidden="true">WIP </span>
{total}/{column.wip_limit}
</span>
)}
<button
id={`add-btn-${column.id}`}
type="button"
className="icon-btn column-add"
aria-label={`Add a card to ${column.title}`}
onClick={onStartAdd}
>
<Icon.Plus size={16} />
</button>
</header>
{isBlockedTarget && (
<p className="column-blocked" role="status">
<Icon.Alert size={14} />
Limit reached ({column.wip_limit}). Move a card out first.
</p>
)}
<div className="column-list" data-card-list>
{items.map((it, i) =>
it.kind === "placeholder" ? (
<div key="placeholder" className="card-placeholder" style={{ height: drag?.height ?? 80 }} aria-hidden="true" />
) : (
<CardItem
key={it.card.id}
card={it.card}
column={column}
columns={columns}
index={column.card_ids.indexOf(it.card.id)}
count={total}
lifted={lift?.cardId === it.card.id}
wipReason={wipReasonFor(it.card.id)}
onOpen={onOpen}
onMoveTo={onMoveTo}
onDeleteRequest={onDeleteRequest}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
suppressClick={suppressClick}
/>
),
)}
{items.length === 0 && !adding && (
<p className="column-empty">
{filtersActive && total > 0
? `No matching cards (${total} hidden)`
: drag && dragCard && drag.fromColumnId === column.id
? "Drop here to return"
: "Nothing here yet"}
</p>
)}
{adding && <AddCardForm column={column} onSubmit={onCreate} onCancel={onCancelAdd} />}
</div>
{!adding && (
<button type="button" className="column-add-full" onClick={onStartAdd}>
<Icon.Plus size={14} />
Add card
</button>
)}
</section>
);
}