diff --git a/internal/web/posts_test.go b/internal/web/posts_test.go index 8e798a1..1b63317 100644 --- a/internal/web/posts_test.go +++ b/internal/web/posts_test.go @@ -458,6 +458,8 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { "Shop response", "Edited", `action="/posts"`, + `data-submit-once`, + `data-submit-button`, `action="/posts/` + root.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`, `>The model number is 123A.`, diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 988ae49..5a9393d 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -168,6 +168,16 @@ func TestRegisterLoginAsk(t *testing.T) { if rec.Code != 200 { t.Fatalf("submit form %d", rec.Code) } + for _, want := range []string{ + `src="/static/app.js"`, + `id="submit-progress"`, + `data-submit-once`, + `data-submit-button`, + } { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("submit form missing %q: %s", want, rec.Body.String()) + } + } csrf := csrfFrom(rec.Body.String()) form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland") req = httptest.NewRequest(http.MethodPost, "/submit", form) diff --git a/static/app.css b/static/app.css index 38898e0..c5fa9f7 100644 --- a/static/app.css +++ b/static/app.css @@ -41,6 +41,31 @@ body { background-size: 100% 100%, 48px 48px; } +.submit-progress { + position: fixed; + inset: 0 0 auto; + z-index: 100; + height: 3px; + overflow: hidden; + pointer-events: none; +} + +.submit-progress[hidden] { display: none; } + +.submit-progress-bar { + display: block; + width: 35%; + height: 100%; + background: var(--signal); + box-shadow: 0 0 12px var(--signal); + animation: submit-progress 900ms ease-in-out infinite; +} + +@keyframes submit-progress { + from { transform: translateX(-100%); } + to { transform: translateX(300%); } +} + img, svg { display: block; } a { @@ -253,6 +278,12 @@ a { .btn-primary:hover { filter: brightness(1.08); } +.btn:disabled { + cursor: wait; + opacity: 0.65; + filter: none; +} + .btn-ghost { background: transparent; color: var(--ink); @@ -763,6 +794,10 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn: @media (prefers-reduced-motion: reduce) { .btn-primary:hover { filter: none; } + .submit-progress-bar { + width: 100%; + animation: none; + } } .admin-users { margin-top: 20px; overflow-x: auto; } diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..1ec32a4 --- /dev/null +++ b/static/app.js @@ -0,0 +1,58 @@ +(() => { + const formSelector = "form[data-submit-once]"; + + function progressIndicator() { + return document.getElementById("submit-progress"); + } + + function resetForm(form) { + form.removeAttribute("aria-busy"); + delete form.dataset.submitting; + + const button = form.querySelector("[data-submit-button]"); + if (!button) { + return; + } + button.disabled = false; + button.removeAttribute("aria-disabled"); + if (button.dataset.idleLabel) { + button.textContent = button.dataset.idleLabel; + delete button.dataset.idleLabel; + } + } + + document.addEventListener("submit", (event) => { + const form = event.target.closest(formSelector); + if (!form) { + return; + } + if (form.dataset.submitting === "true") { + event.preventDefault(); + return; + } + + form.dataset.submitting = "true"; + form.setAttribute("aria-busy", "true"); + + const button = event.submitter || form.querySelector("[data-submit-button]"); + if (button) { + button.dataset.idleLabel = button.textContent; + button.textContent = form.dataset.submittingLabel || "Posting…"; + button.disabled = true; + button.setAttribute("aria-disabled", "true"); + } + + const progress = progressIndicator(); + if (progress) { + progress.hidden = false; + } + }); + + window.addEventListener("pageshow", () => { + document.querySelectorAll(formSelector).forEach(resetForm); + const progress = progressIndicator(); + if (progress) { + progress.hidden = true; + } + }); +})(); diff --git a/templates/base.html b/templates/base.html index f7a32d0..591038f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,8 +11,13 @@ +
+