ee8797c142
Co-authored-by: Copilot <copilot@github.com>
253 lines
8.2 KiB
Markdown
253 lines
8.2 KiB
Markdown
# Dialog
|
||
|
||
A modal dialog using the native HTML `<dialog>` element. Content is organized into optional title, description, body, and footer sections. Open/close is handled by client-side JS via delegated click events on `data-dialog-open` and `data-dialog-close` attributes.
|
||
|
||
---
|
||
|
||
## HTML structure
|
||
|
||
```
|
||
dialog[id, class=...]
|
||
div.dialog-panel.relative.bg-background.p-6.rounded-lg.shadow-xl.w-full.max-w-md...
|
||
button.absolute.top-4.right-4[data-dialog-close={id}] ← × close button
|
||
h2.text-lg.font-semibold ← title (omitted when empty)
|
||
p.text-sm.text-muted-foreground.mt-1 ← description (omitted when empty)
|
||
div.mt-4 ← body content
|
||
{content}
|
||
div.mt-6.flex.justify-end.gap-2 ← footer (omitted when empty)
|
||
{footer}
|
||
```
|
||
|
||
---
|
||
|
||
## CSS mechanics
|
||
|
||
| Class | Effect |
|
||
|---|---|
|
||
| `dialog::backdrop` (in `input.css`) | Semi-transparent black overlay behind the dialog |
|
||
| `animate-in fade-in-0 zoom-in-95` | CSS entry animation when dialog opens |
|
||
| `max-w-md w-full` | Responsive: full width on small screens, capped at `md` |
|
||
| `overflow-y-auto max-h-[90vh]` | Scrollable body for tall content |
|
||
|
||
---
|
||
|
||
## JavaScript (delegated clicks in `components.js`)
|
||
|
||
Set up once on `document` and works for all dialogs on the page, including those HTMX-swapped in.
|
||
|
||
### Open
|
||
Any element with `data-dialog-open="myDialogId"` calls `document.getElementById('myDialogId').showModal()`.
|
||
|
||
### Close
|
||
Any element with `data-dialog-close="myDialogId"` calls `document.getElementById('myDialogId').close()`.
|
||
|
||
The `×` close button inside the dialog panel already has `data-dialog-close` set to the dialog's id.
|
||
|
||
Clicking the `::backdrop` (outside the panel) also closes the dialog — the click handler checks whether the click target is the `<dialog>` element itself.
|
||
|
||
---
|
||
|
||
## Constructor signature
|
||
|
||
```csharp
|
||
public Dialog(
|
||
string id,
|
||
string content,
|
||
string title = "",
|
||
string description = "",
|
||
string footer = "")
|
||
```
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `id` | Element id — must be unique per page; also used by `data-dialog-open` |
|
||
| `content` | Raw HTML for the dialog body |
|
||
| `title` | Optional heading at the top of the panel |
|
||
| `description` | Optional subheading below the title |
|
||
| `footer` | Optional raw HTML for the bottom button row |
|
||
|
||
---
|
||
|
||
## Usage examples
|
||
|
||
### Simple information dialog
|
||
|
||
```csharp
|
||
// In the page component:
|
||
Dialog = new Dialog(
|
||
id: "about-dialog",
|
||
title: "About BeepBoop",
|
||
description: "A fast AOT-safe HTMX framework.",
|
||
content: "<p>Version 1.0 — built with ❤️ and .NET 10.</p>",
|
||
footer: """<button data-dialog-close="about-dialog" class="...">Close</button>""");
|
||
```
|
||
|
||
Trigger button anywhere on the page:
|
||
|
||
```html
|
||
<button data-dialog-open="about-dialog" class="...">About</button>
|
||
```
|
||
|
||
### Confirmation dialog
|
||
|
||
```csharp
|
||
new Dialog(
|
||
id: "confirm-delete",
|
||
title: "Delete item",
|
||
content: "<p>This action cannot be undone.</p>",
|
||
footer: """
|
||
<button data-dialog-close="confirm-delete" class="...">Cancel</button>
|
||
<button hx-delete="/items/42" hx-confirm="" data-dialog-close="confirm-delete"
|
||
class="bg-destructive text-destructive-foreground ...">
|
||
Delete
|
||
</button>
|
||
""")
|
||
```
|
||
|
||
### HTMX-powered content reload
|
||
|
||
```csharp
|
||
new Dialog(
|
||
id: "user-detail",
|
||
content: """<div hx-get="/users/42/detail" hx-trigger="revealed"></div>""")
|
||
```
|
||
|
||
The `revealed` trigger fires when the dialog becomes visible, loading content on demand.
|
||
|
||
### Embedding inside a page slot
|
||
|
||
```html
|
||
<!-- MyPage.htmx -->
|
||
$$DeleteDialog$$
|
||
<button data-dialog-open="confirm-delete" class="...">Delete</button>
|
||
```
|
||
|
||
```csharp
|
||
public IHtmxComponent DeleteDialog { get; }
|
||
|
||
public MyPage()
|
||
{
|
||
DeleteDialog = new Dialog(
|
||
id: "confirm-delete",
|
||
title: "Confirm deletion",
|
||
content: "<p>Are you sure?</p>",
|
||
footer: """<button data-dialog-close="confirm-delete">Cancel</button>""");
|
||
}
|
||
|
||
protected override void RenderDeleteDialog(HtmxRenderContext ctx)
|
||
=> DeleteDialog.Render(ctx.Next());
|
||
```
|
||
|
||
---
|
||
|
||
## Tips and tricks
|
||
|
||
- The `id` is used both on the `<dialog>` element and in `data-dialog-open`/`data-dialog-close` — keep it unique per page.
|
||
- The `×` close button is always rendered; `data-dialog-close` on footer buttons is optional but improves UX.
|
||
- Use the native `<dialog>` `close` event for any cleanup needed after dismissal: `document.getElementById('id').addEventListener('close', fn)`.
|
||
- Dialog content, title, description, and footer are raw HTML — HTML-encode user-supplied values.
|
||
- For dialogs that load content via HTMX, place the HTMX attributes on a child div inside `content` rather than on the `<dialog>` element itself to avoid interfering with the dialog open/close lifecycle.
|
||
- For dialogs that load content via HTMX, place the HTMX attributes on a child div inside `content` rather than on the `<dialog>` element itself to avoid interfering with the dialog open/close lifecycle.
|
||
|
||
---
|
||
|
||
## Complete page example
|
||
|
||
**`Templates/ItemsPage.htmx`**
|
||
```html
|
||
<div class="max-w-3xl mx-auto py-10">
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h1 class="text-2xl font-bold">Items</h1>
|
||
</div>
|
||
$$ItemsTable$$
|
||
$$DeleteDialog$$
|
||
</div>
|
||
```
|
||
|
||
**`Templates/ItemsPage.htmx.cs`**
|
||
```csharp
|
||
namespace Htmx.ApiDemo.Templates;
|
||
|
||
public sealed class ItemsPage : ItemsPageBase
|
||
{
|
||
private readonly IHtmxComponent _table;
|
||
private readonly IHtmxComponent _deleteDialog;
|
||
|
||
public ItemsPage(IEnumerable<Item> items, string? deleteTargetId = null)
|
||
{
|
||
// Build table with a per-row Delete button that opens the dialog
|
||
_table = new Components.Table(
|
||
headers: new[] { "Name", "Created", "Actions" },
|
||
rows: items.Select(item => new[]
|
||
{
|
||
System.Net.WebUtility.HtmlEncode(item.Name),
|
||
item.CreatedAt.ToShortDateString(),
|
||
$"""<button data-dialog-open="confirm-delete"
|
||
hx-on:click="document.getElementById('delete-id').value='{item.Id}'"
|
||
class="text-sm text-destructive underline">Delete</button>""",
|
||
}));
|
||
|
||
_deleteDialog = new Components.Dialog(
|
||
id: "confirm-delete",
|
||
title: "Delete item",
|
||
content: """
|
||
<p class="text-sm">This action cannot be undone.</p>
|
||
<input type="hidden" id="delete-id" name="itemId">
|
||
""",
|
||
footer: """
|
||
<button data-dialog-close="confirm-delete"
|
||
class="inline-flex h-9 rounded-md border border-input px-4 text-sm mr-2">
|
||
Cancel
|
||
</button>
|
||
<button hx-delete="/items"
|
||
hx-include="#delete-id"
|
||
hx-target="closest tr"
|
||
hx-swap="outerHTML"
|
||
data-dialog-close="confirm-delete"
|
||
class="inline-flex h-9 rounded-md bg-destructive text-destructive-foreground px-4 text-sm">
|
||
Delete
|
||
</button>
|
||
""");
|
||
}
|
||
|
||
protected override void RenderItemsTable(HtmxRenderContext ctx) => _table.Render(ctx.Next());
|
||
protected override void RenderDeleteDialog(HtmxRenderContext ctx) => _deleteDialog.Render(ctx.Next());
|
||
}
|
||
```
|
||
|
||
**GET + DELETE handlers**
|
||
```csharp
|
||
[Handler]
|
||
[MapGet("/items")]
|
||
public static partial class GetItemsHandler
|
||
{
|
||
public record Query();
|
||
|
||
private static Task<IResult> HandleAsync(
|
||
Query _, HttpContext ctx, CancellationToken ct)
|
||
{
|
||
var items = new[]
|
||
{
|
||
new Item("1", "Widget A", DateTime.Today.AddDays(-10)),
|
||
new Item("2", "Widget B", DateTime.Today.AddDays(-5)),
|
||
};
|
||
return ctx.WriteHtmxPage(new ItemsPage(items), title: "Items");
|
||
}
|
||
}
|
||
|
||
[Handler]
|
||
[MapDelete("/items")]
|
||
public static partial class DeleteItemHandler
|
||
{
|
||
public record Command([property: FromForm] string ItemId);
|
||
|
||
private static IResult HandleAsync([AsParameters] Command cmd, CancellationToken ct)
|
||
{
|
||
// Delete item from database…
|
||
return Results.Ok(); // HTMX swaps the row out with outerHTML
|
||
}
|
||
}
|
||
|
||
public record Item(string Id, string Name, DateTime CreatedAt);
|
||
```
|