TodoMVC with the DOM alone

This page walks through the TodoMVC variant written with no library at all. The view reaches the page through JavaScript objects, as the JavaScript page describes them. It shows the same model as TodoMVC with Silk, on the same page. Its view has the same shape: one class that binds and renders, and one that shows a todo. This page shows only what differs. The variant is the baseline. Every library on the pages that follow is measured against what the DOM costs here.

The page

dom.html is silk.html with its own title, its own bundle, and its own class to start, DomTodoApp. Its parts carry the hidden attribute as there.

The view

The view is the package TodoMVC-DOM, with two classes, DomTodoApp and DomTodoItem. It imports 'amber/core/Platform-DOM' beside the model, for one conversion. It keeps the todos under todos-amber-dom. DomTodoApp has three slots: todoList, the TodoList; app, the .todoapp element; and listElement, the ul. start attaches it to document querySelector: '.todoapp', and attachTo: keeps the two elements, binds and renders, as SilkTodoApp >> attachTo: does.

Finding and binding

An element finds another with querySelector:, and binds an event with addEventListener:do:. A property is read and written as a message:

bindNewTodo
	| input |
	input := app querySelector: '.new-todo'.
	input addEventListener: 'keydown' do: [ :event |
		event key = 'Enter' ifTrue: [
			todoList add: input value.
			input value: ''.
			self render ] ]

The first keyword names the function, and the second is free to choose. input value reads the property, and input value: '' assigns it. The checkbox above the list is bound the same way, with change, and its state is checkbox checked. The button is bound with click. The window takes a listener as an element does, so followRoute binds hashchange on it.

Rendering

render renders the same five things as the Silk variant, in the same order. The counter is built element by element:

renderCount
	| count strong |
	count := app querySelector: '.todo-count'.
	strong := document createElement: 'strong'.
	strong textContent: todoList activeCount.
	count replaceChildren: strong.
	count append: ' ', todoList activeCountLabel

replaceChildren: empties the element and puts the new child in. append: takes a string too, and the DOM makes the text node. The list is emptied the same way, with nothing to put in:

renderList
	listElement replaceChildren.
	todoList shownTodos do: [ :each | self renderTodo: each ]

listElement replaceChildren is a unary message, and the property is a function, so the proxy calls it. The three other things are properties of elements that stay on the page. renderVisibility sets hidden on three elements, and renderToggleAll sets checked. The links of the footer keep their classes, so renderFilters goes to classList:

renderFilters
	(PlatformDom toArray: (app querySelectorAll: '.filters a')) do: [ :each |
		each classList
			toggle: 'selected'
			force: (TodoFilter fromRoute: (each getAttribute: 'href')) == todoList filter ]

querySelectorAll: answers a NodeList, and PlatformDom toArray: makes an Array of it, so that do: works. toggle:force: adds the class when its second argument is true, and removes it when false.

One todo

DomTodoItem has four slots: todo and todoApp, as SilkTodoItem has, and listItem and edit, the elements it keeps for editing. renderIn: builds the item of the template and puts it into the list:

renderIn: anElement
	| view |
	listItem := document createElement: 'li'.
	todo isCompleted ifTrue: [ listItem classList add: 'completed' ].
	view := document createElement: 'div'.
	view className: 'view'.
	view append: self newToggle with: self newLabel with: self newDestroy.
	edit := document createElement: 'input'.
	edit className: 'edit'.
	listItem append: view with: edit.
	anElement appendChild: listItem

append:with:with: is append(toggle, label, destroy), one call with three arguments. Each part is a method of its own, since an element takes four or five lines to make:

newToggle
	| toggle |
	toggle := document createElement: 'input'.
	toggle className: 'toggle'.
	toggle type: 'checkbox'.
	toggle checked: todo isCompleted.
	toggle addEventListener: 'change' do: [ todoApp toggle: todo ].
	^ toggle

newLabel sets textContent and binds dblclick, and newDestroy binds click. The bindings do what those of SilkTodoItem do. The item tells the app, and the app changes the list and renders again.

Editing

startEditing gives the item the class editing, puts the title into the input, binds the edit and focuses the input. The DOM has a word for taking a handler off, so the item binds as SilkTodoItem does:

bindEdit
	| save |
	save := [ todoApp retitle: todo to: edit value ].
	edit addEventListener: 'blur' do: save.
	edit addEventListener: 'keydown' do: [ :event |
		event key = 'Enter' ifTrue: [
			edit removeEventListener: 'blur' with: save.
			save value ].
		event key = 'Escape' ifTrue: [
			edit removeEventListener: 'blur' with: save.
			todoApp render ] ]

removeEventListener:with: takes the same block that addEventListener:do: was given. A block is a function, and the DOM matches it by identity.

What each point costs

The table of TodoMVC with Silk, for this variant:

The specification says Where it is What it takes
Hide main and footer with no todos renderVisibility two hidden:
Focus the input on load dom.html autofocus, from the template
Enter adds, trimmed, empty ignored bindNewTodo, TodoList >> add: one listener; the model trims
Mark all as complete bindToggleAll, renderToggleAll one listener, one checked:
Mark all cleared after Clear completed renderToggleAll, TodoList >> allAreCompleted nothing; the model answers false for no todos
Toggle, class completed DomTodoItem >> newToggle, renderIn:, DomTodoApp >> toggle: one listener, one classList add:
Destroy button on hover DomTodoItem >> newDestroy, DomTodoApp >> remove: one listener; the stylesheet shows the button
Double-click edits DomTodoItem >> newLabel, startEditing one listener, one method
Enter or blur saves, Escape cancels, empty destroys DomTodoItem >> bindEdit, DomTodoApp >> retitle:to:, TodoList >> retitle:to: two listeners; the model removes the todo
The counter, item or items renderCount, TodoList >> activeCountLabel one createElement:, two sends; the model words it
Clear completed, hidden when none bindClearCompleted, renderVisibility one listener, one hidden:
Persistence under todos-amber-dom start, TodoStore one key; the model saves
Routing, class selected, kept on reload followRoute, routeTo:, renderFilters one listener on the window, one fromRoute: per link

The DOM has a word for everything the specification asks, and the proxy makes each word a message. Nothing reaches past anything. What costs is building. An element takes a line to make, a line per attribute and a line per listener. renderIn: with its three helpers is 32 lines for one list item. SilkTodoItem >> renderOnSilk: is nine. The one conversion is PlatformDom toArray:, since a NodeList is no Array.

The tests of the view

DomTodoAppTest, in TodoMVC-DOM-Tests, answers attach:to: with a DomTodoApp attached to the element itself. The 25 tests of TodoAppTest are the same as for the other variants.

Running it

Open http://localhost:4000/dom.html, and try it as TodoMVC with Silk says. The todos are in localStorage under todos-amber-dom. The test runner of Helios runs the 25 tests of DomTodoAppTest on the page.