// With JavaScript on, the page behaves like TodoMVC's own apps. Every // change still goes through the same form POSTs as without JavaScript, // sent with X-Fragment: todoapp so the server answers with its rendering // of section.todoapp; the list and footer are swapped in from that. The // server is the only place the list lives. const app = document.querySelector('.todoapp'); // Requests run one at a time, so responses can't land out of order. let queue = Promise.resolve(); function post(form) { const body = new URLSearchParams(new FormData(form)); queue = queue .then(async () => { const res = await fetch(form.action, { method: 'POST', body, headers: { 'X-Fragment': 'todoapp' }, }); if (!res.ok) throw new Error(`${res.status} from ${form.action}`); render(await res.text()); }) .catch((err) => { console.error(err); location.reload(); }); } // True while render() swaps the list, so that losing focus to the swap // doesn't count as finishing an edit. let swapping = false; // Replaces section.main and footer.footer with the server's; either may be // absent when the list is empty. The header, and what's typed in it, stays, // and so does an edit in progress: the item being edited stays open in the // new list, with its unsaved text, focus and cursor. function render(html) { const next = new DOMParser().parseFromString(html, 'text/html').querySelector('.todoapp'); const editing = app.querySelector('.todo-list li.editing'); const input = editing?.querySelector('.edit'); const focused = input && document.activeElement === input; const [start, end] = input ? [input.selectionStart, input.selectionEnd] : []; swapping = true; try { for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove(); for (const part of ['.main', '.footer']) { const el = next.querySelector(`:scope > ${part}`); if (el) app.append(el); } } finally { swapping = false; } const li = editing && app.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`); if (!li) return; li.classList.add('editing'); const fresh = li.querySelector('.edit'); fresh.value = input.value; if (focused) { fresh.focus(); fresh.setSelectionRange(start, end); } } function startEditing(li) { li.classList.add('editing'); const input = li.querySelector('.edit'); input.focus(); input.setSelectionRange(input.value.length, input.value.length); } function save(li) { if (!li.classList.contains('editing')) return; li.classList.remove('editing'); const input = li.querySelector('.edit'); li.querySelector('label').textContent = input.value.trim(); post(input.form); } function cancel(li) { const input = li.querySelector('.edit'); input.value = input.defaultValue; li.classList.remove('editing'); } app.addEventListener('submit', (e) => { e.preventDefault(); const form = e.target; if (form.classList.contains('edit-form')) { save(form.closest('li')); } else { post(form); const input = form.querySelector('.new-todo'); if (input) input.value = ''; } }); app.addEventListener('change', (e) => { if (e.target.matches('.toggle, .toggle-all')) post(e.target.form); }); app.addEventListener('dblclick', (e) => { const label = e.target.closest('.todo-list label'); if (label) startEditing(label.closest('li')); }); app.addEventListener('keydown', (e) => { if (e.key === 'Escape' && e.target.matches('.edit')) cancel(e.target.closest('li')); }); app.addEventListener('focusout', (e) => { if (!swapping && e.target.matches('.edit')) save(e.target.closest('li')); });