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