// Progressive enhancement for the server-rendered TodoMVC. // // Every action is already a working HTML form. This script sends those same // forms with fetch, asks for the re-rendered page instead of a redirect, and // swaps in the new list and footer. It adds TodoMVC's own gestures on top: // click a checkbox to toggle, double-click a label to edit, Enter or blur to // save, Escape to cancel. const app = document.querySelector('.todoapp'); // Requests go out one at a time, so the page always shows the last answer. let queue = Promise.resolve(); function send(form) { const body = new URLSearchParams(new FormData(form)); queue = queue.then(async () => { const res = await fetch(form.action, { method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body, }); if (!res.ok) throw new Error(`${res.status} from ${form.action}`); apply(new DOMParser().parseFromString(await res.text(), 'text/html')); }).catch((err) => { console.error(err); location.reload(); }); } // Swaps in the server's fresh list and footer. An editor that's open when // the answer arrives stays open with its unsaved text: its item is kept in // place (never detached, so it keeps focus) and only refreshed around it. function apply(fresh) { app.querySelector('.footer').replaceWith(fresh.querySelector('.footer')); const editing = app.querySelector('.todo-list li.editing'); const update = editing && fresh.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`); if (!update) { app.querySelector('.main').replaceWith(fresh.querySelector('.main')); return; } const main = app.querySelector('.main'); const freshMain = fresh.querySelector('.main'); main.hidden = freshMain.hidden; main.querySelector('form').replaceWith(freshMain.querySelector('form')); for (const item of [...main.querySelectorAll('.todo-list > li')]) { if (item !== editing) item.remove(); } let before = true; for (const item of [...freshMain.querySelectorAll('.todo-list > li')]) { if (item === update) { before = false; } else if (before) { editing.before(item); } else { editing.parentElement.append(item); } } // Bring the kept item's view up to date, leaving the editor's text alone. editing.className = `${update.className} editing`; editing.querySelector('.view').replaceWith(update.querySelector('.view')); editing.querySelector('.edit').defaultValue = update.querySelector('.edit').defaultValue; } app.addEventListener('submit', (event) => { event.preventDefault(); const form = event.target; const editing = form.closest('li.editing'); if (editing) editing.classList.remove('editing'); send(form); if (form.closest('.header')) form.querySelector('.new-todo').value = ''; }); app.addEventListener('change', (event) => { const box = event.target; if (box.matches('.toggle')) { send(box.closest('li').querySelector('form[action$="/toggle"]')); } else if (box.matches('.toggle-all')) { send(box.form); } }); app.addEventListener('dblclick', (event) => { const label = event.target.closest('.todo-list label'); if (!label) return; const item = label.closest('li'); const input = item.querySelector('.edit'); item.classList.add('editing'); input.focus(); input.setSelectionRange(input.value.length, input.value.length); }); app.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !event.target.matches('.edit')) return; const input = event.target; input.value = input.defaultValue; input.closest('li').classList.remove('editing'); }); // Leaving the editor saves it, unless Enter or Escape already closed it. app.addEventListener('focusout', (event) => { const input = event.target; if (input.matches('.edit') && input.closest('li.editing')) input.form.requestSubmit(); });