| 1 | // TodoMVC behaviour on top of the server-rendered page. Every change is |
| 2 | // still the page's own form post; this sends it with fetch and swaps in |
| 3 | // the new list instead of loading the page again. |
| 4 | |
| 5 | const app = document.querySelector('.todoapp'); |
| 6 | |
| 7 | // Actions run one at a time, in the order they were made, so each answer |
| 8 | // is the newest state when it's swapped in and an older one can never |
| 9 | // overwrite it. |
| 10 | let queue = Promise.resolve(); |
| 11 | |
| 12 | // Sends a form and replaces section.main and footer.footer with what the |
| 13 | // server answers. The form is read when it's submitted, so the visitor can |
| 14 | // carry on typing. Anything unexpected falls back to a full page load. |
| 15 | function send(form) { |
| 16 | const action = form.action; |
| 17 | const body = new URLSearchParams(new FormData(form)); |
| 18 | queue = queue.then(async () => { |
| 19 | const res = await fetch(action, { |
| 20 | method: 'POST', |
| 21 | headers: { 'X-Todo-Partial': '1' }, |
| 22 | body, |
| 23 | }); |
| 24 | if (!res.ok) throw new Error(`${res.status}`); |
| 25 | swap(await res.text()); |
| 26 | }).catch(() => location.reload()); |
| 27 | } |
| 28 | |
| 29 | function swap(html) { |
| 30 | const tpl = document.createElement('template'); |
| 31 | tpl.innerHTML = html; |
| 32 | app.querySelectorAll(':scope > .main, :scope > .footer').forEach((el) => el.remove()); |
| 33 | app.append(tpl.content); |
| 34 | } |
| 35 | |
| 36 | app.addEventListener('submit', (e) => { |
| 37 | e.preventDefault(); |
| 38 | const form = e.target; |
| 39 | send(form); |
| 40 | // The new-todo input is ready for the next title straight away. |
| 41 | if (form.closest('.header')) form.reset(); |
| 42 | // An edit is saved once: the blur that follows doesn't save it again. |
| 43 | const li = form.closest('.todo-list li'); |
| 44 | if (li) li.dataset.saved = '1'; |
| 45 | }); |
| 46 | |
| 47 | app.addEventListener('change', (e) => { |
| 48 | const li = e.target.closest('li[data-id]'); |
| 49 | if (e.target.matches('.toggle') && li) send(document.getElementById(`toggle-${li.dataset.id}`)); |
| 50 | if (e.target.matches('.toggle-all')) send(app.querySelector('[data-action="toggle-all"]').form); |
| 51 | }); |
| 52 | |
| 53 | // Editing: double-click opens the editor, Enter or blur saves, Escape |
| 54 | // cancels. An empty title deletes the item (the server decides that). |
| 55 | function startEditing(li) { |
| 56 | li.classList.add('editing'); |
| 57 | const input = li.querySelector('.edit'); |
| 58 | input.focus(); |
| 59 | input.setSelectionRange(input.value.length, input.value.length); |
| 60 | } |
| 61 | |
| 62 | app.addEventListener('dblclick', (e) => { |
| 63 | const li = e.target.closest('.todo-list li'); |
| 64 | if (li && e.target.matches('.view label')) startEditing(li); |
| 65 | }); |
| 66 | |
| 67 | app.addEventListener('keydown', (e) => { |
| 68 | if (e.key !== 'Escape' || !e.target.matches('.edit')) return; |
| 69 | const input = e.target; |
| 70 | input.value = input.defaultValue; |
| 71 | input.closest('li').classList.remove('editing'); |
| 72 | input.blur(); |
| 73 | }); |
| 74 | |
| 75 | app.addEventListener('focusout', (e) => { |
| 76 | if (!e.target.matches('.edit')) return; |
| 77 | const li = e.target.closest('li'); |
| 78 | if (!li.classList.contains('editing') || li.dataset.saved) return; |
| 79 | li.dataset.saved = '1'; |
| 80 | send(e.target.form); |
| 81 | }); |
| 82 | |
| 83 | // The server's edit view (?edit=<id>) opens with that item's editor; put |
| 84 | // the cursor at the end, as double-click does. |
| 85 | const editing = app.querySelector('.todo-list li.editing'); |
| 86 | if (editing) startEditing(editing); |
| 87 | |