Modal form with validation
A dialog that hosts a form. Validation errors render inside the dialog; a successful save closes it and refreshes the list.
#When to use
"New X" / "Edit X" creates/edits that shouldn't take the user off the current page. Pair with for the dialog chrome and HTMX for the form submit.
#Markup
@* Trigger button — wrap in <div x-data> so $dispatch resolves *@
<div x-data class="flex gap-2">
<noundry-button variant="primary" x-on:click="$dispatch('open-modal', 'product_dialog')">
New product
</noundry-button>
</div>
<noundry-modal id="product_dialog" title="New product" size="lg">
<form id="product_form"
hx-post="/products?handler=Save"
hx-target="#products_table"
hx-swap="outerHTML"
hx-disabled-elt="find button[type=submit]">
<partial name="_ProductForm" model="Model.NewProduct" />
<div class="flex justify-end gap-2 pt-2">
<noundry-button variant="secondary" type="button"
x-on:click="$dispatch('close-modal', 'product_dialog')">
Cancel
</noundry-button>
<noundry-button variant="primary" type="submit">Save</noundry-button>
</div>
</form>
</noundry-modal>
#How the close-on-success works
The server emits an HX-Trigger response header on the success path. Htmx parses it and dispatches a corresponding CustomEvent on the page. The listens for close-modal events and closes itself when the event detail matches its id:
public async Task<IActionResult> OnPostSaveAsync(ProductEditForm form, CancellationToken ct)
{
if (!ModelState.IsValid)
{
return this.PageOrPartial("_ProductForm", form, AjaxExtensions.UnprocessableEntity);
}
await _products.SaveAsync(form, ct);
this.AddToast("Saved.", ToastLevel.Success);
// Tell htmx to fire close-modal on the client after the swap.
Response.HtmxTrigger("close-modal");
return this.PageOrPartial("_ProductsTable", await _products.ListAsync(ct));
}
For events that need a payload, use the JSON form of HX-Trigger:
Response.Headers[AjaxExtensions.HtmxTriggerHeader] =
"""{"close-modal":{"id":"product_dialog"}}""";
#What 422 does
On validation failure the server returns 422 with the form partial. The starter's _Layout.cshtml includes a htmx:beforeSwap hook that promotes 422 responses to the form element itself (rather than the success target), so the user sees the failed form re-render in place inside the open dialog. See Inline validation for the swap-promotion script.
#Related
- Inline validation — the 422 swap underneath.
- Toast after action — show "Saved." after the dialog closes.