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// the same form POST the page makes without JavaScript; with the
3// `X-Fragment: 1` header the server answers with the re-rendered
4// `section.main` and `footer.footer` instead of a 303, and we swap them in.
5
6const app = document.querySelector('.todoapp');
7const filter = app.dataset.filter;
8const newTodo = app.querySelector('.new-todo');
9
10// Requests run one at a time, so responses apply in the order they were made.
11let queue = Promise.resolve();
12
13function post(action, fields) {
14 const body = new URLSearchParams({ ...fields, filter });
15 const run = async () => {
16 const res = await fetch(action, {
17 method: 'POST',
18 body,
19 headers: { 'X-Fragment': '1' },
20 credentials: 'same-origin',
21 });
22 // The item is gone (another tab removed it): show the list as it is.
23 if (!res.ok) return location.reload();
24 render(await res.text());
25 };
26 queue = queue.then(run, run);
27 return queue;
28}
29
30// True while render() swaps the list, so the edit field it removes isn't
31// taken for a blur that saves.
32let rendering = false;
33
34function render(html) {
35 // An edit in progress survives a response to some other action: carry
36 // its text, cursor and focus over to the same item in the new list.
37 const editing = app.querySelector('.todo-list li.editing');
38 const input = editing?.querySelector('.edit');
39 const kept = editing && {
40 id: editing.dataset.id,
41 value: input.value,
42 focused: document.activeElement === input,
43 start: input.selectionStart,
44 end: input.selectionEnd,
45 };
46 rendering = true;
47 try {
48 app.querySelector('.main')?.remove();
49 app.querySelector('.footer')?.remove();
50 app.querySelector('.header').insertAdjacentHTML('afterend', html);
51 } finally {
52 rendering = false;
53 }
54 const li = kept && app.querySelector(`.todo-list li[data-id="${kept.id}"]`);
55 if (!li) return;
56 const edit = li.querySelector('.edit');
57 li.classList.add('editing');
58 edit.value = kept.value;
59 if (kept.focused) {
60 edit.focus();
61 edit.setSelectionRange(kept.start, kept.end);
62 }
63}
64
65function itemOf(el) {
66 return el.closest('.todo-list li');
67}
68
69// Forms: adding, deleting and clearing completed.
70app.addEventListener('submit', (e) => {
71 const form = e.target;
72 e.preventDefault();
73 const fields = Object.fromEntries(new FormData(form));
74 delete fields.filter;
75 post(form.getAttribute('action'), fields);
76 if (form.contains(newTodo)) newTodo.value = '';
77});
78
79app.addEventListener('change', (e) => {
80 if (e.target.matches('.toggle')) {
81 post(`/todos/${itemOf(e.target).dataset.id}/toggle`, {});
82 } else if (e.target.matches('.toggle-all')) {
83 post('/todos/toggle-all', {});
84 }
85});
86
87// Editing: double-click a label; Enter or blur saves, Escape cancels.
88app.addEventListener('dblclick', (e) => {
89 if (!e.target.matches('.todo-list label')) return;
90 const li = itemOf(e.target);
91 const input = li.querySelector('.edit');
92 li.classList.add('editing');
93 input.value = e.target.textContent;
94 input.focus();
95 input.setSelectionRange(input.value.length, input.value.length);
96});
97
98function finishEditing(input, save) {
99 const li = itemOf(input);
100 if (!li?.classList.contains('editing')) return;
101 li.classList.remove('editing');
102 // A page opened from an edit link (?edit=<id>) is done with it now.
103 if (location.search) history.replaceState(null, '', location.pathname);
104 if (save) {
105 post(`/todos/${li.dataset.id}/edit`, { title: input.value });
106 } else {
107 input.value = li.querySelector('label').textContent;
108 }
109}
110
111app.addEventListener('keydown', (e) => {
112 if (!e.target.matches('.todo-list .edit')) return;
113 if (e.key === 'Enter') {
114 e.preventDefault();
115 finishEditing(e.target, true);
116 } else if (e.key === 'Escape') {
117 finishEditing(e.target, false);
118 }
119});
120
121app.addEventListener('focusout', (e) => {
122 if (rendering) return;
123 if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true);
124});
125