fix: no proper loading feedback on forms

This commit is contained in:
juancwu 2026-02-14 23:12:43 +00:00
commit d224f5a10a
27 changed files with 192 additions and 57 deletions

View file

@ -164,4 +164,30 @@
"rlig" 1,
"calt" 1;
}
/* HTMX submit button loading states */
.htmx-submit-btn .btn-spinner {
display: none;
}
.htmx-request .htmx-submit-btn .btn-label,
.htmx-request.htmx-submit-btn .btn-label {
visibility: hidden;
}
.htmx-request .htmx-submit-btn .btn-spinner,
.htmx-request.htmx-submit-btn .btn-spinner {
display: flex;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
}
.htmx-request .htmx-submit-btn,
.htmx-request.htmx-submit-btn {
position: relative;
pointer-events: none;
opacity: 0.7;
}
}

8
assets/js/form-submit.js Normal file
View file

@ -0,0 +1,8 @@
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('submit', function(e) {
var btn = e.target.querySelector('.htmx-submit-btn');
if (btn) {
e.target.classList.add('htmx-request');
}
});
});

4
go.mod
View file

@ -4,7 +4,7 @@ go 1.25.1
require (
github.com/Oudwins/tailwind-merge-go v0.2.1
github.com/a-h/templ v0.3.977
github.com/a-h/templ v0.3.960
github.com/alexedwards/argon2id v1.0.0
github.com/emersion/go-imap v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
@ -62,7 +62,7 @@ require (
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/templui/templui v1.5.0 // indirect
github.com/templui/templui v1.0.0 // indirect
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect
github.com/vertica/vertica-sql-go v1.3.3 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect

5
go.sum
View file

@ -23,6 +23,8 @@ github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuG
github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
@ -148,6 +150,7 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -215,6 +218,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/templui/templui v1.0.0 h1:nsCh+tTL8U9rhh0hpwkvDpiDCPP43aoBB85TLgCh/Kg=
github.com/templui/templui v1.0.0/go.mod h1:SnKmOIs7t/ngsdWUws97CVodbz89ne9kQv3ivgdhiHo=
github.com/templui/templui v1.5.0 h1:nLWZVCEH/Mh86ZSzqMMa3Blpq+oXQKZWIM2rJ33yHQI=
github.com/templui/templui v1.5.0/go.mod h1:9CP7NRm+tXEA6K3/KRny+yANApKCjwXvdR8ahyfglgM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=

View file

@ -122,14 +122,14 @@ func (h *authHandler) SendMagicLink(w http.ResponseWriter, r *http.Request) {
}
if r.URL.Query().Get("resend") == "true" {
ui.RenderOOB(w, r, toast.Toast(toast.Props{
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Magic link sent",
Description: "Check your email for a new magic link",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}), "beforeend:#toast-container")
}))
return
}

View file

@ -8,6 +8,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
@ -69,6 +70,13 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
return
}
// Password set successfully — render page with success message
// Password set successfully — render page with success toast
ui.Render(w, r, pages.AppSettings(true, ""))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Password updated",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}

View file

@ -215,6 +215,13 @@ func (h *SpaceHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
w.Header().Set("HX-Redirect", "/app/spaces/"+spaceID+"/lists")
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "List deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) ListPage(w http.ResponseWriter, r *http.Request) {
@ -337,6 +344,13 @@ func (h *SpaceHandler) DeleteItem(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Item deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) TagsPage(w http.ResponseWriter, r *http.Request) {
@ -393,6 +407,13 @@ func (h *SpaceHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Tag deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
@ -783,6 +804,13 @@ func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
balance -= totalAllocated
ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Expense deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
@ -807,7 +835,7 @@ func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
return
}
ui.Render(w, r, toast.Toast(toast.Props{
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Invitation sent",
Description: "An email has been sent to " + email,
Variant: toast.VariantSuccess,
@ -1035,6 +1063,13 @@ func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Member removed",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) CancelInvite(w http.ResponseWriter, r *http.Request) {
@ -1060,6 +1095,13 @@ func (h *SpaceHandler) CancelInvite(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Invitation cancelled",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request) {
@ -1240,6 +1282,13 @@ func (h *SpaceHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
}
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Account deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
@ -1372,6 +1421,13 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Transfer deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
// --- Payment Methods ---
@ -1484,6 +1540,13 @@ func (h *SpaceHandler) DeletePaymentMethod(w http.ResponseWriter, r *http.Reques
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Payment method deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
// --- Recurring Expenses ---
@ -1787,6 +1850,13 @@ func (h *SpaceHandler) DeleteRecurringExpense(w http.ResponseWriter, r *http.Req
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Recurring expense deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) ToggleRecurringExpense(w http.ResponseWriter, r *http.Request) {
@ -2002,6 +2072,13 @@ func (h *SpaceHandler) DeleteBudget(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Budget deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *SpaceHandler) GetBudgetsList(w http.ResponseWriter, r *http.Request) {

View file

@ -45,8 +45,8 @@ type ExpenseItem struct {
}
type TagExpenseSummary struct {
TagID string `db:"tag_id"`
TagName string `db:"tag_name"`
TagID string `db:"tag_id"`
TagName string `db:"tag_name"`
TagColor *string `db:"tag_color"`
TotalAmount int `db:"total_amount"`
TotalAmount int `db:"total_amount"`
}

View file

@ -0,0 +1,22 @@
package button
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
templ Submit(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Type == "" {
{{ p.Type = TypeSubmit }}
}
{{ p.Class = p.Class + " htmx-submit-btn" }}
@Button(p) {
<span class="btn-label contents">
{ children... }
</span>
<span class="btn-spinner">
@icon.LoaderCircle(icon.Props{Class: "animate-spin"})
</span>
}
}

View file

@ -165,7 +165,7 @@ templ AddExpenseForm(props AddExpenseFormProps) {
// Shopping list items selector
@ItemSelectorSection(props.ListsWithItems, false)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>
@ -267,7 +267,7 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
// Payment Method
@paymentmethod.MethodSelector(methods, exp.PaymentMethodID)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>

View file

@ -184,7 +184,7 @@ templ CreateAccountForm(spaceID string, dialogID string) {
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Create
}
</div>
@ -212,7 +212,7 @@ templ EditAccountForm(spaceID string, acct *model.MoneyAccount, dialogID string)
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>
@ -253,7 +253,7 @@ templ TransferForm(spaceID string, accountID string, direction model.TransferDir
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
if direction == model.TransferDirectionDeposit {
Deposit
} else {

View file

@ -154,7 +154,7 @@ templ CreateMethodForm(spaceID string, dialogID string) {
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Create
}
</div>
@ -231,7 +231,7 @@ templ EditMethodForm(spaceID string, method *model.PaymentMethod, dialogID strin
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>

View file

@ -268,7 +268,7 @@ templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.Payme
// Payment Method
@paymentmethod.MethodSelector(methods, nil)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>
@ -401,7 +401,7 @@ templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMet
// Payment Method
@paymentmethod.MethodSelector(methods, re.PaymentMethodID)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>

View file

@ -35,8 +35,7 @@ templ ListCard(spaceID string, list *model.ShoppingList, items []*model.ListItem
"autocomplete": "off",
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
Size: button.SizeSm,
}) {
@icon.Plus(icon.Props{Size: 16})

View file

@ -56,6 +56,8 @@ templ Base(props ...SEOProps) {
@smoothScrollScript()
// HTMX CSRF configuration
@htmxCSRFScript()
// Form submit spinner for non-HTMX forms
@formSubmitScript()
// Google Analytics
if cfg := ctxkeys.Config(ctx); cfg != nil && cfg.GoogleMeasuringID != "" {
@googleAnalyticsScript(cfg.GoogleMeasuringID)
@ -118,6 +120,10 @@ templ htmxCSRFScript() {
<script src="/assets/js/htmx-csrf.js"></script>
}
templ formSubmitScript() {
<script src="/assets/js/form-submit.js"></script>
}
templ googleAnalyticsScript(id string) {
<script async src={ "https://www.googletagmanager.com/gtag/js?id=" + id }></script>
<script type="text/javascript">

View file

@ -79,7 +79,7 @@ templ Dashboard(spaces []*model.Space) {
Cancel
}
}
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Create
}
}

View file

@ -89,9 +89,7 @@ templ AppSettings(hasPassword bool, errorMsg string) {
}
}
}
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
@button.Submit() {
if hasPassword {
Change Password
} else {

View file

@ -261,7 +261,7 @@ templ AddBudgetForm(spaceID string, tags []*model.Tag) {
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>
@ -374,7 +374,7 @@ templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag
}
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
@button.Submit() {
Save
}
</div>

View file

@ -63,9 +63,7 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
"autocomplete": "off",
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
@button.Submit() {
Add Item
}
</form>

View file

@ -27,9 +27,7 @@ templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
Name: "name",
Placeholder: "New list name...",
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
@button.Submit() {
Create
}
</form>

View file

@ -46,9 +46,7 @@ templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProf
"required": true,
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
@button.Submit() {
Save
}
</form>
@ -110,9 +108,7 @@ templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProf
"required": true,
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
@button.Submit() {
@icon.UserPlus(icon.Props{Class: "size-4"})
Invite
}

View file

@ -30,9 +30,7 @@ templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
"autocomplete": "off",
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
@button.Submit() {
Create
}
</form>

View file

@ -57,8 +57,7 @@ templ Auth(errorMsg string) {
}
}
}
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
FullWidth: true,
}) {
Continue with Email

View file

@ -39,10 +39,9 @@ templ MagicLinkSent(email string) {
>
@csrf.Token()
<input type="hidden" name="email" value={ email }/>
@button.Button(button.Props{
@button.Submit(button.Props{
Variant: button.VariantOutline,
FullWidth: true,
Type: button.TypeSubmit,
}) {
Resend magic link
}

View file

@ -76,8 +76,7 @@ templ AuthPassword(errorMsg string) {
</a>
</div>
}
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
FullWidth: true,
}) {
Sign In

View file

@ -66,8 +66,7 @@ templ OnboardingWelcome() {
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()
<span class="text-sm text-muted-foreground">Not you? </span>
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
Variant: button.VariantLink,
Class: "p-0 h-auto text-sm",
}) {
@ -138,8 +137,7 @@ templ OnboardingName(errorMsg string) {
@icon.ArrowLeft()
Back
}
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
Class: "grow",
}) {
Continue
@ -150,8 +148,7 @@ templ OnboardingName(errorMsg string) {
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()
<span class="text-sm text-muted-foreground">Not you? </span>
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
Variant: button.VariantLink,
Class: "p-0 h-auto text-sm",
}) {
@ -222,8 +219,7 @@ templ OnboardingSpace(name string, errorMsg string) {
@icon.ArrowLeft()
Back
}
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
Class: "grow",
}) {
Create Space
@ -233,8 +229,7 @@ templ OnboardingSpace(name string, errorMsg string) {
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()
<span class="text-sm text-muted-foreground">Not you? </span>
@button.Button(button.Props{
Type: button.TypeSubmit,
@button.Submit(button.Props{
Variant: button.VariantLink,
Class: "p-0 h-auto text-sm",
}) {

View file

@ -24,6 +24,10 @@ func RenderFragment(w http.ResponseWriter, r *http.Request, c templ.Component, f
}
}
func RenderToast(w http.ResponseWriter, r *http.Request, c templ.Component) {
RenderOOB(w, r, c, "beforeend:#toast-container")
}
func RenderOOB(w http.ResponseWriter, r *http.Request, c templ.Component, target string) {
// Write OOB wrapper start
_, err := fmt.Fprintf(w, `<div hx-swap-oob="%s">`, target)