| 1 | // With JavaScript on, the page behaves like TodoMVC's own apps. Every |
| 2 | // change still goes through the same form POSTs as without JavaScript, |
| 3 | // sent with X-Fragment: todoapp so the server answers with its rendering |
| 4 | // of section.todoapp; the list and footer are swapped in from that. The |
| 5 | // server is the only place the list lives. |
| 6 | |
| 7 | const app = document.querySelector('.todoapp'); |
| 8 | |
| 9 | // Requests run one at a time, so responses can't land out of order. |
| 10 | let queue = Promise.resolve(); |
| 11 | |
| 12 | function post(form) { |
| 13 | const body = new URLSearchParams(new FormData(form)); |
| 14 | queue = queue |
| 15 | .then(async () => { |
| 16 | const res = await fetch(form.action, { |
| 17 | method: 'POST', |
| 18 | body, |
| 19 | headers: { 'X-Fragment': 'todoapp' }, |
| 20 | }); |
| 21 | if (!res.ok) throw new Error(`${res.status} from ${form.action}`); |
| 22 | render(await res.text()); |
| 23 | }) |
| 24 | .catch((err) => { |
| 25 | console.error(err); |
| 26 | location.reload(); |
| 27 | }); |
| 28 | } |
| 29 | |
| 30 | // True while render() swaps the list, so that losing focus to the swap |
| 31 | // doesn't count as finishing an edit. |
| 32 | let swapping = false; |
| 33 | |
| 34 | // Replaces section.main and footer.footer with the server's; either may be |
| 35 | // absent when the list is empty. The header, and what's typed in it, stays, |
| 36 | // and so does an edit in progress: the item being edited stays open in the |
| 37 | // new list, with its unsaved text, focus and cursor. |
| 38 | function render(html) { |
| 39 | const next = new DOMParser().parseFromString(html, 'text/html').querySelector('.todoapp'); |
| 40 | const editing = app.querySelector('.todo-list li.editing'); |
| 41 | const input = editing?.querySelector('.edit'); |
| 42 | const focused = input && document.activeElement === input; |
| 43 | const [start, end] = input ? [input.selectionStart, input.selectionEnd] : []; |
| 44 | |
| 45 | swapping = true; |
| 46 | try { |
| 47 | for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove(); |
| 48 | for (const part of ['.main', '.footer']) { |
| 49 | const el = next.querySelector(`:scope > ${part}`); |
| 50 | if (el) app.append(el); |
| 51 | } |
| 52 | } finally { |
| 53 | swapping = false; |
| 54 | } |
| 55 | |
| 56 | const li = editing && app.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`); |
| 57 | if (!li) return; |
| 58 | li.classList.add('editing'); |
| 59 | const fresh = li.querySelector('.edit'); |
| 60 | fresh.value = input.value; |
| 61 | if (focused) { |
| 62 | fresh.focus(); |
| 63 | fresh.setSelectionRange(start, end); |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | function startEditing(li) { |
| 68 | li.classList.add('editing'); |
| 69 | const input = li.querySelector('.edit'); |
| 70 | input.focus(); |
| 71 | input.setSelectionRange(input.value.length, input.value.length); |
| 72 | } |
| 73 | |
| 74 | function save(li) { |
| 75 | if (!li.classList.contains('editing')) return; |
| 76 | li.classList.remove('editing'); |
| 77 | const input = li.querySelector('.edit'); |
| 78 | li.querySelector('label').textContent = input.value.trim(); |
| 79 | post(input.form); |
| 80 | } |
| 81 | |
| 82 | function cancel(li) { |
| 83 | const input = li.querySelector('.edit'); |
| 84 | input.value = input.defaultValue; |
| 85 | li.classList.remove('editing'); |
| 86 | } |
| 87 | |
| 88 | app.addEventListener('submit', (e) => { |
| 89 | e.preventDefault(); |
| 90 | const form = e.target; |
| 91 | if (form.classList.contains('edit-form')) { |
| 92 | save(form.closest('li')); |
| 93 | } else { |
| 94 | post(form); |
| 95 | const input = form.querySelector('.new-todo'); |
| 96 | if (input) input.value = ''; |
| 97 | } |
| 98 | }); |
| 99 | |
| 100 | app.addEventListener('change', (e) => { |
| 101 | if (e.target.matches('.toggle, .toggle-all')) post(e.target.form); |
| 102 | }); |
| 103 | |
| 104 | app.addEventListener('dblclick', (e) => { |
| 105 | const label = e.target.closest('.todo-list label'); |
| 106 | if (label) startEditing(label.closest('li')); |
| 107 | }); |
| 108 | |
| 109 | app.addEventListener('keydown', (e) => { |
| 110 | if (e.key === 'Escape' && e.target.matches('.edit')) cancel(e.target.closest('li')); |
| 111 | }); |
| 112 | |
| 113 | app.addEventListener('focusout', (e) => { |
| 114 | if (!swapping && e.target.matches('.edit')) save(e.target.closest('li')); |
| 115 | }); |
| 116 | |