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// Progressive enhancement for the server-rendered TodoMVC. Every action is
2// still the page's own form POST; this script sends it with fetch, asks for
3// the list fragment back (X-Todo-Fragment), and swaps it in, so the page
4// behaves like the reference TodoMVC without full page loads.
5
6let queue = Promise.resolve();
7
8// Sends one action. Actions queue so they reach the server in order.
9function send(action, body) {
10 queue = queue.then(async () => {
11 const res = await fetch(action, {
12 method: 'POST',
13 headers: { 'X-Todo-Fragment': '1' },
14 body: new URLSearchParams(body),
15 });
16 if (!res.ok) throw new Error(`${action} answered ${res.status}`);
17 swap(await res.text());
18 }).catch((err) => {
19 console.error(err);
20 location.reload();
21 });
22 return queue;
23}
24
25// Replaces section.main and footer.footer with the server's fresh copies.
26// An edit still in progress (not yet sent) survives: its li stays in the
27// page, focus and draft included, and the fresh items go around it.
28function swap(html) {
29 const tpl = document.createElement('template');
30 tpl.innerHTML = html;
31 const freshMain = tpl.content.querySelector('section.main');
32 const freshFooter = tpl.content.querySelector('footer.footer');
33 const main = document.querySelector('.todoapp section.main');
34 const footer = document.querySelector('.todoapp footer.footer');
35 if (!freshMain || !freshFooter || !main || !footer) return;
36 footer.replaceWith(freshFooter);
37
38 const editing = [...main.querySelectorAll('.todo-list li.editing')]
39 .find((li) => !li.querySelector('.edit').dataset.done);
40 const keep = editing && freshMain.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`);
41 if (!keep) {
42 // Gone from this view: drop the edit without the blur saving it.
43 if (editing) editing.querySelector('.edit').dataset.done = '1';
44 main.replaceWith(freshMain);
45 return;
46 }
47
48 // Patch main in place so the editing li is never detached.
49 main.hidden = freshMain.hidden;
50 const list = main.querySelector('.todo-list');
51 for (const el of [...main.children]) if (el !== list) el.remove();
52 for (const el of [...freshMain.children]) if (!el.matches('.todo-list')) list.before(el);
53 for (const li of [...list.children]) if (li !== editing) li.remove();
54 editing.querySelector('.view').replaceWith(keep.querySelector('.view'));
55 editing.classList.toggle('completed', keep.classList.contains('completed'));
56 let before = true;
57 for (const li of [...freshMain.querySelector('.todo-list').children]) {
58 if (li === keep) before = false;
59 else if (before) editing.before(li);
60 else list.append(li);
61 }
62}
63
64// The form a control belongs to, as fields to send.
65const fields = (form) => new FormData(form);
66
67document.addEventListener('submit', (e) => {
68 const form = e.target;
69 e.preventDefault();
70 const input = form.querySelector('.new-todo');
71 const body = fields(form);
72 if (input) input.value = '';
73 send(form.action, body);
74});
75
76document.addEventListener('change', (e) => {
77 const el = e.target;
78 if (el.matches('.todo-list .toggle')) {
79 const form = el.closest('li').querySelector('form[action$="/toggle"]');
80 send(form.action, fields(form));
81 } else if (el.matches('#toggle-all')) {
82 const form = document.querySelector('form[action="/todos/toggle-all"]');
83 send(form.action, fields(form));
84 }
85});
86
87// Editing: double-click a label, Enter or blur saves, Escape cancels.
88document.addEventListener('dblclick', (e) => {
89 const label = e.target.closest('.todo-list li label');
90 if (!label) return;
91 const li = label.closest('li');
92 const input = li.querySelector('.edit');
93 li.classList.add('editing');
94 input.dataset.original = input.value;
95 input.focus();
96 input.setSelectionRange(input.value.length, input.value.length);
97});
98
99function finishEditing(input, save) {
100 const li = input.closest('li');
101 if (!li || !li.classList.contains('editing') || input.dataset.done) return;
102 input.dataset.done = '1';
103 if (save) {
104 send(input.form.action, fields(input.form));
105 } else {
106 input.value = input.dataset.original ?? input.defaultValue;
107 li.classList.remove('editing');
108 delete input.dataset.done;
109 }
110}
111
112document.addEventListener('keydown', (e) => {
113 const input = e.target;
114 if (!input.matches('.todo-list .edit')) return;
115 if (e.key === 'Enter') {
116 e.preventDefault();
117 finishEditing(input, true);
118 } else if (e.key === 'Escape') {
119 finishEditing(input, false);
120 }
121});
122
123document.addEventListener('focusout', (e) => {
124 if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true);
125});
126