TodoMVC

TodoMVC 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, State } from '@expressive/mvc';
import { Link, Route, Router } from '@expressive/router';

import { Todo } from './Todo';

type Filter = 'all' | 'active' | 'completed';

export default () => (
  <div className="container">
    <h1>TodoMVC</h1>
    <p>
      Todos live in an owned <code>has(Todo)</code> pool. Each one is a
      Component holding its own text, done flag and edit draft, and renders its
      own row - the list drops instances into the tree with no keys or props,
      so toggling or editing re-renders that row alone. Counts are getters over
      the pool; member fields notify it, so nothing is reassigned.
    </p>
    <Todos />
    <small>
      Filters are routes: the list reads <code>:filter</code> off its own match,
      and each tab is a <code>Link</code> subclass lit only by an exact{' '}
      <code>match</code>. Double-click to edit - committing empty text destroys
      the todo, which evicts it from the pool without telling the store.
    </small>
  </div>
);

const Todos = () => (
  <Component for={Store}>
    <Router>
      <Route as={Layout}>
        <Route as={List} />
        <Route to=":filter" as={List} />
      </Route>
    </Router>
  </Component>
);

class Store extends State {
  items = has(Todo);
  draft = '';

  get remaining() {
    return this.items.filter((todo) => !todo.done).length;
  }

  get completed() {
    return this.items.size - this.remaining;
  }

  get allDone() {
    return this.items.size > 0 && this.remaining === 0;
  }

  add() {
    const text = this.draft.trim();

    if (!text) return;

    this.items.add({ text });
    this.draft = '';
  }

  toggleAll(done: boolean) {
    for (const todo of this.items) todo.done = done;
  }

  clearCompleted() {
    for (const todo of [...this.items])
      if (todo.done) this.items.delete(todo);
  }

  protected new() {
    this.items.add({ text: 'Taste JavaScript', done: true });
    this.items.add({ text: 'Buy a unicorn' });
  }
}

const Layout = (props: { children?: Component.Node }) => {
  const { is: store, draft } = Store.get();

  return (
    <div className="todomvc">
      <form
        className="new"
        onSubmit={(e) => {
          e.preventDefault();
          store.add();
        }}>
        <input
          autoFocus
          placeholder="What needs to be done?"
          value={draft}
          onInput={(e) => (store.draft = e.currentTarget.value)}
        />
      </form>
      {props.children}
      <Footer />
    </div>
  );
};

const Footer = () => {
  const {
    is: store,
    remaining,
    completed,
    items: { size },
  } = Store.get();

  if (size)
    return (
      <footer className="footer">
        <span className="count">
          <b>{remaining}</b> {remaining === 1 ? 'item' : 'items'} left
        </span>
        <nav className="filters">
          <Tab to="/">All</Tab>
          <Tab to="/active">Active</Tab>
          <Tab to="/completed">Completed</Tab>
        </nav>
        {completed > 0 && (
          <button className="clear" onClick={() => store.clearCompleted()}>
            Clear completed
          </button>
        )}
      </footer>
    );
};

class Tab extends Link {
  render() {
    const { href, go, match } = this;

    return (
      <a
        href={href}
        onClick={go}
        className={match === true ? 'selected' : undefined}>
        {this.props.children}
      </a>
    );
  }
}

class List extends Component {
  store = get(Store);
  route = get(Route);

  get filter(): Filter {
    const filter = this.route.match?.filter;

    return filter === 'active' || filter === 'completed' ? filter : 'all';
  }

  get shown() {
    const { items } = this.store;

    switch (this.filter) {
      case 'active':
        return items.filter((todo) => !todo.done);
      case 'completed':
        return items.filter((todo) => todo.done);
      default:
        return [...items];
    }
  }

  render() {
    const {
      shown,
      store: {
        is: store,
        allDone,
        items: { size },
      },
    } = this;

    if (size)
      return (
        <section className="main">
          <label className="toggle-all">
            <input
              type="checkbox"
              checked={allDone}
              onChange={(e) => store.toggleAll(e.target.checked)}
            />
            Toggle all
          </label>
          <ul className="list">{shown}</ul>
        </section>
      );
  }
}

App.css

.todomvc {
  width: 100%;
  max-width: 34rem;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  text-align: left;
}

.todomvc input[type='text'],
.new input,
.edit {
  width: 100%;
  box-sizing: border-box;
}

.new input {
  font-size: var(--t-lg);
}

.main,
.footer {
  border: 1px solid var(--border);
  border-radius: var(--r);
  background: var(--surface);
}

.main {
  overflow: hidden;
}

.todomvc input[type='checkbox'] {
  width: auto;
  flex: none;
  margin: 0;
  padding: 0;
}

.toggle-all {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  border-bottom: 1px solid var(--border);
  color: var(--muted);
  font-size: var(--t-sm);
}

.list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.list li {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  border-bottom: 1px solid var(--border);
}

.list li:last-child {
  border-bottom: none;
}

.list label {
  flex: 1;
  cursor: pointer;
}

.list li.done label {
  color: var(--muted);
  text-decoration: line-through;
}

.destroy {
  margin: 0;
  padding: 0 var(--s2);
  border: none;
  background: none;
  color: var(--muted);
  font-size: var(--t-xl);
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s;
}

.list li:hover .destroy {
  opacity: 1;
}

.destroy:hover {
  color: var(--accent);
}

.edit {
  font-size: var(--t-base);
}

.footer {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s2) var(--s3);
  font-size: var(--t-sm);
  color: var(--muted);
}

.filters {
  display: flex;
  gap: var(--s1);
  margin-left: auto;
}

.filters a {
  padding: 2px var(--s2);
  border-radius: var(--r-sm);
  color: var(--muted);
  text-decoration: none;
}

.filters a.selected {
  color: var(--accent);
  border: 1px solid var(--accent-soft);
}

.clear {
  margin: 0;
  padding: 0;
  border: none;
  background: none;
  color: var(--muted);
  cursor: pointer;
}

.clear:hover {
  text-decoration: underline;
}

Todo.tsx

import { Component } from '@expressive/mvc';

export class Todo extends Component {
  text = '';
  done = false;
  editing = false;
  draft = '';

  toggle() {
    this.done = !this.done;
  }

  begin() {
    this.draft = this.text;
    this.editing = true;
  }

  commit() {
    const text = this.draft.trim();

    if (!text) return this.set(null);

    this.text = text;
    this.editing = false;
  }

  cancel() {
    this.editing = false;
  }

  remove() {
    this.set(null);
  }

  render() {
    const { text, done, editing, draft } = this;

    if (editing)
      return (
        <li className="editing">
          <input
            key="edit"
            className="edit"
            autoFocus
            value={draft}
            onInput={(e) => (this.draft = e.currentTarget.value)}
            onBlur={this.commit}
            onKeyDown={(e) => {
              if (e.key === 'Enter') this.commit();
              else if (e.key === 'Escape') this.cancel();
            }}
          />
        </li>
      );

    return (
      <li className={done ? 'done' : ''}>
        <input
          type="checkbox"
          className="toggle"
          checked={done}
          onChange={this.toggle}
        />
        <label onDoubleClick={() => this.begin()}>{text}</label>
        <button className="destroy" onClick={this.remove}>
          ×
        </button>
      </li>
    );
  }
}