Kanban board
Drag cards between columns, or move them with the keyboard.
The board is a store with one array of cards per column. Dragging a card, or pressing the arrow buttons, moves it from one array to another. Each card has a menu that closes on a click outside it.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Kanban board - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<style>
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
}
.column {
min-height: 12rem;
padding: 0.75rem;
border: 2px dashed transparent;
border-radius: var(--i-border-radius-large);
background: var(--i-color-subtle-border);
}
.column.over {
border-color: var(--i-color-primary);
}
.column ul {
padding: 0;
list-style: none;
}
.kanban-card {
position: relative;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: var(--i-border-radius);
background: var(--i-color-background);
cursor: grab;
}
.kanban-card .cluster {
align-items: center;
margin-top: 0.5rem;
}
.kanban-card button {
margin: 0;
}
.card-menu ul {
display: grid;
gap: 0.25rem;
margin: 0;
list-style: none;
position: absolute;
inset-inline-end: 0.5rem;
z-index: 1;
padding: 0.25rem;
border-radius: var(--i-border-radius);
background: var(--i-color-background);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const { Component, Signal, application, html, store, produce, effect, For, Show, DOM, createRef } = Signy;
const STORAGE_KEY = "signy-kanban";
const START = [
{ id: "todo", title: "To do", cards: [{ id: 1, text: "Write the release notes" }, { id: 2, text: "Plan the demo" }] },
{ id: "doing", title: "Doing", cards: [{ id: 3, text: "Fix the router guards" }] },
{ id: "done", title: "Done", cards: [{ id: 4, text: "Ship the website" }] },
];
function load() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? START;
} catch {
return START;
}
}
class Kanban extends Component {
board = store({ columns: load() });
nextId = Math.max(0, ...this.board.state.columns.flatMap((c) => c.cards.map((card) => card.id))) + 1;
over = new Signal(null);
menuFor = new Signal(null);
menus = new Map();
editing = new Signal(null);
dialog = createRef();
editor = createRef();
setup() {
// The open card menu closes on a click anywhere else
DOM.onClickOutside(() => this.menus.get(this.menuFor.peek()), () => (this.menuFor.value = null));
// Reading the whole state tracks every card, so any change saves
effect(() => {
const json = JSON.stringify(this.board.state.columns);
try {
localStorage.setItem(STORAGE_KEY, json);
} catch {}
});
}
locate(cardId) {
const columns = this.board.state.columns;
for (let c = 0; c < columns.length; c++) {
const index = columns[c].cards.findIndex((card) => card.id === cardId);
if (index !== -1) return { column: c, index };
}
return null;
}
move(cardId, toColumn) {
const from = this.locate(cardId);
if (!from || from.column === toColumn) return;
this.board.set("columns", produce((columns) => {
const [card] = columns[from.column].cards.splice(from.index, 1);
columns[toColumn].cards.push(card);
}));
}
add(column) {
const id = this.nextId++;
this.board.set("columns", column, "cards", (cards) => [...cards, { id, text: "New card" }]);
this.edit(id);
}
remove(cardId) {
const at = this.locate(cardId);
this.board.set("columns", at.column, "cards", (cards) => cards.filter((card) => card.id !== cardId));
this.menuFor.value = null;
}
edit(cardId) {
const at = this.locate(cardId);
this.menuFor.value = null;
this.editing.value = cardId;
this.editor.current.value = this.board.state.columns[at.column].cards[at.index].text;
this.dialog.current.showModal();
}
save(event) {
event.preventDefault();
const at = this.locate(this.editing.peek());
const text = this.editor.current.value.trim();
if (at && text) this.board.set("columns", at.column, "cards", at.index, "text", text);
this.dialog.current.close();
}
// A card is a plain object, so its text is read through the store to stay live
textOf(cardId) {
const at = this.locate(cardId);
return at ? this.board.state.columns[at.column].cards[at.index].text : "";
}
card(card, columnIndex) {
const columns = this.board.state.columns;
return html`
<li class="kanban-card" draggable="true"
onDragStart=${(e) => e.dataTransfer.setData("text/plain", String(card.id))}>
${() => this.textOf(card.id)}
<div class="cluster">
<button class="small outlined" aria-label=${() => `Move ${this.textOf(card.id)} to ${columns[columnIndex() - 1]?.title}`}
disabled=${() => columnIndex() === 0} onClick=${() => this.move(card.id, columnIndex() - 1)}>←</button>
<button class="small outlined" aria-label=${() => `Move ${this.textOf(card.id)} to ${columns[columnIndex() + 1]?.title}`}
disabled=${() => columnIndex() === columns.length - 1} onClick=${() => this.move(card.id, columnIndex() + 1)}>→</button>
<div class="card-menu" ref=${(el) => this.menus.set(card.id, el)}>
<button class="small outlined" aria-label=${() => `Options for ${this.textOf(card.id)}`}
aria-expanded=${() => String(this.menuFor.value === card.id)}
onClick=${() => this.menuFor.set((id) => (id === card.id ? null : card.id))}>⋯</button>
${Show({
when: () => this.menuFor.value === card.id,
children: () => html`
<ul>
<li><button class="small" onClick=${() => this.edit(card.id)}>Edit</button></li>
<li><button class="small danger" onClick=${() => this.remove(card.id)}>Delete</button></li>
</ul>
`,
})}
</div>
</div>
</li>
`;
}
render() {
return html`
<h1>Kanban</h1>
<p><small>Drag cards between columns, or use the arrow buttons.</small></p>
<div class="board">
${For({
each: () => this.board.state.columns,
keyBy: "id",
children: (column, columnIndex) => html`
<section class=${() => `column ${this.over.value === column.id ? "over" : ""}`}
aria-label=${column.title}
onDragOver=${(e) => {
e.preventDefault();
this.over.value = column.id;
}}
onDragLeave=${() => (this.over.value = null)}
onDrop=${(e) => {
e.preventDefault();
this.over.value = null;
this.move(Number(e.dataTransfer.getData("text/plain")), columnIndex());
}}>
<h2>${column.title} <small>${() => this.board.state.columns[columnIndex()].cards.length}</small></h2>
<ul>
${For({
each: () => this.board.state.columns[columnIndex()].cards,
keyBy: "id",
children: (card) => this.card(card, columnIndex),
})}
</ul>
<button class="small secondary outlined" onClick=${() => this.add(columnIndex())}>Add a card</button>
</section>
`,
})}
</div>
<dialog ref=${this.dialog} aria-labelledby="edit-title">
<article>
<header><h2 id="edit-title">Edit card</h2></header>
<form id="edit-form" onSubmit=${(e) => this.save(e)}>
<label for="card-text">Text</label>
<textarea id="card-text" ref=${this.editor} rows="3"></textarea>
</form>
<footer class="cluster">
<button class="secondary outlined" onClick=${() => this.dialog.current.close()}>Cancel</button>
<button class="primary" type="submit" form="edit-form">Save</button>
</footer>
</article>
</dialog>
`;
}
}
application(Kanban).mount("#app");
</script>
</body>
</html>
What it uses
- Store with
produce() - For per column
- DOM.onClickOutside
- Effect that saves to localStorage