// Layered over the server-rendered page, which already works as plain // forms. With this running it behaves like TodoMVC's own apps: actions // happen without a page load, and a double-click edits. // // Every action posts the same form it would without JavaScript, with an // X-Fragment header. The server answers with fresh section.main and // footer.footer, which replace the old ones. Requests go one at a time, // in order. document.documentElement.classList.add('js'); const app = document.querySelector('.todoapp'); let queue = Promise.resolve(); // True while a response replaces the list, so the edit input it removes // isn't taken for one the user left. let swapping = false; function send(action, body) { queue = queue .then(async () => { const res = await fetch(action, { method: 'POST', headers: { 'X-Fragment': '1' }, body }); if (!res.ok) throw new Error(`${action}: ${res.status}`); const next = document.createElement('template'); next.innerHTML = await res.text(); const edit = pendingEdit(); swapping = true; try { for (const part of ['.main', '.footer']) { app.querySelector(part).replaceWith(next.content.querySelector(part)); } } finally { swapping = false; } if (edit) resumeEdit(edit); }) // The page is the source of truth: if anything went wrong, show it. .catch(() => location.reload()); return queue; } // An edit the user is in the middle of, captured before a response // replaces the list, and put back into the fresh copy of that item. function pendingEdit() { const li = app.querySelector('.todo-list li.editing'); if (!li) return null; const input = li.querySelector('.edit'); return { id: li.dataset.id, value: input.value, start: input.selectionStart, end: input.selectionEnd, focused: document.activeElement === input, }; } function resumeEdit(edit) { const li = app.querySelector(`.todo-list li[data-id="${CSS.escape(edit.id)}"]`); if (!li) return; const input = li.querySelector('.edit'); li.classList.add('editing'); input.value = edit.value; if (edit.focused) { input.focus(); input.setSelectionRange(edit.start, edit.end); } } const fields = (form) => new URLSearchParams(new FormData(form)); const filter = () => app.querySelector('input[name="filter"]').value; app.addEventListener('submit', (e) => { const form = e.target; e.preventDefault(); if (form.querySelector('.edit')) { finishEditing(form.closest('li'), true); return; } const body = fields(form); send(form.action, body); const input = form.querySelector('.new-todo'); if (input) input.value = ''; }); app.addEventListener('change', (e) => { const box = e.target; if (box.matches('.toggle')) { send(`/todos/${box.closest('li').dataset.id}/toggle`, new URLSearchParams({ filter: filter() })); } else if (box.matches('.toggle-all')) { send('/todos/toggle-all', new URLSearchParams({ filter: filter() })); } }); app.addEventListener('dblclick', (e) => { const label = e.target.closest('.todo-list label'); if (!label) return; const li = label.closest('li'); const input = li.querySelector('.edit'); li.classList.add('editing'); input.focus(); input.setSelectionRange(input.value.length, input.value.length); }); app.addEventListener('keydown', (e) => { if (e.key === 'Escape' && e.target.matches('.edit')) finishEditing(e.target.closest('li'), false); }); app.addEventListener('focusout', (e) => { if (!swapping && e.target.matches('.edit')) finishEditing(e.target.closest('li'), true); }); // Ends an edit once: Enter submits and then blurs, and Escape blurs too. function finishEditing(li, save) { if (!li.classList.contains('editing')) return; const input = li.querySelector('.edit'); li.classList.remove('editing'); if (save) { // Show the new title straight away; the server's answer follows. const title = input.value.trim(); if (title) li.querySelector('label a').textContent = title; else li.hidden = true; send(input.form.action, fields(input.form)); } else { input.value = input.defaultValue; } }