@@ -0,0 +1,175 @@
|
||||
# Accordion
|
||||
|
||||
An expand/collapse panel list. Items are collapsed by default; one item can be pre-expanded at server render time. Client-side toggle is handled by `components.js`.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.accordion-root[id] ← outer wrapper
|
||||
div.accordion-item ← one per item, border-b separator
|
||||
h3
|
||||
button.accordion-trigger ← clickable header; aria-expanded tracks state
|
||||
{title text}
|
||||
svg.accordion-chevron ← rotates 180° when open
|
||||
div.accordion-panel ← collapsible area; height/opacity driven by JS
|
||||
div.pb-4
|
||||
{content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class / property | Effect |
|
||||
|---|---|
|
||||
| `overflow-hidden` on panel | Prevents content leaking outside the panel during animation |
|
||||
| `transition-all duration-200` on panel | Smooth height and opacity animation |
|
||||
| `height: 0; opacity: 0` (collapsed) | Starting state set server-side for closed items |
|
||||
| `height: auto; opacity: 1` (open) | Starting state for the pre-expanded item |
|
||||
| `accordion-chevron` + JS `rotate(180deg)` | Chevron rotates down when expanded |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initAccordion` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and on `htmx:afterSwap` so HTMX-swapped accordions are correctly initialized.
|
||||
|
||||
**Per-instance initialization:**
|
||||
|
||||
1. Guard `root._accInitialised` prevents double-binding after re-renders
|
||||
2. For each `.accordion-trigger`, attach a `click` listener:
|
||||
- Read current state from `aria-expanded`
|
||||
- If currently open → set `panel.style.height = "0"`, `opacity = "0"`, `aria-expanded = "false"`
|
||||
- If currently closed → set `panel.style.height = scrollHeight + "px"`, `opacity = "1"`, `aria-expanded = "true"`
|
||||
3. Rotate `.accordion-chevron` via `transform: rotate(180deg)` when open
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Accordion(
|
||||
string id,
|
||||
IEnumerable<(string Title, string Content)> items,
|
||||
int openIndex = -1)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Unique element id for the root `div` |
|
||||
| `items` | List of `(Title, Content)` tuples |
|
||||
| `openIndex` | Zero-based index of the pre-expanded item; `-1` = all closed |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### All closed
|
||||
|
||||
```csharp
|
||||
new Accordion(
|
||||
id: "faq",
|
||||
items: new[]
|
||||
{
|
||||
("What is this?", "A fast HTMX app framework."),
|
||||
("Is it AOT-safe?", "Yes, fully."),
|
||||
("Do I need Node?", "Only to run the Tailwind build step."),
|
||||
})
|
||||
```
|
||||
|
||||
### One pre-expanded
|
||||
|
||||
```csharp
|
||||
new Accordion(
|
||||
id: "setup-steps",
|
||||
items: new[]
|
||||
{
|
||||
("Step 1 — Install", "Run <code>npm install</code> in the project folder."),
|
||||
("Step 2 — Configure", "Edit <code>appsettings.json</code> with your connection string."),
|
||||
("Step 3 — Run", "Use <code>dotnet run</code> to start the server."),
|
||||
},
|
||||
openIndex: 0)
|
||||
```
|
||||
|
||||
### HTML content in items
|
||||
|
||||
```csharp
|
||||
new Accordion(
|
||||
id: "code-examples",
|
||||
items: new[]
|
||||
{
|
||||
("C# snippet", "<pre><code>var x = 1 + 1;</code></pre>"),
|
||||
("Tip", "<p>Use <strong>AOT-safe</strong> serialization patterns.</p>"),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- `openIndex` only controls the initial server-rendered state. After the page loads, the user can open/close any item freely.
|
||||
- Item `Title` and `Content` strings are inserted as raw HTML — HTML-encode any user-supplied values before passing them in.
|
||||
- Multiple items can be opened simultaneously by the user — there is no "only one open at a time" constraint in the JS.
|
||||
- If you need to identify which accordion is which after a click, listen to the parent element and inspect `event.target.closest('.accordion-item')`.
|
||||
- The `id` must be unique on the page if you place more than one accordion.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/FaqPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-2xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-2">Frequently Asked Questions</h1>
|
||||
<p class="text-muted-foreground mb-8">Everything you need to know about BeepBoop.</p>
|
||||
$$FaqAccordion$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/FaqPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class FaqPage : FaqPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _faq;
|
||||
|
||||
public FaqPage()
|
||||
{
|
||||
_faq = new Components.Accordion(
|
||||
id: "faq",
|
||||
items: new[]
|
||||
{
|
||||
("What is BeepBoop?",
|
||||
"A fast, AOT-safe HTMX web framework built on .NET 10."),
|
||||
("Do I need Node.js?",
|
||||
"Only to run the Tailwind CSS build step during development."),
|
||||
("Is MongoDB required?",
|
||||
"No — swap in any data store you prefer."),
|
||||
("How do I deploy?",
|
||||
"Run <code>dotnet publish -c Release</code> for a native AOT binary."),
|
||||
});
|
||||
}
|
||||
|
||||
protected override void RenderFaqAccordion(HtmxRenderContext ctx)
|
||||
=> _faq.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**`Templates/FaqPage.htmx.cs` — GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/faq")]
|
||||
public static partial class GetFaqHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
Query _,
|
||||
HttpContext ctx,
|
||||
CancellationToken ct)
|
||||
=> ctx.WriteHtmxPage(new FaqPage(), title: "FAQ");
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,177 @@
|
||||
# Alert
|
||||
|
||||
A contextual callout box for informational or error messages. Two variants: `default` (neutral) and `destructive` (red). An optional inline SVG icon is positioned automatically.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div[role=alert].{variant classes}
|
||||
{icon SVG} ← positioned absolute top-left via Tailwind arbitrary selectors
|
||||
div
|
||||
h5.font-medium ← title (always rendered)
|
||||
div.text-sm ← description (omitted when empty)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class / selector | Effect |
|
||||
|---|---|
|
||||
| `[&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4` | Positions any direct SVG child at top-left |
|
||||
| `[&>svg~*]:pl-7` | Adds left padding to all siblings after the SVG so text is not covered by the icon |
|
||||
| `[&>svg+div]:translate-y-[-3px]` | Vertically aligns the text div with the icon center |
|
||||
| `border-destructive/50 text-destructive` | Red destructive variant |
|
||||
|
||||
The arbitrary selector approach (`[&>svg]:*`) means you can pass any SVG and it will be positioned correctly without extra wrapper divs.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Alert(
|
||||
string title,
|
||||
string description = "",
|
||||
string variant = "default",
|
||||
string icon = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `title` | Required heading text |
|
||||
| `description` | Optional body text below the title |
|
||||
| `variant` | `"default"` or `"destructive"` |
|
||||
| `icon` | Raw SVG string; omit for a text-only alert |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Informational (no icon)
|
||||
|
||||
```csharp
|
||||
new Alert(
|
||||
title: "Heads up",
|
||||
description: "Your session expires in 5 minutes.")
|
||||
```
|
||||
|
||||
### Destructive
|
||||
|
||||
```csharp
|
||||
new Alert(
|
||||
title: "Error",
|
||||
description: "Invalid email or password.",
|
||||
variant: "destructive")
|
||||
```
|
||||
|
||||
### With an icon
|
||||
|
||||
```csharp
|
||||
new Alert(
|
||||
title: "New message",
|
||||
description: "You have 3 unread messages.",
|
||||
variant: "default",
|
||||
icon: """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07
|
||||
A19.5 19.5 0 013.07 9.77a19.79 19.79 0 01-3.07-8.67
|
||||
A2 2 0 012 .18L5 0a2 2 0 012 1.72 ..."/>
|
||||
</svg>
|
||||
""")
|
||||
```
|
||||
|
||||
### Title-only
|
||||
|
||||
```csharp
|
||||
new Alert(title: "Changes saved successfully.", variant: "default")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The icon SVG should be `h-4 w-4` — larger sizes will push text out of alignment.
|
||||
- For the `destructive` variant the icon automatically inherits `text-destructive` color via the variant class.
|
||||
- The `description` slot is a raw HTML string — you can include `<a>` links or `<code>` spans.
|
||||
- Use `Alert` inside a page's optional error slot rather than always rendering it — pass an empty byte array (`[]`) when there is no error so the slot renders nothing.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/SystemStatusPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-2xl mx-auto py-10 space-y-4">
|
||||
<h1 class="text-2xl font-bold mb-6">System Status</h1>
|
||||
$$MaintenanceAlert$$
|
||||
$$DatabaseAlert$$
|
||||
$$ApiAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/SystemStatusPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class SystemStatusPage : SystemStatusPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _maintenance;
|
||||
private readonly IHtmxComponent _database;
|
||||
private readonly IHtmxComponent _api;
|
||||
|
||||
public SystemStatusPage(bool maintenanceScheduled, bool dbDegraded, bool apiOk)
|
||||
{
|
||||
_maintenance = maintenanceScheduled
|
||||
? new Components.Alert(
|
||||
title: "Scheduled maintenance",
|
||||
description: "The service will be unavailable on Saturday 00:00–02:00 UTC.",
|
||||
variant: "default")
|
||||
: HtmxEmpty.Instance;
|
||||
|
||||
_database = dbDegraded
|
||||
? new Components.Alert(
|
||||
title: "Database degraded",
|
||||
description: "Query latency is elevated. Our team is investigating.",
|
||||
variant: "destructive")
|
||||
: HtmxEmpty.Instance;
|
||||
|
||||
_api = apiOk
|
||||
? new Components.Alert(title: "All systems operational.")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderMaintenanceAlert(HtmxRenderContext ctx)
|
||||
=> _maintenance.Render(ctx.Next());
|
||||
protected override void RenderDatabaseAlert(HtmxRenderContext ctx)
|
||||
=> _database.Render(ctx.Next());
|
||||
protected override void RenderApiAlert(HtmxRenderContext ctx)
|
||||
=> _api.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/status")]
|
||||
public static partial class GetSystemStatusHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
Query _,
|
||||
HttpContext ctx,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var page = new SystemStatusPage(
|
||||
maintenanceScheduled: true,
|
||||
dbDegraded: false,
|
||||
apiOk: true);
|
||||
return ctx.WriteHtmxPage(page, title: "System Status");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
# Avatar
|
||||
|
||||
A circular user avatar. Shows an image when a `src` URL is provided; falls back to a text/initials span otherwise.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
span.relative.flex.{size classes}.shrink-0.overflow-hidden.rounded-full
|
||||
img[src, alt, class] ← when src is provided
|
||||
span.flex.items-center... ← fallback when no src
|
||||
{fallback text}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `rounded-full overflow-hidden` | Clips content to a circle |
|
||||
| `aspect-square h-full w-full object-cover` | Image fills the circle without distortion |
|
||||
| `bg-muted text-muted-foreground` | Neutral background for the initials fallback |
|
||||
| Size `h-8 w-8` / `h-10 w-10` / `h-14 w-14` / `h-20 w-20` | sm / default / lg / xl |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Avatar(
|
||||
string fallback,
|
||||
string? src = null,
|
||||
string size = "default")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `fallback` | Text shown when no `src` is given; also used as `alt` text on the image |
|
||||
| `src` | Optional image URL |
|
||||
| `size` | `"sm"` / `"default"` / `"lg"` / `"xl"` |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Initials avatar
|
||||
|
||||
```csharp
|
||||
new Avatar(fallback: "JD")
|
||||
new Avatar(fallback: "JD", size: "lg")
|
||||
```
|
||||
|
||||
### Image avatar with fallback
|
||||
|
||||
```csharp
|
||||
new Avatar(fallback: "Jane Doe", src: "/avatars/jane.jpg", size: "default")
|
||||
```
|
||||
|
||||
### Sizes
|
||||
|
||||
```csharp
|
||||
new Avatar(fallback: "SM", size: "sm") // 32×32
|
||||
new Avatar(fallback: "DF", size: "default") // 40×40
|
||||
new Avatar(fallback: "LG", size: "lg") // 56×56
|
||||
new Avatar(fallback: "XL", size: "xl") // 80×80
|
||||
```
|
||||
|
||||
### Inside a user card
|
||||
|
||||
```csharp
|
||||
var avatar = new Avatar(fallback: user.Initials, src: user.AvatarUrl, size: "lg");
|
||||
|
||||
// In a page's RenderUserCard override:
|
||||
protected override void RenderUserAvatar(HtmxRenderContext ctx)
|
||||
=> avatar.Render(ctx.Next());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Compute initials before constructing the Avatar — the component does not extract them from a full name. See `MainLayout`'s `GetInitials` helper for a reference implementation.
|
||||
- Always provide `fallback` even when you also provide `src` — it serves as the `alt` attribute for accessibility.
|
||||
- The Avatar does not handle image load errors. If you need a graceful image fallback on 404, add an `onerror="this.style.display='none'"` attribute by embedding it in the `src` or use `hxAttrs` in a subclassed version.
|
||||
- For a group of overlapping avatars (avatar stack), wrap several Avatars in a flex container with negative margin: `<div class="flex -space-x-2">`.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ProfilePage.htmx`**
|
||||
```html
|
||||
<div class="max-w-lg mx-auto py-10">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
$$UserAvatar$$
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">$$DisplayName$$</h1>
|
||||
<p class="text-sm text-muted-foreground">$$Email$$</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm">Member since $$JoinDate$$</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ProfilePage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ProfilePage : ProfilePageBase
|
||||
{
|
||||
private readonly IHtmxComponent _avatar;
|
||||
private readonly byte[] _displayName;
|
||||
private readonly byte[] _email;
|
||||
private readonly byte[] _joinDate;
|
||||
|
||||
public ProfilePage(AppUser user)
|
||||
{
|
||||
_avatar = new Components.Avatar(
|
||||
fallback: GetInitials(user.DisplayName),
|
||||
size: "lg");
|
||||
|
||||
_displayName = (user.DisplayName ?? "Unknown").ToUtf8Bytes();
|
||||
_email = user.Email.ToUtf8Bytes();
|
||||
_joinDate = user.CreatedAt.ToString("MMMM yyyy").ToUtf8Bytes();
|
||||
}
|
||||
|
||||
private static string GetInitials(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "?";
|
||||
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 2
|
||||
? $"{parts[0][0]}{parts[^1][0]}"
|
||||
: name[..1].ToUpperInvariant();
|
||||
}
|
||||
|
||||
protected override void RenderUserAvatar(HtmxRenderContext ctx)
|
||||
=> _avatar.Render(ctx.Next());
|
||||
protected override void RenderDisplayName(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_displayName);
|
||||
protected override void RenderEmail(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_email);
|
||||
protected override void RenderJoinDate(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_joinDate);
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/profile")]
|
||||
public static partial class GetProfileHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query _,
|
||||
HttpContext ctx,
|
||||
MongoDbService db,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var email = ctx.User.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var user = await db.FindByNormalizedEmailAsync(email.ToUpperInvariant(), ct);
|
||||
if (user is null) return Results.Redirect("/login");
|
||||
return await ctx.WriteHtmxPage(new ProfilePage(user), title: "Profile");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# Badge
|
||||
|
||||
A small inline label pill. Used to indicate status, category, or count. Four variants cover most use-cases.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
span.{base classes + variant classes}
|
||||
{text}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `inline-flex items-center rounded-full` | Pill shape that sits inline with text |
|
||||
| `px-2.5 py-0.5 text-xs font-semibold` | Compact size and bold label |
|
||||
| `transition-colors` | Smooth color changes on hover |
|
||||
| `focus:ring-2 focus:ring-ring focus:ring-offset-2` | Keyboard focus outline |
|
||||
|
||||
**Variants:**
|
||||
|
||||
| Variant | Classes |
|
||||
|---|---|
|
||||
| `default` | `bg-primary text-primary-foreground hover:bg-primary/80` |
|
||||
| `secondary` | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
|
||||
| `destructive` | `bg-destructive text-destructive-foreground hover:bg-destructive/80` |
|
||||
| `outline` | `text-foreground border border-input hover:bg-accent` |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Badge(string text, string variant = "default")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `text` | Label displayed inside the badge |
|
||||
| `variant` | `"default"` / `"secondary"` / `"destructive"` / `"outline"` |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic badges
|
||||
|
||||
```csharp
|
||||
new Badge("New")
|
||||
new Badge("Beta", variant: "secondary")
|
||||
new Badge("Error", variant: "destructive")
|
||||
new Badge("Pending", variant: "outline")
|
||||
```
|
||||
|
||||
### Status indicator in a table cell
|
||||
|
||||
```csharp
|
||||
// Render to bytes and embed in table cell HTML
|
||||
var writer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Badge("Active", variant: "default").Render(new HtmxRenderContext(writer));
|
||||
var badgeHtml = System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
|
||||
|
||||
new Table(
|
||||
headers: new[] { "Name", "Status" },
|
||||
rows: users.Select(u => new[] { u.DisplayName ?? "", badgeHtml }))
|
||||
```
|
||||
|
||||
### Embedding in a page slot
|
||||
|
||||
```html
|
||||
<!-- MyPage.htmx -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">Status:</span>
|
||||
$$StatusBadge$$
|
||||
</div>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// MyPage.htmx.cs
|
||||
public IHtmxComponent StatusBadge { get; }
|
||||
|
||||
public MyPage(string status)
|
||||
{
|
||||
StatusBadge = status == "active"
|
||||
? new Badge("Active")
|
||||
: new Badge("Inactive", variant: "secondary");
|
||||
}
|
||||
|
||||
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
||||
=> StatusBadge.Render(ctx.Next());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Badge does not have a click handler — wrap it in an `<a>` or a `Button` if you need interactivity.
|
||||
- All four variants respond to focus, so a Badge embedded inside a focusable element will show a ring.
|
||||
- For a count badge (e.g. `"3 new"`) just include the count in the text string.
|
||||
- To render a Badge inside raw HTML strings (e.g. inside a `Table` cell or `Card` content), render it eagerly to a string in the constructor rather than relying on slot rendering.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/OrdersPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-4xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Orders</h1>
|
||||
$$OrdersTable$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/OrdersPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class OrdersPage : OrdersPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _table;
|
||||
|
||||
public OrdersPage(IEnumerable<Order> orders)
|
||||
{
|
||||
_table = new Components.Table(
|
||||
headers: new[] { "Order", "Customer", "Amount", "Status" },
|
||||
rows: orders.Select(o => new[]
|
||||
{
|
||||
System.Net.WebUtility.HtmlEncode(o.Id),
|
||||
System.Net.WebUtility.HtmlEncode(o.CustomerName),
|
||||
$"${o.Total:F2}",
|
||||
BadgeHtml(o.Status),
|
||||
}));
|
||||
}
|
||||
|
||||
private static string BadgeHtml(string status)
|
||||
{
|
||||
var variant = status switch
|
||||
{
|
||||
"paid" => "default",
|
||||
"pending" => "secondary",
|
||||
"cancelled" => "destructive",
|
||||
_ => "outline",
|
||||
};
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Components.Badge(status, variant).Render(new HtmxRenderContext(buf));
|
||||
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||
}
|
||||
|
||||
protected override void RenderOrdersTable(HtmxRenderContext ctx)
|
||||
=> _table.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/orders")]
|
||||
public static partial class GetOrdersHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
Query _,
|
||||
HttpContext ctx,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Replace with real data source
|
||||
var orders = new[]
|
||||
{
|
||||
new Order("ORD-001", "Alice Smith", 42.00m, "paid"),
|
||||
new Order("ORD-002", "Bob Jones", 18.50m, "pending"),
|
||||
new Order("ORD-003", "Carol White", 99.99m, "cancelled"),
|
||||
};
|
||||
return ctx.WriteHtmxPage(new OrdersPage(orders), title: "Orders");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
# Breadcrumb
|
||||
|
||||
A navigation trail showing the user's location in the app hierarchy. Items are separated by chevron icons. The last item is always rendered as plain text (current page); earlier items are links.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
nav[aria-label=Breadcrumb]
|
||||
ol.flex.flex-wrap.items-center.gap-1.5.text-sm.text-muted-foreground
|
||||
li.inline-flex.items-center.gap-1.5 ← one per item
|
||||
a | span ← a = link, span = non-linked or current
|
||||
span[role=presentation, aria-hidden] ← chevron separator (omitted after last item)
|
||||
svg (3.5×3.5, chevron-right)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `text-muted-foreground` | Dimmed color for all non-current items |
|
||||
| `font-normal text-foreground` | Full-opacity color applied to the last (current) item |
|
||||
| `hover:text-foreground transition-colors` | Link hover state |
|
||||
| `flex-wrap` | Items wrap on narrow screens |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `items` | Ordered list of `(Label, Href)` tuples. The last item is always the current page. |
|
||||
|
||||
Rules:
|
||||
- The **last** item is always non-linked and rendered in full `text-foreground` color, regardless of its `Href` value.
|
||||
- Any **non-last** item with an empty `Href` is rendered as a plain `<span>` rather than a link.
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Simple three-level breadcrumb
|
||||
|
||||
```csharp
|
||||
new Breadcrumb(new[]
|
||||
{
|
||||
("Home", "/"),
|
||||
("Settings", "/settings"),
|
||||
("Profile", ""), // current page — href is ignored for the last item
|
||||
})
|
||||
```
|
||||
|
||||
### Dynamic breadcrumb from a data path
|
||||
|
||||
```csharp
|
||||
// Build items from a category tree
|
||||
var crumbs = categoryPath
|
||||
.Select((cat, i) => (cat.Name, i < categoryPath.Count - 1 ? cat.Url : ""))
|
||||
.ToArray();
|
||||
|
||||
new Breadcrumb(crumbs)
|
||||
```
|
||||
|
||||
### Embedded in a page
|
||||
|
||||
```html
|
||||
<!-- MyPage.htmx -->
|
||||
<div class="mb-6">
|
||||
$$Breadcrumb$$
|
||||
</div>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// MyPage.htmx.cs
|
||||
public IHtmxComponent Breadcrumb { get; }
|
||||
|
||||
public MyPage()
|
||||
{
|
||||
Breadcrumb = new Breadcrumb(new[]
|
||||
{
|
||||
("Home", "/"),
|
||||
("Reports", "/reports"),
|
||||
("Monthly", ""),
|
||||
});
|
||||
}
|
||||
|
||||
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
||||
=> Breadcrumb.Render(ctx.Next());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Always make the last item the current page — its href is ignored anyway, and it gets the visual "active" treatment automatically.
|
||||
- If you have a non-navigable segment (e.g. a category separator with no URL), pass an empty `Href` for that item and it will render as a plain span.
|
||||
- For very deep hierarchies, consider truncating the middle items and replacing them with a `…` span — build the items list conditionally before passing to the constructor.
|
||||
- The chevron separator is `aria-hidden` so screen readers announce only the labels in sequence.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ArticlePage.htmx`**
|
||||
```html
|
||||
<div class="max-w-3xl mx-auto py-10">
|
||||
<div class="mb-6">$$Breadcrumb$$</div>
|
||||
<h1 class="text-3xl font-bold mb-4">$$ArticleTitle$$</h1>
|
||||
<div class="prose">$$ArticleBody$$</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ArticlePage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ArticlePage : ArticlePageBase
|
||||
{
|
||||
private readonly IHtmxComponent _breadcrumb;
|
||||
private readonly byte[] _title;
|
||||
private readonly byte[] _body;
|
||||
|
||||
public ArticlePage(string category, string categorySlug, Article article)
|
||||
{
|
||||
_breadcrumb = new Components.Breadcrumb(new[]
|
||||
{
|
||||
("Home", "/"),
|
||||
("Blog", "/blog"),
|
||||
(category, $"/blog/{categorySlug}"),
|
||||
(article.Title, ""), // current page
|
||||
});
|
||||
|
||||
_title = System.Net.WebUtility.HtmlEncode(article.Title).ToUtf8Bytes();
|
||||
_body = article.HtmlContent.ToUtf8Bytes();
|
||||
}
|
||||
|
||||
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
||||
=> _breadcrumb.Render(ctx.Next());
|
||||
protected override void RenderArticleTitle(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_title);
|
||||
protected override void RenderArticleBody(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_body);
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/blog/{category}/{slug}")]
|
||||
public static partial class GetArticleHandler
|
||||
{
|
||||
public record Query(
|
||||
[property: FromRoute] string Category,
|
||||
[property: FromRoute] string Slug);
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query q,
|
||||
HttpContext ctx,
|
||||
ArticleService articles,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var article = await articles.GetBySlugAsync(q.Slug, ct);
|
||||
if (article is null) return Results.NotFound();
|
||||
var page = new ArticlePage(q.Category, q.Category.ToLower(), article);
|
||||
return await ctx.WriteHtmxPage(page, title: article.Title);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,193 @@
|
||||
# Button
|
||||
|
||||
A styled `<button>` element. Supports six visual variants and four sizes. HTMX attributes can be injected directly via the `hxAttrs` parameter.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
button[type=$$Type$$, class=$$Classes$$, $$HxAttrs$$]
|
||||
$$Label$$
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
**Base classes** (always applied):
|
||||
|
||||
```
|
||||
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium
|
||||
ring-offset-background transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
```
|
||||
|
||||
**Variant** appended:
|
||||
|
||||
| Variant | Added classes |
|
||||
|---|---|
|
||||
| `default` | `bg-primary text-primary-foreground hover:bg-primary/90` |
|
||||
| `destructive` | `bg-destructive text-destructive-foreground hover:bg-destructive/90` |
|
||||
| `outline` | `border border-input bg-transparent hover:bg-accent hover:text-accent-foreground` |
|
||||
| `secondary` | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
|
||||
| `ghost` | `hover:bg-accent hover:text-accent-foreground` |
|
||||
| `link` | `text-primary underline-offset-4 hover:underline` |
|
||||
|
||||
**Size** appended:
|
||||
|
||||
| Size | Added classes |
|
||||
|---|---|
|
||||
| `default` | `h-10 px-4 py-2 text-sm` |
|
||||
| `sm` | `h-9 rounded-md px-3 text-xs` |
|
||||
| `lg` | `h-11 rounded-md px-8 text-base` |
|
||||
| `icon` | `h-10 w-10` |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Button(
|
||||
string label,
|
||||
string variant = "default",
|
||||
string size = "default",
|
||||
string type = "button",
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `label` | Button text (raw HTML — can include inline SVG) |
|
||||
| `variant` | Visual style; see table above |
|
||||
| `size` | Physical size; see table above |
|
||||
| `type` | HTML button type: `"button"` / `"submit"` / `"reset"` |
|
||||
| `hxAttrs` | Verbatim string appended as extra HTML attributes (HTMX, data-*, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Standard actions
|
||||
|
||||
```csharp
|
||||
new Button("Save changes", type: "submit")
|
||||
new Button("Cancel", variant: "outline")
|
||||
new Button("Delete", variant: "destructive")
|
||||
new Button("Learn more", variant: "link")
|
||||
```
|
||||
|
||||
### Sizes
|
||||
|
||||
```csharp
|
||||
new Button("Small", size: "sm")
|
||||
new Button("Default", size: "default")
|
||||
new Button("Large", size: "lg")
|
||||
new Button("⚙", size: "icon") // icon-only square button
|
||||
```
|
||||
|
||||
### HTMX trigger
|
||||
|
||||
```csharp
|
||||
new Button(
|
||||
"Load more",
|
||||
hxAttrs: """hx-get="/items?page=2" hx-target="#item-list" hx-swap="beforeend"""")
|
||||
```
|
||||
|
||||
### Submit button inside a form
|
||||
|
||||
```csharp
|
||||
new Button("Sign in", variant: "default", type: "submit", size: "default")
|
||||
```
|
||||
|
||||
### Ghost button with inline SVG icon
|
||||
|
||||
```csharp
|
||||
new Button(
|
||||
label: """
|
||||
<svg class="h-4 w-4" .../>
|
||||
<span>Refresh</span>
|
||||
""",
|
||||
variant: "ghost")
|
||||
```
|
||||
|
||||
### Disabled appearance (via HTML)
|
||||
|
||||
The Button component does not have a `disabled` constructor parameter. Set it via `hxAttrs` if needed:
|
||||
|
||||
```csharp
|
||||
new Button("Processing...", variant: "default", hxAttrs: "disabled aria-disabled='true'")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Use `type: "submit"` only inside `<form>` elements. Outside a form, always use `type: "button"` to prevent accidental page reloads in some browsers.
|
||||
- `hxAttrs` is written verbatim between the class and the closing `>` of the button tag — you can add any attribute here: `hx-*`, `data-*`, `aria-*`, `onclick`, etc.
|
||||
- The `ghost` variant has no visible background at rest — use it for toolbar actions or secondary icon buttons.
|
||||
- The `link` variant looks like an anchor but behaves as a button — useful for inline text actions that trigger JS or HTMX requests rather than navigation.
|
||||
- To use Button as a DropdownMenu trigger, pass a `Button` instance to `DropdownMenu`'s `trigger` parameter.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/SettingsPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-lg mx-auto py-10 space-y-6">
|
||||
<h1 class="text-2xl font-bold">Settings</h1>
|
||||
<div class="flex gap-3">
|
||||
$$SaveBtn$$
|
||||
$$CancelBtn$$
|
||||
$$DangerBtn$$
|
||||
</div>
|
||||
<div class="border-t pt-4">
|
||||
$$LearnMoreLink$$
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/SettingsPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class SettingsPage : SettingsPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _save;
|
||||
private readonly IHtmxComponent _cancel;
|
||||
private readonly IHtmxComponent _danger;
|
||||
private readonly IHtmxComponent _learn;
|
||||
|
||||
public SettingsPage()
|
||||
{
|
||||
_save = new Components.Button("Save changes", type: "submit");
|
||||
_cancel = new Components.Button("Cancel", variant: "outline");
|
||||
_danger = new Components.Button("Delete account", variant: "destructive",
|
||||
hxAttrs: "data-dialog-open=\"confirm-delete\"");
|
||||
_learn = new Components.Button("Learn more about settings", variant: "link");
|
||||
}
|
||||
|
||||
protected override void RenderSaveBtn(HtmxRenderContext ctx) => _save.Render(ctx.Next());
|
||||
protected override void RenderCancelBtn(HtmxRenderContext ctx) => _cancel.Render(ctx.Next());
|
||||
protected override void RenderDangerBtn(HtmxRenderContext ctx) => _danger.Render(ctx.Next());
|
||||
protected override void RenderLearnMoreLink(HtmxRenderContext ctx) => _learn.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/settings")]
|
||||
public static partial class GetSettingsHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
Query _,
|
||||
HttpContext ctx,
|
||||
CancellationToken ct)
|
||||
=> ctx.WriteHtmxPage(new SettingsPage(), title: "Settings");
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,237 @@
|
||||
# Calendar
|
||||
|
||||
A single-date picker rendered server-side with full client-side interaction. The selected date is stored in a hidden input and submitted as part of a form. Supports three drill-down views: days → months → years.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.calendar-root[id=cal-{id}, data-year, data-month, data-sel-day,
|
||||
data-sel-month, data-sel-year, data-view="days"]
|
||||
div.mb-3.flex.items-center.justify-between ← navigation row
|
||||
button.cal-prev ← previous month/year/decade
|
||||
button.cal-month-label ← shows "Month YYYY" / "YYYY" / decade range
|
||||
button.cal-next ← next
|
||||
div.cal-dow-row.grid.grid-cols-7 ← Sun–Sat headings (hidden in month/year views)
|
||||
div.cal-grid.grid.grid-cols-7 ← day/month/year cells, built by JS
|
||||
input.cal-hidden-input[type=hidden, name] ← holds selected date as yyyy-MM-dd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `cal-day` | Base day button style (text-center, rounded, hover highlight) |
|
||||
| `cal-day-selected` | Filled primary circle on the selected day |
|
||||
| `cal-view-btn` | Base style for month/year selection buttons |
|
||||
| `cal-view-btn-selected` | Highlighted active month or year |
|
||||
| Grid is 7-column for days, 3-column for months/years | Switched via `gridTemplateColumns` inline style |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initCalendar` in `components.js`)
|
||||
|
||||
State is stored entirely in `data-*` attributes on the root element. JS reads and writes these attributes — no hidden state in closures.
|
||||
|
||||
### `renderCalendar(root)` — three view modes
|
||||
|
||||
**Days view:**
|
||||
1. Reads `data-year` and `data-month` (0-based, JS-style)
|
||||
2. Calculates leading empty cells for the first weekday offset
|
||||
3. Renders numbered `<button>` elements; adds `cal-day-selected` to the matching date
|
||||
4. Each day button stores `yyyy-MM-dd` in `data-date`
|
||||
5. On click: updates `data-sel-*`, highlights the new selection, writes value to `.cal-hidden-input`, fires `calendarChange` CustomEvent
|
||||
|
||||
**Months view:**
|
||||
- Renders Jan–Dec abbreviated buttons in a 3-column grid
|
||||
- Click drills back to days view for that month
|
||||
|
||||
**Years view:**
|
||||
- Renders 12 consecutive years (decade rounded to nearest 12)
|
||||
- Click drills back to months view for that year
|
||||
|
||||
### Navigation buttons
|
||||
- Prev/Next adjust month ± 1 (wrapping year), year ± 1, or decade ± 12 depending on `data-view`
|
||||
- Month-label click drills down: days → months → years (no further drill from years)
|
||||
|
||||
### Re-initialization
|
||||
`initAll` re-queries `.calendar-root` after `htmx:afterSwap`, so HTMX-swapped calendars work correctly.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Calendar(
|
||||
string id,
|
||||
string name = "date",
|
||||
DateOnly? selected = null)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Logical id; element gets `id="cal-{id}"` |
|
||||
| `name` | Form field name for the hidden input |
|
||||
| `selected` | Pre-selected date; defaults to today |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic date picker
|
||||
|
||||
```csharp
|
||||
new Calendar(id: "dob", name: "dateOfBirth")
|
||||
```
|
||||
|
||||
### Pre-selected date
|
||||
|
||||
```csharp
|
||||
new Calendar(
|
||||
id: "appointment",
|
||||
name: "appointmentDate",
|
||||
selected: new DateOnly(2026, 9, 15))
|
||||
```
|
||||
|
||||
### Inside a form
|
||||
|
||||
```html
|
||||
<!-- Templates/BookingForm.htmx -->
|
||||
<form method="post" action="/book">
|
||||
$$AntiforgeryToken$$
|
||||
<label class="text-sm font-medium">Pick a date</label>
|
||||
$$DatePicker$$
|
||||
<button type="submit">Book</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Templates/BookingForm.htmx.cs
|
||||
public IHtmxComponent DatePicker { get; }
|
||||
|
||||
public BookingForm(string? afToken = null)
|
||||
{
|
||||
DatePicker = new Calendar(id: "booking", name: "bookingDate");
|
||||
_afTokenData = /* antiforgery hidden input */;
|
||||
}
|
||||
|
||||
protected override void RenderDatePicker(HtmxRenderContext ctx)
|
||||
=> DatePicker.Render(ctx.Next());
|
||||
```
|
||||
|
||||
**Reading the submitted value on the server:**
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string BookingDate // "yyyy-MM-dd"
|
||||
);
|
||||
|
||||
// Parse:
|
||||
var date = DateOnly.ParseExact(command.BookingDate, "yyyy-MM-dd");
|
||||
```
|
||||
|
||||
### Listening for selection changes client-side
|
||||
|
||||
```js
|
||||
document.getElementById('cal-appointment').addEventListener('calendarChange', e => {
|
||||
console.log(e.detail.date); // "2026-09-15"
|
||||
// update other UI elements based on selection
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The hidden input is always named with the `name` parameter — use this as the form field name when reading the submitted POST.
|
||||
- Months are 0-based in the JS `data-*` attributes (matching `Date` object convention) but the hidden input always stores `yyyy-MM-dd` with 1-based months.
|
||||
- If you need to clear the selection client-side, set `document.querySelector('#cal-myid .cal-hidden-input').value = ''` and remove `cal-day-selected` from any button.
|
||||
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
|
||||
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/AppointmentPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-sm mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Book an appointment</h1>
|
||||
<form method="post" action="/appointments">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="mb-4">
|
||||
<label class="text-sm font-medium block mb-2">Select a date</label>
|
||||
$$DatePicker$$
|
||||
</div>
|
||||
$$SubmitBtn$$
|
||||
</form>
|
||||
$$Confirmation$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/AppointmentPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class AppointmentPage : AppointmentPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _datePicker;
|
||||
private readonly IHtmxComponent _submitBtn;
|
||||
private readonly IHtmxComponent _confirmation;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public AppointmentPage(IAntiforgery af, HttpContext ctx, string? confirmedDate = null)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_datePicker = new Components.Calendar(id: "appt", name: "appointmentDate");
|
||||
_submitBtn = new Components.Button("Book", type: "submit");
|
||||
_confirmation = confirmedDate is not null
|
||||
? new Components.Alert(title: "Booked!", description: $"Your appointment is on {confirmedDate}.")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderDatePicker(HtmxRenderContext ctx) => _datePicker.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submitBtn.Render(ctx.Next());
|
||||
protected override void RenderConfirmation(HtmxRenderContext ctx) => _confirmation.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET + POST handlers**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/appointments/new")]
|
||||
public static partial class GetAppointmentHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
Query _, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
=> ctx.WriteHtmxPage(new AppointmentPage(af, ctx), title: "Book appointment");
|
||||
}
|
||||
|
||||
[Handler]
|
||||
[MapPost("/appointments")]
|
||||
public static partial class PostAppointmentHandler
|
||||
{
|
||||
public record Command([property: FromForm] string AppointmentDate);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
var date = DateOnly.ParseExact(cmd.AppointmentDate, "yyyy-MM-dd");
|
||||
var page = new AppointmentPage(af, ctx, confirmedDate: date.ToLongDateString());
|
||||
return ctx.WriteHtmxPage(page, title: "Book appointment");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs` — add the new command**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostAppointmentHandler.Command), TypeInfoPropertyName = "AppointmentCommand")]
|
||||
```
|
||||
@@ -0,0 +1,228 @@
|
||||
# CalendarRange
|
||||
|
||||
A date-range picker. The user selects a start date and then an end date. Hover preview shades the range before the second click commits it. Fires a `rangeChange` CustomEvent on every selection change.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.calr-root[id=calr-{id}, data-year, data-month, data-start, data-end, data-view="days"]
|
||||
div.mb-3.flex.items-center.justify-between ← navigation row
|
||||
button.calr-prev
|
||||
button.calr-month-label
|
||||
button.calr-next
|
||||
div.cal-dow-row.grid.grid-cols-7 ← day-of-week headings
|
||||
div.calr-grid.grid.grid-cols-7 ← day cells, rebuilt by JS on each interaction
|
||||
span.calr-label ← "start → end" or "start → pick end date"
|
||||
input.calr-hidden-start[type=hidden, name={name}-start]
|
||||
input.calr-hidden-end[type=hidden, name={name}-end]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `calr-day-start` | Filled primary circle on start date |
|
||||
| `calr-day-end` | Filled primary circle on end date |
|
||||
| `calr-day-mid` | Lighter primary tint for dates between start and end |
|
||||
| `calr-day-plain` | Default un-selected day style |
|
||||
|
||||
Hover preview is applied by `updateHoverClasses` by toggling the same CSS classes without rebuilding the DOM.
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initCalendarRange` in `components.js`)
|
||||
|
||||
### State
|
||||
|
||||
Stored in `data-start` and `data-end` attributes on the root (empty string = not selected).
|
||||
|
||||
### Click logic (`grid.onclick`)
|
||||
|
||||
1. **Nothing or both selected** → set `start = clicked`, clear `end`
|
||||
2. **Only start selected:**
|
||||
- Click after start → set `end`, fire `rangeChange`
|
||||
- Click before start → move `start` to clicked, clear `end`
|
||||
- Click on start → clear both (toggle off)
|
||||
3. Writes values to hidden inputs, fires `rangeChange` CustomEvent: `{ start: "yyyy-MM-dd", end: "yyyy-MM-dd" }`
|
||||
4. Calls `renderRange` to rebuild grid and `updateLabel` to update the text summary
|
||||
|
||||
### Hover preview (`updateHoverClasses`)
|
||||
|
||||
- Runs on `grid.onmouseover` without rebuilding the grid — only toggles CSS classes
|
||||
- Shades the tentative range from `start` to the hovered date before a click commits it
|
||||
- Cleared on `grid.onmouseleave`
|
||||
|
||||
### View navigation
|
||||
|
||||
Same as Calendar: Prev/Next, month-label click drills days → months → years. `renderRange` rebuilds the grid on each navigation.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public CalendarRange(
|
||||
string id,
|
||||
string name = "date",
|
||||
DateOnly? selectedStart = null,
|
||||
DateOnly? selectedEnd = null)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Logical id; element gets `id="calr-{id}"` |
|
||||
| `name` | Base form field name; hidden inputs are `{name}-start` and `{name}-end` |
|
||||
| `selectedStart` | Pre-selected start date |
|
||||
| `selectedEnd` | Pre-selected end date |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Empty picker
|
||||
|
||||
```csharp
|
||||
new CalendarRange(id: "vacation", name: "vacation")
|
||||
```
|
||||
|
||||
### Pre-selected range
|
||||
|
||||
```csharp
|
||||
new CalendarRange(
|
||||
id: "vacation",
|
||||
name: "vacation",
|
||||
selectedStart: new DateOnly(2026, 7, 1),
|
||||
selectedEnd: new DateOnly(2026, 7, 14))
|
||||
```
|
||||
|
||||
### Inside a form
|
||||
|
||||
```html
|
||||
<!-- Templates/VacationForm.htmx -->
|
||||
<form method="post" action="/vacation">
|
||||
$$AntiforgeryToken$$
|
||||
<label class="text-sm font-medium">Select vacation dates</label>
|
||||
$$RangePicker$$
|
||||
<button type="submit">Request</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Reading the submitted values:**
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string VacationStart, // "yyyy-MM-dd"
|
||||
[property: FromForm] string VacationEnd // "yyyy-MM-dd"
|
||||
);
|
||||
|
||||
var start = DateOnly.ParseExact(command.VacationStart, "yyyy-MM-dd");
|
||||
var end = DateOnly.ParseExact(command.VacationEnd, "yyyy-MM-dd");
|
||||
```
|
||||
|
||||
### Listening for range changes client-side
|
||||
|
||||
```js
|
||||
document.getElementById('calr-vacation').addEventListener('rangeChange', e => {
|
||||
console.log(e.detail.start, e.detail.end);
|
||||
// e.g. "2026-07-01", "2026-07-14"
|
||||
});
|
||||
```
|
||||
|
||||
### Showing a summary label elsewhere on the page
|
||||
|
||||
The `.calr-label` span inside the component automatically updates to show `start → end` or `start → pick end date`. You don't need custom JS for this.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Both hidden inputs are always submitted with the form. An empty string means the date was not selected — validate server-side before parsing.
|
||||
- The user can clear the selection by clicking the start date again after both are set.
|
||||
- To enforce a minimum range length (e.g. at least 2 nights), use the `rangeChange` event to validate client-side and show an error message.
|
||||
- The calendar always shows a single month. For a two-month range view, render two `CalendarRange` components side by side and sync their state via the `rangeChange` event.
|
||||
- The calendar always shows a single month. For a two-month range view, render two `CalendarRange` components side by side and sync their state via the `rangeChange` event.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ReportRangePage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Generate report</h1>
|
||||
<form method="post" action="/reports">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="mb-6">
|
||||
<label class="text-sm font-medium block mb-2">Date range</label>
|
||||
$$RangePicker$$
|
||||
</div>
|
||||
$$SubmitBtn$$
|
||||
</form>
|
||||
$$Error$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ReportRangePage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ReportRangePage : ReportRangePageBase
|
||||
{
|
||||
private readonly IHtmxComponent _rangePicker;
|
||||
private readonly IHtmxComponent _submitBtn;
|
||||
private readonly IHtmxComponent _error;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public ReportRangePage(IAntiforgery af, HttpContext ctx, string? errorMessage = null)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
_rangePicker = new Components.CalendarRange(id: "report", name: "reportDate");
|
||||
_submitBtn = new Components.Button("Generate", type: "submit");
|
||||
_error = errorMessage is not null
|
||||
? new Components.Alert(title: "Error", description: errorMessage, variant: "destructive")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderRangePicker(HtmxRenderContext ctx) => _rangePicker.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submitBtn.Render(ctx.Next());
|
||||
protected override void RenderError(HtmxRenderContext ctx) => _error.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/reports")]
|
||||
public static partial class PostReportHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] string ReportDateStart,
|
||||
[property: FromForm] string ReportDateEnd);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cmd.ReportDateStart) || string.IsNullOrEmpty(cmd.ReportDateEnd))
|
||||
{
|
||||
var errorPage = new ReportRangePage(af, ctx, "Please select both a start and end date.");
|
||||
return ctx.WriteHtmxPage(errorPage, title: "Generate report");
|
||||
}
|
||||
|
||||
var start = DateOnly.ParseExact(cmd.ReportDateStart, "yyyy-MM-dd");
|
||||
var end = DateOnly.ParseExact(cmd.ReportDateEnd, "yyyy-MM-dd");
|
||||
|
||||
return Task.FromResult(Results.Redirect($"/reports/result?from={start}&to={end}"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs` — add the new command**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostReportHandler.Command), TypeInfoPropertyName = "ReportCommand")]
|
||||
```
|
||||
@@ -0,0 +1,184 @@
|
||||
# Card
|
||||
|
||||
A styled container with optional header (title + description) and footer sections. The body content is always rendered; header and footer are conditionally included.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.rounded-lg.border.border-border.bg-card.text-card-foreground.shadow-sm.{extraClasses}
|
||||
div.flex.flex-col.space-y-1.5.p-6 ← header (omitted when no title/description)
|
||||
h3.text-2xl.font-semibold ← title
|
||||
p.text-sm.text-muted-foreground ← description
|
||||
div.p-6.pt-0 ← content (always present)
|
||||
{content}
|
||||
div.flex.items-center.p-6.pt-0 ← footer (omitted when empty)
|
||||
{footer}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `bg-card text-card-foreground` | Pulls from CSS variables — dark mode works automatically |
|
||||
| `rounded-lg border border-border shadow-sm` | Subtle rounded box with border and drop shadow |
|
||||
| `p-6 pt-0` on content | Full padding except top (header provides the top spacing) |
|
||||
| `space-y-1.5` on header | Controlled gap between title and description |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Card(
|
||||
string content,
|
||||
string title = "",
|
||||
string description = "",
|
||||
string footer = "",
|
||||
string extraClasses = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `content` | Raw HTML for the card body (always rendered) |
|
||||
| `title` | Optional heading in the header area |
|
||||
| `description` | Optional subheading below the title |
|
||||
| `footer` | Optional raw HTML in the footer area |
|
||||
| `extraClasses` | Additional Tailwind classes on the outer `div` |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Simple content card
|
||||
|
||||
```csharp
|
||||
new Card(content: "<p>Your subscription renews on July 1.</p>")
|
||||
```
|
||||
|
||||
### Card with title and description
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
content: "<p>Manage your billing details and invoices.</p>",
|
||||
title: "Billing",
|
||||
description: "Your current plan: Pro")
|
||||
```
|
||||
|
||||
### Card with footer actions
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
content: "<p>Are you sure you want to cancel your account?</p>",
|
||||
title: "Delete account",
|
||||
description: "This action cannot be undone.",
|
||||
footer: """
|
||||
<button class="inline-flex h-9 rounded-md border border-input px-4 text-sm mr-2">Cancel</button>
|
||||
<button class="inline-flex h-9 rounded-md bg-destructive text-destructive-foreground px-4 text-sm">Delete</button>
|
||||
""")
|
||||
```
|
||||
|
||||
### Constrained width
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
content: "<p>Hello world</p>",
|
||||
title: "Welcome",
|
||||
extraClasses: "max-w-sm mx-auto")
|
||||
```
|
||||
|
||||
### Embedding a component as content
|
||||
|
||||
```csharp
|
||||
// Render a Badge to a string then embed in the card body
|
||||
var writer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Badge("Active").Render(new HtmxRenderContext(writer));
|
||||
var badgeHtml = System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
|
||||
|
||||
new Card(
|
||||
content: $"<p class='mb-2'>Status:</p>{badgeHtml}",
|
||||
title: "Account")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- `content`, `footer`, title, and description are inserted as raw HTML — HTML-encode any user-supplied strings before passing them in.
|
||||
- Use `extraClasses` to set max-width, margin, or custom background without subclassing.
|
||||
- If you need a completely custom header layout, omit `title` and `description` and build the header HTML in `content`, adding `p-6` padding yourself.
|
||||
- Cards can be placed in a CSS grid: `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">`.
|
||||
- Cards can be placed in a CSS grid: `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">`.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/DashboardPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-5xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-8">Dashboard</h1>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
$$UsersCard$$
|
||||
$$RevenueCard$$
|
||||
$$OrdersCard$$
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/DashboardPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class DashboardPage : DashboardPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _users;
|
||||
private readonly IHtmxComponent _revenue;
|
||||
private readonly IHtmxComponent _orders;
|
||||
|
||||
public DashboardPage(DashboardStats stats)
|
||||
{
|
||||
_users = new Components.Card(
|
||||
title: "Total users",
|
||||
description: "Registered accounts",
|
||||
content: $"<p class=\"text-4xl font-bold\">{stats.UserCount:N0}</p>");
|
||||
|
||||
_revenue = new Components.Card(
|
||||
title: "Revenue",
|
||||
description: "This month",
|
||||
content: $"<p class=\"text-4xl font-bold\">${stats.MonthlyRevenue:N2}</p>");
|
||||
|
||||
_orders = new Components.Card(
|
||||
title: "Open orders",
|
||||
description: "Awaiting fulfillment",
|
||||
content: $"<p class=\"text-4xl font-bold\">{stats.OpenOrders}</p>",
|
||||
footer: """<a href="/orders" class="text-sm text-primary underline">View all</a>""");
|
||||
}
|
||||
|
||||
protected override void RenderUsersCard(HtmxRenderContext ctx) => _users.Render(ctx.Next());
|
||||
protected override void RenderRevenueCard(HtmxRenderContext ctx) => _revenue.Render(ctx.Next());
|
||||
protected override void RenderOrdersCard(HtmxRenderContext ctx) => _orders.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/dashboard")]
|
||||
public static partial class GetDashboardHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
Query _, HttpContext ctx, CancellationToken ct)
|
||||
{
|
||||
var stats = new DashboardStats(UserCount: 1_204, MonthlyRevenue: 48_320.50m, OpenOrders: 37);
|
||||
return ctx.WriteHtmxPage(new DashboardPage(stats), title: "Dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
public record DashboardStats(int UserCount, decimal MonthlyRevenue, int OpenOrders);
|
||||
```
|
||||
@@ -0,0 +1,207 @@
|
||||
# Checkbox
|
||||
|
||||
A styled checkbox input with an optional visible label. Uses the `accent-primary` Tailwind class so the checkmark color follows your primary theme color.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.items-center.space-x-2
|
||||
input[type=checkbox, id, name, value, class, $$Checked$$]
|
||||
label[for={id}] ← omitted when label is empty
|
||||
{label text}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `accent-primary` | Checkmark color follows the `--color-primary` CSS variable |
|
||||
| `h-4 w-4 rounded` | Consistent 16×16 size with slightly rounded corners |
|
||||
| `cursor-pointer` | Pointer cursor on label |
|
||||
| `text-sm font-medium leading-none peer-disabled:opacity-70` | Standard label styling |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Checkbox(
|
||||
string id,
|
||||
string label = "",
|
||||
string name = "",
|
||||
string value = "true",
|
||||
bool @checked = false)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id and the `for` attribute on the label |
|
||||
| `label` | Visible text next to the checkbox; omit for a standalone checkbox |
|
||||
| `name` | Form field name (required when used in a form) |
|
||||
| `value` | Submitted value when checked (default: `"true"`) |
|
||||
| `checked` | Pre-checked state |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic opt-in checkbox
|
||||
|
||||
```csharp
|
||||
new Checkbox(
|
||||
id: "newsletter",
|
||||
label: "Subscribe to newsletter",
|
||||
name: "newsletter")
|
||||
```
|
||||
|
||||
### Pre-checked
|
||||
|
||||
```csharp
|
||||
new Checkbox(
|
||||
id: "remember",
|
||||
label: "Remember me",
|
||||
name: "rememberMe",
|
||||
checked: true)
|
||||
```
|
||||
|
||||
### No visible label
|
||||
|
||||
```csharp
|
||||
new Checkbox(id: "select-all", name: "selectAll")
|
||||
```
|
||||
|
||||
### Custom submitted value
|
||||
|
||||
```csharp
|
||||
new Checkbox(
|
||||
id: "agree",
|
||||
label: "I agree to the terms",
|
||||
name: "terms",
|
||||
value: "accepted")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string? Newsletter = null, // null when unchecked
|
||||
[property: FromForm] string? RememberMe = null
|
||||
);
|
||||
|
||||
bool wantsNewsletter = command.Newsletter == "true";
|
||||
bool rememberUser = command.RememberMe == "true";
|
||||
```
|
||||
|
||||
> Note: Unchecked checkboxes are not included in form data. Always use a nullable string or a default value of `null`.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Because HTML forms only submit checked checkboxes, pair a checkbox with a hidden input of the same name and value `"false"` if you need the unchecked state explicitly in your command.
|
||||
- The label `for` attribute ties to the `id`, so clicking the label text toggles the checkbox — always set `id`.
|
||||
- If you need multi-select (select multiple rows in a table), use the same `name` for all checkboxes; they will be submitted as a comma-separated list or multiple values depending on form binding.
|
||||
- `accent-primary` is a modern CSS property — all current browsers support it.
|
||||
- `accent-primary` is a modern CSS property — all current browsers support it.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/PreferencesPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Preferences</h1>
|
||||
<form method="post" action="/preferences">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-4 mb-6">
|
||||
$$NewsletterCheck$$
|
||||
$$MarketingCheck$$
|
||||
$$RememberCheck$$
|
||||
</div>
|
||||
$$SaveBtn$$
|
||||
</form>
|
||||
$$SuccessAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/PreferencesPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class PreferencesPage : PreferencesPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _newsletter;
|
||||
private readonly IHtmxComponent _marketing;
|
||||
private readonly IHtmxComponent _remember;
|
||||
private readonly IHtmxComponent _save;
|
||||
private readonly IHtmxComponent _success;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public PreferencesPage(
|
||||
IAntiforgery af,
|
||||
HttpContext ctx,
|
||||
UserPrefs? prefs = null,
|
||||
bool saved = false)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_newsletter = new Components.Checkbox(
|
||||
id: "newsletter", label: "Receive newsletter",
|
||||
name: "newsletter", @checked: prefs?.Newsletter ?? false);
|
||||
_marketing = new Components.Checkbox(
|
||||
id: "marketing", label: "Receive marketing emails",
|
||||
name: "marketing", @checked: prefs?.Marketing ?? false);
|
||||
_remember = new Components.Checkbox(
|
||||
id: "remember", label: "Keep me signed in",
|
||||
name: "remember", @checked: prefs?.Remember ?? false);
|
||||
_save = new Components.Button("Save preferences", type: "submit");
|
||||
_success = saved
|
||||
? new Components.Alert(title: "Preferences saved.")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderNewsletterCheck(HtmxRenderContext ctx) => _newsletter.Render(ctx.Next());
|
||||
protected override void RenderMarketingCheck(HtmxRenderContext ctx) => _marketing.Render(ctx.Next());
|
||||
protected override void RenderRememberCheck(HtmxRenderContext ctx) => _remember.Render(ctx.Next());
|
||||
protected override void RenderSaveBtn(HtmxRenderContext ctx) => _save.Render(ctx.Next());
|
||||
protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/preferences")]
|
||||
public static partial class PostPreferencesHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] string? Newsletter = null,
|
||||
[property: FromForm] string? Marketing = null,
|
||||
[property: FromForm] string? Remember = null);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
var prefs = new UserPrefs(
|
||||
Newsletter: cmd.Newsletter != null,
|
||||
Marketing: cmd.Marketing != null,
|
||||
Remember: cmd.Remember != null);
|
||||
// Persist prefs…
|
||||
return ctx.WriteHtmxPage(new PreferencesPage(af, ctx, prefs, saved: true), title: "Preferences");
|
||||
}
|
||||
}
|
||||
|
||||
public record UserPrefs(bool Newsletter, bool Marketing, bool Remember);
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostPreferencesHandler.Command), TypeInfoPropertyName = "PreferencesCommand")]
|
||||
```
|
||||
@@ -0,0 +1,252 @@
|
||||
# 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);
|
||||
```
|
||||
@@ -0,0 +1,198 @@
|
||||
# DropdownMenu
|
||||
|
||||
A button that reveals a floating list of links or actions when clicked. Closes when the user clicks outside or presses Escape. Positioned below the trigger by default.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.relative.inline-block ← anchor for absolute positioning
|
||||
{trigger rendered inline} ← any IHtmxComponent (usually a Button)
|
||||
div.dropdown-menu.absolute... ← the floating panel; hidden by default
|
||||
div.w-48.rounded-md.border.bg-popover.shadow-md.p-1
|
||||
a.dropdown-item ← link item
|
||||
hr.dropdown-separator ← separator (when isSeparator=true)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `hidden` (initial) on panel | Hides the dropdown until toggled by JS |
|
||||
| `absolute z-50` | Floats above surrounding content |
|
||||
| `top-full mt-1` | Placed below the trigger with a small gap |
|
||||
| `right-0` / `left-0` | Controlled by the `position` parameter |
|
||||
| `dropdown-item` | `flex items-center px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer` |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (delegated click in `components.js`)
|
||||
|
||||
Set up once on `document` — works for HTMX-swapped dropdowns.
|
||||
|
||||
**Open / close toggle:**
|
||||
1. Click on the trigger element (`[data-dropdown-trigger]`) → toggle `.hidden` on the sibling `.dropdown-menu`
|
||||
2. Click outside the dropdown root → close all open dropdowns
|
||||
3. `Escape` keydown → close all open dropdowns
|
||||
4. Click on a `.dropdown-item` link → close the parent dropdown and follow the link
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public DropdownMenu(
|
||||
IHtmxComponent trigger,
|
||||
IEnumerable<(string Label, string Href, bool IsSeparator)> items,
|
||||
string position = "right")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `trigger` | Any `IHtmxComponent` — shown as the visible toggle element |
|
||||
| `items` | Menu items; `IsSeparator=true` renders an `<hr>` (Label/Href ignored) |
|
||||
| `position` | `"right"` (default) aligns panel right edge; `"left"` aligns left edge |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### User menu
|
||||
|
||||
```csharp
|
||||
new DropdownMenu(
|
||||
trigger: new Button("Account", variant: "outline"),
|
||||
items: new[]
|
||||
{
|
||||
("Profile", "/profile", false),
|
||||
("Settings", "/settings", false),
|
||||
("", "", true), // separator
|
||||
("Sign out", "/logout", false),
|
||||
})
|
||||
```
|
||||
|
||||
### Icon-button dropdown
|
||||
|
||||
```csharp
|
||||
new DropdownMenu(
|
||||
trigger: new Button("⋯", size: "icon", variant: "ghost"),
|
||||
items: new[]
|
||||
{
|
||||
("Edit", "/items/42/edit", false),
|
||||
("Delete", "/items/42/delete", false),
|
||||
})
|
||||
```
|
||||
|
||||
### Left-aligned dropdown (useful when near the right edge of the viewport)
|
||||
|
||||
```csharp
|
||||
new DropdownMenu(
|
||||
trigger: new Button("Actions"),
|
||||
items: actions,
|
||||
position: "left")
|
||||
```
|
||||
|
||||
### HTMX action items
|
||||
|
||||
Items use `<a href>` — if you need HTMX requests, override by building the HTML manually:
|
||||
|
||||
```csharp
|
||||
// Pass a synthetic IHtmxComponent for trigger and use a raw slot override
|
||||
// for items that need hx-delete / hx-post, since items only support href links.
|
||||
// Alternatively, use a Dialog for confirmation dialogs linked from the dropdown.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The `trigger` is any `IHtmxComponent` — pass a `Button`, an `Avatar`, or any custom component.
|
||||
- All items are rendered as `<a href>` links. For actions that should POST/DELETE, either route them through normal GET links to a form redirect, or pair them with a confirmation Dialog.
|
||||
- For a context menu that appears at a table row, pass `new Button("⋯", size: "icon", variant: "ghost")` as the trigger.
|
||||
- Setting `position: "left"` prevents the dropdown from overflowing the right side of the viewport when the trigger is near the right edge.
|
||||
- Multiple dropdowns on the same page are handled independently — clicking one will close others.
|
||||
- Multiple dropdowns on the same page are handled independently — clicking one will close others.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/UserHeaderPage.htmx`**
|
||||
```html
|
||||
<header class="border-b px-6 py-3 flex items-center justify-between">
|
||||
<a href="/" class="font-bold text-lg">MyApp</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/dashboard" class="text-sm text-muted-foreground hover:text-foreground">Dashboard</a>
|
||||
$$UserMenu$$
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-3xl mx-auto py-10">
|
||||
$$Body$$
|
||||
</main>
|
||||
```
|
||||
|
||||
**`Templates/UserHeaderPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class UserHeaderPage : UserHeaderPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _userMenu;
|
||||
private readonly IHtmxComponent _body;
|
||||
|
||||
public UserHeaderPage(AppUser user, IHtmxComponent body)
|
||||
{
|
||||
_body = body;
|
||||
|
||||
var trigger = new Components.Avatar(
|
||||
fallback: GetInitials(user.DisplayName),
|
||||
size: "sm");
|
||||
|
||||
_userMenu = new Components.DropdownMenu(
|
||||
trigger: trigger,
|
||||
items: new[]
|
||||
{
|
||||
("Profile", "/profile", false),
|
||||
("Settings", "/settings", false),
|
||||
("", "", true), // separator
|
||||
("Sign out", "/logout", false),
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetInitials(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "?";
|
||||
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 2 ? $"{parts[0][0]}{parts[^1][0]}" : name[..1].ToUpperInvariant();
|
||||
}
|
||||
|
||||
protected override void RenderUserMenu(HtmxRenderContext ctx) => _userMenu.Render(ctx.Next());
|
||||
protected override void RenderBody(HtmxRenderContext ctx) => _body.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/home")]
|
||||
public static partial class GetHomeHandler
|
||||
{
|
||||
public record Query();
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
|
||||
{
|
||||
var email = ctx.User.FindFirst(ClaimTypes.Email)?.Value ?? "";
|
||||
var user = await db.FindByNormalizedEmailAsync(email.ToUpperInvariant(), ct)
|
||||
?? new AppUser { Email = email };
|
||||
|
||||
// The inner body can be any page component
|
||||
var innerBody = new WelcomePage(user);
|
||||
var page = new UserHeaderPage(user, innerBody);
|
||||
return await ctx.WriteHtmxPage(page, title: "Home");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,212 @@
|
||||
# FileInput
|
||||
|
||||
A styled file upload field with an optional visible label and description. Supports `accept` MIME types, multiple file selection, and HTMX attributes for server-driven interactions.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label text}
|
||||
input[type=file, id, name, accept, class, $$Multiple$$, $$HxAttrs$$]
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description text}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `file:mr-4 file:py-2 file:px-4 file:rounded-md` | Styles the browser's "Choose file" button via `::file-selector-button` |
|
||||
| `file:border-0 file:bg-primary file:text-primary-foreground` | Gives the file button the primary color |
|
||||
| `file:text-sm file:font-semibold file:cursor-pointer` | Consistent text treatment |
|
||||
| `hover:file:bg-primary/90` | Slight darkening on hover |
|
||||
| `w-full rounded-md border border-input bg-background text-sm` | Full-width field with border |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public FileInput(
|
||||
string id,
|
||||
string name = "",
|
||||
string accept = "",
|
||||
bool multiple = false,
|
||||
string label = "",
|
||||
string description = "",
|
||||
string extraClasses = "",
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `accept` | MIME types or file extensions, e.g. `"image/*"` or `".pdf,.docx"` |
|
||||
| `multiple` | Allows selecting more than one file |
|
||||
| `label` | Visible label above the field |
|
||||
| `description` | Helper text below the field |
|
||||
| `extraClasses` | Additional Tailwind classes on the input |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic single file
|
||||
|
||||
```csharp
|
||||
new FileInput(
|
||||
id: "avatar",
|
||||
name: "avatar",
|
||||
accept: "image/*",
|
||||
label: "Profile picture",
|
||||
description: "PNG, JPG or GIF up to 2 MB")
|
||||
```
|
||||
|
||||
### Multiple files
|
||||
|
||||
```csharp
|
||||
new FileInput(
|
||||
id: "attachments",
|
||||
name: "attachments",
|
||||
accept: ".pdf,.docx,.xlsx",
|
||||
multiple: true,
|
||||
label: "Attachments",
|
||||
description: "Select one or more documents")
|
||||
```
|
||||
|
||||
### HTMX auto-upload on change
|
||||
|
||||
```csharp
|
||||
new FileInput(
|
||||
id: "import-csv",
|
||||
name: "csv",
|
||||
accept: ".csv",
|
||||
label: "Import CSV",
|
||||
hxAttrs: """hx-post="/import" hx-encoding="multipart/form-data" hx-target="#result" hx-trigger="change"""")
|
||||
```
|
||||
|
||||
### No label
|
||||
|
||||
```csharp
|
||||
new FileInput(id: "doc", name: "document", accept: ".pdf")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- `accept` filters in the browser's file picker dialog but does not validate server-side — always validate the uploaded file type in your handler.
|
||||
- For HTMX file uploads set `hx-encoding="multipart/form-data"` in `hxAttrs`; HTMX does not infer encoding from the input type.
|
||||
- Multiple files are bound as a list: `IFormFileCollection` or `List<IFormFile>` in the handler. `FromForm` attribute on the command record field is required.
|
||||
- To show a preview of the selected image before upload, add a small JS snippet that listens to the `change` event and sets `src` on an `<img>` element via `URL.createObjectURL(e.target.files[0])`.
|
||||
- `extraClasses` is added to the `<input>` element, not the wrapper `<div>` — use it for overriding width, borders, or custom ring colors.
|
||||
- `extraClasses` is added to the `<input>` element, not the wrapper `<div>` — use it for overriding width, borders, or custom ring colors.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/UploadPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Upload document</h1>
|
||||
<form method="post" action="/upload" enctype="multipart/form-data">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-4 mb-6">
|
||||
$$FileField$$
|
||||
</div>
|
||||
$$SubmitBtn$$
|
||||
</form>
|
||||
$$Result$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/UploadPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class UploadPage : UploadPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _fileField;
|
||||
private readonly IHtmxComponent _submitBtn;
|
||||
private readonly IHtmxComponent _result;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public UploadPage(IAntiforgery af, HttpContext ctx, string? uploadedFileName = null, string? error = null)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_fileField = new Components.FileInput(
|
||||
id: "document",
|
||||
name: "document",
|
||||
accept: ".pdf,.docx",
|
||||
label: "Select a file",
|
||||
description: "PDF or Word document, max 10 MB");
|
||||
_submitBtn = new Components.Button("Upload", type: "submit");
|
||||
_result = error is not null
|
||||
? new Components.Alert(title: "Upload failed", description: error, variant: "destructive")
|
||||
: uploadedFileName is not null
|
||||
? new Components.Alert(title: "Uploaded!", description: $"Saved as: {uploadedFileName}")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderFileField(HtmxRenderContext ctx) => _fileField.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submitBtn.Render(ctx.Next());
|
||||
protected override void RenderResult(HtmxRenderContext ctx) => _result.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET + POST handlers**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/upload")]
|
||||
public static partial class GetUploadHandler
|
||||
{
|
||||
public record Query();
|
||||
private static Task<IResult> HandleAsync(Query _, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
=> ctx.WriteHtmxPage(new UploadPage(af, ctx), title: "Upload");
|
||||
}
|
||||
|
||||
[Handler]
|
||||
[MapPost("/upload")]
|
||||
[DisableRequestSizeLimit]
|
||||
public static partial class PostUploadHandler
|
||||
{
|
||||
public record Command([property: FromForm] IFormFile? Document = null);
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
if (cmd.Document is null)
|
||||
{
|
||||
return await ctx.WriteHtmxPage(
|
||||
new UploadPage(af, ctx, error: "No file was selected."), title: "Upload");
|
||||
}
|
||||
|
||||
var allowedTypes = new[] { ".pdf", ".docx" };
|
||||
var ext = Path.GetExtension(cmd.Document.FileName).ToLowerInvariant();
|
||||
if (!allowedTypes.Contains(ext))
|
||||
{
|
||||
return await ctx.WriteHtmxPage(
|
||||
new UploadPage(af, ctx, error: "Only PDF and Word files are allowed."), title: "Upload");
|
||||
}
|
||||
|
||||
var safeName = Path.GetRandomFileName() + ext;
|
||||
var savePath = Path.Combine("uploads", safeName);
|
||||
await using var stream = File.Create(savePath);
|
||||
await cmd.Document.CopyToAsync(stream, ct);
|
||||
|
||||
return await ctx.WriteHtmxPage(
|
||||
new UploadPage(af, ctx, uploadedFileName: safeName), title: "Upload");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,221 @@
|
||||
# Input
|
||||
|
||||
A styled text input with optional label and description. Supports all standard HTML input types and HTMX attributes.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label text}
|
||||
input[type, id, name, placeholder, class, $$HxAttrs$$]
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description text}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `flex h-10 w-full rounded-md border border-input bg-background` | Full-width 40px height field with border |
|
||||
| `px-3 py-2 text-sm` | Inner padding and text size |
|
||||
| `ring-offset-background` | Focus ring offset matches the page background |
|
||||
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` | Keyboard-visible focus ring |
|
||||
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
|
||||
| `placeholder:text-muted-foreground` | Placeholder inherits muted color |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Input(
|
||||
string id,
|
||||
string name = "",
|
||||
string inputType = "text",
|
||||
string placeholder = "",
|
||||
string label = "",
|
||||
string description = "",
|
||||
string extraClasses = "",
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `inputType` | HTML type attribute: `text` / `email` / `password` / `number` / `search` / `tel` / `url` / `date` / `time` |
|
||||
| `placeholder` | Placeholder text |
|
||||
| `label` | Visible label above the field |
|
||||
| `description` | Helper text below the field |
|
||||
| `extraClasses` | Additional Tailwind classes on the input |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Email and password fields
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
id: "email",
|
||||
name: "email",
|
||||
inputType: "email",
|
||||
placeholder: "you@example.com",
|
||||
label: "Email address")
|
||||
|
||||
new Input(
|
||||
id: "password",
|
||||
name: "password",
|
||||
inputType: "password",
|
||||
placeholder: "••••••••",
|
||||
label: "Password",
|
||||
description: "At least 8 characters")
|
||||
```
|
||||
|
||||
### Search with HTMX live search
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
id: "search",
|
||||
name: "q",
|
||||
inputType: "search",
|
||||
placeholder: "Search...",
|
||||
hxAttrs: """hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:300ms"""")
|
||||
```
|
||||
|
||||
### Number input with constraints (via extraClasses / hxAttrs)
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
id: "quantity",
|
||||
name: "qty",
|
||||
inputType: "number",
|
||||
label: "Quantity",
|
||||
hxAttrs: """min="1" max="100" step="1"""")
|
||||
```
|
||||
|
||||
### URL input
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
id: "website",
|
||||
name: "websiteUrl",
|
||||
inputType: "url",
|
||||
placeholder: "https://example.com",
|
||||
label: "Website",
|
||||
description: "Include https://")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string Email,
|
||||
[property: FromForm] string Password
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- `inputType: "password"` does not add any server-side security — validate and hash passwords in your handler (see [AuthService](../../Htmx.ApiDemo/Data/AuthService.cs)).
|
||||
- `hxAttrs` is verbatim — you can add HTML attributes like `min`, `max`, `step`, `autocomplete`, `required`, `readonly`, or `aria-*` here.
|
||||
- For a pre-filled input (edit form), there is no `value` parameter in the constructor — add `value="..."` via `hxAttrs`: `hxAttrs: $"""value="{Html.Encode(existingValue)}" """`.
|
||||
- For date and time inputs (`inputType: "date"` / `"time"`), the browser renders a native picker — consider `Calendar` or `TimePicker` for a custom-styled experience.
|
||||
- Pair with `Alert` (destructive variant) or a description to show server-side validation errors beneath the field.
|
||||
- Pair with `Alert` (destructive variant) or a description to show server-side validation errors beneath the field.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ContactPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Contact us</h1>
|
||||
$$ErrorAlert$$
|
||||
<form method="post" action="/contact">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-4 mb-6">
|
||||
$$NameField$$
|
||||
$$EmailField$$
|
||||
$$SubjectField$$
|
||||
</div>
|
||||
$$SubmitBtn$$
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ContactPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ContactPage : ContactPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _error;
|
||||
private readonly IHtmxComponent _name;
|
||||
private readonly IHtmxComponent _email;
|
||||
private readonly IHtmxComponent _subject;
|
||||
private readonly IHtmxComponent _submit;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public ContactPage(IAntiforgery af, HttpContext ctx, string? errorMessage = null)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_error = errorMessage is not null
|
||||
? new Components.Alert(title: "Please fix the errors below", description: errorMessage, variant: "destructive")
|
||||
: HtmxEmpty.Instance;
|
||||
_name = new Components.Input(id: "name", name: "name", label: "Full name", placeholder: "Jane Doe");
|
||||
_email = new Components.Input(id: "email", name: "email", label: "Email", placeholder: "you@example.com", inputType: "email");
|
||||
_subject = new Components.Input(id: "subject", name: "subject", label: "Subject", placeholder: "How can we help?");
|
||||
_submit = new Components.Button("Send message", type: "submit");
|
||||
}
|
||||
|
||||
protected override void RenderErrorAlert(HtmxRenderContext ctx) => _error.Render(ctx.Next());
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderNameField(HtmxRenderContext ctx) => _name.Render(ctx.Next());
|
||||
protected override void RenderEmailField(HtmxRenderContext ctx) => _email.Render(ctx.Next());
|
||||
protected override void RenderSubjectField(HtmxRenderContext ctx) => _subject.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submit.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/contact")]
|
||||
public static partial class PostContactHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] string Name,
|
||||
[property: FromForm] string Email,
|
||||
[property: FromForm] string Subject);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cmd.Name) || string.IsNullOrWhiteSpace(cmd.Email))
|
||||
{
|
||||
return ctx.WriteHtmxPage(
|
||||
new ContactPage(af, ctx, "Name and email are required."), title: "Contact");
|
||||
}
|
||||
|
||||
// Send email or persist enquiry…
|
||||
return Task.FromResult(Results.Redirect("/contact/thank-you"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")]
|
||||
```
|
||||
@@ -0,0 +1,172 @@
|
||||
# Pagination
|
||||
|
||||
A page navigation row with Prev/Next links and numbered page buttons. The current page is highlighted. Links are built from a URL pattern.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
nav[aria-label=Pagination].flex.items-center.justify-center.gap-1
|
||||
a.pag-btn[href=prevUrl, aria-label=Previous] ← disabled styling when current=1
|
||||
svg (chevron-left)
|
||||
a.pag-btn[href=url] ← one per page in the visible window
|
||||
{pageNumber} ← current page has pag-btn-active class
|
||||
span.pag-ellipsis ← rendered when pages are skipped
|
||||
a.pag-btn[href=nextUrl, aria-label=Next] ← disabled styling when current=total
|
||||
svg (chevron-right)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `pag-btn` | `inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm hover:bg-accent` |
|
||||
| `pag-btn-active` | `bg-primary text-primary-foreground border-primary hover:bg-primary/90` |
|
||||
| `pag-ellipsis` | `inline-flex h-9 w-9 items-center justify-center text-sm text-muted-foreground` |
|
||||
| `pointer-events-none opacity-50` | Applied to Prev when `current == 1`, to Next when `current == total` |
|
||||
|
||||
The visible page window is limited to 7 buttons maximum. For large page counts the component collapses interior pages into ellipsis spans, keeping first page, last page, and the pages immediately around `current` always visible.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Pagination(
|
||||
int current,
|
||||
int total,
|
||||
string urlPattern)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `current` | 1-based current page number |
|
||||
| `total` | Total number of pages |
|
||||
| `urlPattern` | URL template with `{0}` replaced by the page number, e.g. `"/items?page={0}"` |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic pagination
|
||||
|
||||
```csharp
|
||||
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
|
||||
```
|
||||
|
||||
### Preserving query parameters
|
||||
|
||||
```csharp
|
||||
// Build the URL pattern from the current request
|
||||
var query = HttpUtility.ParseQueryString(Request.QueryString.ToString());
|
||||
query["page"] = "{0}";
|
||||
var pattern = "/search?" + query.ToString();
|
||||
|
||||
new Pagination(current: page, total: totalPages, urlPattern: pattern)
|
||||
```
|
||||
|
||||
### HTMX-powered pagination (swap content without full navigation)
|
||||
|
||||
The links are standard `<a>` tags. To intercept them with HTMX, use `hx-boost` on the container or wrap in a boosted `<div>`:
|
||||
|
||||
```html
|
||||
<div hx-boost="true" hx-target="#item-list" hx-push-url="true">
|
||||
$$Pagination$$
|
||||
</div>
|
||||
```
|
||||
|
||||
### Single page (hides naturally)
|
||||
|
||||
```csharp
|
||||
// When total == 1, Prev and Next are both disabled and only "1" is rendered.
|
||||
new Pagination(current: 1, total: 1, urlPattern: "/items?page={0}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The `urlPattern` uses `string.Format`-style `{0}` — do not use `{page}` or other named placeholders.
|
||||
- Page numbers are 1-based throughout — the first page is page `1`.
|
||||
- When `total` is 0 or negative the component renders nothing — guard `total > 1` in the page if you want to hide it entirely when there is only one page.
|
||||
- To preserve sort order or filters across pages, include those values in the `urlPattern` query string.
|
||||
- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
|
||||
- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/BlogPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-3xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Blog</h1>
|
||||
<div class="space-y-6 mb-10">
|
||||
$$PostList$$
|
||||
</div>
|
||||
$$Pagination$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/BlogPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class BlogPage : BlogPageBase
|
||||
{
|
||||
private readonly byte[] _postList;
|
||||
private readonly IHtmxComponent _pagination;
|
||||
|
||||
public BlogPage(IEnumerable<BlogPost> posts, int currentPage, int totalPages)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var post in posts)
|
||||
{
|
||||
sb.Append($"""
|
||||
<article class="border-b pb-6">
|
||||
<h2 class="text-lg font-semibold mb-1">
|
||||
<a href="/blog/{System.Net.WebUtility.HtmlEncode(post.Slug)}"
|
||||
class="hover:underline">
|
||||
{System.Net.WebUtility.HtmlEncode(post.Title)}
|
||||
</a>
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground mb-2">{post.PublishedAt:MMMM d, yyyy}</p>
|
||||
<p class="text-sm">{System.Net.WebUtility.HtmlEncode(post.Summary)}</p>
|
||||
</article>
|
||||
""");
|
||||
}
|
||||
_postList = sb.ToString().ToUtf8Bytes();
|
||||
_pagination = new Components.Pagination(
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
urlPattern: "/blog?page={0}");
|
||||
}
|
||||
|
||||
protected override void RenderPostList(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_postList);
|
||||
protected override void RenderPagination(HtmxRenderContext ctx) => _pagination.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/blog")]
|
||||
public static partial class GetBlogHandler
|
||||
{
|
||||
public record Query([property: FromQuery] int Page = 1);
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query q, HttpContext ctx, BlogService blog, CancellationToken ct)
|
||||
{
|
||||
const int pageSize = 10;
|
||||
var (posts, total) = await blog.GetPageAsync(q.Page, pageSize, ct);
|
||||
int totalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||
|
||||
return await ctx.WriteHtmxPage(
|
||||
new BlogPage(posts, q.Page, totalPages), title: "Blog");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
# Progress
|
||||
|
||||
A horizontal progress bar. Value is clamped to 0–100. Three sizes control the bar height.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.w-full.bg-secondary.rounded-full.overflow-hidden.{size class}
|
||||
div.bg-primary.rounded-full.h-full.transition-all[style="width: {value}%"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `bg-secondary` | Neutral track color |
|
||||
| `bg-primary` | Filled indicator color |
|
||||
| `rounded-full overflow-hidden` | Pill-shaped track; fills also become pill-shaped |
|
||||
| `transition-all` | Smooth animation when `width` changes |
|
||||
|
||||
**Size classes applied to the outer track:**
|
||||
|
||||
| Size | Class | Height |
|
||||
|---|---|---|
|
||||
| `sm` | `h-1.5` | 6 px |
|
||||
| `default` | `h-2.5` | 10 px |
|
||||
| `lg` | `h-4` | 16 px |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Progress(int value, string size = "default")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `value` | Fill percentage; clamped to 0–100 |
|
||||
| `size` | `"sm"` / `"default"` / `"lg"` |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Inline usage
|
||||
|
||||
```csharp
|
||||
new Progress(value: 72)
|
||||
new Progress(value: 40, size: "sm")
|
||||
new Progress(value: 100, size: "lg")
|
||||
```
|
||||
|
||||
### Inside a Card
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
title: "Disk usage",
|
||||
content: $"""
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<span>Used</span>
|
||||
<span>{used} GB / {total} GB</span>
|
||||
</div>
|
||||
{progressHtml}
|
||||
""")
|
||||
```
|
||||
|
||||
(Pre-render the `Progress` to a string using `HtmxRenderContext` and `ArrayBufferWriter<byte>`.)
|
||||
|
||||
### HTMX live update
|
||||
|
||||
```html
|
||||
<div id="progress-bar"
|
||||
hx-get="/job/42/progress"
|
||||
hx-trigger="every 1s"
|
||||
hx-swap="outerHTML">
|
||||
$$ProgressBar$$
|
||||
</div>
|
||||
```
|
||||
|
||||
The endpoint returns a partial re-render of this fragment with the updated `value`.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Values below 0 are treated as 0; values above 100 are treated as 100 — no manual clamping needed.
|
||||
- Use `size: "sm"` for compact UI areas such as table rows.
|
||||
- To animate progress smoothly, let `transition-all` do the work: re-render the component via HTMX on a polling interval or push updates via SSE.
|
||||
- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
|
||||
- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/JobStatusPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-2">Processing</h1>
|
||||
<p class="text-sm text-muted-foreground mb-6">$$StatusText$$</p>
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>$$ProgressLabel$$</span>
|
||||
</div>
|
||||
$$ProgressBar$$
|
||||
$$DoneAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/JobStatusPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class JobStatusPage : JobStatusPageBase
|
||||
{
|
||||
private readonly byte[] _statusText;
|
||||
private readonly byte[] _progressLabel;
|
||||
private readonly IHtmxComponent _progressBar;
|
||||
private readonly IHtmxComponent _doneAlert;
|
||||
|
||||
public JobStatusPage(int percent, string statusText)
|
||||
{
|
||||
_statusText = System.Net.WebUtility.HtmlEncode(statusText).ToUtf8Bytes();
|
||||
_progressLabel = $"{percent}%".ToUtf8Bytes();
|
||||
_progressBar = new Components.Progress(value: percent);
|
||||
_doneAlert = percent >= 100
|
||||
? new Components.Alert(title: "Complete!", description: "Your export is ready.")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderStatusText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_statusText);
|
||||
protected override void RenderProgressLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_progressLabel);
|
||||
protected override void RenderProgressBar(HtmxRenderContext ctx) => _progressBar.Render(ctx.Next());
|
||||
protected override void RenderDoneAlert(HtmxRenderContext ctx) => _doneAlert.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler with HTMX polling**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/jobs/{jobId}/status")]
|
||||
public static partial class GetJobStatusHandler
|
||||
{
|
||||
public record Query([property: FromRoute] string JobId);
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query q, HttpContext ctx, JobQueue jobs, CancellationToken ct)
|
||||
{
|
||||
var job = await jobs.GetAsync(q.JobId, ct);
|
||||
if (job is null) return Results.NotFound();
|
||||
|
||||
var page = new JobStatusPage(job.PercentComplete, job.StatusText);
|
||||
|
||||
// If polling (HTMX partial), only return the progress fragment
|
||||
if (ctx.Request.Headers.ContainsKey("HX-Request"))
|
||||
{
|
||||
// Stop polling when done
|
||||
if (job.PercentComplete >= 100)
|
||||
ctx.Response.Headers.Append("HX-Trigger", "jobComplete");
|
||||
|
||||
return await ctx.WriteHtmxPage(page, title: "Processing");
|
||||
}
|
||||
|
||||
// Full page load — include polling trigger
|
||||
ctx.Response.Headers.Append("HX-Trigger-After-Settle",
|
||||
"""{"startPolling": {"interval": 1000, "target": "#progress-region"}}""");
|
||||
|
||||
return await ctx.WriteHtmxPage(page, title: "Processing");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,211 @@
|
||||
# RadioGroup
|
||||
|
||||
A group of radio buttons sharing the same `name` attribute. Supports horizontal or vertical layout. One option can be pre-selected.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label.text-sm.font-medium ← group label (omitted when empty)
|
||||
{label}
|
||||
div.flex.{direction}.gap-3 ← flex-col or flex-row
|
||||
label.flex.items-center.gap-2.cursor-pointer ← one per option
|
||||
input[type=radio, name, value, class, $$Checked$$]
|
||||
span.text-sm
|
||||
{option label}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `accent-primary` | Radio circle color follows `--color-primary` CSS variable |
|
||||
| `h-4 w-4` | 16×16 radio circle |
|
||||
| `cursor-pointer` | Pointer cursor on the label |
|
||||
| `flex-col` (default) | Stacks options vertically |
|
||||
| `flex-row` | Places options side by side |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public RadioGroup(
|
||||
string name,
|
||||
IEnumerable<(string Value, string Label, bool Selected)> options,
|
||||
string label = "",
|
||||
string direction = "flex-col")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `name` | Shared `name` attribute for all radio inputs in the group |
|
||||
| `options` | List of `(Value, Label, Selected)` tuples |
|
||||
| `label` | Optional visible group heading above the options |
|
||||
| `direction` | `"flex-col"` (vertical, default) or `"flex-row"` (horizontal) |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Vertical list
|
||||
|
||||
```csharp
|
||||
new RadioGroup(
|
||||
name: "plan",
|
||||
label: "Select a plan",
|
||||
options: new[]
|
||||
{
|
||||
("free", "Free", true),
|
||||
("pro", "Pro", false),
|
||||
("teams", "Teams", false),
|
||||
})
|
||||
```
|
||||
|
||||
### Horizontal inline options
|
||||
|
||||
```csharp
|
||||
new RadioGroup(
|
||||
name: "size",
|
||||
label: "Size",
|
||||
direction: "flex-row",
|
||||
options: new[]
|
||||
{
|
||||
("sm", "S", false),
|
||||
("md", "M", true),
|
||||
("lg", "L", false),
|
||||
("xl", "XL", false),
|
||||
})
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] string Plan);
|
||||
|
||||
// command.Plan == "free" | "pro" | "teams"
|
||||
```
|
||||
|
||||
### Dynamic options from database
|
||||
|
||||
```csharp
|
||||
var options = categories
|
||||
.Select((cat, i) => (cat.Slug, cat.Name, i == 0))
|
||||
.ToArray();
|
||||
|
||||
new RadioGroup(name: "category", label: "Category", options: options)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Only one option in the group can have `Selected = true`; if multiple are marked selected the last one wins (standard HTML behavior).
|
||||
- An unselected `RadioGroup` submits nothing — validate server-side that the field is present.
|
||||
- For a "none of the above" option, add a tuple with the intended empty value: `("", "None", false)`.
|
||||
- To conditionally show additional fields when a radio is selected, add an `htmx` attribute via inline HTML after the component — or use a custom slot that includes both the radio and a reveal div.
|
||||
- To conditionally show additional fields when a radio is selected, add an `htmx` attribute via inline HTML after the component — or use a custom slot that includes both the radio and a reveal div.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/SurveyPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-2">Quick survey</h1>
|
||||
<p class="text-sm text-muted-foreground mb-8">Help us improve BeepBoop.</p>
|
||||
<form method="post" action="/survey">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-8 mb-8">
|
||||
$$ExperienceGroup$$
|
||||
$$FeatureGroup$$
|
||||
</div>
|
||||
$$SubmitBtn$$
|
||||
</form>
|
||||
$$SuccessAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/SurveyPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class SurveyPage : SurveyPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _experience;
|
||||
private readonly IHtmxComponent _feature;
|
||||
private readonly IHtmxComponent _submit;
|
||||
private readonly IHtmxComponent _success;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public SurveyPage(IAntiforgery af, HttpContext ctx, bool submitted = false)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_experience = new Components.RadioGroup(
|
||||
name: "experience",
|
||||
label: "How would you rate your experience?",
|
||||
options: new[]
|
||||
{
|
||||
("1", "Poor", false),
|
||||
("2", "Fair", false),
|
||||
("3", "Good", true),
|
||||
("4", "Very good", false),
|
||||
("5", "Excellent", false),
|
||||
},
|
||||
direction: "flex-row");
|
||||
|
||||
_feature = new Components.RadioGroup(
|
||||
name: "favourite",
|
||||
label: "Which feature do you use most?",
|
||||
options: new[]
|
||||
{
|
||||
("htmx", "HTMX integration", false),
|
||||
("aot", "AOT publishing", false),
|
||||
("generator", "Source generator", false),
|
||||
("tailwind", "Tailwind CSS", false),
|
||||
});
|
||||
|
||||
_submit = new Components.Button("Submit", type: "submit");
|
||||
_success = submitted
|
||||
? new Components.Alert(title: "Thank you!", description: "Your responses have been recorded.")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderExperienceGroup(HtmxRenderContext ctx) => _experience.Render(ctx.Next());
|
||||
protected override void RenderFeatureGroup(HtmxRenderContext ctx) => _feature.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submit.Render(ctx.Next());
|
||||
protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/survey")]
|
||||
public static partial class PostSurveyHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] string Experience,
|
||||
[property: FromForm] string Favourite);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
// Persist responses…
|
||||
return ctx.WriteHtmxPage(new SurveyPage(af, ctx, submitted: true), title: "Survey");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostSurveyHandler.Command), TypeInfoPropertyName = "SurveyCommand")]
|
||||
```
|
||||
@@ -0,0 +1,234 @@
|
||||
# Select
|
||||
|
||||
A styled `<select>` dropdown. Supports a pre-selected value, optional label, and optional description text. HTMX attributes can be added.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label}
|
||||
select[id, name, class, $$HxAttrs$$]
|
||||
option[value, $$Selected$$] ← one per option; selected="selected" when matched
|
||||
{display}
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `flex h-10 w-full rounded-md border border-input bg-background` | Full-width 40px select field |
|
||||
| `px-3 py-2 text-sm` | Inner padding and text size |
|
||||
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
|
||||
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
|
||||
| `bg-background` | Ensures the dropdown matches the page background in dark mode |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Select(
|
||||
string id,
|
||||
IEnumerable<(string Value, string Display)> options,
|
||||
string? selectedValue = null,
|
||||
string name = "",
|
||||
string label = "",
|
||||
string description = "",
|
||||
string extraClasses = "",
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `options` | List of `(Value, Display)` tuples |
|
||||
| `selectedValue` | Pre-selected option value; `null` = no pre-selection (first option shown) |
|
||||
| `name` | Form field name |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text below the field |
|
||||
| `extraClasses` | Additional Tailwind classes on the `<select>` element |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Country selector
|
||||
|
||||
```csharp
|
||||
new Select(
|
||||
id: "country",
|
||||
name: "country",
|
||||
label: "Country",
|
||||
options: new[]
|
||||
{
|
||||
("us", "United States"),
|
||||
("gb", "United Kingdom"),
|
||||
("ca", "Canada"),
|
||||
("au", "Australia"),
|
||||
},
|
||||
selectedValue: "us")
|
||||
```
|
||||
|
||||
### Dynamic options from data
|
||||
|
||||
```csharp
|
||||
var options = categories.Select(c => (c.Slug, c.Name));
|
||||
|
||||
new Select(
|
||||
id: "category",
|
||||
name: "category",
|
||||
label: "Category",
|
||||
options: options,
|
||||
selectedValue: existingCategory)
|
||||
```
|
||||
|
||||
### HTMX on-change reload
|
||||
|
||||
```csharp
|
||||
new Select(
|
||||
id: "region",
|
||||
name: "region",
|
||||
label: "Region",
|
||||
options: regions,
|
||||
hxAttrs: """hx-get="/cities" hx-target="#city-select" hx-trigger="change" hx-include="[name='region']"""")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] string Country);
|
||||
|
||||
// command.Country == "us" | "gb" | "ca" | "au"
|
||||
```
|
||||
|
||||
### Placeholder option (no pre-selection)
|
||||
|
||||
```csharp
|
||||
new Select(
|
||||
id: "role",
|
||||
name: "role",
|
||||
label: "Role",
|
||||
options: new[]
|
||||
{
|
||||
("", "— Select a role —"),
|
||||
("admin", "Administrator"),
|
||||
("user", "Regular user"),
|
||||
},
|
||||
selectedValue: "")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Pass an empty-value placeholder as the first option (`("", "Select…")`) to force the user to make an explicit selection.
|
||||
- `selectedValue` comparison is exact — make sure the value you pass matches one of the `Value` strings in `options`.
|
||||
- `hxAttrs` is verbatim — you can add `multiple`, `size`, `disabled`, `autocomplete`, or any other native attribute here.
|
||||
- To conditionally disable individual options, build the raw `<select>` HTML manually or subclass the component.
|
||||
- To conditionally disable individual options, build the raw `<select>` HTML manually or subclass the component.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/FilterProductsPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-4xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Products</h1>
|
||||
<form class="flex gap-4 mb-8 items-end"
|
||||
hx-get="/products/filter"
|
||||
hx-target="#product-list"
|
||||
hx-trigger="change">
|
||||
$$CategorySelect$$
|
||||
$$SortSelect$$
|
||||
</form>
|
||||
<div id="product-list">
|
||||
$$ProductTable$$
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/FilterProductsPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class FilterProductsPage : FilterProductsPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _category;
|
||||
private readonly IHtmxComponent _sort;
|
||||
private readonly IHtmxComponent _table;
|
||||
|
||||
public FilterProductsPage(
|
||||
IEnumerable<Product> products,
|
||||
string selectedCategory = "",
|
||||
string selectedSort = "name-asc")
|
||||
{
|
||||
_category = new Components.Select(
|
||||
id: "category",
|
||||
name: "category",
|
||||
label: "Category",
|
||||
options: new[]
|
||||
{
|
||||
("", "All categories"),
|
||||
("electronics","Electronics"),
|
||||
("clothing", "Clothing"),
|
||||
("books", "Books"),
|
||||
},
|
||||
selectedValue: selectedCategory);
|
||||
|
||||
_sort = new Components.Select(
|
||||
id: "sort",
|
||||
name: "sort",
|
||||
label: "Sort by",
|
||||
options: new[]
|
||||
{
|
||||
("name-asc", "Name A–Z"),
|
||||
("name-desc", "Name Z–A"),
|
||||
("price-asc", "Price: low to high"),
|
||||
("price-desc", "Price: high to low"),
|
||||
},
|
||||
selectedValue: selectedSort);
|
||||
|
||||
_table = new Components.Table(
|
||||
headers: new[] { "Name", "Category", "Price" },
|
||||
rows: products.Select(p => new[]
|
||||
{
|
||||
System.Net.WebUtility.HtmlEncode(p.Name),
|
||||
System.Net.WebUtility.HtmlEncode(p.Category),
|
||||
$"${p.Price:F2}",
|
||||
}));
|
||||
}
|
||||
|
||||
protected override void RenderCategorySelect(HtmxRenderContext ctx) => _category.Render(ctx.Next());
|
||||
protected override void RenderSortSelect(HtmxRenderContext ctx) => _sort.Render(ctx.Next());
|
||||
protected override void RenderProductTable(HtmxRenderContext ctx) => _table.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler (full page + HTMX partial)**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/products")]
|
||||
public static partial class GetProductsHandler
|
||||
{
|
||||
public record Query(
|
||||
[property: FromQuery] string Category = "",
|
||||
[property: FromQuery] string Sort = "name-asc");
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query q, HttpContext ctx, ProductService products, CancellationToken ct)
|
||||
{
|
||||
var items = await products.FilterAsync(q.Category, q.Sort, ct);
|
||||
return await ctx.WriteHtmxPage(
|
||||
new FilterProductsPage(items, q.Category, q.Sort), title: "Products");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
# Separator
|
||||
|
||||
A thin divider line. Renders as a horizontal `<hr>` or a vertical bar depending on orientation.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
**Horizontal:**
|
||||
```
|
||||
hr.border-t.border-border.my-4.{extraClasses}
|
||||
```
|
||||
|
||||
**Vertical:**
|
||||
```
|
||||
span.inline-block.border-l.border-border.mx-2.h-4.{extraClasses}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `border-t border-border` | Top border in the theme's border color (horizontal) |
|
||||
| `border-l border-border` | Left border in the theme's border color (vertical) |
|
||||
| `my-4` | Default vertical margin for horizontal separators |
|
||||
| `mx-2` | Default horizontal margin for vertical separators |
|
||||
| `h-4` | 16px height for vertical separators |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Separator(
|
||||
string orientation = "horizontal",
|
||||
string extraClasses = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `orientation` | `"horizontal"` (default) or `"vertical"` |
|
||||
| `extraClasses` | Additional Tailwind classes on the element |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Horizontal divider
|
||||
|
||||
```csharp
|
||||
new Separator()
|
||||
```
|
||||
|
||||
### Vertical divider in a flex toolbar
|
||||
|
||||
```html
|
||||
<div class="flex items-center gap-2">
|
||||
<button>Bold</button>
|
||||
$$VertSep$$
|
||||
<button>Italic</button>
|
||||
$$VertSep$$
|
||||
<button>Underline</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```csharp
|
||||
var VertSep = new Separator(orientation: "vertical");
|
||||
```
|
||||
|
||||
### Custom margin
|
||||
|
||||
```csharp
|
||||
new Separator(extraClasses: "my-8") // extra vertical space
|
||||
new Separator(extraClasses: "my-0 mt-2") // override default margin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The horizontal `Separator` is an `<hr>` element — it carries semantic meaning as a thematic break. Use it between content sections.
|
||||
- The vertical `Separator` is an inline `<span>` — use it inside `flex` rows (toolbars, breadcrumb rows, stat rows, etc.).
|
||||
- Override margins using `extraClasses` when the default `my-4` / `mx-2` doesn't fit the surrounding layout.
|
||||
- Override margins using `extraClasses` when the default `my-4` / `mx-2` doesn't fit the surrounding layout.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/AboutPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-2xl mx-auto py-10">
|
||||
<h1 class="text-3xl font-bold mb-2">About BeepBoop</h1>
|
||||
<p class="text-muted-foreground mb-4">A fast AOT-safe HTMX framework for .NET 10.</p>
|
||||
$$SectionSep1$$
|
||||
<h2 class="text-xl font-semibold mb-2">Mission</h2>
|
||||
<p class="text-sm mb-4">$$MissionText$$</p>
|
||||
$$SectionSep2$$
|
||||
<h2 class="text-xl font-semibold mb-2">Team</h2>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span>Alice</span>
|
||||
$$InlineSep$$
|
||||
<span>Bob</span>
|
||||
$$InlineSep$$
|
||||
<span>Carol</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/AboutPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class AboutPage : AboutPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _sep1;
|
||||
private readonly IHtmxComponent _sep2;
|
||||
private readonly IHtmxComponent _inline;
|
||||
private readonly byte[] _mission;
|
||||
|
||||
public AboutPage()
|
||||
{
|
||||
_sep1 = new Components.Separator();
|
||||
_sep2 = new Components.Separator();
|
||||
_inline = new Components.Separator(orientation: "vertical");
|
||||
_mission = "BeepBoop makes building server-rendered HTMX apps as fast and safe as possible.".ToUtf8Bytes();
|
||||
}
|
||||
|
||||
protected override void RenderSectionSep1(HtmxRenderContext ctx) => _sep1.Render(ctx.Next());
|
||||
protected override void RenderSectionSep2(HtmxRenderContext ctx) => _sep2.Render(ctx.Next());
|
||||
protected override void RenderInlineSep(HtmxRenderContext ctx) => _inline.Render(ctx.Next());
|
||||
protected override void RenderMissionText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_mission);
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/about")]
|
||||
public static partial class GetAboutHandler
|
||||
{
|
||||
public record Query();
|
||||
private static Task<IResult> HandleAsync(Query _, HttpContext ctx, CancellationToken ct)
|
||||
=> ctx.WriteHtmxPage(new AboutPage(), title: "About");
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,185 @@
|
||||
# Skeleton
|
||||
|
||||
An animated loading placeholder. Use it in place of real content while data is being fetched or rendered asynchronously. The animation communicates to the user that content is loading.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.animate-pulse.rounded-md.bg-muted.{classes}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `animate-pulse` | Tailwind's built-in fade-in/out animation (1.5s loop) |
|
||||
| `bg-muted` | Neutral muted background color from the theme |
|
||||
| `rounded-md` | Slightly rounded corners |
|
||||
| User-supplied `classes` | Control size and shape (e.g. `h-4 w-32`, `h-10 w-full`, `rounded-full h-12 w-12`) |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Skeleton(string classes = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `classes` | Tailwind classes controlling size, shape, and spacing |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Text line placeholders
|
||||
|
||||
```csharp
|
||||
new Skeleton("h-4 w-3/4 mb-2")
|
||||
new Skeleton("h-4 w-1/2")
|
||||
```
|
||||
|
||||
### Avatar placeholder
|
||||
|
||||
```csharp
|
||||
new Skeleton("rounded-full h-12 w-12")
|
||||
```
|
||||
|
||||
### Card skeleton loader
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
content: """
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Render each Skeleton eagerly to a string or use slot injection -->
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
</div>
|
||||
""")
|
||||
```
|
||||
|
||||
### Full-width block placeholder
|
||||
|
||||
```csharp
|
||||
new Skeleton("h-10 w-full")
|
||||
```
|
||||
|
||||
### HTMX skeleton swap pattern
|
||||
|
||||
```html
|
||||
<!-- Shown immediately; HTMX replaces with real content -->
|
||||
<div id="user-list"
|
||||
hx-get="/users"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
$$UserListSkeleton$$
|
||||
</div>
|
||||
```
|
||||
|
||||
The page renders the skeleton on initial load; the HTMX request fires immediately and replaces it once the data arrives.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Multiple `Skeleton` elements stacked in a `div.space-y-2` create a convincing text-block placeholder.
|
||||
- `rounded-full` makes a circle — useful for avatar skeletons. Combine with equal `h-*` and `w-*` values.
|
||||
- The `classes` parameter replaces the default empty string — provide complete size + spacing classes.
|
||||
- For table skeletons, render a `Table` with each cell containing a Skeleton HTML string (pre-rendered to a string via `ArrayBufferWriter<byte>`).
|
||||
- Do not use Skeleton for truly empty states (no data to show) — use an `Alert` or empty-state illustration instead.
|
||||
- Do not use Skeleton for truly empty states (no data to show) — use an `Alert` or empty-state illustration instead.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/UserListPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-3xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Users</h1>
|
||||
<div id="user-list"
|
||||
hx-get="/users/data"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
$$LoadingSkeleton$$
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/UserListPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class UserListPage : UserListPageBase
|
||||
{
|
||||
private readonly byte[] _skeleton;
|
||||
|
||||
public UserListPage()
|
||||
{
|
||||
// Build a table-shaped skeleton: 5 rows × 3 columns
|
||||
var row = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
row.Append("""<div class="flex gap-4 py-3 border-b">""");
|
||||
row.Append(SkeletonHtml("h-4 w-1/3"));
|
||||
row.Append(SkeletonHtml("h-4 w-1/4"));
|
||||
row.Append(SkeletonHtml("h-4 w-1/5"));
|
||||
row.Append("</div>");
|
||||
}
|
||||
_skeleton = row.ToString().ToUtf8Bytes();
|
||||
}
|
||||
|
||||
private static string SkeletonHtml(string classes)
|
||||
{
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Components.Skeleton(classes).Render(new HtmxRenderContext(buf));
|
||||
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||
}
|
||||
|
||||
protected override void RenderLoadingSkeleton(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_skeleton);
|
||||
}
|
||||
```
|
||||
|
||||
**GET handlers**
|
||||
```csharp
|
||||
// Shell page — renders immediately with skeleton placeholder
|
||||
[Handler]
|
||||
[MapGet("/users")]
|
||||
public static partial class GetUsersShellHandler
|
||||
{
|
||||
public record Query();
|
||||
private static Task<IResult> HandleAsync(Query _, HttpContext ctx, CancellationToken ct)
|
||||
=> ctx.WriteHtmxPage(new UserListPage(), title: "Users");
|
||||
}
|
||||
|
||||
// Data endpoint — HTMX swaps this in place of the skeleton
|
||||
[Handler]
|
||||
[MapGet("/users/data")]
|
||||
public static partial class GetUsersDataHandler
|
||||
{
|
||||
public record Query();
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
|
||||
{
|
||||
var users = await db.GetAllUsersAsync(ct);
|
||||
var table = new Components.Table(
|
||||
headers: new[] { "Name", "Email", "Role" },
|
||||
rows: users.Select(u => new[]
|
||||
{
|
||||
System.Net.WebUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||
System.Net.WebUtility.HtmlEncode(u.Email),
|
||||
"user",
|
||||
}));
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
table.Render(new HtmxRenderContext(buf));
|
||||
return Results.Content(
|
||||
System.Text.Encoding.UTF8.GetString(buf.WrittenSpan), "text/html");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,203 @@
|
||||
# Slider
|
||||
|
||||
A styled `<input type="range">` with optional label and description. Supports min/max/step/value and HTMX attributes.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label}
|
||||
input[type=range, id, name, min, max, step, value, class, $$HxAttrs$$]
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `w-full h-2 rounded-lg appearance-none cursor-pointer accent-primary` | Full-width, pill-shaped track; thumb follows primary color |
|
||||
| `bg-secondary` | Track fill color |
|
||||
| `accent-primary` | Thumb and active track color follows `--color-primary` |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Slider(
|
||||
string id,
|
||||
string name = "",
|
||||
int min = 0,
|
||||
int max = 100,
|
||||
int step = 1,
|
||||
int value = 50,
|
||||
string label = "",
|
||||
string description = "",
|
||||
string extraClasses = "",
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `min` | Minimum value (default: 0) |
|
||||
| `max` | Maximum value (default: 100) |
|
||||
| `step` | Increment step (default: 1) |
|
||||
| `value` | Initial value (default: 50) |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text |
|
||||
| `extraClasses` | Additional Tailwind classes on the input |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic 0–100 slider
|
||||
|
||||
```csharp
|
||||
new Slider(
|
||||
id: "volume",
|
||||
name: "volume",
|
||||
label: "Volume")
|
||||
```
|
||||
|
||||
### Fixed range with step
|
||||
|
||||
```csharp
|
||||
new Slider(
|
||||
id: "brightness",
|
||||
name: "brightness",
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 10,
|
||||
value: 70,
|
||||
label: "Brightness",
|
||||
description: "10–100")
|
||||
```
|
||||
|
||||
### Live HTMX update
|
||||
|
||||
```csharp
|
||||
new Slider(
|
||||
id: "fontSize",
|
||||
name: "fontSize",
|
||||
min: 12,
|
||||
max: 24,
|
||||
value: 16,
|
||||
label: "Font size",
|
||||
hxAttrs: """hx-post="/settings/font-size" hx-trigger="change" hx-include="[name='fontSize']"""")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] int Volume);
|
||||
|
||||
// command.Volume is the slider value at submit time
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Display the current numeric value next to the slider by adding a small `<output>` element and a JS `input` event listener: `slider.addEventListener('input', e => output.value = e.target.value)`. This can be added via `hxAttrs: "oninput=\"document.getElementById('vol-val').textContent=this.value\""`.
|
||||
- `value` is the **initial** server-rendered position. After the user moves the slider, only the form submission captures the new value.
|
||||
- Use `step` to snap to meaningful increments (e.g. `step: 5` for a 0–100 percentage slider).
|
||||
- `accent-primary` is supported in all modern browsers and requires no custom CSS.
|
||||
- `accent-primary` is supported in all modern browsers and requires no custom CSS.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/AudioSettingsPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Audio settings</h1>
|
||||
<form method="post" action="/settings/audio">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-6 mb-8">
|
||||
$$VolumeSlider$$
|
||||
$$BassSlider$$
|
||||
$$TrebleSlider$$
|
||||
</div>
|
||||
$$SaveBtn$$
|
||||
</form>
|
||||
$$SuccessAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/AudioSettingsPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class AudioSettingsPage : AudioSettingsPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _volume;
|
||||
private readonly IHtmxComponent _bass;
|
||||
private readonly IHtmxComponent _treble;
|
||||
private readonly IHtmxComponent _save;
|
||||
private readonly IHtmxComponent _success;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public AudioSettingsPage(
|
||||
IAntiforgery af,
|
||||
HttpContext ctx,
|
||||
AudioPrefs? prefs = null,
|
||||
bool saved = false)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_volume = new Components.Slider(id: "volume", name: "volume", label: "Volume", value: prefs?.Volume ?? 70, description: "0 – 100");
|
||||
_bass = new Components.Slider(id: "bass", name: "bass", label: "Bass", value: prefs?.Bass ?? 50, min: -10, max: 10, step: 1);
|
||||
_treble = new Components.Slider(id: "treble", name: "treble", label: "Treble", value: prefs?.Treble ?? 50, min: -10, max: 10, step: 1);
|
||||
_save = new Components.Button("Save", type: "submit");
|
||||
_success = saved ? new Components.Alert(title: "Audio settings saved.") : HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderVolumeSlider(HtmxRenderContext ctx) => _volume.Render(ctx.Next());
|
||||
protected override void RenderBassSlider(HtmxRenderContext ctx) => _bass.Render(ctx.Next());
|
||||
protected override void RenderTrebleSlider(HtmxRenderContext ctx) => _treble.Render(ctx.Next());
|
||||
protected override void RenderSaveBtn(HtmxRenderContext ctx) => _save.Render(ctx.Next());
|
||||
protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/settings/audio")]
|
||||
public static partial class PostAudioSettingsHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] int Volume,
|
||||
[property: FromForm] int Bass,
|
||||
[property: FromForm] int Treble);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
var prefs = new AudioPrefs(cmd.Volume, cmd.Bass, cmd.Treble);
|
||||
// Persist prefs…
|
||||
return ctx.WriteHtmxPage(
|
||||
new AudioSettingsPage(af, ctx, prefs, saved: true), title: "Audio settings");
|
||||
}
|
||||
}
|
||||
|
||||
public record AudioPrefs(int Volume, int Bass, int Treble);
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostAudioSettingsHandler.Command), TypeInfoPropertyName = "AudioSettingsCommand")]
|
||||
```
|
||||
@@ -0,0 +1,210 @@
|
||||
# Switch
|
||||
|
||||
A toggle switch (on/off). Renders as a hidden `<input type="checkbox">` with a styled track and thumb driven by JavaScript. Fires no custom events — read the underlying checkbox value in form submissions.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
label[for={id}].flex.items-center.gap-3.cursor-pointer
|
||||
div.switch-root.relative.w-11.h-6.rounded-full ← outer track
|
||||
input[type=checkbox, id, name, class="sr-only", $$Checked$$] ← hidden; holds true state
|
||||
div.switch-thumb.absolute.top-0.5.left-0.5... ← animated thumb
|
||||
span.text-sm.select-none ← label text (omitted when empty)
|
||||
{label}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `sr-only` | Hides the real checkbox visually but keeps it accessible |
|
||||
| `switch-root` | `bg-input` (off) / `bg-primary` (on) — toggled by JS adding `switch-on` class |
|
||||
| `switch-thumb` | `h-5 w-5 rounded-full bg-background shadow transition-transform` |
|
||||
| `translate-x-5` | Added to thumb by JS when switch is on (slides right) |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initSwitch` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
|
||||
|
||||
**Per-switch initialization:**
|
||||
|
||||
1. Guard `_switchInit` prevents double-binding
|
||||
2. Sync visual state from the hidden checkbox `checked` property on load
|
||||
3. On `label` click: toggle `checked`, toggle `switch-on` on the track, toggle `translate-x-5` on the thumb
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Switch(
|
||||
string id,
|
||||
string label = "",
|
||||
string name = "",
|
||||
bool isChecked = false)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id for the hidden checkbox; label's `for` attribute |
|
||||
| `label` | Optional visible text to the right of the toggle |
|
||||
| `name` | Form field name for the hidden checkbox |
|
||||
| `isChecked` | Initial on/off state |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic on/off toggle
|
||||
|
||||
```csharp
|
||||
new Switch(
|
||||
id: "notifications",
|
||||
label: "Enable notifications",
|
||||
name: "enableNotifications",
|
||||
isChecked: true)
|
||||
```
|
||||
|
||||
### Toggle without label
|
||||
|
||||
```csharp
|
||||
new Switch(id: "darkMode", name: "darkMode")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string? EnableNotifications = null
|
||||
);
|
||||
|
||||
bool notificationsOn = command.EnableNotifications != null;
|
||||
```
|
||||
|
||||
> Like all checkboxes, an unchecked switch is not included in the form submission. Use `null` as the default in your command record.
|
||||
|
||||
### HTMX auto-save on change
|
||||
|
||||
```csharp
|
||||
// The hidden checkbox is named, so wrap in a form or use hx-include:
|
||||
new Switch(
|
||||
id: "maintenance",
|
||||
name: "maintenanceMode",
|
||||
label: "Maintenance mode",
|
||||
isChecked: currentState)
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Parent form with HTMX:
|
||||
<form hx-post="/settings" hx-trigger="change from:#maintenance">
|
||||
$$MaintenanceSwitch$$
|
||||
</form>
|
||||
-->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The hidden checkbox carries the value `"on"` when checked (standard checkbox default). If you need `"true"`, add `value="true"` by subclassing or via a wrapper form.
|
||||
- Because the click is handled on the `<label>` element, the switch works correctly even when the hidden input is not directly clicked.
|
||||
- For an HTMX auto-save switch, trigger on `change` from the hidden checkbox using `hx-trigger="change from:#myId"` on a parent element.
|
||||
- Pairing `isChecked` with a server-read preference ensures the switch reflects the saved state on every page load.
|
||||
- Pairing `isChecked` with a server-read preference ensures the switch reflects the saved state on every page load.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/NotificationsPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-md mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Notifications</h1>
|
||||
<form method="post" action="/notifications">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-5 mb-8">
|
||||
$$EmailSwitch$$
|
||||
$$PushSwitch$$
|
||||
$$SmsSwitch$$
|
||||
</div>
|
||||
$$SaveBtn$$
|
||||
</form>
|
||||
$$SuccessAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/NotificationsPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class NotificationsPage : NotificationsPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _email;
|
||||
private readonly IHtmxComponent _push;
|
||||
private readonly IHtmxComponent _sms;
|
||||
private readonly IHtmxComponent _save;
|
||||
private readonly IHtmxComponent _success;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public NotificationsPage(
|
||||
IAntiforgery af,
|
||||
HttpContext ctx,
|
||||
NotificationPrefs? prefs = null,
|
||||
bool saved = false)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_email = new Components.Switch(id: "email-notif", label: "Email notifications", name: "emailNotif", isChecked: prefs?.Email ?? true);
|
||||
_push = new Components.Switch(id: "push-notif", label: "Push notifications", name: "pushNotif", isChecked: prefs?.Push ?? false);
|
||||
_sms = new Components.Switch(id: "sms-notif", label: "SMS notifications", name: "smsNotif", isChecked: prefs?.Sms ?? false);
|
||||
_save = new Components.Button("Save", type: "submit");
|
||||
_success = saved ? new Components.Alert(title: "Notification preferences saved.") : HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderEmailSwitch(HtmxRenderContext ctx) => _email.Render(ctx.Next());
|
||||
protected override void RenderPushSwitch(HtmxRenderContext ctx) => _push.Render(ctx.Next());
|
||||
protected override void RenderSmsSwitch(HtmxRenderContext ctx) => _sms.Render(ctx.Next());
|
||||
protected override void RenderSaveBtn(HtmxRenderContext ctx) => _save.Render(ctx.Next());
|
||||
protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/notifications")]
|
||||
public static partial class PostNotificationsHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] string? EmailNotif = null,
|
||||
[property: FromForm] string? PushNotif = null,
|
||||
[property: FromForm] string? SmsNotif = null);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
var prefs = new NotificationPrefs(
|
||||
Email: cmd.EmailNotif != null,
|
||||
Push: cmd.PushNotif != null,
|
||||
Sms: cmd.SmsNotif != null);
|
||||
// Persist prefs…
|
||||
return ctx.WriteHtmxPage(
|
||||
new NotificationsPage(af, ctx, prefs, saved: true), title: "Notifications");
|
||||
}
|
||||
}
|
||||
|
||||
public record NotificationPrefs(bool Email, bool Push, bool Sms);
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostNotificationsHandler.Command), TypeInfoPropertyName = "NotificationsCommand")]
|
||||
```
|
||||
@@ -0,0 +1,200 @@
|
||||
# Table
|
||||
|
||||
A styled HTML data table with a header row, optional caption, optional footer row, and one or more data rows. All data is plain strings.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.overflow-auto.rounded-md.border.border-border
|
||||
table.w-full.text-sm.caption-bottom
|
||||
caption.mt-4.text-sm.text-muted-foreground ← omitted when empty
|
||||
{caption}
|
||||
thead
|
||||
tr.border-b.bg-muted/50
|
||||
th.h-12.px-4.text-left.font-medium ← one per header
|
||||
{header}
|
||||
tbody
|
||||
tr.border-b.hover:bg-muted/40 ← one per row; last row has no border
|
||||
td.p-4 ← one per cell; raw HTML
|
||||
{cell}
|
||||
tfoot ← omitted when empty
|
||||
tr
|
||||
td[colspan=N].p-4.text-muted-foreground
|
||||
{footer}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `overflow-auto` on wrapper | Horizontal scroll on small screens |
|
||||
| `bg-muted/50` on header | Slightly tinted header row |
|
||||
| `hover:bg-muted/40` on data rows | Subtle hover highlight |
|
||||
| `border-b` on rows | Row separator lines |
|
||||
| `caption-bottom` | Caption appears below the table |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Table(
|
||||
IEnumerable<string> headers,
|
||||
IEnumerable<IEnumerable<string>> rows,
|
||||
string caption = "",
|
||||
string footer = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `headers` | Column heading strings |
|
||||
| `rows` | Each inner `IEnumerable<string>` is one row; cells are raw HTML |
|
||||
| `caption` | Optional caption below the table |
|
||||
| `footer` | Optional footer cell (spans all columns) |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic data table
|
||||
|
||||
```csharp
|
||||
new Table(
|
||||
headers: new[] { "Name", "Email", "Role" },
|
||||
rows: users.Select(u => new[] { u.DisplayName ?? "", u.Email, u.Role }))
|
||||
```
|
||||
|
||||
### With caption and footer
|
||||
|
||||
```csharp
|
||||
new Table(
|
||||
headers: new[] { "Product", "Price", "Stock" },
|
||||
rows: products.Select(p => new[]
|
||||
{
|
||||
p.Name,
|
||||
$"${p.Price:F2}",
|
||||
p.Stock.ToString()
|
||||
}),
|
||||
caption: $"Showing {products.Count} products",
|
||||
footer: "Prices include VAT")
|
||||
```
|
||||
|
||||
### Cells with HTML content (e.g. badges)
|
||||
|
||||
```csharp
|
||||
// Pre-render a Badge to HTML string
|
||||
string ActiveBadge()
|
||||
{
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Badge("Active").Render(new HtmxRenderContext(buf));
|
||||
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||
}
|
||||
|
||||
new Table(
|
||||
headers: new[] { "Name", "Status" },
|
||||
rows: users.Select(u => new[]
|
||||
{
|
||||
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||
u.IsActive ? ActiveBadge() : ""
|
||||
}))
|
||||
```
|
||||
|
||||
### With action buttons per row
|
||||
|
||||
```csharp
|
||||
string EditBtn(string id) => $"""
|
||||
<button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>
|
||||
""";
|
||||
|
||||
new Table(
|
||||
headers: new[] { "Name", "Actions" },
|
||||
rows: users.Select(u => new[]
|
||||
{
|
||||
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||
EditBtn(u.Id!)
|
||||
}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Cells are raw HTML — HTML-encode any user-supplied string values before inserting them to prevent XSS.
|
||||
- Use `System.Web.HttpUtility.HtmlEncode(value)` or `System.Net.WebUtility.HtmlEncode(value)` for any untrusted data.
|
||||
- Pair with `Pagination` below the table for large datasets.
|
||||
- For sortable columns, replace header strings with anchor tags containing HTMX sort-request attributes.
|
||||
- The `footer` string spans all columns — use it for totals, notes, or a "load more" link.
|
||||
- The `footer` string spans all columns — use it for totals, notes, or a "load more" link.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/AdminUsersPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-5xl mx-auto py-10">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Users</h1>
|
||||
$$InviteBtn$$
|
||||
</div>
|
||||
$$UsersTable$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/AdminUsersPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class AdminUsersPage : AdminUsersPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _table;
|
||||
private readonly IHtmxComponent _invite;
|
||||
|
||||
public AdminUsersPage(IEnumerable<ApplicationUser> users, int total)
|
||||
{
|
||||
_invite = new Components.Button(
|
||||
"Invite user",
|
||||
variant: "default",
|
||||
extraAttributes: """hx-get="/admin/users/invite" hx-target="#modal" hx-swap="innerHTML" """);
|
||||
|
||||
var rows = users.Select(u => new[]
|
||||
{
|
||||
System.Net.WebUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||
System.Net.WebUtility.HtmlEncode(u.Email),
|
||||
u.CreatedAt.ToString("yyyy-MM-dd"),
|
||||
$"""<button class="text-destructive text-xs" hx-delete="/admin/users/{u.Id}" hx-confirm="Delete this user?">Delete</button>""",
|
||||
});
|
||||
|
||||
_table = new Components.Table(
|
||||
caption: "All registered accounts",
|
||||
headers: new[] { "Name", "Email", "Joined", "Actions" },
|
||||
rows: rows,
|
||||
footer: $"{total} users total");
|
||||
}
|
||||
|
||||
protected override void RenderInviteBtn(HtmxRenderContext ctx) => _invite.Render(ctx.Next());
|
||||
protected override void RenderUsersTable(HtmxRenderContext ctx) => _table.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/admin/users")]
|
||||
public static partial class GetAdminUsersHandler
|
||||
{
|
||||
public record Query();
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
|
||||
{
|
||||
var users = await db.GetAllUsersAsync(ct);
|
||||
var list = users.ToList();
|
||||
return await ctx.WriteHtmxPage(
|
||||
new AdminUsersPage(list, list.Count), title: "Admin – Users");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,200 @@
|
||||
# Tabs
|
||||
|
||||
A tabbed interface. One tab panel is visible at a time. The active tab has a highlighted style; all others are hidden. Client-side JS switches panels without a server round-trip.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div[id].tabs-root
|
||||
div.tabs-list.flex.gap-1.border-b.mb-4 ← tab button strip
|
||||
button.tabs-trigger[data-tab={tabId}] ← one per tab; ACTIVE/INACTIVE variant
|
||||
{label}
|
||||
div.tabs-panel[data-tab={tabId}] ← one per tab; hidden or visible
|
||||
{content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `tabs-trigger` | `px-4 py-2 text-sm font-medium rounded-t-md -mb-px` |
|
||||
| Active trigger | `bg-background border border-b-0 border-border text-foreground` |
|
||||
| Inactive trigger | `text-muted-foreground hover:text-foreground hover:bg-muted/40` |
|
||||
| `tabs-panel[hidden]` | `display: none` via the HTML `hidden` attribute |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initTabs` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
|
||||
|
||||
**Per-instance initialization:**
|
||||
|
||||
1. Guard `_tabsInit` prevents double-binding
|
||||
2. Reads all `.tabs-trigger` and `.tabs-panel` elements within the root
|
||||
3. Activates the first tab on init (removes `hidden`, applies active class)
|
||||
4. On trigger click:
|
||||
- Deactivate all panels (set `hidden`, downgrade trigger class to inactive)
|
||||
- Activate the clicked panel by matching `data-tab` attribute
|
||||
- Apply active class to the clicked trigger
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Tabs(
|
||||
string id,
|
||||
IEnumerable<(string Id, string Label, string Content)> tabs)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Root element id — must be unique per page if multiple Tabs are rendered |
|
||||
| `tabs` | List of `(Id, Label, Content)` tuples; `Id` must be unique within this instance |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Simple tabbed content
|
||||
|
||||
```csharp
|
||||
new Tabs(
|
||||
id: "settings-tabs",
|
||||
tabs: new[]
|
||||
{
|
||||
("general", "General", "<p>General settings content here.</p>"),
|
||||
("security", "Security", "<p>Security settings content here.</p>"),
|
||||
("billing", "Billing", "<p>Billing details here.</p>"),
|
||||
})
|
||||
```
|
||||
|
||||
### HTML-rich content in a tab
|
||||
|
||||
```csharp
|
||||
new Tabs(
|
||||
id: "code-tabs",
|
||||
tabs: new[]
|
||||
{
|
||||
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
|
||||
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
|
||||
("vb", "VB.NET", "<pre><code>Dim x As Integer = 42</code></pre>"),
|
||||
})
|
||||
```
|
||||
|
||||
### Embedding a full component in a tab
|
||||
|
||||
```csharp
|
||||
// Pre-render the inner component to HTML string
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Table(headers: cols, rows: data).Render(new HtmxRenderContext(buf));
|
||||
var tableHtml = System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||
|
||||
new Tabs(
|
||||
id: "report",
|
||||
tabs: new[]
|
||||
{
|
||||
("summary", "Summary", "<p>High level numbers.</p>"),
|
||||
("detail", "Detail", tableHtml),
|
||||
})
|
||||
```
|
||||
|
||||
### Multiple independent tab groups
|
||||
|
||||
```csharp
|
||||
new Tabs(id: "tabs-a", tabs: setA)
|
||||
new Tabs(id: "tabs-b", tabs: setB)
|
||||
```
|
||||
|
||||
The `id` scopes JS initialization — each Tabs instance is independent.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The `Id` of each tab tuple is used as the `data-tab` attribute — keep it URL-safe and unique within the instance.
|
||||
- The first tab is always activated on page load regardless of which tab was active before navigation.
|
||||
- Tab `Content` is raw HTML — HTML-encode any user-supplied values.
|
||||
- For lazy-loaded tab content, place HTMX attributes in the `Content` string and use `hx-trigger="revealed"` to load content when the panel becomes visible.
|
||||
- Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the `click` event on `.tabs-trigger` elements and update `location.hash`.
|
||||
- Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the `click` event on `.tabs-trigger` elements and update `location.hash`.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ProfileSettingsPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-2xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Profile settings</h1>
|
||||
$$SettingsTabs$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ProfileSettingsPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ProfileSettingsPage : ProfileSettingsPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _tabs;
|
||||
|
||||
public ProfileSettingsPage(ApplicationUser user, IAntiforgery af, HttpContext ctx)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
var afHtml = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""";
|
||||
|
||||
// Build each tab's content as raw HTML strings rendered into the Tabs component
|
||||
var generalContent = $"""
|
||||
<form method="post" action="/profile/general">
|
||||
{afHtml}
|
||||
<label class="block mb-1 text-sm">Display name</label>
|
||||
<input name="displayName" value="{System.Net.WebUtility.HtmlEncode(user.DisplayName ?? "")}"
|
||||
class="input mb-4 w-full">
|
||||
<button class="btn" type="submit">Save</button>
|
||||
</form>
|
||||
""";
|
||||
|
||||
var securityContent = $"""
|
||||
<form method="post" action="/profile/password">
|
||||
{afHtml}
|
||||
<label class="block mb-1 text-sm">New password</label>
|
||||
<input name="newPassword" type="password" class="input mb-4 w-full">
|
||||
<button class="btn" type="submit">Change password</button>
|
||||
</form>
|
||||
""";
|
||||
|
||||
_tabs = new Components.Tabs(
|
||||
defaultValue: "general",
|
||||
tabs: new[]
|
||||
{
|
||||
new TabItem(Value: "general", Label: "General", Content: generalContent),
|
||||
new TabItem(Value: "security", Label: "Security", Content: securityContent),
|
||||
});
|
||||
}
|
||||
|
||||
protected override void RenderSettingsTabs(HtmxRenderContext ctx) => _tabs.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/profile")]
|
||||
public static partial class GetProfileSettingsHandler
|
||||
{
|
||||
public record Query();
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query _, HttpContext ctx, MongoDbService db, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
var user = await db.GetCurrentUserAsync(ctx, ct);
|
||||
return await ctx.WriteHtmxPage(
|
||||
new ProfileSettingsPage(user, af, ctx), title: "Profile settings");
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,229 @@
|
||||
# Textarea
|
||||
|
||||
A styled multi-line text input with optional label, description, default value, and HTMX attributes.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label}
|
||||
textarea[id, name, placeholder, rows, class, $$HxAttrs$$]
|
||||
{defaultValue}
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `flex min-h-[80px] w-full rounded-md border border-input bg-background` | Full-width field with minimum height |
|
||||
| `px-3 py-2 text-sm` | Inner padding and text size |
|
||||
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
|
||||
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
|
||||
| `placeholder:text-muted-foreground` | Muted placeholder text |
|
||||
| `resize-y` | Allows vertical resize only |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Textarea(
|
||||
string id,
|
||||
string name = "",
|
||||
string placeholder = "",
|
||||
string label = "",
|
||||
string description = "",
|
||||
string defaultValue = "",
|
||||
string extraClasses = "",
|
||||
string hxAttrs = "",
|
||||
int rows = 3)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `placeholder` | Placeholder text |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text below the field |
|
||||
| `defaultValue` | Pre-filled content of the textarea |
|
||||
| `extraClasses` | Additional Tailwind classes on the textarea |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
||||
| `rows` | Number of visible rows (default: 3) |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Comment field
|
||||
|
||||
```csharp
|
||||
new Textarea(
|
||||
id: "comment",
|
||||
name: "comment",
|
||||
placeholder: "Write a comment…",
|
||||
label: "Comment",
|
||||
rows: 5)
|
||||
```
|
||||
|
||||
### Bio field with default value
|
||||
|
||||
```csharp
|
||||
new Textarea(
|
||||
id: "bio",
|
||||
name: "bio",
|
||||
label: "Bio",
|
||||
description: "Tell us about yourself (max 280 characters)",
|
||||
defaultValue: user.Bio ?? "")
|
||||
```
|
||||
|
||||
### Auto-expand with HTMX
|
||||
|
||||
```csharp
|
||||
new Textarea(
|
||||
id: "notes",
|
||||
name: "notes",
|
||||
label: "Notes",
|
||||
rows: 3,
|
||||
hxAttrs: """oninput="this.style.height=''; this.style.height=this.scrollHeight+'px'"""")
|
||||
```
|
||||
|
||||
### Auto-save on input
|
||||
|
||||
```csharp
|
||||
new Textarea(
|
||||
id: "draft",
|
||||
name: "content",
|
||||
label: "Draft",
|
||||
hxAttrs: """hx-post="/drafts/save" hx-trigger="keyup changed delay:500ms" hx-include="[name='content']"""")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] string Comment);
|
||||
|
||||
// command.Comment contains the textarea value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- HTML-encode the `defaultValue` if it contains user-supplied content — it is placed directly inside the `<textarea>` element.
|
||||
- `rows` controls the initial visible height but the user can resize vertically. For a fixed-height textarea, add `resize-none` in `extraClasses`.
|
||||
- For a character counter, add `maxlength` via `hxAttrs` and pair with a small JS snippet or a sibling `<span>` updated on `input`.
|
||||
- `placeholder` text is not submitted — always use `defaultValue` for edit forms where existing content should be pre-filled.
|
||||
- `placeholder` text is not submitted — always use `defaultValue` for edit forms where existing content should be pre-filled.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/FeedbackPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-lg mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-2">Send feedback</h1>
|
||||
<p class="text-sm text-muted-foreground mb-6">We read every message.</p>
|
||||
<form method="post" action="/feedback">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-5 mb-6">
|
||||
$$SubjectInput$$
|
||||
$$MessageArea$$
|
||||
</div>
|
||||
$$SubmitBtn$$
|
||||
</form>
|
||||
$$SuccessAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/FeedbackPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class FeedbackPage : FeedbackPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _subject;
|
||||
private readonly IHtmxComponent _message;
|
||||
private readonly IHtmxComponent _submit;
|
||||
private readonly IHtmxComponent _success;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public FeedbackPage(
|
||||
IAntiforgery af,
|
||||
HttpContext ctx,
|
||||
string subjectError = "",
|
||||
string messageError = "",
|
||||
bool submitted = false)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_subject = new Components.Input(
|
||||
id: "subject",
|
||||
name: "subject",
|
||||
label: "Subject",
|
||||
placeholder: "What's on your mind?",
|
||||
errorMessage: subjectError);
|
||||
|
||||
_message = new Components.Textarea(
|
||||
id: "message",
|
||||
name: "message",
|
||||
label: "Message",
|
||||
rows: 6,
|
||||
placeholder: "Describe your feedback in detail…",
|
||||
errorMessage: messageError);
|
||||
|
||||
_submit = new Components.Button("Send feedback", type: "submit");
|
||||
_success = submitted
|
||||
? new Components.Alert(title: "Thank you!", description: "Your feedback has been received.")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderSubjectInput(HtmxRenderContext ctx) => _subject.Render(ctx.Next());
|
||||
protected override void RenderMessageArea(HtmxRenderContext ctx) => _message.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submit.Render(ctx.Next());
|
||||
protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/feedback")]
|
||||
public static partial class PostFeedbackHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] string Subject,
|
||||
[property: FromForm] string Message);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cmd.Subject))
|
||||
return ctx.WriteHtmxPage(
|
||||
new FeedbackPage(af, ctx, subjectError: "Subject is required."), title: "Feedback");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cmd.Message))
|
||||
return ctx.WriteHtmxPage(
|
||||
new FeedbackPage(af, ctx, messageError: "Message is required."), title: "Feedback");
|
||||
|
||||
// Persist feedback…
|
||||
return ctx.WriteHtmxPage(
|
||||
new FeedbackPage(af, ctx, submitted: true), title: "Feedback");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostFeedbackHandler.Command), TypeInfoPropertyName = "FeedbackCommand")]
|
||||
```
|
||||
@@ -0,0 +1,236 @@
|
||||
# TimePicker
|
||||
|
||||
A styled time picker. The user selects hours, minutes, and optionally AM/PM. The component always writes the selected time as `HH:MM` (24-hour) to the hidden input, regardless of whether 12-hour display mode is used. Optionally renders a visible label and description.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label.text-sm.font-medium ← omitted when empty
|
||||
{label}
|
||||
div.flex.items-center.gap-1.rounded-md.border.border-input.bg-background.px-3.py-2
|
||||
select.timepicker-h[name={name}-h] ← hour select (1–12 or 0–23)
|
||||
span.text-muted-foreground :
|
||||
select.timepicker-m[name={name}-m] ← minute select (00–59)
|
||||
select.timepicker-ampm[name={name}-ampm] ← AM/PM (12h mode only)
|
||||
input.sr-only[type=hidden, name={name}] ← hidden input holding HH:MM
|
||||
p.text-sm.text-muted-foreground ← omitted when empty
|
||||
{description}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `rounded-md border border-input bg-background` | Consistent styling with other form fields |
|
||||
| `sr-only` on hidden input | Hidden visually but included in form submission |
|
||||
| `appearance-none` on `<select>` elements | Removes native browser dropdown arrow for uniform style |
|
||||
| `focus:outline-none` on selects | Focus ring deferred to the wrapper `div` |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`syncTime` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
|
||||
|
||||
### `syncTime(wrapper)`
|
||||
|
||||
1. Finds `.timepicker-h`, `.timepicker-m`, `.timepicker-ampm`, and the hidden `input`
|
||||
2. On any `change` event across the three visible selects:
|
||||
- Reads hour, minute, and AM/PM values
|
||||
- Converts 12h → 24h if AM/PM select is present
|
||||
- Writes `HH:MM` to the hidden input
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public TimePicker(
|
||||
string name,
|
||||
string? selected = null,
|
||||
string label = "",
|
||||
string description = "",
|
||||
bool use12h = false)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `name` | Form field name; hidden input gets this name, visible selects get `{name}-h`, `{name}-m`, `{name}-ampm` |
|
||||
| `selected` | Pre-selected time as `"HH:MM"` (24h format); defaults to current time |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text |
|
||||
| `use12h` | If `true`, shows AM/PM select and hour range 1–12 |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic time picker (24h)
|
||||
|
||||
```csharp
|
||||
new TimePicker(name: "startTime", label: "Start time")
|
||||
```
|
||||
|
||||
### 12-hour mode
|
||||
|
||||
```csharp
|
||||
new TimePicker(
|
||||
name: "meetingTime",
|
||||
label: "Meeting time",
|
||||
use12h: true)
|
||||
```
|
||||
|
||||
### Pre-selected time
|
||||
|
||||
```csharp
|
||||
new TimePicker(
|
||||
name: "alarmTime",
|
||||
selected: "07:30",
|
||||
label: "Alarm",
|
||||
use12h: true)
|
||||
```
|
||||
|
||||
### Inside a form
|
||||
|
||||
```html
|
||||
<!-- ScheduleForm.htmx -->
|
||||
<form method="post" action="/schedule">
|
||||
$$AntiforgeryToken$$
|
||||
$$StartTime$$
|
||||
$$EndTime$$
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
```csharp
|
||||
public ScheduleForm()
|
||||
{
|
||||
StartTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
|
||||
EndTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
|
||||
}
|
||||
```
|
||||
|
||||
**Reading the submitted values:**
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string StartTime, // "09:00"
|
||||
[property: FromForm] string EndTime // "17:00"
|
||||
);
|
||||
|
||||
var start = TimeOnly.ParseExact(command.StartTime, "HH:mm");
|
||||
var end = TimeOnly.ParseExact(command.EndTime, "HH:mm");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The hidden input always stores 24h `HH:MM` regardless of `use12h` — parse with `"HH:mm"` format on the server.
|
||||
- `selected` defaults to the current server time if not specified — pass `"00:00"` if you want the picker to start at midnight.
|
||||
- The visible hour/minute selects are independent form fields (`{name}-h`, `{name}-m`) — only the hidden input with `name` is needed in your command record. Ignore the `-h`, `-m`, and `-ampm` fields server-side.
|
||||
- For a date+time combination, pair `Calendar` (for date) with `TimePicker` (for time) and combine their values in the handler.
|
||||
- For a date+time combination, pair `Calendar` (for date) with `TimePicker` (for time) and combine their values in the handler.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ScheduleMeetingPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-lg mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-2">Schedule a meeting</h1>
|
||||
<p class="text-sm text-muted-foreground mb-8">Pick a date and time that works for you.</p>
|
||||
<form method="post" action="/meetings/new">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Date</label>
|
||||
$$DatePicker$$
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Time</label>
|
||||
$$TimePicker$$
|
||||
</div>
|
||||
</div>
|
||||
$$TitleInput$$
|
||||
<div class="mt-6">
|
||||
$$SubmitBtn$$
|
||||
</div>
|
||||
</form>
|
||||
$$SuccessAlert$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ScheduleMeetingPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ScheduleMeetingPage : ScheduleMeetingPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _calendar;
|
||||
private readonly IHtmxComponent _timePicker;
|
||||
private readonly IHtmxComponent _title;
|
||||
private readonly IHtmxComponent _submit;
|
||||
private readonly IHtmxComponent _success;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public ScheduleMeetingPage(
|
||||
IAntiforgery af,
|
||||
HttpContext ctx,
|
||||
DateOnly? selectedDate = null,
|
||||
string selectedTime = "",
|
||||
bool booked = false)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_calendar = new Components.Calendar(name: "date", selectedDate: selectedDate);
|
||||
_timePicker = new Components.TimePicker(name: "time", value: selectedTime, placeholder: "09:00");
|
||||
_title = new Components.Input(id: "title", name: "title", label: "Meeting title", placeholder: "Sync call");
|
||||
_submit = new Components.Button("Book meeting", type: "submit");
|
||||
_success = booked
|
||||
? new Components.Alert(title: "Meeting booked!", description: $"Scheduled for {selectedDate:d} at {selectedTime}.")
|
||||
: HtmxEmpty.Instance;
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderDatePicker(HtmxRenderContext ctx) => _calendar.Render(ctx.Next());
|
||||
protected override void RenderTimePicker(HtmxRenderContext ctx) => _timePicker.Render(ctx.Next());
|
||||
protected override void RenderTitleInput(HtmxRenderContext ctx) => _title.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submit.Render(ctx.Next());
|
||||
protected override void RenderSuccessAlert(HtmxRenderContext ctx) => _success.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/meetings/new")]
|
||||
public static partial class PostScheduleMeetingHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] DateOnly Date,
|
||||
[property: FromForm] string Time,
|
||||
[property: FromForm] string Title);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
// Persist meeting…
|
||||
return ctx.WriteHtmxPage(
|
||||
new ScheduleMeetingPage(af, ctx, cmd.Date, cmd.Time, booked: true),
|
||||
title: "Schedule meeting");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostScheduleMeetingHandler.Command), TypeInfoPropertyName = "ScheduleMeetingCommand")]
|
||||
```
|
||||
@@ -0,0 +1,213 @@
|
||||
# Toast
|
||||
|
||||
A transient notification that appears in the bottom-right corner (or wherever `ToastViewport` is placed), auto-dismisses after a configurable duration, and can be dismissed manually.
|
||||
|
||||
Toasts are triggered **client-side** via `window.showToast(...)` from JavaScript — they are not server-rendered components like most others. The `Toast` component class produces the initial toast markup for use as a static template or in the ToastViewport; in practice most toasts are created dynamically by the JS API.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure (dynamically created by JS)
|
||||
|
||||
```
|
||||
div.toast[role=alert, aria-live=polite, data-variant]
|
||||
div.flex.items-start.gap-3
|
||||
div.flex-1
|
||||
p.font-medium.text-sm ← title
|
||||
p.text-sm.text-muted-foreground ← description (omitted when empty)
|
||||
button.ml-auto[aria-label=Dismiss] ← × close button
|
||||
svg (×)
|
||||
```
|
||||
|
||||
The outer `div.toast` is appended to the `ToastViewport` container by JS and removed after `duration` ms.
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `toast` | Defined in `input.css`: `w-80 rounded-lg border bg-background p-4 shadow-lg pointer-events-auto` |
|
||||
| `toast-enter` / `toast-exit` | CSS keyframe animation classes applied by JS for slide-in/fade-out |
|
||||
| `data-variant="default"` | Border `border-border` |
|
||||
| `data-variant="destructive"` | Border `border-destructive`, title `text-destructive` |
|
||||
| `data-variant="success"` | Border `border-green-500` |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`showToast` in `components.js`)
|
||||
|
||||
```js
|
||||
window.showToast({
|
||||
title: "Operation complete", // required
|
||||
description: "All items saved.", // optional
|
||||
variant: "success", // "default" | "destructive" | "success"
|
||||
duration: 4000 // milliseconds before auto-dismiss
|
||||
});
|
||||
```
|
||||
|
||||
**Implementation steps:**
|
||||
|
||||
1. Build the toast `div` element with the classes and markup described above
|
||||
2. Apply `toast-enter` class → CSS slide-in animation plays
|
||||
3. Append to the `ToastViewport` (`#toast-viewport` by default, or the first `.toast-viewport` found)
|
||||
4. After `duration` ms, apply `toast-exit` class → CSS fade-out animation plays
|
||||
5. After fade-out completes, remove the element from the DOM
|
||||
6. Dismiss button click runs the same fade-out + remove cycle immediately
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Toast(
|
||||
string title,
|
||||
string description = "",
|
||||
string variant = "default")
|
||||
```
|
||||
|
||||
The constructor builds a static initial toast element. Most use-cases call `window.showToast(...)` from JS instead.
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `title` | Required notification heading |
|
||||
| `description` | Optional body text |
|
||||
| `variant` | `"default"` / `"destructive"` / `"success"` |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Trigger from JavaScript after an HTMX event
|
||||
|
||||
```js
|
||||
document.body.addEventListener('htmx:afterRequest', function (e) {
|
||||
if (e.detail.successful) {
|
||||
window.showToast({ title: 'Saved', variant: 'success', duration: 3000 });
|
||||
} else {
|
||||
window.showToast({ title: 'Error', description: 'Could not save.', variant: 'destructive' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Trigger from a server response header
|
||||
|
||||
Add a response header `HX-Trigger` in your handler:
|
||||
|
||||
```csharp
|
||||
ctx.Response.Headers.Append("HX-Trigger",
|
||||
"""{"showToast":{"title":"Saved!","variant":"success","duration":3000}}""");
|
||||
```
|
||||
|
||||
Client-side listener:
|
||||
|
||||
```js
|
||||
document.body.addEventListener('showToast', function (e) {
|
||||
window.showToast(e.detail);
|
||||
});
|
||||
```
|
||||
|
||||
### Server-rendered initial toast (rare)
|
||||
|
||||
```csharp
|
||||
// Used as a slot inside a page that always shows a greeting on first load:
|
||||
protected override void RenderWelcomeToast(HtmxRenderContext ctx)
|
||||
=> new Toast("Welcome back!", "Your dashboard is ready.", "success").Render(ctx.Next());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Always place a single `ToastViewport` in your main layout so toasts have a container to render into. See [ToastViewport.md](ToastViewport.md).
|
||||
- Use the `HX-Trigger` header pattern to trigger toasts from HTMX responses — it keeps toast logic on the server without requiring extra HTMX endpoints.
|
||||
- `duration: 0` means the toast never auto-dismisses — the user must click the × button.
|
||||
- Multiple toasts stack upward in the viewport (new ones appear above older ones) due to `flex-col-reverse` in `ToastViewport`.
|
||||
- For progress toasts that update as a background job runs, call `showToast` once and then use a reference to the element to update the description text.
|
||||
- For progress toasts that update as a background job runs, call `showToast` once and then use a reference to the element to update the description text.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ContactFormPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-lg mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Contact us</h1>
|
||||
<form hx-post="/contact"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
$$AntiforgeryToken$$
|
||||
<div class="space-y-4 mb-6">
|
||||
$$NameInput$$
|
||||
$$EmailInput$$
|
||||
$$MessageArea$$
|
||||
</div>
|
||||
$$SubmitBtn$$
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ContactFormPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ContactFormPage : ContactFormPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _name;
|
||||
private readonly IHtmxComponent _email;
|
||||
private readonly IHtmxComponent _message;
|
||||
private readonly IHtmxComponent _submit;
|
||||
private readonly byte[] _afToken;
|
||||
|
||||
public ContactFormPage(IAntiforgery af, HttpContext ctx)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
_afToken = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""".ToUtf8Bytes();
|
||||
|
||||
_name = new Components.Input(id: "name", name: "name", label: "Name", placeholder: "Jane Smith");
|
||||
_email = new Components.Input(id: "email", name: "email", label: "Email", placeholder: "jane@example.com", type: "email");
|
||||
_message = new Components.Textarea(id: "message", name: "message", label: "Message", rows: 4);
|
||||
_submit = new Components.Button("Send message", type: "submit");
|
||||
}
|
||||
|
||||
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afToken);
|
||||
protected override void RenderNameInput(HtmxRenderContext ctx) => _name.Render(ctx.Next());
|
||||
protected override void RenderEmailInput(HtmxRenderContext ctx) => _email.Render(ctx.Next());
|
||||
protected override void RenderMessageArea(HtmxRenderContext ctx) => _message.Render(ctx.Next());
|
||||
protected override void RenderSubmitBtn(HtmxRenderContext ctx) => _submit.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**POST handler — triggers a toast via `HX-Trigger`**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapPost("/contact")]
|
||||
public static partial class PostContactHandler
|
||||
{
|
||||
public record Command(
|
||||
[property: FromForm] string Name,
|
||||
[property: FromForm] string Email,
|
||||
[property: FromForm] string Message);
|
||||
|
||||
private static Task<IResult> HandleAsync(
|
||||
[AsParameters] Command cmd, HttpContext ctx, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
// Persist / send message…
|
||||
|
||||
// Re-render empty form so user can send another message
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new ContactFormPage(af, ctx).Render(new HtmxRenderContext(buf));
|
||||
|
||||
ctx.Response.Headers["HX-Trigger"] = """{"showToast":{"title":"Message sent!","description":"We'll get back to you soon."}}""";
|
||||
return Task.FromResult(Results.Content(
|
||||
System.Text.Encoding.UTF8.GetString(buf.WrittenSpan), "text/html"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Tip**: The `HX-Trigger` header fires the `showToast` custom event that the `<toast-viewport>` element listens for (see `ToastViewport`).
|
||||
|
||||
**`AppJsonSerializerContext.cs`**
|
||||
```csharp
|
||||
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")]
|
||||
```
|
||||
@@ -0,0 +1,154 @@
|
||||
# ToastViewport
|
||||
|
||||
The fixed container that holds all `Toast` notifications. Place exactly one `ToastViewport` in your main layout (e.g. `MainLayout.htmx`). The viewport is invisible when empty and stacks toasts upward as they are added.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div[id={id}].toast-viewport.fixed.bottom-4.right-4.z-50.flex.flex-col-reverse.gap-2.w-80.pointer-events-none
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `fixed bottom-4 right-4` | Anchored to the bottom-right corner of the viewport |
|
||||
| `z-50` | Floats above all other content including dialogs and dropdowns |
|
||||
| `flex flex-col-reverse gap-2` | New toasts appear on top; older ones push downward |
|
||||
| `w-80` | Matches the default toast width |
|
||||
| `pointer-events-none` | The container itself doesn't capture clicks — toasts set `pointer-events-auto` individually |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public ToastViewport(string id = "toast-viewport")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id (default: `"toast-viewport"`). `components.js` queries `#toast-viewport` by default — only change this if you also update the JS lookup. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Place in MainLayout
|
||||
|
||||
```html
|
||||
<!-- MainLayout.htmx -->
|
||||
<body class="...">
|
||||
<main>$$Body$$</main>
|
||||
$$ToastViewport$$
|
||||
</body>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// MainLayout.htmx.cs
|
||||
public IHtmxComponent ToastViewport { get; } = new ToastViewport();
|
||||
|
||||
protected override void RenderToastViewport(HtmxRenderContext ctx)
|
||||
=> ToastViewport.Render(ctx.Next());
|
||||
```
|
||||
|
||||
### Custom id (advanced)
|
||||
|
||||
```csharp
|
||||
new ToastViewport(id: "notifications-container")
|
||||
```
|
||||
|
||||
Then update the JS lookup:
|
||||
|
||||
```js
|
||||
// In components.js or a custom script:
|
||||
const viewport = document.getElementById('notifications-container');
|
||||
```
|
||||
|
||||
### Custom position (bottom-left)
|
||||
|
||||
The position is set by Tailwind classes on the rendered element. To change position, subclass the component or pass `extraClasses` if supported, or override the `toast-viewport` class in your `input.css`:
|
||||
|
||||
```css
|
||||
.toast-viewport {
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
right: auto;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Place `ToastViewport` once in the outermost layout — not inside HTMX swap targets, since HTMX replaces the target's contents and would remove the viewport.
|
||||
- The default id `"toast-viewport"` is hard-coded in `components.js` for the `showToast` lookup. If you rename it, update the JS too.
|
||||
- `ToastViewport` renders as an empty `div` — it has no visual presence until a toast is appended to it.
|
||||
- Multiple viewports on the same page are valid for different toast regions (e.g. top-right and bottom-right), but `showToast` will target whichever viewport id it is configured for.
|
||||
- Multiple viewports on the same page are valid for different toast regions (e.g. top-right and bottom-right), but `showToast` will target whichever viewport id it is configured for.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
`ToastViewport` is a layout-level concern — it lives in `MainLayout`, not in individual page templates. The example below shows the full integration pattern.
|
||||
|
||||
**`Templates/MainLayout.htmx`** (excerpt)
|
||||
```html
|
||||
<body class="min-h-screen bg-background text-foreground">
|
||||
$$NavBar$$
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
$$Content$$
|
||||
</main>
|
||||
$$ToastViewport$$
|
||||
$$Scripts$$
|
||||
</body>
|
||||
```
|
||||
|
||||
**`Templates/MainLayout.htmx.cs`** (excerpt)
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class MainLayout : MainLayoutBase
|
||||
{
|
||||
private readonly IHtmxComponent _nav;
|
||||
private readonly IHtmxComponent _content;
|
||||
private readonly IHtmxComponent _viewport;
|
||||
|
||||
public MainLayout(IHtmxComponent content, IHtmxComponent nav)
|
||||
{
|
||||
_nav = nav;
|
||||
_content = content;
|
||||
|
||||
// Place one viewport at bottom-right for all pages
|
||||
_viewport = new Components.ToastViewport(
|
||||
id: "toast-viewport",
|
||||
position: "bottom-right");
|
||||
}
|
||||
|
||||
protected override void RenderNavBar(HtmxRenderContext ctx) => _nav.Render(ctx.Next());
|
||||
protected override void RenderContent(HtmxRenderContext ctx) => _content.Render(ctx.Next());
|
||||
protected override void RenderToastViewport(HtmxRenderContext ctx) => _viewport.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**Triggering a toast from any handler**
|
||||
```csharp
|
||||
// Any POST handler can fire a toast without changing the ToastViewport markup.
|
||||
ctx.Response.Headers["HX-Trigger"] =
|
||||
"""{"showToast":{"title":"Saved!","description":"Your changes have been persisted."}}""";
|
||||
```
|
||||
|
||||
**Two-viewport layout** (top-right errors + bottom-right successes)
|
||||
```csharp
|
||||
_errorViewport = new Components.ToastViewport(id: "error-viewport", position: "top-right");
|
||||
_successViewport = new Components.ToastViewport(id: "success-viewport", position: "bottom-right");
|
||||
|
||||
// Error toast
|
||||
ctx.Response.Headers["HX-Trigger"] =
|
||||
"""{"showToast":{"viewportId":"error-viewport","title":"Something went wrong."}}""";
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
# Tooltip
|
||||
|
||||
A text hint that appears on hover. Implemented entirely in CSS using Tailwind's `group` and `group-hover` utilities — no JavaScript required.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
span.relative.inline-flex.items-center.group
|
||||
{trigger component rendered inline}
|
||||
span.tooltip-text.absolute.z-50.px-2.py-1.text-xs.rounded.bg-foreground.text-background
|
||||
.whitespace-nowrap.pointer-events-none
|
||||
.opacity-0.group-hover:opacity-100.transition-opacity.duration-150
|
||||
.{position classes}
|
||||
{tooltip text}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Utility | Effect |
|
||||
|---|---|
|
||||
| `group` on wrapper | Enables `group-hover:*` utilities on descendants |
|
||||
| `opacity-0` | Tooltip invisible at rest |
|
||||
| `group-hover:opacity-100` | Tooltip fades in when the wrapper (group) is hovered |
|
||||
| `transition-opacity duration-150` | 150ms fade animation |
|
||||
| `pointer-events-none` | Tooltip itself doesn't interfere with hover detection |
|
||||
| `bg-foreground text-background` | Dark-on-light / light-on-dark automatically via CSS variables |
|
||||
| `whitespace-nowrap` | Prevents the tooltip from wrapping |
|
||||
| `z-50` | Floats above surrounding content |
|
||||
|
||||
**Position classes by `position` parameter:**
|
||||
|
||||
| Position | Classes |
|
||||
|---|---|
|
||||
| `top` (default) | `bottom-full mb-1.5 left-1/2 -translate-x-1/2` |
|
||||
| `bottom` | `top-full mt-1.5 left-1/2 -translate-x-1/2` |
|
||||
| `left` | `right-full mr-1.5 top-1/2 -translate-y-1/2` |
|
||||
| `right` | `left-full ml-1.5 top-1/2 -translate-y-1/2` |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Tooltip(
|
||||
string text,
|
||||
IHtmxComponent trigger,
|
||||
string position = "top")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `text` | Tooltip label (plain text; HTML not supported) |
|
||||
| `trigger` | Any `IHtmxComponent` that acts as the hover target |
|
||||
| `position` | `"top"` / `"bottom"` / `"left"` / `"right"` |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Icon button with tooltip
|
||||
|
||||
```csharp
|
||||
new Tooltip(
|
||||
text: "Delete item",
|
||||
trigger: new Button("🗑", size: "icon", variant: "ghost"))
|
||||
```
|
||||
|
||||
### Top/bottom/left/right positions
|
||||
|
||||
```csharp
|
||||
new Tooltip(text: "Above", trigger: new Button("Hover me"), position: "top")
|
||||
new Tooltip(text: "Below", trigger: new Button("Hover me"), position: "bottom")
|
||||
new Tooltip(text: "Left", trigger: new Button("Hover me"), position: "left")
|
||||
new Tooltip(text: "Right", trigger: new Button("Hover me"), position: "right")
|
||||
```
|
||||
|
||||
### Tooltip on an Avatar
|
||||
|
||||
```csharp
|
||||
new Tooltip(
|
||||
text: user.DisplayName ?? "Unknown user",
|
||||
trigger: new Avatar(fallback: user.Initials, src: user.AvatarUrl))
|
||||
```
|
||||
|
||||
### Tooltip on a disabled-looking button
|
||||
|
||||
```csharp
|
||||
new Tooltip(
|
||||
text: "You need admin access",
|
||||
trigger: new Button(
|
||||
"Publish",
|
||||
variant: "default",
|
||||
hxAttrs: "disabled aria-disabled='true' tabindex='-1'"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The tooltip text is plain text — HTML special characters in `text` will be HTML-encoded automatically.
|
||||
- Tooltip position may overflow the viewport if the trigger is near an edge — test all four positions and choose the one that fits.
|
||||
- Since there is no JS, the tooltip works even when JavaScript is disabled.
|
||||
- The trigger receives the `group` class implicitly — this means `group-hover:*` utilities on any child of the trigger will also activate on hover. Keep this in mind if the trigger component uses nested group utilities.
|
||||
- For touch devices the hover state is never triggered — consider providing the tooltip content elsewhere (e.g. as a `description` on a form field) if the information is essential.
|
||||
- To show a tooltip on a non-interactive element (e.g. a truncated table cell), wrap the element in a `<span>` via a custom slot and pass that as the trigger.
|
||||
- To show a tooltip on a non-interactive element (e.g. a truncated table cell), wrap the element in a `<span>` via a custom slot and pass that as the trigger.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ActionToolbarPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-3xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Document editor</h1>
|
||||
<div class="flex items-center gap-2 border rounded-md p-2 mb-6">
|
||||
$$BoldBtn$$
|
||||
$$ItalicBtn$$
|
||||
$$UnderlineBtn$$
|
||||
$$SepToolbar$$
|
||||
$$UndoBtn$$
|
||||
$$RedoBtn$$
|
||||
</div>
|
||||
<div class="border rounded-md p-4 min-h-64 prose">
|
||||
$$EditorContent$$
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ActionToolbarPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ActionToolbarPage : ActionToolbarPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _bold;
|
||||
private readonly IHtmxComponent _italic;
|
||||
private readonly IHtmxComponent _underline;
|
||||
private readonly IHtmxComponent _sep;
|
||||
private readonly IHtmxComponent _undo;
|
||||
private readonly IHtmxComponent _redo;
|
||||
private readonly byte[] _content;
|
||||
|
||||
public ActionToolbarPage()
|
||||
{
|
||||
_bold = TooltipButton("Bold", "B", "font-bold");
|
||||
_italic = TooltipButton("Italic", "I", "italic");
|
||||
_underline = TooltipButton("Underline", "U", "underline");
|
||||
_sep = new Components.Separator(orientation: "vertical", extraClasses: "mx-1 h-6");
|
||||
_undo = TooltipButton("Undo (Ctrl+Z)", "↩", "");
|
||||
_redo = TooltipButton("Redo (Ctrl+Y)", "↪", "");
|
||||
_content = "<p>Start typing...</p>".ToUtf8Bytes();
|
||||
}
|
||||
|
||||
private static IHtmxComponent TooltipButton(string tip, string label, string textClass)
|
||||
=> new Components.Tooltip(
|
||||
content: tip,
|
||||
trigger: new Components.Button(
|
||||
label,
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
extraClasses: textClass));
|
||||
|
||||
protected override void RenderBoldBtn(HtmxRenderContext ctx) => _bold.Render(ctx.Next());
|
||||
protected override void RenderItalicBtn(HtmxRenderContext ctx) => _italic.Render(ctx.Next());
|
||||
protected override void RenderUnderlineBtn(HtmxRenderContext ctx) => _underline.Render(ctx.Next());
|
||||
protected override void RenderSepToolbar(HtmxRenderContext ctx) => _sep.Render(ctx.Next());
|
||||
protected override void RenderUndoBtn(HtmxRenderContext ctx) => _undo.Render(ctx.Next());
|
||||
protected override void RenderRedoBtn(HtmxRenderContext ctx) => _redo.Render(ctx.Next());
|
||||
protected override void RenderEditorContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_content);
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/editor")]
|
||||
public static partial class GetEditorHandler
|
||||
{
|
||||
public record Query();
|
||||
private static Task<IResult> HandleAsync(Query _, HttpContext ctx, CancellationToken ct)
|
||||
=> ctx.WriteHtmxPage(new ActionToolbarPage(), title: "Document editor");
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user