| 1 | // Layered over the server-rendered page, which already works as plain |
| 2 | // forms. With this running it behaves like TodoMVC's own apps: actions |
| 3 | // happen without a page load, and a double-click edits. |
| 4 | // |
| 5 | // Every action posts the same form it would without JavaScript, with an |
| 6 | // X-Fragment header. The server answers with fresh section.main and |
| 7 | // footer.footer, which replace the old ones. Requests go one at a time, |
| 8 | // in order. |
| 9 | |
| 10 | document.documentElement.classList.add('js'); |
| 11 | |
| 12 | const app = document.querySelector('.todoapp'); |
| 13 | let queue = Promise.resolve(); |
| 14 | // True while a response replaces the list, so the edit input it removes |
| 15 | // isn't taken for one the user left. |
| 16 | let swapping = false; |
| 17 | |
| 18 | function send(action, body) { |
| 19 | queue = queue |
| 20 | .then(async () => { |
| 21 | const res = await fetch(action, { method: 'POST', headers: { 'X-Fragment': '1' }, body }); |
| 22 | if (!res.ok) throw new Error(`${action}: ${res.status}`); |
| 23 | const next = document.createElement('template'); |
| 24 | next.innerHTML = await res.text(); |
| 25 | const edit = pendingEdit(); |
| 26 | swapping = true; |
| 27 | try { |
| 28 | for (const part of ['.main', '.footer']) { |
| 29 | app.querySelector(part).replaceWith(next.content.querySelector(part)); |
| 30 | } |
| 31 | } finally { |
| 32 | swapping = false; |
| 33 | } |
| 34 | if (edit) resumeEdit(edit); |
| 35 | }) |
| 36 | // The page is the source of truth: if anything went wrong, show it. |
| 37 | .catch(() => location.reload()); |
| 38 | return queue; |
| 39 | } |
| 40 | |
| 41 | // An edit the user is in the middle of, captured before a response |
| 42 | // replaces the list, and put back into the fresh copy of that item. |
| 43 | function pendingEdit() { |
| 44 | const li = app.querySelector('.todo-list li.editing'); |
| 45 | if (!li) return null; |
| 46 | const input = li.querySelector('.edit'); |
| 47 | return { |
| 48 | id: li.dataset.id, |
| 49 | value: input.value, |
| 50 | start: input.selectionStart, |
| 51 | end: input.selectionEnd, |
| 52 | focused: document.activeElement === input, |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | function resumeEdit(edit) { |
| 57 | const li = app.querySelector(`.todo-list li[data-id="${CSS.escape(edit.id)}"]`); |
| 58 | if (!li) return; |
| 59 | const input = li.querySelector('.edit'); |
| 60 | li.classList.add('editing'); |
| 61 | input.value = edit.value; |
| 62 | if (edit.focused) { |
| 63 | input.focus(); |
| 64 | input.setSelectionRange(edit.start, edit.end); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | const fields = (form) => new URLSearchParams(new FormData(form)); |
| 69 | const filter = () => app.querySelector('input[name="filter"]').value; |
| 70 | |
| 71 | app.addEventListener('submit', (e) => { |
| 72 | const form = e.target; |
| 73 | e.preventDefault(); |
| 74 | if (form.querySelector('.edit')) { |
| 75 | finishEditing(form.closest('li'), true); |
| 76 | return; |
| 77 | } |
| 78 | const body = fields(form); |
| 79 | send(form.action, body); |
| 80 | const input = form.querySelector('.new-todo'); |
| 81 | if (input) input.value = ''; |
| 82 | }); |
| 83 | |
| 84 | app.addEventListener('change', (e) => { |
| 85 | const box = e.target; |
| 86 | if (box.matches('.toggle')) { |
| 87 | send(`/todos/${box.closest('li').dataset.id}/toggle`, new URLSearchParams({ filter: filter() })); |
| 88 | } else if (box.matches('.toggle-all')) { |
| 89 | send('/todos/toggle-all', new URLSearchParams({ filter: filter() })); |
| 90 | } |
| 91 | }); |
| 92 | |
| 93 | app.addEventListener('dblclick', (e) => { |
| 94 | const label = e.target.closest('.todo-list label'); |
| 95 | if (!label) return; |
| 96 | const li = label.closest('li'); |
| 97 | const input = li.querySelector('.edit'); |
| 98 | li.classList.add('editing'); |
| 99 | input.focus(); |
| 100 | input.setSelectionRange(input.value.length, input.value.length); |
| 101 | }); |
| 102 | |
| 103 | app.addEventListener('keydown', (e) => { |
| 104 | if (e.key === 'Escape' && e.target.matches('.edit')) finishEditing(e.target.closest('li'), false); |
| 105 | }); |
| 106 | |
| 107 | app.addEventListener('focusout', (e) => { |
| 108 | if (!swapping && e.target.matches('.edit')) finishEditing(e.target.closest('li'), true); |
| 109 | }); |
| 110 | |
| 111 | // Ends an edit once: Enter submits and then blurs, and Escape blurs too. |
| 112 | function finishEditing(li, save) { |
| 113 | if (!li.classList.contains('editing')) return; |
| 114 | const input = li.querySelector('.edit'); |
| 115 | li.classList.remove('editing'); |
| 116 | if (save) { |
| 117 | // Show the new title straight away; the server's answer follows. |
| 118 | const title = input.value.trim(); |
| 119 | if (title) li.querySelector('label a').textContent = title; |
| 120 | else li.hidden = true; |
| 121 | send(input.form.action, fields(input.form)); |
| 122 | } else { |
| 123 | input.value = input.defaultValue; |
| 124 | } |
| 125 | } |
| 126 | |