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// Layered over the server-rendered page, which already works as plain
2// forms. With this running it behaves like TodoMVC's own apps: actions
3// happen without a page load, and a double-click edits.
4//
5// Every action posts the same form it would without JavaScript, with an
6// X-Fragment header. The server answers with fresh section.main and
7// footer.footer, which replace the old ones. Requests go one at a time,
8// in order.
9
10document.documentElement.classList.add('js');
11
12const app = document.querySelector('.todoapp');
13let queue = Promise.resolve();
14// True while a response replaces the list, so the edit input it removes
15// isn't taken for one the user left.
16let swapping = false;
17
18function send(action, body) {
19 queue = queue
20 .then(async () => {
21 const res = await fetch(action, { method: 'POST', headers: { 'X-Fragment': '1' }, body });
22 if (!res.ok) throw new Error(`${action}: ${res.status}`);
23 const next = document.createElement('template');
24 next.innerHTML = await res.text();
25 const edit = pendingEdit();
26 swapping = true;
27 try {
28 for (const part of ['.main', '.footer']) {
29 app.querySelector(part).replaceWith(next.content.querySelector(part));
30 }
31 } finally {
32 swapping = false;
33 }
34 if (edit) resumeEdit(edit);
35 })
36 // The page is the source of truth: if anything went wrong, show it.
37 .catch(() => location.reload());
38 return queue;
39}
40
41// An edit the user is in the middle of, captured before a response
42// replaces the list, and put back into the fresh copy of that item.
43function pendingEdit() {
44 const li = app.querySelector('.todo-list li.editing');
45 if (!li) return null;
46 const input = li.querySelector('.edit');
47 return {
48 id: li.dataset.id,
49 value: input.value,
50 start: input.selectionStart,
51 end: input.selectionEnd,
52 focused: document.activeElement === input,
53 };
54}
55
56function resumeEdit(edit) {
57 const li = app.querySelector(`.todo-list li[data-id="${CSS.escape(edit.id)}"]`);
58 if (!li) return;
59 const input = li.querySelector('.edit');
60 li.classList.add('editing');
61 input.value = edit.value;
62 if (edit.focused) {
63 input.focus();
64 input.setSelectionRange(edit.start, edit.end);
65 }
66}
67
68const fields = (form) => new URLSearchParams(new FormData(form));
69const filter = () => app.querySelector('input[name="filter"]').value;
70
71app.addEventListener('submit', (e) => {
72 const form = e.target;
73 e.preventDefault();
74 if (form.querySelector('.edit')) {
75 finishEditing(form.closest('li'), true);
76 return;
77 }
78 const body = fields(form);
79 send(form.action, body);
80 const input = form.querySelector('.new-todo');
81 if (input) input.value = '';
82});
83
84app.addEventListener('change', (e) => {
85 const box = e.target;
86 if (box.matches('.toggle')) {
87 send(`/todos/${box.closest('li').dataset.id}/toggle`, new URLSearchParams({ filter: filter() }));
88 } else if (box.matches('.toggle-all')) {
89 send('/todos/toggle-all', new URLSearchParams({ filter: filter() }));
90 }
91});
92
93app.addEventListener('dblclick', (e) => {
94 const label = e.target.closest('.todo-list label');
95 if (!label) return;
96 const li = label.closest('li');
97 const input = li.querySelector('.edit');
98 li.classList.add('editing');
99 input.focus();
100 input.setSelectionRange(input.value.length, input.value.length);
101});
102
103app.addEventListener('keydown', (e) => {
104 if (e.key === 'Escape' && e.target.matches('.edit')) finishEditing(e.target.closest('li'), false);
105});
106
107app.addEventListener('focusout', (e) => {
108 if (!swapping && e.target.matches('.edit')) finishEditing(e.target.closest('li'), true);
109});
110
111// Ends an edit once: Enter submits and then blurs, and Escape blurs too.
112function finishEditing(li, save) {
113 if (!li.classList.contains('editing')) return;
114 const input = li.querySelector('.edit');
115 li.classList.remove('editing');
116 if (save) {
117 // Show the new title straight away; the server's answer follows.
118 const title = input.value.trim();
119 if (title) li.querySelector('label a').textContent = title;
120 else li.hidden = true;
121 send(input.form.action, fields(input.form));
122 } else {
123 input.value = input.defaultValue;
124 }
125}
126