Kanban
Kanban demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.
App.tsx
import './App.css';
import { Component, get, has } from '@expressive/react';
// The board owns every card in one pool and its columns in another, and
// provides itself to both (and to the cards) so they reach it with
// get(Board). Columns are a pool, not a list: the pool spawns each column
// owned and activated inside the board's context, which is what lets a
// column resolve get(Board) and pull its own cards.
export default class Board extends Component {
cards = has(Card);
columns = has(Column);
// Where a drop would land: a card's key (insert before it) or a column
// id (append). Empty between drags. Cards and columns read it to draw the
// indicator, so a hover anywhere refreshes only what the position touches.
over = '';
protected new() {
const { columns } = this;
columns.add({ id: "todo", label: "To Do", accent: "#6366f1" });
columns.add({ id: "doing", label: "In Progress", accent: "#f59e0b" });
columns.add({ id: "done", label: "Done", accent: "#10b981" });
this.add('Ship map + has', 'done');
this.add('Wire drag and drop', 'doing');
this.add('Write the migration guide', 'todo');
this.add('Retire hot (#263)', 'done');
}
add(title: string, column: string) {
this.cards.add({ title, column, order: this.next() });
}
// One increasing order shared across columns - only compared within a
// column, so a global value still sorts each correctly. Reads the current
// max, not the count, so it survives deletes and the fractional orders a
// drop inserts.
next() {
return Math.max(0, ...this.cards.map((c) => c.order)) + 1;
}
// Place the dragged card into a column, before `target` if given -
// fractional ordering keeps neighbors untouched.
drop(key: string, column: string, target?: Card) {
this.over = '';
const card = [...this.cards].find((c) => c.key === key);
if (!card) return;
// Clear here, not in onDragEnd: the drop reparents the card's node into
// another column, and a moved node may never fire dragend.
card.dragging = false;
if (card === target) return;
if (target) {
const siblings = ordered(this.cards, column).filter((c) => c !== card);
const prev = siblings[siblings.indexOf(target) - 1];
card.order = prev ? (prev.order + target.order) / 2 : target.order - 1;
} else {
card.order = this.next();
}
card.column = column;
}
render() {
return (
<div className="container kanban">
<h1>Kanban</h1>
<p>Drag cards between columns; double-click a card to rename.</p>
<div className="board">{this.columns}</div>
</div>
);
}
}
// The cards of one column, in order. A plain function, not a method: read
// through a component's render proxy (`ordered(this.cards, ...)`) it also
// subscribes that component to member moves.
const ordered = (cards: has.Pool<Card>, column: string) =>
cards.filter((card) => card.column === column).sort((a, b) => a.order - b.order);
// A card is a Component - it owns its data, its inline editor, and its
// own markup. An activated instance renders directly as an element, so
// columns drop the card straight into JSX; no <Row>, props, or keys.
class Card extends Component {
board = get(Board);
title = '';
column = 'todo';
order = 0;
editing = false;
dragging = false;
rename(title: string) {
title = title.trim();
if (title) this.title = title;
this.editing = false;
}
// Destroying a member is a complete removal: the owning pool evicts it.
remove() {
this.set(null);
}
render() {
const { title, editing, dragging, board } = this;
const over = board.over === this.key && !dragging;
return (
<li
className={`card${dragging ? ' dragging' : ''}${over ? ' over' : ''}`}
draggable={!editing}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', this.key);
this.dragging = true;
}}
onDragEnd={() => {
this.dragging = false;
board.over = '';
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
board.over = this.key;
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
board.drop(e.dataTransfer.getData('text/plain'), this.column, this);
}}>
{editing ? (
<input
autoFocus
defaultValue={title}
onBlur={(e) => this.rename(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') this.rename(e.currentTarget.value);
if (e.key === 'Escape') this.editing = false;
}}
/>
) : (
<>
<span onDoubleClick={() => (this.editing = true)}>{title}</span>
<button className="remove" onClick={this.remove} aria-label="delete">
×
</button>
</>
)}
</li>
);
}
}
// A column pulls its own slice of the board's cards. Reading
// this.board.cards through the render proxy (via the ordered() helper, not
// a bound method) subscribes the column to its members' moves.
class Column extends Component {
board = get(Board);
id = '';
label = '';
accent = '';
draft = '';
get cards() {
return ordered(this.board.cards, this.id);
}
add() {
const title = this.draft.trim();
if (!title) return;
this.draft = '';
this.board.cards.add({
title,
column: this.id,
order: this.board.next()
});
}
render() {
const { cards, label, accent, draft, board } = this;
const over = board.over === this.id;
return (
<section
className={over ? 'column over-end' : 'column'}
style={{ '--accent': accent } as any}
onDragOver={(e) => {
e.preventDefault();
board.over = this.id;
}}
onDrop={(e) => board.drop(e.dataTransfer.getData('text/plain'), this.id)}>
<header>
<h2>{label}</h2>
<span className="count">{cards.length}</span>
</header>
<ul className="cards">{cards}</ul>
<form
onSubmit={(e) => {
e.preventDefault();
this.add();
}}>
<input
value={draft}
placeholder="+ Add a card"
onChange={(e) => (this.draft = e.target.value)}
/>
</form>
</section>
);
}
}App.css
/* Give this example room for three columns; the shell caps #root narrow. */
body.example:has(.kanban) #root {
max-width: 56rem;
}
.kanban {
max-width: 56rem;
}
.board {
display: flex;
gap: var(--s4);
width: 100%;
align-items: flex-start;
overflow-x: auto;
padding-bottom: var(--s2);
text-align: left;
}
.column {
flex: 1 0 15rem;
display: flex;
flex-direction: column;
gap: var(--s3);
padding: var(--s3);
background: var(--surface);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: var(--r-lg);
}
.column > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s2);
}
.column h2 {
margin: 0;
font-size: var(--t-sm);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.count {
min-width: 1.5rem;
padding: 0 var(--s2);
font-size: var(--t-xs);
font-weight: 600;
line-height: 1.5rem;
text-align: center;
color: var(--accent);
background: var(--accent-soft);
border-radius: 999px;
}
ul.cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s2);
min-height: 2.5rem;
}
.card {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s2);
padding: var(--s3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
cursor: grab;
transition: box-shadow 0.15s, transform 0.15s;
}
.card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card.dragging {
opacity: 0.5;
cursor: grabbing;
transform: rotate(2deg) scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
}
/* Drop indicators: an accent line where the card would land - above the
hovered card, or at the end of the column over its empty space. */
.card.over::before,
.column.over-end .cards::after {
content: '';
height: 2px;
border-radius: 1px;
background: var(--accent);
}
.card.over::before {
position: absolute;
left: 0;
right: 0;
top: calc(var(--s2) / -2 - 1px);
}
.column.over-end .cards::after {
display: block;
}
.card span {
flex: 1;
font-size: var(--t-base);
color: var(--fg);
}
.card .remove {
padding: 0;
width: 1.5rem;
height: 1.5rem;
line-height: 1;
font-size: var(--t-lg);
color: var(--muted);
background: transparent;
border: none;
border-radius: var(--r-sm);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.card:hover .remove {
opacity: 1;
}
.card .remove:hover {
color: var(--fg);
background: var(--surface-2);
}
.card input,
.column form input {
width: 100%;
padding: var(--s2) var(--s3);
font: inherit;
font-size: var(--t-base);
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
}
.card input:focus,
.column form input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.column form {
margin: 0;
}