| 1 | // Progressive enhancement for the server-rendered TodoMVC. Every action is |
| 2 | // still the page's own form POST; this script sends it with fetch, asks for |
| 3 | // the list fragment back (X-Todo-Fragment), and swaps it in, so the page |
| 4 | // behaves like the reference TodoMVC without full page loads. |
| 5 | |
| 6 | let queue = Promise.resolve(); |
| 7 | |
| 8 | // Sends one action. Actions queue so they reach the server in order. |
| 9 | function send(action, body) { |
| 10 | queue = queue.then(async () => { |
| 11 | const res = await fetch(action, { |
| 12 | method: 'POST', |
| 13 | headers: { 'X-Todo-Fragment': '1' }, |
| 14 | body: new URLSearchParams(body), |
| 15 | }); |
| 16 | if (!res.ok) throw new Error(`${action} answered ${res.status}`); |
| 17 | swap(await res.text()); |
| 18 | }).catch((err) => { |
| 19 | console.error(err); |
| 20 | location.reload(); |
| 21 | }); |
| 22 | return queue; |
| 23 | } |
| 24 | |
| 25 | // Replaces section.main and footer.footer with the server's fresh copies. |
| 26 | // An edit still in progress (not yet sent) survives: its li stays in the |
| 27 | // page, focus and draft included, and the fresh items go around it. |
| 28 | function swap(html) { |
| 29 | const tpl = document.createElement('template'); |
| 30 | tpl.innerHTML = html; |
| 31 | const freshMain = tpl.content.querySelector('section.main'); |
| 32 | const freshFooter = tpl.content.querySelector('footer.footer'); |
| 33 | const main = document.querySelector('.todoapp section.main'); |
| 34 | const footer = document.querySelector('.todoapp footer.footer'); |
| 35 | if (!freshMain || !freshFooter || !main || !footer) return; |
| 36 | footer.replaceWith(freshFooter); |
| 37 | |
| 38 | const editing = [...main.querySelectorAll('.todo-list li.editing')] |
| 39 | .find((li) => !li.querySelector('.edit').dataset.done); |
| 40 | const keep = editing && freshMain.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`); |
| 41 | if (!keep) { |
| 42 | // Gone from this view: drop the edit without the blur saving it. |
| 43 | if (editing) editing.querySelector('.edit').dataset.done = '1'; |
| 44 | main.replaceWith(freshMain); |
| 45 | return; |
| 46 | } |
| 47 | |
| 48 | // Patch main in place so the editing li is never detached. |
| 49 | main.hidden = freshMain.hidden; |
| 50 | const list = main.querySelector('.todo-list'); |
| 51 | for (const el of [...main.children]) if (el !== list) el.remove(); |
| 52 | for (const el of [...freshMain.children]) if (!el.matches('.todo-list')) list.before(el); |
| 53 | for (const li of [...list.children]) if (li !== editing) li.remove(); |
| 54 | editing.querySelector('.view').replaceWith(keep.querySelector('.view')); |
| 55 | editing.classList.toggle('completed', keep.classList.contains('completed')); |
| 56 | let before = true; |
| 57 | for (const li of [...freshMain.querySelector('.todo-list').children]) { |
| 58 | if (li === keep) before = false; |
| 59 | else if (before) editing.before(li); |
| 60 | else list.append(li); |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // The form a control belongs to, as fields to send. |
| 65 | const fields = (form) => new FormData(form); |
| 66 | |
| 67 | document.addEventListener('submit', (e) => { |
| 68 | const form = e.target; |
| 69 | e.preventDefault(); |
| 70 | const input = form.querySelector('.new-todo'); |
| 71 | const body = fields(form); |
| 72 | if (input) input.value = ''; |
| 73 | send(form.action, body); |
| 74 | }); |
| 75 | |
| 76 | document.addEventListener('change', (e) => { |
| 77 | const el = e.target; |
| 78 | if (el.matches('.todo-list .toggle')) { |
| 79 | const form = el.closest('li').querySelector('form[action$="/toggle"]'); |
| 80 | send(form.action, fields(form)); |
| 81 | } else if (el.matches('#toggle-all')) { |
| 82 | const form = document.querySelector('form[action="/todos/toggle-all"]'); |
| 83 | send(form.action, fields(form)); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | // Editing: double-click a label, Enter or blur saves, Escape cancels. |
| 88 | document.addEventListener('dblclick', (e) => { |
| 89 | const label = e.target.closest('.todo-list li label'); |
| 90 | if (!label) return; |
| 91 | const li = label.closest('li'); |
| 92 | const input = li.querySelector('.edit'); |
| 93 | li.classList.add('editing'); |
| 94 | input.dataset.original = input.value; |
| 95 | input.focus(); |
| 96 | input.setSelectionRange(input.value.length, input.value.length); |
| 97 | }); |
| 98 | |
| 99 | function finishEditing(input, save) { |
| 100 | const li = input.closest('li'); |
| 101 | if (!li || !li.classList.contains('editing') || input.dataset.done) return; |
| 102 | input.dataset.done = '1'; |
| 103 | if (save) { |
| 104 | send(input.form.action, fields(input.form)); |
| 105 | } else { |
| 106 | input.value = input.dataset.original ?? input.defaultValue; |
| 107 | li.classList.remove('editing'); |
| 108 | delete input.dataset.done; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | document.addEventListener('keydown', (e) => { |
| 113 | const input = e.target; |
| 114 | if (!input.matches('.todo-list .edit')) return; |
| 115 | if (e.key === 'Enter') { |
| 116 | e.preventDefault(); |
| 117 | finishEditing(input, true); |
| 118 | } else if (e.key === 'Escape') { |
| 119 | finishEditing(input, false); |
| 120 | } |
| 121 | }); |
| 122 | |
| 123 | document.addEventListener('focusout', (e) => { |
| 124 | if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true); |
| 125 | }); |
| 126 | |