// Progressive enhancement for the server-rendered TodoMVC. Every action is // still the page's own form POST; this script sends it with fetch, asks for // the list fragment back (X-Todo-Fragment), and swaps it in, so the page // behaves like the reference TodoMVC without full page loads. let queue = Promise.resolve(); // Sends one action. Actions queue so they reach the server in order. function send(action, body) { queue = queue.then(async () => { const res = await fetch(action, { method: 'POST', headers: { 'X-Todo-Fragment': '1' }, body: new URLSearchParams(body), }); if (!res.ok) throw new Error(`${action} answered ${res.status}`); swap(await res.text()); }).catch((err) => { console.error(err); location.reload(); }); return queue; } // Replaces section.main and footer.footer with the server's fresh copies. // An edit still in progress (not yet sent) survives: its li stays in the // page, focus and draft included, and the fresh items go around it. function swap(html) { const tpl = document.createElement('template'); tpl.innerHTML = html; const freshMain = tpl.content.querySelector('section.main'); const freshFooter = tpl.content.querySelector('footer.footer'); const main = document.querySelector('.todoapp section.main'); const footer = document.querySelector('.todoapp footer.footer'); if (!freshMain || !freshFooter || !main || !footer) return; footer.replaceWith(freshFooter); const editing = [...main.querySelectorAll('.todo-list li.editing')] .find((li) => !li.querySelector('.edit').dataset.done); const keep = editing && freshMain.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`); if (!keep) { // Gone from this view: drop the edit without the blur saving it. if (editing) editing.querySelector('.edit').dataset.done = '1'; main.replaceWith(freshMain); return; } // Patch main in place so the editing li is never detached. main.hidden = freshMain.hidden; const list = main.querySelector('.todo-list'); for (const el of [...main.children]) if (el !== list) el.remove(); for (const el of [...freshMain.children]) if (!el.matches('.todo-list')) list.before(el); for (const li of [...list.children]) if (li !== editing) li.remove(); editing.querySelector('.view').replaceWith(keep.querySelector('.view')); editing.classList.toggle('completed', keep.classList.contains('completed')); let before = true; for (const li of [...freshMain.querySelector('.todo-list').children]) { if (li === keep) before = false; else if (before) editing.before(li); else list.append(li); } } // The form a control belongs to, as fields to send. const fields = (form) => new FormData(form); document.addEventListener('submit', (e) => { const form = e.target; e.preventDefault(); const input = form.querySelector('.new-todo'); const body = fields(form); if (input) input.value = ''; send(form.action, body); }); document.addEventListener('change', (e) => { const el = e.target; if (el.matches('.todo-list .toggle')) { const form = el.closest('li').querySelector('form[action$="/toggle"]'); send(form.action, fields(form)); } else if (el.matches('#toggle-all')) { const form = document.querySelector('form[action="/todos/toggle-all"]'); send(form.action, fields(form)); } }); // Editing: double-click a label, Enter or blur saves, Escape cancels. document.addEventListener('dblclick', (e) => { const label = e.target.closest('.todo-list li label'); if (!label) return; const li = label.closest('li'); const input = li.querySelector('.edit'); li.classList.add('editing'); input.dataset.original = input.value; input.focus(); input.setSelectionRange(input.value.length, input.value.length); }); function finishEditing(input, save) { const li = input.closest('li'); if (!li || !li.classList.contains('editing') || input.dataset.done) return; input.dataset.done = '1'; if (save) { send(input.form.action, fields(input.form)); } else { input.value = input.dataset.original ?? input.defaultValue; li.classList.remove('editing'); delete input.dataset.done; } } document.addEventListener('keydown', (e) => { const input = e.target; if (!input.matches('.todo-list .edit')) return; if (e.key === 'Enter') { e.preventDefault(); finishEditing(input, true); } else if (e.key === 'Escape') { finishEditing(input, false); } }); document.addEventListener('focusout', (e) => { if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true); });