// Progressive enhancement for the server-rendered TodoMVC. Every action is // the same form POST the page makes without JavaScript; with the // `X-Fragment: 1` header the server answers with the re-rendered // `section.main` and `footer.footer` instead of a 303, and we swap them in. const app = document.querySelector('.todoapp'); const filter = app.dataset.filter; const newTodo = app.querySelector('.new-todo'); // Requests run one at a time, so responses apply in the order they were made. let queue = Promise.resolve(); function post(action, fields) { const body = new URLSearchParams({ ...fields, filter }); const run = async () => { const res = await fetch(action, { method: 'POST', body, headers: { 'X-Fragment': '1' }, credentials: 'same-origin', }); // The item is gone (another tab removed it): show the list as it is. if (!res.ok) return location.reload(); render(await res.text()); }; queue = queue.then(run, run); return queue; } // True while render() swaps the list, so the edit field it removes isn't // taken for a blur that saves. let rendering = false; function render(html) { // An edit in progress survives a response to some other action: carry // its text, cursor and focus over to the same item in the new list. const editing = app.querySelector('.todo-list li.editing'); const input = editing?.querySelector('.edit'); const kept = editing && { id: editing.dataset.id, value: input.value, focused: document.activeElement === input, start: input.selectionStart, end: input.selectionEnd, }; rendering = true; try { app.querySelector('.main')?.remove(); app.querySelector('.footer')?.remove(); app.querySelector('.header').insertAdjacentHTML('afterend', html); } finally { rendering = false; } const li = kept && app.querySelector(`.todo-list li[data-id="${kept.id}"]`); if (!li) return; const edit = li.querySelector('.edit'); li.classList.add('editing'); edit.value = kept.value; if (kept.focused) { edit.focus(); edit.setSelectionRange(kept.start, kept.end); } } function itemOf(el) { return el.closest('.todo-list li'); } // Forms: adding, deleting and clearing completed. app.addEventListener('submit', (e) => { const form = e.target; e.preventDefault(); const fields = Object.fromEntries(new FormData(form)); delete fields.filter; post(form.getAttribute('action'), fields); if (form.contains(newTodo)) newTodo.value = ''; }); app.addEventListener('change', (e) => { if (e.target.matches('.toggle')) { post(`/todos/${itemOf(e.target).dataset.id}/toggle`, {}); } else if (e.target.matches('.toggle-all')) { post('/todos/toggle-all', {}); } }); // Editing: double-click a label; Enter or blur saves, Escape cancels. app.addEventListener('dblclick', (e) => { if (!e.target.matches('.todo-list label')) return; const li = itemOf(e.target); const input = li.querySelector('.edit'); li.classList.add('editing'); input.value = e.target.textContent; input.focus(); input.setSelectionRange(input.value.length, input.value.length); }); function finishEditing(input, save) { const li = itemOf(input); if (!li?.classList.contains('editing')) return; li.classList.remove('editing'); // A page opened from an edit link (?edit=) is done with it now. if (location.search) history.replaceState(null, '', location.pathname); if (save) { post(`/todos/${li.dataset.id}/edit`, { title: input.value }); } else { input.value = li.querySelector('label').textContent; } } app.addEventListener('keydown', (e) => { if (!e.target.matches('.todo-list .edit')) return; if (e.key === 'Enter') { e.preventDefault(); finishEditing(e.target, true); } else if (e.key === 'Escape') { finishEditing(e.target, false); } }); app.addEventListener('focusout', (e) => { if (rendering) return; if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true); });