// TodoMVC behaviour on top of the server-rendered page. Every action is // still the page's own form post; this script sends it with fetch and // the X-Todo-Fragment header, and the server answers with the new // contents of section.todoapp instead of a 303. Without this script the // forms post normally and nothing here is needed. const app = document.querySelector('.todoapp'); // Posts run one at a time, in the order they were made, so a quick // toggle-then-delete can't land the other way round. let queue = Promise.resolve(); function send(form) { const body = new URLSearchParams(new FormData(form)); const newTodo = form.querySelector('.new-todo'); // TodoMVC clears the field once a todo is added. Clear it now, as the // post goes, so anything typed while it is in flight is kept. if (newTodo) newTodo.value = ''; queue = queue .then(() => fetch(form.action, { method: 'POST', body, headers: { 'X-Todo-Fragment': '1' }, credentials: 'same-origin', })) .then((res) => { if (!res.ok) throw new Error(`${res.status} from ${form.action}`); return res.text(); }) .then((html) => swap(html, newTodo !== null)) .catch(() => location.reload()); } // swap replaces the list with the server's markup, keeping whatever is // in the new-todo field and its focus. After an add the field keeps // focus, as TodoMVC does. function swap(html, addsTodo) { const input = app.querySelector('.new-todo'); const typing = input.value; const focused = addsTodo || document.activeElement === input; const [start, end] = [input.selectionStart, input.selectionEnd]; app.innerHTML = html; const next = app.querySelector('.new-todo'); if (typing) next.value = typing; if (focused) { next.focus(); if (typing) next.setSelectionRange(start, end); } } app.addEventListener('submit', (e) => { e.preventDefault(); send(e.target); }); // The checkboxes are the page's own controls with JavaScript on. app.addEventListener('change', (e) => { if (e.target.matches('.toggle, .toggle-all')) send(e.target.form); }); // Double-click a title to edit it. 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); }); // Enter saves (the edit form's own submit), Escape cancels. app.addEventListener('keydown', (e) => { if (!e.target.matches('.edit') || e.key !== 'Escape') return; const input = e.target; const li = input.closest('li'); input.value = input.defaultValue; li.classList.remove('editing'); input.blur(); }); // Leaving the editor saves it, unless Escape already closed it. app.addEventListener('focusout', (e) => { if (!e.target.matches('.edit')) return; const li = e.target.closest('li'); if (!li.classList.contains('editing') || li.dataset.saving) return; li.dataset.saving = '1'; send(e.target.form); }); // Enter's submit also blurs as the list is replaced; mark it so the // focusout above doesn't post the same edit twice. app.addEventListener('submit', (e) => { const li = e.target.closest('li'); if (li) li.dataset.saving = '1'; }, true);