carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1// TodoMVC behaviour on top of the server-rendered page. Every change is
2// still the page's own form post; this sends it with fetch and swaps in
3// the new list instead of loading the page again.
4
5const app = document.querySelector('.todoapp');
6
7// Actions run one at a time, in the order they were made, so each answer
8// is the newest state when it's swapped in and an older one can never
9// overwrite it.
10let queue = Promise.resolve();
11
12// Sends a form and replaces section.main and footer.footer with what the
13// server answers. The form is read when it's submitted, so the visitor can
14// carry on typing. Anything unexpected falls back to a full page load.
15function send(form) {
16 const action = form.action;
17 const body = new URLSearchParams(new FormData(form));
18 queue = queue.then(async () => {
19 const res = await fetch(action, {
20 method: 'POST',
21 headers: { 'X-Todo-Partial': '1' },
22 body,
23 });
24 if (!res.ok) throw new Error(`${res.status}`);
25 swap(await res.text());
26 }).catch(() => location.reload());
27}
28
29function swap(html) {
30 const tpl = document.createElement('template');
31 tpl.innerHTML = html;
32 app.querySelectorAll(':scope > .main, :scope > .footer').forEach((el) => el.remove());
33 app.append(tpl.content);
34}
35
36app.addEventListener('submit', (e) => {
37 e.preventDefault();
38 const form = e.target;
39 send(form);
40 // The new-todo input is ready for the next title straight away.
41 if (form.closest('.header')) form.reset();
42 // An edit is saved once: the blur that follows doesn't save it again.
43 const li = form.closest('.todo-list li');
44 if (li) li.dataset.saved = '1';
45});
46
47app.addEventListener('change', (e) => {
48 const li = e.target.closest('li[data-id]');
49 if (e.target.matches('.toggle') && li) send(document.getElementById(`toggle-${li.dataset.id}`));
50 if (e.target.matches('.toggle-all')) send(app.querySelector('[data-action="toggle-all"]').form);
51});
52
53// Editing: double-click opens the editor, Enter or blur saves, Escape
54// cancels. An empty title deletes the item (the server decides that).
55function startEditing(li) {
56 li.classList.add('editing');
57 const input = li.querySelector('.edit');
58 input.focus();
59 input.setSelectionRange(input.value.length, input.value.length);
60}
61
62app.addEventListener('dblclick', (e) => {
63 const li = e.target.closest('.todo-list li');
64 if (li && e.target.matches('.view label')) startEditing(li);
65});
66
67app.addEventListener('keydown', (e) => {
68 if (e.key !== 'Escape' || !e.target.matches('.edit')) return;
69 const input = e.target;
70 input.value = input.defaultValue;
71 input.closest('li').classList.remove('editing');
72 input.blur();
73});
74
75app.addEventListener('focusout', (e) => {
76 if (!e.target.matches('.edit')) return;
77 const li = e.target.closest('li');
78 if (!li.classList.contains('editing') || li.dataset.saved) return;
79 li.dataset.saved = '1';
80 send(e.target.form);
81});
82
83// The server's edit view (?edit=<id>) opens with that item's editor; put
84// the cursor at the end, as double-click does.
85const editing = app.querySelector('.todo-list li.editing');
86if (editing) startEditing(editing);
87