Documentations added

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-04 19:57:48 +05:00
parent 40a7d9018c
commit ee8797c142
35 changed files with 6655 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
# Getting Started
This guide gets the solution running locally and explains what happens during startup.
## What is in this solution
- `Htmx.ApiDemo`: ASP.NET Core app (Minimal API + generated HTMX endpoints)
- `Htmx.SourceGenerator`: Roslyn source generator that discovers `.htmx` files and generates endpoint mapping code
- `Htmx.slnx`: solution file at the repository root
## Prerequisites
- .NET SDK 10.x (target framework is `net10.0`)
- Node.js + npm (used for Tailwind CSS compilation during build)
- MongoDB running locally on `mongodb://localhost:27017`
## First-time setup
From the repository root:
```bash
cd Htmx.ApiDemo
npm install
```
Why this is required:
- The app build runs Tailwind via `npx @tailwindcss/cli ...` in a custom MSBuild target.
- Without `npm install`, build fails because the Tailwind CLI package is missing.
## Run the app
From the repository root:
```bash
dotnet run --project Htmx.ApiDemo/Htmx.ApiDemo.csproj
```
Default local URL:
- `http://localhost:5120`
This comes from the launch profile in `Htmx.ApiDemo/Properties/launchSettings.json`.
## Verify it works
1. Open `http://localhost:5120`
2. If you are not authenticated, middleware redirects to `/login`
3. Create an account at `/register`
4. Sign in and navigate the app
## What startup config does
`Htmx.ApiDemo/Program.cs` configures:
- MongoDB DI and index initialization (`EnsureIndexesAsync`)
- Cookie authentication + authorization
- Antiforgery middleware
- AOT-friendly JSON resolver chain using `AppJsonSerializerContext`
- Endpoint registration via generated mapping call:
- `app.MapHtmxApiDemoEndpoints();`
## Build behavior worth knowing
- Tailwind CSS is compiled before build into `Htmx.ApiDemo/wwwroot/css/output.css`
- `.htmx` files are treated as generator inputs (`<AdditionalFiles Include="**/*.htmx" />`)
- AOT is enabled (`<PublishAot>true</PublishAot>`), so reflection-heavy patterns can break publish/runtime
## Optional: publish as AOT
```bash
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
```
Use this early to catch AOT issues while developing features.
## Troubleshooting
### Build fails on Tailwind command
- Run `npm install` inside `Htmx.ApiDemo`
- Confirm `node -v` and `npm -v` are available
### Mongo connection errors
- Confirm MongoDB is running on `localhost:27017`
- Confirm `ConnectionStrings:DefaultConnection` in `Htmx.ApiDemo/appsettings.json`
### App keeps redirecting to login
- This is expected for unauthenticated routes
- Register at `/register` or sign in at `/login`
+181
View File
@@ -0,0 +1,181 @@
# Creating a New Page
This guide explains the full lifecycle of adding a new page to the app: the template file, the code-behind class, the handler, and the sidebar link.
## How pages work
Every page is a pair of files:
| File | Purpose |
|---|---|
| `Templates/MyPage.htmx` | HTML markup with `$$SlotName$$` slots |
| `Templates/MyPage.htmx.cs` | C# class that fills slots + declares the Minimal API handler |
The Roslyn source generator (`Htmx.SourceGenerator`) reads every `.htmx` file at build time and generates an abstract base class for it. You then write a concrete class in the companion `.htmx.cs` file that inherits from that base.
## How `$$SlotName$$` becomes code
Take this simple template:
```html
<!-- Templates/MyPage.htmx -->
<div class="p-6">
<h1>$$Title$$</h1>
<p>$$Body$$</p>
</div>
```
The generator splits the file on `$$...$$` patterns and produces:
```csharp
// auto-generated — do NOT edit
public abstract partial class MyPageBase : IHtmxComponent
{
protected abstract void RenderTitle(HtmxRenderContext context);
protected abstract void RenderBody(HtmxRenderContext context);
// static HTML segments stored as ReadOnlySpan<byte> for zero-allocation output
private static ReadOnlySpan<byte> _part0 => new byte[] { ... };
private static ReadOnlySpan<byte> _part1 => new byte[] { ... };
private static ReadOnlySpan<byte> _part2 => new byte[] { ... };
public void Render(HtmxRenderContext context)
{
context.Writer.WriteUtf8(_part0); // <div class="p-6"><h1>
RenderTitle(context.Next());
context.Writer.WriteUtf8(_part1); // </h1><p>
RenderBody(context.Next());
context.Writer.WriteUtf8(_part2); // </p></div>
}
}
```
Your job is to write the concrete class that implements each `RenderXxx` method.
## Step 1 — Create the `.htmx` template
Create `Htmx.ApiDemo/Templates/MyPage.htmx`:
```html
<div class="p-6 space-y-4">
<h1 class="text-2xl font-bold text-foreground">$$Heading$$</h1>
<p class="text-muted-foreground">$$Description$$</p>
</div>
```
Rules:
- Slot names are **PascalCase** and surrounded by `$$` — e.g. `$$MySlot$$`
- A slot can hold plain text, HTML, or another rendered component
- The file must be saved in `Templates/` (or a subfolder) so the `.csproj` `AdditionalFiles` glob picks it up
## Step 2 — Create the `.htmx.cs` code-behind
Create `Htmx.ApiDemo/Templates/MyPage.htmx.cs`:
```csharp
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
namespace Htmx.ApiDemo.Templates;
// Concrete template — inherits from the generated base
public sealed class MyPage : MyPageBase
{
private byte[] _headingData = [];
private byte[] _descriptionData = [];
// Use `init`-only setters to pre-encode strings to UTF-8 bytes once
public required string Heading { init => _headingData = value.ToUtf8Bytes(); }
public required string Description { init => _descriptionData = value.ToUtf8Bytes(); }
protected override void RenderHeading(HtmxRenderContext context)
=> context.Writer.WriteUtf8(_headingData);
protected override void RenderDescription(HtmxRenderContext context)
=> context.Writer.WriteUtf8(_descriptionData);
}
// Minimal API handler — discovered and registered by the source generator
[Handler]
[MapGet("/my-page")]
public static partial class GetMyPageHandler
{
public record Query; // add route/query parameters here if needed
private static ValueTask HandleAsync(
Query query,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var page = new MyPage
{
Heading = "My New Page",
Description = "This is a minimal example page."
};
// WriteHtmxPage: full HTML shell for direct browser loads,
// bare fragment for HTMX partial swaps (HX-Request header present)
ctx.WriteHtmxPage(page, title: "My Page", appName: "HtmxApp", pageTitle: "My Page");
return ValueTask.CompletedTask;
}
}
```
## Step 3 — Add a sidebar link (optional but typical)
Open `Templates/MainLayout.htmx` and add a nav entry inside the `<nav>` block. Existing entries look like this:
```html
<a href="/my-page"
hx-get="/my-page" hx-target="#main-view" hx-push-url="true"
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<!-- inline SVG icon here -->
My Page
</a>
```
Key HTMX attributes:
- `hx-get` — makes the navigation a partial swap instead of a full page reload
- `hx-target="#main-view"` — replaces only the content area, keeping the sidebar in place
- `hx-push-url="true"` — updates the browser URL bar so deep-links still work
## How `WriteHtmxPage` decides what to render
```
Request has HX-Request header?
YES → render bare fragment + set HX-Title response header (browser tab title updates)
NO → wrap fragment in MainLayout (full HTML page with sidebar, navbar, etc.)
```
The logic lives in `HtmxPageExtensions.WriteHtmxPage`. You never need to fork on this yourself.
## Slots that hold components
A slot does not have to render plain text. If you need to embed a reusable component, assign the component instance and call `Render` from the override:
```csharp
public IHtmxComponent MyCard { get; }
public MyPage(IHtmxComponent myCard)
{
MyCard = myCard;
}
protected override void RenderMyCard(HtmxRenderContext context)
=> MyCard.Render(context);
```
See [03-creating-a-component.md](03-creating-a-component.md) for the full component pattern.
## Checklist
- [ ] `MyPage.htmx` created in `Templates/`
- [ ] `MyPage.htmx.cs` created with a class inheriting `MyPageBase`
- [ ] Each `$$Slot$$` has a matching `RenderSlot` override
- [ ] `[Handler]` + `[MapGet(...)]` (or `MapPost` etc.) on the handler class
- [ ] `ctx.WriteHtmxPage(...)` called from `HandleAsync`
- [ ] Build once — if a slot is missing its override, the compiler will tell you
+215
View File
@@ -0,0 +1,215 @@
# Creating a New Component
Components are the reusable building blocks of the UI. They follow the same `.htmx` + `.htmx.cs` pair pattern as pages, but they live in `Templates/Components/`, implement `IHtmxComponent`, and are never responsible for HTTP routing.
## The three component patterns
All existing components fall into one of three shapes. Pick the one that fits what you are building.
---
### Pattern A — Simple slot component
Use this when every piece of output is a plain string set from outside.
```html
<!-- Templates/Components/Badge.htmx -->
<span class="$$Classes$$">$$Label$$</span>
```
```csharp
// Templates/Components/Badge.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;
public sealed class Badge : BadgeBase
{
private readonly byte[] _labelData;
private readonly byte[] _classesData;
// Compute the final class string once in the constructor,
// encode to UTF-8 bytes, never allocate again during render
public Badge(string label, string variant = "default")
{
_labelData = label.ToUtf8Bytes();
var variantClass = variant switch
{
"secondary" => "bg-secondary text-secondary-foreground",
"destructive" => "bg-destructive text-destructive-foreground",
"outline" => "border border-border text-foreground",
_ => "bg-primary text-primary-foreground",
};
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClass}"
.ToUtf8Bytes();
}
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
}
```
---
### Pattern B — Conditionally built sections
Use this when parts of the template are optional (e.g. a card header that only renders when a title is provided). Build the HTML string in the constructor and store as bytes; leave the byte array empty `[]` when not needed — `WriteUtf8` on an empty span is a no-op.
```html
<!-- Templates/Components/Card.htmx -->
<div class="rounded-lg border border-border bg-card text-card-foreground shadow-sm $$ExtraClasses$$">
$$Header$$
<div class="p-6 pt-0">$$Content$$</div>
$$Footer$$
</div>
```
```csharp
// Templates/Components/Card.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;
public sealed class Card : CardBase
{
private readonly byte[] _extraClassesData;
private readonly byte[] _headerData;
private readonly byte[] _contentData;
private readonly byte[] _footerData;
public Card(
string content,
string title = "",
string description = "",
string footer = "",
string extraClasses = "")
{
_extraClassesData = extraClasses.ToUtf8Bytes();
_contentData = content.ToUtf8Bytes();
// Header is only rendered when a title or description is supplied
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
? []
: BuildHeader(title, description);
_footerData = string.IsNullOrEmpty(footer)
? []
: $"""<div class="flex items-center p-6 pt-0">{footer}</div>""".ToUtf8Bytes();
}
private static byte[] BuildHeader(string title, string description)
{
var sb = new System.Text.StringBuilder();
sb.Append("""<div class="flex flex-col space-y-1.5 p-6">""");
if (!string.IsNullOrEmpty(title))
sb.Append($"""<h3 class="text-2xl font-semibold leading-none tracking-tight">{title}</h3>""");
if (!string.IsNullOrEmpty(description))
sb.Append($"""<p class="text-sm text-muted-foreground">{description}</p>""");
sb.Append("</div>");
return sb.ToString().ToUtf8Bytes();
}
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
protected override void RenderHeader(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headerData);
protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData);
protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData);
}
```
---
### Pattern C — Component slots (embedding other components)
Use this when a slot should itself be rendered by another `IHtmxComponent`. Store the sub-component as a property and call `component.Render(context)` from the override.
```html
<!-- Templates/Components/MyWrapper.htmx -->
<div class="wrapper p-4">
$$Inner$$
</div>
```
```csharp
// Templates/Components/MyWrapper.htmx.cs
namespace Htmx.ApiDemo.Templates.Components;
public sealed class MyWrapper : MyWrapperBase
{
private readonly IHtmxComponent _inner;
public MyWrapper(IHtmxComponent inner)
{
_inner = inner;
}
// Pass context.Next() so the recursion depth counter increments;
// the runtime throws if nesting exceeds 512 levels
protected override void RenderInner(HtmxRenderContext ctx)
=> _inner.Render(ctx.Next());
}
```
The depth guard (`context.Next()`) is automatically enforced by the infrastructure generated in `HtmxInfrastructure.g.cs`. You do not need to check it yourself.
---
## Embedding a component in a page
Once a component implements `IHtmxComponent`, use it from a page's code-behind by assigning an instance to an `IHtmxComponent` property and delegating `Render`:
```csharp
// inside MyPage.htmx.cs
public IHtmxComponent MyBadge { get; }
public MyPage(...)
{
MyBadge = new Badge("New", variant: "secondary");
}
protected override void RenderMyBadge(HtmxRenderContext ctx)
=> MyBadge.Render(ctx.Next());
```
The corresponding slot in `MyPage.htmx`:
```html
<div class="flex gap-2">
<span>Status:</span>
$$MyBadge$$
</div>
```
---
## File naming and namespace rules
| File location | Generated namespace |
|---|---|
| `Templates/Components/MyComp.htmx` | `Htmx.ApiDemo.Templates.Components` |
| `Templates/MyPage.htmx` | `Htmx.ApiDemo.Templates` |
The source generator derives the namespace from the folder path relative to the project root. Keep components in `Templates/Components/` so they land in the right namespace and stay separate from page templates.
---
## HTML user content safety
The `WriteUtf8` method writes raw bytes directly to the response. **It does not HTML-encode.**
- Static strings you write in the constructor are trusted — you control them.
- Any value that comes from user input (e.g. a form field, a database string) **must be HTML-encoded before calling `ToUtf8Bytes()`**.
```csharp
// Safe — user-supplied string is encoded first
_displayNameData = System.Web.HttpUtility.HtmlEncode(userDisplayName).ToUtf8Bytes();
```
The existing `MainLayout` constructor demonstrates this for the user initials section.
---
## Checklist
- [ ] `MyComp.htmx` created in `Templates/Components/`
- [ ] `MyComp.htmx.cs` created with class inheriting `MyCompBase`
- [ ] All `$$Slot$$`s have a matching `RenderSlot` override
- [ ] User-supplied strings are HTML-encoded before `ToUtf8Bytes()`
- [ ] Build once to confirm the compiler catches any missing overrides
+163
View File
@@ -0,0 +1,163 @@
# Data Models and AOT Safety
This guide explains how to define MongoDB document models, register them for AOT-safe serialization, and avoid the common patterns that break Native AOT compilation.
## Why AOT matters
The project is compiled with `<PublishAot>true</PublishAot>`. AOT (Ahead-of-Time) compilation strips out the JIT and eliminates reflection-based code paths at runtime. Any code that relies on `Type.GetProperties()`, `Activator.CreateInstance()`, `Expression.Compile()`, or similar reflection primitives will either:
- Produce a build warning during `dotnet publish`, or
- Throw a `MissingMethodException` / `InvalidOperationException` at runtime
The two main risks in this project are MongoDB BSON serialization and System.Text.Json serialization. Both require explicit registration rather than auto-discovery.
---
## Defining a document model
A document class is a plain C# class annotated with BSON attribute hints. Keep it simple:
```csharp
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Htmx.ApiDemo.Data;
public sealed class AppUser
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("email")]
public string Email { get; set; } = "";
[BsonElement("normalizedEmail")]
public string NormalizedEmail { get; set; } = "";
[BsonElement("passwordHash")]
public string PasswordHash { get; set; } = "";
[BsonElement("displayName")]
public string? DisplayName { get; set; }
[BsonElement("createdAtUtc")]
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
```
Rules:
- Always annotate the primary key with `[BsonId]`
- Always annotate every persisted property with `[BsonElement("fieldName")]` — this makes the MongoDB field name explicit and independent of C# naming conventions
- Use `SetIgnoreExtraElements(true)` in the class map (see below) so old documents with extra fields do not crash deserialization
---
## Registering the class map (AOT-safe)
MongoDB's default `AutoMap()` uses reflection to discover properties at runtime. This is not AOT-safe. Instead, register an explicit `BsonClassMap` in `Program.cs` **before** `WebApplication.CreateSlimBuilder`:
```csharp
// Program.cs — must appear before builder construction
BsonClassMap.RegisterClassMap<AppUser>(cm =>
{
cm.MapIdProperty(u => u.Id).SetSerializer(new ObjectIdSerializer());
cm.MapProperty(u => u.Email).SetElementName("email");
cm.MapProperty(u => u.NormalizedEmail).SetElementName("normalizedEmail");
cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash");
cm.MapProperty(u => u.DisplayName).SetElementName("displayName");
cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc");
cm.SetIgnoreExtraElements(true);
});
```
This replaces AutoMap entirely. Every property you want persisted must be listed here.
### Adding a new model
1. Create the model class in `Data/` with `[BsonId]` and `[BsonElement]` attributes
2. Add a `BsonClassMap.RegisterClassMap<YourModel>(...)` block in `Program.cs` before the builder
3. Wire the collection into `MongoDbService`
---
## MongoDbService pattern
`MongoDbService` is the single place that owns typed MongoDB collections. Add new collections here:
```csharp
// Data/MongoDbService.cs
public sealed class MongoDbService
{
private readonly IMongoCollection<AppUser> _users;
// add more collections here
public MongoDbService(IMongoClient client, IConfiguration configuration)
{
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
_users = db.GetCollection<AppUser>("users");
// _posts = db.GetCollection<Post>("posts");
}
}
```
All queries use the strongly-typed `Builders<T>` API:
```csharp
// Exact-match lookup — no LINQ translation, no reflection at runtime
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
return await _users.Find(filter).FirstOrDefaultAsync(ct);
```
The `Builders<T>` API compiles query expressions to BSON at build time via source generators in the MongoDB driver — it does not require runtime reflection.
---
## Index management
Indexes are created via `EnsureIndexesAsync`, called once at startup from `Program.cs`:
```csharp
using (var scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync();
```
Add new indexes to `EnsureIndexesAsync`:
```csharp
public async Task EnsureIndexesAsync(CancellationToken ct = default)
{
var indexKeys = Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail);
var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" };
var model = new CreateIndexModel<AppUser>(indexKeys, indexOptions);
await _users.Indexes.CreateOneAsync(model, cancellationToken: ct);
// add more index creation calls here — CreateOneAsync is idempotent
}
```
The call is idempotent: if the index already exists with the same definition, MongoDB silently succeeds.
---
## AOT anti-patterns to avoid
| Pattern | Why it breaks AOT | Safe alternative |
|---|---|---|
| `BsonClassMap.RegisterClassMap<T>()` without explicit mapping | Uses AutoMap reflection | Explicit `cm.MapProperty(...)` for every field |
| `collection.AsQueryable().Where(...)` | EF-style LINQ translation uses reflection | `Builders<T>.Filter.Eq(...)` |
| `JsonSerializer.Deserialize<T>(json)` without a type resolver | Reflects on T at runtime | Register T in `AppJsonSerializerContext` |
| `Activator.CreateInstance(type)` | Requires reflection metadata | Use `new T()` directly |
| `typeof(T).GetProperties()` | Stripped by trimmer | Not needed with explicit class maps |
---
## Checking for AOT warnings
Run a Release publish and watch the output:
```bash
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
```
Any trim/AOT warning in the output is a potential runtime failure. Fix each warning before it reaches production. The most common suppressable case is third-party libraries that are not fully AOT-annotated — suppress only after manually verifying the code path is not exercised at runtime.
+200
View File
@@ -0,0 +1,200 @@
# Form Submission and AppJsonSerializerContext
This guide explains how to wire a form POST endpoint, why `AppJsonSerializerContext` must be kept up to date, and what happens if you forget.
## How form POST endpoints work
Form submissions use standard HTML `method="post"` forms. The server-side handler receives the posted fields as a strongly-typed `record` using `[FromForm]` bindings:
```csharp
[Handler]
[MapPost("/login")]
public static partial class PostLoginHandler
{
public record Command(
[property: FromForm] string Email,
[property: FromForm] string Password
);
private static async ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
AuthService authService,
CancellationToken token)
{
// ...
}
}
```
Key parts:
- `[property: FromForm]` on each record property tells the Minimal API binder to read the value from the form body, not from the route or query string
- `[AsParameters]` on the `Command` argument tells Minimal API to bind the record's properties individually rather than deserializing the whole body as JSON
- The handler is discovered and registered by the `Immediate.Apis` source generator — no `app.MapPost(...)` call is needed
---
## Antiforgery tokens
All mutating form POST endpoints must be protected against CSRF. The middleware chain includes `app.UseAntiforgery()` (added in `Program.cs`), which validates the `__RequestVerificationToken` field automatically for any `POST`/`PUT`/`DELETE` form submission.
To include the token in a form rendered server-side:
```csharp
// Inside a page/component constructor, inject IAntiforgery:
var afTokens = antiforgery.GetAndStoreTokens(ctx);
var tokenField = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afTokens.RequestToken)}" />""";
```
The `MainLayout` constructor and all auth page constructors already do this. Any new form page must follow the same pattern.
---
## Why AppJsonSerializerContext is required
The project uses `WebApplication.CreateSlimBuilder`, which enables the Minimal API **Request Delegate Generator**. This generator produces the endpoint binding code at compile time instead of using runtime reflection.
For form-body binding to work under AOT, the JSON serializer's type resolver chain must know about every request/response type it will encounter. This is configured in `Program.cs`:
```csharp
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
```
`AppJsonSerializerContext` is a source-generated `JsonSerializerContext`. It is declared in `AppJsonSerializerContext.cs`:
```csharp
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
```
The `[JsonSerializable]` attribute tells the source generator to emit the serialization metadata for that type at compile time. Without this, the runtime falls back to reflection — which is stripped under AOT and will throw at runtime.
---
## What breaks if you forget to register a type
If you add a new form POST endpoint with a new `Command` record and do not register the record in `AppJsonSerializerContext`:
- A `dotnet build` will succeed and may even run fine in Development with the JIT
- A `dotnet publish -c Release` (AOT) will either emit a trim warning or silently produce a binary that throws `NotSupportedException: Serialization and deserialization of ... is not supported` at runtime when the endpoint is first hit
This is one of the most common mistakes when adding new endpoints.
---
## Step-by-step: adding a new form POST
### 1. Define the command record
```csharp
[Handler]
[MapPost("/contact")]
public static partial class PostContactHandler
{
public record Command(
[property: FromForm] string Name,
[property: FromForm] string Message
);
private static ValueTask HandleAsync(
[AsParameters] Command command,
IHttpContextAccessor httpContextAccessor,
CancellationToken token)
{
// handle the form submission
var ctx = httpContextAccessor.HttpContext!;
ctx.Response.Redirect("/");
return ValueTask.CompletedTask;
}
}
```
### 2. Register the command in AppJsonSerializerContext
Open `Htmx.ApiDemo/AppJsonSerializerContext.cs` and add a `[JsonSerializable]` entry:
```csharp
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] // ← add this
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
```
`TypeInfoPropertyName` is optional but recommended — it gives the generated type-info property a readable name and avoids collisions when two commands have the same type name in different namespaces.
### 3. Include the antiforgery token in the form template
```html
<!-- Templates/Contact.htmx -->
<form method="post" action="/contact" class="space-y-4">
$$AntiforgeryToken$$
<input name="name" type="text" placeholder="Your name" />
<input name="message" type="text" placeholder="Message" />
<button type="submit">Send</button>
</form>
```
```csharp
// Templates/Contact.htmx.cs
public sealed class Contact : ContactBase
{
private readonly byte[] _afTokenData;
public Contact(string? afToken = null)
{
_afTokenData = string.IsNullOrEmpty(afToken)
? []
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""".ToUtf8Bytes();
}
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx)
=> ctx.Writer.WriteUtf8(_afTokenData);
}
```
### 4. Inject and pass the token from the GET handler
```csharp
[Handler]
[MapGet("/contact")]
public static partial class GetContactHandler
{
public record Query;
private static ValueTask HandleAsync(
Query _,
IHttpContextAccessor httpContextAccessor,
IAntiforgery antiforgery,
CancellationToken token)
{
var ctx = httpContextAccessor.HttpContext!;
var afTokens = antiforgery.GetAndStoreTokens(ctx);
ctx.WriteHtmxPage(new Contact(afToken: afTokens.RequestToken), title: "Contact");
return ValueTask.CompletedTask;
}
}
```
---
## Checklist
- [ ] `Command` record properties use `[property: FromForm]`
- [ ] Handler uses `[AsParameters]` on the command
- [ ] `Command` type is registered in `AppJsonSerializerContext` with `[JsonSerializable]`
- [ ] Form template includes the antiforgery hidden input
- [ ] GET handler resolves `IAntiforgery`, calls `GetAndStoreTokens`, and passes the token to the template
- [ ] Tested with `dotnet publish -c Release` (not just `dotnet run`) before considering it done
+35
View File
@@ -0,0 +1,35 @@
# Component Reference
All components live in `Htmx.ApiDemo/Templates/Components/`. Each is a `.htmx` + `.htmx.cs` pair implementing `IHtmxComponent`.
| Component | JS? | File |
|---|---|---|
| Accordion | Yes | [Components/Accordion.md](Components/Accordion.md) |
| Alert | No | [Components/Alert.md](Components/Alert.md) |
| Avatar | No | [Components/Avatar.md](Components/Avatar.md) |
| Badge | No | [Components/Badge.md](Components/Badge.md) |
| Breadcrumb | No | [Components/Breadcrumb.md](Components/Breadcrumb.md) |
| Button | No | [Components/Button.md](Components/Button.md) |
| Calendar | Yes | [Components/Calendar.md](Components/Calendar.md) |
| CalendarRange | Yes | [Components/CalendarRange.md](Components/CalendarRange.md) |
| Card | No | [Components/Card.md](Components/Card.md) |
| Checkbox | No | [Components/Checkbox.md](Components/Checkbox.md) |
| Dialog | Yes | [Components/Dialog.md](Components/Dialog.md) |
| DropdownMenu | Yes | [Components/DropdownMenu.md](Components/DropdownMenu.md) |
| FileInput | No | [Components/FileInput.md](Components/FileInput.md) |
| Input | No | [Components/Input.md](Components/Input.md) |
| Pagination | No | [Components/Pagination.md](Components/Pagination.md) |
| Progress | No | [Components/Progress.md](Components/Progress.md) |
| RadioGroup | No | [Components/RadioGroup.md](Components/RadioGroup.md) |
| Select | No | [Components/Select.md](Components/Select.md) |
| Separator | No | [Components/Separator.md](Components/Separator.md) |
| Skeleton | No | [Components/Skeleton.md](Components/Skeleton.md) |
| Slider | No | [Components/Slider.md](Components/Slider.md) |
| Switch | Yes | [Components/Switch.md](Components/Switch.md) |
| Table | No | [Components/Table.md](Components/Table.md) |
| Tabs | Yes | [Components/Tabs.md](Components/Tabs.md) |
| Textarea | No | [Components/Textarea.md](Components/Textarea.md) |
| TimePicker | Yes | [Components/TimePicker.md](Components/TimePicker.md) |
| Toast | Yes | [Components/Toast.md](Components/Toast.md) |
| ToastViewport | Paired with Toast | [Components/ToastViewport.md](Components/ToastViewport.md) |
| Tooltip | No (pure CSS) | [Components/Tooltip.md](Components/Tooltip.md) |
+175
View File
@@ -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");
}
```
+177
View File
@@ -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:0002: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");
}
}
```
+169
View File
@@ -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");
}
}
```
+182
View File
@@ -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");
}
}
```
+175
View File
@@ -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);
}
}
```
+193
View File
@@ -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");
}
```
+237
View File
@@ -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 ← SunSat 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 JanDec 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")]
```
+228
View File
@@ -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")]
```
+184
View File
@@ -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);
```
+207
View File
@@ -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")]
```
+252
View File
@@ -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);
```
+198
View File
@@ -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");
}
}
```
+212
View File
@@ -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");
}
}
```
+221
View File
@@ -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")]
```
+172
View File
@@ -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 (23 pages), all page buttons are shown with no ellipsis.
- For very small page counts (23 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");
}
}
```
+176
View File
@@ -0,0 +1,176 @@
# Progress
A horizontal progress bar. Value is clamped to 0100. 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 0100 |
| `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");
}
}
```
+211
View File
@@ -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")]
```
+234
View File
@@ -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 AZ"),
("name-desc", "Name ZA"),
("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");
}
}
```
+148
View File
@@ -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");
}
```
+185
View File
@@ -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");
}
}
```
+203
View File
@@ -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 0100 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: "10100")
```
### 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 0100 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")]
```
+210
View File
@@ -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")]
```
+200
View File
@@ -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");
}
}
```
+200
View File
@@ -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");
}
}
```
+229
View File
@@ -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")]
```
+236
View File
@@ -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 (112 or 023)
span.text-muted-foreground :
select.timepicker-m[name={name}-m] ← minute select (0059)
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 112 |
---
## 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")]
```
+213
View File
@@ -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")]
```
+154
View File
@@ -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."}}""";
```
+188
View File
@@ -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");
}
```