Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone f6f2112e77bc8c011e38c83a
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Kanban.tsx
// The board view: lists side by side, cards in them. Cards and lists move by dragging with a mouse,
// by press-and-hold then drag on a touch screen, and with the keyboard (Space picks a card up, the
// arrow keys move it, Space drops it, Escape puts it back).
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as RKeyboardEvent, type PointerEvent as RPointerEvent } from "react";
import { useBoard } from "./lib/board";
import { matches, filterCount, type Filter } from "./lib/filter";
import { Icon, Popover, say } from "./ui";
import { CardTile } from "./CardTile";
import type { Card } from "../functions/lib/tasks";
type Over = { listId: string; index: number } | { index: number };
interface Drag { kind: "card" | "list"; id: string; w: number; h: number; dx: number; dy: number; over: Over }
interface Pending { kind: "card" | "list"; id: string; x0: number; y0: number; touch: boolean; timer?: number; el: HTMLElement }
interface KbMove { id: string; listId: string; index: number }
const EDGE = 64;
const gap = (n: number) => ({ "--gap": `${n}px` }) as CSSProperties;
export function Kanban({ filter, onOpen }: { filter: Filter; onOpen: (cardId: string) => void }) {
const b = useBoard();
const readOnly = Boolean(b.board?.archived);
const [drag, setDrag] = useState<Drag | null>(null);
const [kb, setKb] = useState<KbMove | null>(null);
const scroller = useRef<HTMLDivElement>(null);
const ghost = useRef<HTMLDivElement>(null);
const pointer = useRef({ x: 0, y: 0 });
const pending = useRef<Pending | null>(null);
const dragRef = useRef<Drag | null>(null);
dragRef.current = drag;
const suppressClick = useRef(false);
const filtering = filterCount(filter) > 0;
const visibleIn = (listId: string, without?: string) => b.cards.filter((c) => c.listId === listId && c.id !== without && (!filtering || matches(c, filter, b.labels)));
const allIn = (listId: string, without?: string) => b.cards.filter((c) => c.listId === listId && c.id !== without);
const listTitle = (id: string) => b.lists.find((l) => l.id === id)?.title ?? "";
/** Turns a place among the visible cards into a place among all cards of the list (filters hide some). */
function fullIndex(listId: string, cardId: string, visibleIndex: number) {
const visible = visibleIn(listId, cardId);
const all = allIn(listId, cardId);
if (visibleIndex < visible.length) return all.indexOf(visible[visibleIndex]);
return visible.length ? all.indexOf(visible[visible.length - 1]) + 1 : all.length;
}
function locate(x: number, y: number, kind: "card" | "list"): Over | null {
if (kind === "list") {
const cols = [...(scroller.current?.querySelectorAll<HTMLElement>("[data-col]:not(.is-dragging)") ?? [])];
let index = cols.length;
for (let i = 0; i < cols.length; i++) {
const r = cols[i].getBoundingClientRect();
if (x < r.left + r.width / 2) { index = i; break; }
}
return { index };
}
const col = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest<HTMLElement>("[data-col]");
if (!col) return null;
const tiles = [...col.querySelectorAll<HTMLElement>("[data-card]:not(.is-dragging)")];
let index = tiles.length;
for (let i = 0; i < tiles.length; i++) {
const r = tiles[i].getBoundingClientRect();
if (y < r.top + r.height / 2) { index = i; break; }
}
return { listId: col.dataset.col!, index };
}
const sameOver = (a: Over, b2: Over) => a.index === b2.index && ("listId" in a ? "listId" in b2 && a.listId === b2.listId : !("listId" in b2));
function moveGhost() {
const d = dragRef.current;
if (d && ghost.current) ghost.current.style.transform = `translate(${pointer.current.x - d.dx}px, ${pointer.current.y - d.dy}px) rotate(${d.kind === "card" ? 2 : 1}deg)`;
}
function track(x: number, y: number) {
pointer.current = { x, y };
moveGhost();
const d = dragRef.current;
if (!d) return;
const over = locate(x, y, d.kind);
if (over && !sameOver(over, d.over)) setDrag({ ...d, over });
}
function start(p: Pending) {
const r = p.el.getBoundingClientRect();
let over: Over;
if (p.kind === "card") {
const card = b.cards.find((c) => c.id === p.id)!;
over = { listId: card.listId, index: Math.max(0, visibleIn(card.listId).findIndex((c) => c.id === p.id)) };
} else {
over = { index: Math.max(0, b.openLists.findIndex((l) => l.id === p.id)) };
}
const d = { kind: p.kind, id: p.id, w: r.width, h: Math.min(r.height, p.kind === "list" ? 220 : r.height), dx: p.x0 - r.left, dy: p.y0 - r.top, over };
dragRef.current = d;
setDrag(d);
document.body.classList.add("dragging");
if (p.touch) navigator.vibrate?.(8);
}
function finish(commit: boolean) {
const d = dragRef.current;
pending.current = null;
dragRef.current = null;
setDrag(null);
document.body.classList.remove("dragging");
if (!d) return;
suppressClick.current = true;
window.setTimeout(() => { suppressClick.current = false; }, 50);
if (!commit) return;
if (d.kind === "card" && "listId" in d.over) {
const card = b.cards.find((c) => c.id === d.id);
if (!card) return;
const idx = fullIndex(d.over.listId, d.id, d.over.index);
const current = allIn(card.listId).findIndex((c) => c.id === d.id);
if (d.over.listId === card.listId && idx === current) return;
void b.actions.moveCard(d.id, d.over.listId, idx);
say(`Moved ${card.title} to ${listTitle(d.over.listId)}, position ${idx + 1}.`);
} else if (d.kind === "list") {
const current = b.openLists.findIndex((l) => l.id === d.id);
if (d.over.index !== current) void b.actions.moveList(d.id, d.over.index);
}
}
// Window listeners for a press that may become a drag, and for the drag itself.
useEffect(() => {
const onMove = (e: PointerEvent) => {
const p = pending.current;
if (dragRef.current) { track(e.clientX, e.clientY); return; }
if (!p) return;
const dist = Math.hypot(e.clientX - p.x0, e.clientY - p.y0);
if (p.touch) {
if (dist > 10) { window.clearTimeout(p.timer); pending.current = null; }
} else if (dist > 5) {
start(p);
track(e.clientX, e.clientY);
}
};
const onUp = () => {
if (pending.current) window.clearTimeout(pending.current.timer);
if (dragRef.current) finish(true); else pending.current = null;
};
const onCancel = () => {
if (pending.current) window.clearTimeout(pending.current.timer);
if (dragRef.current) finish(false); else pending.current = null;
};
// Once a touch drag has started, the page must not scroll under the finger.
const onTouchMove = (e: TouchEvent) => { if (dragRef.current) e.preventDefault(); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && dragRef.current) { e.preventDefault(); finish(false); } };
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("keydown", onKey);
};
});
// Scrolls the board sideways, and a list up or down, while dragging near an edge.
useEffect(() => {
if (!drag) return;
let raf = 0;
const tick = () => {
const s = scroller.current;
const { x, y } = pointer.current;
if (s) {
const r = s.getBoundingClientRect();
const left = x - r.left;
const right = r.right - x;
let moved = false;
if (left < EDGE) { s.scrollLeft -= Math.ceil((EDGE - left) / 4); moved = true; }
else if (right < EDGE) { s.scrollLeft += Math.ceil((EDGE - right) / 4); moved = true; }
if (dragRef.current?.kind === "card") {
const cards = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest("[data-col]")?.querySelector<HTMLElement>(".col-cards");
if (cards) {
const cr = cards.getBoundingClientRect();
if (y < cr.top + 40) { cards.scrollTop -= Math.ceil((cr.top + 40 - y) / 4); moved = true; }
else if (y > cr.bottom - 40) { cards.scrollTop += Math.ceil((y - (cr.bottom - 40)) / 4); moved = true; }
}
}
if (moved) track(x, y);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [Boolean(drag)]); // eslint-disable-line react-hooks/exhaustive-deps
useLayoutEffect(() => { moveGhost(); });
// Keyboard moves keep focus on the card as it changes lists.
useEffect(() => {
if (!kb) return;
document.querySelector<HTMLElement>(`[data-card="${kb.id}"]`)?.focus({ preventScroll: false });
const onDown = (e: PointerEvent) => { if (!(e.target as HTMLElement).closest?.(`[data-card="${kb.id}"]`)) setKb(null); };
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [kb]);
const press = (kind: "card" | "list", id: string) => (e: RPointerEvent<HTMLElement>) => {
if (readOnly || kb || e.button !== 0 || !e.isPrimary) return;
if (kind === "list" && (e.target as HTMLElement).closest(".col-menu, input")) return;
const touch = e.pointerType !== "mouse";
const p: Pending = { kind, id, x0: e.clientX, y0: e.clientY, touch, el: e.currentTarget.closest<HTMLElement>(kind === "card" ? "[data-card]" : "[data-col]")! };
if (touch) p.timer = window.setTimeout(() => { if (pending.current === p) start(p); }, 260);
pending.current = p;
};
function onCardKey(e: RKeyboardEvent<HTMLElement>, card: Card) {
if (readOnly) return;
if (kb && kb.id === card.id) {
const lists = b.openLists;
const li = lists.findIndex((l) => l.id === kb.listId);
const count = visibleIn(kb.listId, card.id).length;
let next: KbMove | null = null;
if (e.key === "ArrowUp") next = { ...kb, index: Math.max(0, kb.index - 1) };
else if (e.key === "ArrowDown") next = { ...kb, index: Math.min(count, kb.index + 1) };
else if (e.key === "ArrowLeft" && li > 0) next = { id: kb.id, listId: lists[li - 1].id, index: Math.min(kb.index, visibleIn(lists[li - 1].id, card.id).length) };
else if (e.key === "ArrowRight" && li < lists.length - 1) next = { id: kb.id, listId: lists[li + 1].id, index: Math.min(kb.index, visibleIn(lists[li + 1].id, card.id).length) };
else if (e.key === " " || e.key === "Enter") {
e.preventDefault();
const idx = fullIndex(kb.listId, card.id, kb.index);
const current = allIn(card.listId).findIndex((c) => c.id === card.id);
setKb(null);
if (kb.listId !== card.listId || idx !== current) void b.actions.moveCard(card.id, kb.listId, idx);
say(`Dropped ${card.title} in ${listTitle(kb.listId)}, position ${kb.index + 1}.`);
return;
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setKb(null);
say(`Move cancelled. ${card.title} is back in ${listTitle(card.listId)}.`);
return;
} else if (e.key === "Tab") { setKb(null); return; }
if (next || e.key.startsWith("Arrow")) e.preventDefault();
if (next) {
setKb(next);
say(`${listTitle(next.listId)}, position ${next.index + 1} of ${visibleIn(next.listId, card.id).length + 1}.`);
}
return;
}
if (e.key === " ") {
e.preventDefault();
const index = visibleIn(card.listId).findIndex((c) => c.id === card.id);
setKb({ id: card.id, listId: card.listId, index });
say(`Picked up ${card.title}. Use the arrow keys to move it, Space to drop it, Escape to cancel.`);
}
}
const dragged = drag ? (drag.kind === "card" ? b.cards.find((c) => c.id === drag.id) : null) : null;
const draggedList = drag?.kind === "list" ? b.openLists.find((l) => l.id === drag.id) : null;
const listPlaceholder = drag?.kind === "list" ? drag.over.index : -1;
// The dragged card or list stays in the page, hidden, so touch events keep reaching it.
const column = (list: (typeof b.openLists)[number], hidden: boolean) => {
let shown = visibleIn(list.id, kb?.id);
const total = allIn(list.id).length;
const ph = drag?.kind === "card" && "listId" in drag.over && drag.over.listId === list.id ? drag.over.index : -1;
const kbCard = kb && kb.listId === list.id ? b.cards.find((c) => c.id === kb.id) : null;
if (kbCard) shown = [...shown.slice(0, kb!.index), kbCard, ...shown.slice(kb!.index)];
const others = shown.filter((c) => c.id !== dragged?.id).length;
let j = 0;
return (
<section className={`col${hidden ? " is-dragging" : ""}`} data-col={list.id} aria-label={list.title}>
<header className="col-head" onPointerDown={press("list", list.id)} onClickCapture={(e) => { if (suppressClick.current) { e.preventDefault(); e.stopPropagation(); } }}>
<ListTitle id={list.id} title={list.title} readOnly={readOnly} />
<span className="col-count" aria-label={`${total} cards`}>{filtering ? `${visibleIn(list.id).length}/${total}` : total}</span>
{!readOnly && <ListMenu listId={list.id} />}
</header>
<div className="col-cards">
{shown.map((c) => {
const isDragged = c.id === dragged?.id;
const place = isDragged ? -1 : j++;
return (
<div key={c.id} className="tile-slot">
{!isDragged && ph === place && <div className="tile-ph" style={{ height: drag!.h }} />}
<CardTile
card={c}
lifted={kb?.id === c.id}
dragging={isDragged}
onPointerDown={press("card", c.id)}
onClick={() => { if (!suppressClick.current && !kb) onOpen(c.id); }}
onKeyDown={(e) => onCardKey(e, c)}
/>
</div>
);
})}
{ph >= others && <div className="tile-ph" style={{ height: drag!.h }} />}
{!others && ph < 0 && <p className="col-empty">{filtering && total ? "No cards match." : "No cards yet."}</p>}
</div>
{!readOnly && <AddCard listId={list.id} />}
</section>
);
};
let li = 0;
const visibleLists = b.openLists.filter((l) => l.id !== draggedList?.id).length;
return (
<>
<div className="kanban" ref={scroller} aria-describedby="dnd-help">
{b.openLists.map((l) => {
const hidden = l.id === draggedList?.id;
const place = hidden ? -1 : li++;
return (
<div className={`col-slot${hidden ? " is-dragging" : ""}`} key={l.id}>
{!hidden && listPlaceholder === place && <div className="col-ph" style={{ width: drag!.w }} />}
{column(l, hidden)}
</div>
);
})}
{listPlaceholder >= visibleLists && <div className="col-ph" style={{ width: drag!.w }} />}
{!readOnly && <AddList first={!b.openLists.length} />}
<div className="kanban-end" aria-hidden="true" />
</div>
<p id="dnd-help" className="sr-only">Press Enter on a card to open it. Press Space to pick it up, the arrow keys to move it, and Space again to drop it.</p>
{drag && (
<div className="drag-ghost" ref={ghost} style={{ width: drag.w }} aria-hidden="true">
{dragged && <CardTile card={dragged} />}
{draggedList && (
<div className="col col-ghost">
<header className="col-head"><span className="col-title">{draggedList.title}</span><span className="col-count">{allIn(draggedList.id).length}</span></header>
</div>
)}
</div>
)}
</>
);
}
function ListTitle({ id, title, readOnly }: { id: string; title: string; readOnly: boolean }) {
const b = useBoard();
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(title);
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
if (!editing || readOnly) {
return <h2 className="col-title-wrap"><button type="button" className="col-title" onClick={() => !readOnly && setEditing(true)} disabled={readOnly} title={readOnly ? undefined : "Rename list"}>{title}</button></h2>;
}
const save = () => {
const t = value.trim();
setEditing(false);
if (t && t !== title) void b.actions.updateList(id, { title: t.slice(0, 120) });
};
return (
<input className="input col-title-input" value={value} autoFocus maxLength={120} aria-label="List name" onChange={(e) => setValue(e.target.value)} onBlur={save}
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") { setValue(title); setEditing(false); } }} />
);
}
function ListMenu({ listId }: { listId: string }) {
const b = useBoard();
const i = b.openLists.findIndex((l) => l.id === listId);
return (
<Popover className="icon-btn col-menu" label="List actions" button={<Icon name="more" />}>
{(close) => (
<div className="menu">
<button type="button" disabled={i <= 0} onClick={() => { void b.actions.moveList(listId, i - 1); close(); }}><Icon name="left" /> Move list left</button>
<button type="button" disabled={i >= b.openLists.length - 1} onClick={() => { void b.actions.moveList(listId, i + 1); close(); }}><Icon name="right" /> Move list right</button>
<button type="button" onClick={() => { void b.actions.updateList(listId, { archived: true }); close(); }}><Icon name="archive" /> Archive list</button>
</div>
)}
</Popover>
);
}
function AddCard({ listId }: { listId: string }) {
const b = useBoard();
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const ref = useRef<HTMLTextAreaElement>(null);
const submit = async () => {
const t = value.trim();
if (!t) { setOpen(false); return; }
setValue("");
await b.actions.addCard(listId, t.slice(0, 500));
ref.current?.focus();
ref.current?.closest(".col")?.querySelector(".col-cards")?.scrollTo({ top: 1e6 });
};
if (!open) return <button type="button" className="add-card" onClick={() => setOpen(true)}><Icon name="plus" size={16} /> Add a card</button>;
return (
<div className="composer">
<textarea ref={ref} className="textarea composer-input" rows={2} value={value} autoFocus maxLength={500} placeholder="What needs doing?" aria-label="Card title"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); }
if (e.key === "Escape") { setOpen(false); setValue(""); }
}} />
<div className="row" style={gap(8)}>
<button type="button" className="btn btn-sm" onClick={() => void submit()}>Add card</button>
<button type="button" className="btn btn-sm btn-quiet" onClick={() => { setOpen(false); setValue(""); }}>Cancel</button>
</div>
</div>
);
}
function AddList({ first }: { first: boolean }) {
const b = useBoard();
const [open, setOpen] = useState(first);
const [value, setValue] = useState("");
const submit = async () => {
const t = value.trim();
if (!t) return;
setValue("");
await b.actions.addList(t.slice(0, 120));
};
if (!open) return <button type="button" className="add-list" onClick={() => setOpen(true)}><Icon name="plus" size={16} /> Add a list</button>;
return (
<form className="add-list add-list-open" onSubmit={(e) => { e.preventDefault(); void submit(); }}>
<input className="input" value={value} autoFocus={!first} maxLength={120} placeholder="List name, like Waiting" aria-label="List name" onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Escape" && !first) setOpen(false); }} />
<div className="row" style={gap(8)}>
<button type="submit" className="btn btn-sm">Add list</button>
{!first && <button type="button" className="btn btn-sm btn-quiet" onClick={() => setOpen(false)}>Cancel</button>}
</div>
</form>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync