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 action is
2// still the page's own form post; this script sends it with fetch and
3// the X-Todo-Fragment header, and the server answers with the new
4// contents of section.todoapp instead of a 303. Without this script the
5// forms post normally and nothing here is needed.
6
7const app = document.querySelector('.todoapp');
8
9// Posts run one at a time, in the order they were made, so a quick
10// toggle-then-delete can't land the other way round.
11let queue = Promise.resolve();
12
13function send(form) {
14 const body = new URLSearchParams(new FormData(form));
15 const newTodo = form.querySelector('.new-todo');
16 // TodoMVC clears the field once a todo is added. Clear it now, as the
17 // post goes, so anything typed while it is in flight is kept.
18 if (newTodo) newTodo.value = '';
19 queue = queue
20 .then(() => fetch(form.action, {
21 method: 'POST',
22 body,
23 headers: { 'X-Todo-Fragment': '1' },
24 credentials: 'same-origin',
25 }))
26 .then((res) => {
27 if (!res.ok) throw new Error(`${res.status} from ${form.action}`);
28 return res.text();
29 })
30 .then((html) => swap(html, newTodo !== null))
31 .catch(() => location.reload());
32}
33
34// swap replaces the list with the server's markup, keeping whatever is
35// in the new-todo field and its focus. After an add the field keeps
36// focus, as TodoMVC does.
37function swap(html, addsTodo) {
38 const input = app.querySelector('.new-todo');
39 const typing = input.value;
40 const focused = addsTodo || document.activeElement === input;
41 const [start, end] = [input.selectionStart, input.selectionEnd];
42 app.innerHTML = html;
43 const next = app.querySelector('.new-todo');
44 if (typing) next.value = typing;
45 if (focused) {
46 next.focus();
47 if (typing) next.setSelectionRange(start, end);
48 }
49}
50
51app.addEventListener('submit', (e) => {
52 e.preventDefault();
53 send(e.target);
54});
55
56// The checkboxes are the page's own controls with JavaScript on.
57app.addEventListener('change', (e) => {
58 if (e.target.matches('.toggle, .toggle-all')) send(e.target.form);
59});
60
61// Double-click a title to edit it.
62app.addEventListener('dblclick', (e) => {
63 const label = e.target.closest('.todo-list label');
64 if (!label) return;
65 const li = label.closest('li');
66 const input = li.querySelector('.edit');
67 li.classList.add('editing');
68 input.focus();
69 input.setSelectionRange(input.value.length, input.value.length);
70});
71
72// Enter saves (the edit form's own submit), Escape cancels.
73app.addEventListener('keydown', (e) => {
74 if (!e.target.matches('.edit') || e.key !== 'Escape') return;
75 const input = e.target;
76 const li = input.closest('li');
77 input.value = input.defaultValue;
78 li.classList.remove('editing');
79 input.blur();
80});
81
82// Leaving the editor saves it, unless Escape already closed it.
83app.addEventListener('focusout', (e) => {
84 if (!e.target.matches('.edit')) return;
85 const li = e.target.closest('li');
86 if (!li.classList.contains('editing') || li.dataset.saving) return;
87 li.dataset.saving = '1';
88 send(e.target.form);
89});
90
91// Enter's submit also blurs as the list is replaced; mark it so the
92// focusout above doesn't post the same edit twice.
93app.addEventListener('submit', (e) => {
94 const li = e.target.closest('li');
95 if (li) li.dataset.saving = '1';
96}, true);
97