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;
}