// TodoMVC behaviour on top of the server-rendered page. Every change is // still the page's own form post; this sends it with fetch and swaps in // the new list instead of loading the page again. const app = document.querySelector('.todoapp'); // Actions run one at a time, in the order they were made, so each answer // is the newest state when it's swapped in and an older one can never // overwrite it. let queue = Promise.resolve(); // Sends a form and replaces section.main and footer.footer with what the // server answers. The form is read when it's submitted, so the visitor can // carry on typing. Anything unexpected falls back to a full page load. function send(form) { const action = form.action; const body = new URLSearchParams(new FormData(form)); queue = queue.then(async () => { const res = await fetch(action, { method: 'POST', headers: { 'X-Todo-Partial': '1' }, body, }); if (!res.ok) throw new Error(`${res.status}`); swap(await res.text()); }).catch(() => location.reload()); } function swap(html) { const tpl = document.createElement('template'); tpl.innerHTML = html; app.querySelectorAll(':scope > .main, :scope > .footer').forEach((el) => el.remove()); app.append(tpl.content); } app.addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; send(form); // The new-todo input is ready for the next title straight away. if (form.closest('.header')) form.reset(); // An edit is saved once: the blur that follows doesn't save it again. const li = form.closest('.todo-list li'); if (li) li.dataset.saved = '1'; }); app.addEventListener('change', (e) => { const li = e.target.closest('li[data-id]'); if (e.target.matches('.toggle') && li) send(document.getElementById(`toggle-${li.dataset.id}`)); if (e.target.matches('.toggle-all')) send(app.querySelector('[data-action="toggle-all"]').form); }); // Editing: double-click opens the editor, Enter or blur saves, Escape // cancels. An empty title deletes the item (the server decides that). function startEditing(li) { li.classList.add('editing'); const input = li.querySelector('.edit'); input.focus(); input.setSelectionRange(input.value.length, input.value.length); } app.addEventListener('dblclick', (e) => { const li = e.target.closest('.todo-list li'); if (li && e.target.matches('.view label')) startEditing(li); }); app.addEventListener('keydown', (e) => { if (e.key !== 'Escape' || !e.target.matches('.edit')) return; const input = e.target; input.value = input.defaultValue; input.closest('li').classList.remove('editing'); input.blur(); }); app.addEventListener('focusout', (e) => { if (!e.target.matches('.edit')) return; const li = e.target.closest('li'); if (!li.classList.contains('editing') || li.dataset.saved) return; li.dataset.saved = '1'; send(e.target.form); }); // The server's edit view (?edit=) opens with that item's editor; put // the cursor at the end, as double-click does. const editing = app.querySelector('.todo-list li.editing'); if (editing) startEditing(editing);