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