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// With JavaScript on, the page behaves like TodoMVC's own apps. Every
2// change still goes through the same form POSTs as without JavaScript,
3// sent with X-Fragment: todoapp so the server answers with its rendering
4// of section.todoapp; the list and footer are swapped in from that. The
5// server is the only place the list lives.
6
7const app = document.querySelector('.todoapp');
8
9// Requests run one at a time, so responses can't land out of order.
10let queue = Promise.resolve();
11
12function post(form) {
13 const body = new URLSearchParams(new FormData(form));
14 queue = queue
15 .then(async () => {
16 const res = await fetch(form.action, {
17 method: 'POST',
18 body,
19 headers: { 'X-Fragment': 'todoapp' },
20 });
21 if (!res.ok) throw new Error(`${res.status} from ${form.action}`);
22 render(await res.text());
23 })
24 .catch((err) => {
25 console.error(err);
26 location.reload();
27 });
28}
29
30// True while render() swaps the list, so that losing focus to the swap
31// doesn't count as finishing an edit.
32let swapping = false;
33
34// Replaces section.main and footer.footer with the server's; either may be
35// absent when the list is empty. The header, and what's typed in it, stays,
36// and so does an edit in progress: the item being edited stays open in the
37// new list, with its unsaved text, focus and cursor.
38function render(html) {
39 const next = new DOMParser().parseFromString(html, 'text/html').querySelector('.todoapp');
40 const editing = app.querySelector('.todo-list li.editing');
41 const input = editing?.querySelector('.edit');
42 const focused = input && document.activeElement === input;
43 const [start, end] = input ? [input.selectionStart, input.selectionEnd] : [];
44
45 swapping = true;
46 try {
47 for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove();
48 for (const part of ['.main', '.footer']) {
49 const el = next.querySelector(`:scope > ${part}`);
50 if (el) app.append(el);
51 }
52 } finally {
53 swapping = false;
54 }
55
56 const li = editing && app.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`);
57 if (!li) return;
58 li.classList.add('editing');
59 const fresh = li.querySelector('.edit');
60 fresh.value = input.value;
61 if (focused) {
62 fresh.focus();
63 fresh.setSelectionRange(start, end);
64 }
65}
66
67function startEditing(li) {
68 li.classList.add('editing');
69 const input = li.querySelector('.edit');
70 input.focus();
71 input.setSelectionRange(input.value.length, input.value.length);
72}
73
74function save(li) {
75 if (!li.classList.contains('editing')) return;
76 li.classList.remove('editing');
77 const input = li.querySelector('.edit');
78 li.querySelector('label').textContent = input.value.trim();
79 post(input.form);
80}
81
82function cancel(li) {
83 const input = li.querySelector('.edit');
84 input.value = input.defaultValue;
85 li.classList.remove('editing');
86}
87
88app.addEventListener('submit', (e) => {
89 e.preventDefault();
90 const form = e.target;
91 if (form.classList.contains('edit-form')) {
92 save(form.closest('li'));
93 } else {
94 post(form);
95 const input = form.querySelector('.new-todo');
96 if (input) input.value = '';
97 }
98});
99
100app.addEventListener('change', (e) => {
101 if (e.target.matches('.toggle, .toggle-all')) post(e.target.form);
102});
103
104app.addEventListener('dblclick', (e) => {
105 const label = e.target.closest('.todo-list label');
106 if (label) startEditing(label.closest('li'));
107});
108
109app.addEventListener('keydown', (e) => {
110 if (e.key === 'Escape' && e.target.matches('.edit')) cancel(e.target.closest('li'));
111});
112
113app.addEventListener('focusout', (e) => {
114 if (!swapping && e.target.matches('.edit')) save(e.target.closest('li'));
115});
116