| 1 | // Progressive enhancement for the server-rendered TodoMVC. Every action is |
| 2 | // the same form POST the page makes without JavaScript; with the |
| 3 | // `X-Fragment: 1` header the server answers with the re-rendered |
| 4 | // `section.main` and `footer.footer` instead of a 303, and we swap them in. |
| 5 | |
| 6 | const app = document.querySelector('.todoapp'); |
| 7 | const filter = app.dataset.filter; |
| 8 | const newTodo = app.querySelector('.new-todo'); |
| 9 | |
| 10 | // Requests run one at a time, so responses apply in the order they were made. |
| 11 | let queue = Promise.resolve(); |
| 12 | |
| 13 | function post(action, fields) { |
| 14 | const body = new URLSearchParams({ ...fields, filter }); |
| 15 | const run = async () => { |
| 16 | const res = await fetch(action, { |
| 17 | method: 'POST', |
| 18 | body, |
| 19 | headers: { 'X-Fragment': '1' }, |
| 20 | credentials: 'same-origin', |
| 21 | }); |
| 22 | // The item is gone (another tab removed it): show the list as it is. |
| 23 | if (!res.ok) return location.reload(); |
| 24 | render(await res.text()); |
| 25 | }; |
| 26 | queue = queue.then(run, run); |
| 27 | return queue; |
| 28 | } |
| 29 | |
| 30 | // True while render() swaps the list, so the edit field it removes isn't |
| 31 | // taken for a blur that saves. |
| 32 | let rendering = false; |
| 33 | |
| 34 | function render(html) { |
| 35 | // An edit in progress survives a response to some other action: carry |
| 36 | // its text, cursor and focus over to the same item in the new list. |
| 37 | const editing = app.querySelector('.todo-list li.editing'); |
| 38 | const input = editing?.querySelector('.edit'); |
| 39 | const kept = editing && { |
| 40 | id: editing.dataset.id, |
| 41 | value: input.value, |
| 42 | focused: document.activeElement === input, |
| 43 | start: input.selectionStart, |
| 44 | end: input.selectionEnd, |
| 45 | }; |
| 46 | rendering = true; |
| 47 | try { |
| 48 | app.querySelector('.main')?.remove(); |
| 49 | app.querySelector('.footer')?.remove(); |
| 50 | app.querySelector('.header').insertAdjacentHTML('afterend', html); |
| 51 | } finally { |
| 52 | rendering = false; |
| 53 | } |
| 54 | const li = kept && app.querySelector(`.todo-list li[data-id="${kept.id}"]`); |
| 55 | if (!li) return; |
| 56 | const edit = li.querySelector('.edit'); |
| 57 | li.classList.add('editing'); |
| 58 | edit.value = kept.value; |
| 59 | if (kept.focused) { |
| 60 | edit.focus(); |
| 61 | edit.setSelectionRange(kept.start, kept.end); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | function itemOf(el) { |
| 66 | return el.closest('.todo-list li'); |
| 67 | } |
| 68 | |
| 69 | // Forms: adding, deleting and clearing completed. |
| 70 | app.addEventListener('submit', (e) => { |
| 71 | const form = e.target; |
| 72 | e.preventDefault(); |
| 73 | const fields = Object.fromEntries(new FormData(form)); |
| 74 | delete fields.filter; |
| 75 | post(form.getAttribute('action'), fields); |
| 76 | if (form.contains(newTodo)) newTodo.value = ''; |
| 77 | }); |
| 78 | |
| 79 | app.addEventListener('change', (e) => { |
| 80 | if (e.target.matches('.toggle')) { |
| 81 | post(`/todos/${itemOf(e.target).dataset.id}/toggle`, {}); |
| 82 | } else if (e.target.matches('.toggle-all')) { |
| 83 | post('/todos/toggle-all', {}); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | // Editing: double-click a label; Enter or blur saves, Escape cancels. |
| 88 | app.addEventListener('dblclick', (e) => { |
| 89 | if (!e.target.matches('.todo-list label')) return; |
| 90 | const li = itemOf(e.target); |
| 91 | const input = li.querySelector('.edit'); |
| 92 | li.classList.add('editing'); |
| 93 | input.value = e.target.textContent; |
| 94 | input.focus(); |
| 95 | input.setSelectionRange(input.value.length, input.value.length); |
| 96 | }); |
| 97 | |
| 98 | function finishEditing(input, save) { |
| 99 | const li = itemOf(input); |
| 100 | if (!li?.classList.contains('editing')) return; |
| 101 | li.classList.remove('editing'); |
| 102 | // A page opened from an edit link (?edit=<id>) is done with it now. |
| 103 | if (location.search) history.replaceState(null, '', location.pathname); |
| 104 | if (save) { |
| 105 | post(`/todos/${li.dataset.id}/edit`, { title: input.value }); |
| 106 | } else { |
| 107 | input.value = li.querySelector('label').textContent; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | app.addEventListener('keydown', (e) => { |
| 112 | if (!e.target.matches('.todo-list .edit')) return; |
| 113 | if (e.key === 'Enter') { |
| 114 | e.preventDefault(); |
| 115 | finishEditing(e.target, true); |
| 116 | } else if (e.key === 'Escape') { |
| 117 | finishEditing(e.target, false); |
| 118 | } |
| 119 | }); |
| 120 | |
| 121 | app.addEventListener('focusout', (e) => { |
| 122 | if (rendering) return; |
| 123 | if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true); |
| 124 | }); |
| 125 | |