Rewrote all the docs - more noob friendly now.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
+155
-41
@@ -1,32 +1,141 @@
|
|||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
This guide gets the solution running locally and explains what happens during startup.
|
This guide walks you through everything you need to get the project running locally — from installing tools to understanding why certain architectural decisions were made.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What is in this solution
|
## What is in this solution
|
||||||
|
|
||||||
- `Htmx.ApiDemo`: ASP.NET Core app (Minimal API + generated HTMX endpoints)
|
| Project | Purpose |
|
||||||
- `Htmx.SourceGenerator`: Roslyn source generator that discovers `.htmx` files and generates endpoint mapping code
|
|---|---|
|
||||||
- `Htmx.slnx`: solution file at the repository root
|
| `Htmx.ApiDemo` | ASP.NET Core web app using Minimal APIs and server-rendered HTMX templates |
|
||||||
|
| `Htmx.SourceGenerator` | Roslyn source generator that reads `.htmx` template files and generates endpoint mapping code at build time |
|
||||||
|
|
||||||
|
The solution file is `Htmx.slnx` at the repository root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- .NET SDK 10.x (target framework is `net10.0`)
|
Install the following before cloning the repo.
|
||||||
- Node.js + npm (used for Tailwind CSS compilation during build)
|
|
||||||
- MongoDB running locally on `mongodb://localhost:27017`
|
### .NET SDK
|
||||||
|
|
||||||
|
The project targets `net10.0`. Download the .NET 10 SDK from [dot.net](https://dotnet.microsoft.com/download).
|
||||||
|
|
||||||
|
Verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js and npm
|
||||||
|
|
||||||
|
Tailwind CSS is compiled during the build using the Tailwind CLI via `npx`. Node.js must be installed.
|
||||||
|
|
||||||
|
Download from [nodejs.org](https://nodejs.org). Verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### MongoDB
|
||||||
|
|
||||||
|
The app stores data in MongoDB. You need a local instance running on `mongodb://localhost:27017`.
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
|
||||||
|
Download and install [MongoDB Community Server](https://www.mongodb.com/try/download/community). During installation, choose to run MongoDB as a Windows Service so it starts automatically.
|
||||||
|
|
||||||
|
**Linux:**
|
||||||
|
|
||||||
|
Follow the official guide for your distro at [docs.mongodb.com/manual/administration/install-on-linux](https://www.mongodb.com/docs/manual/administration/install-on-linux/). For Ubuntu/Debian:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start mongod
|
||||||
|
sudo systemctl enable mongod # start on boot
|
||||||
|
```
|
||||||
|
|
||||||
|
**MongoDB Compass (optional but recommended):**
|
||||||
|
|
||||||
|
Compass is a GUI for browsing and querying your MongoDB data. Download it from [mongodb.com/products/compass](https://www.mongodb.com/products/compass). Connect it to `mongodb://localhost:27017` to inspect collections while developing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VS Code setup
|
||||||
|
|
||||||
|
### Required extensions
|
||||||
|
|
||||||
|
- **C# Dev Kit** — provides IntelliSense, debugging, and project support for .NET
|
||||||
|
Search for `ms-dotnettools.csdevkit` in the Extensions panel.
|
||||||
|
|
||||||
|
- **C# (OmniSharp / Roslyn)** — included with C# Dev Kit but can also be installed standalone as `ms-dotnettools.csharp`.
|
||||||
|
|
||||||
|
### Recommended settings
|
||||||
|
|
||||||
|
Add the following to your workspace or user `settings.json`. This teaches VS Code to treat `.htmx` files as HTML (for syntax highlighting and formatting) and nests generated companion files under their parent in the Explorer sidebar so the file tree stays clean.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.htmx": "html"
|
||||||
|
},
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
|
"explorer.fileNesting.expand": false,
|
||||||
|
"explorer.fileNesting.patterns": {
|
||||||
|
"*.razor": "$(capture).razor.cs, $(capture).razor.css, $(capture).razor.js",
|
||||||
|
"*.htmx": "${capture}.htmx.cs, ${capture}.htmx.routing.cs, ${capture}.g.cs, ${capture}.css"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `files.associations`, `.htmx` files open as plain text with no highlighting. Without file nesting, every generated `.htmx.cs` and `.htmx.routing.cs` file appears as a separate top-level entry in the Explorer, making it hard to navigate.
|
||||||
|
|
||||||
|
### Optional extension
|
||||||
|
|
||||||
|
- **Tailwind CSS Fold** — collapses long Tailwind class strings in the editor so markup is easier to read. Search for `stivo.tailwind-fold`. This is purely a cosmetic convenience and has no effect on the build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Understanding AOT
|
||||||
|
|
||||||
|
AOT (Ahead-of-Time compilation) means the app is compiled to native machine code before it runs, rather than relying on the .NET JIT at runtime. This project has AOT enabled (`<PublishAot>true</PublishAot>` in the `.csproj`).
|
||||||
|
|
||||||
|
### Why AOT matters here
|
||||||
|
|
||||||
|
AOT produces smaller, faster deployments with no JIT warmup time. For a web app handling many requests, startup time and binary size are real concerns — especially in containerized or serverless environments.
|
||||||
|
|
||||||
|
### What AOT prevents you from doing
|
||||||
|
|
||||||
|
AOT is a significant constraint. It eliminates entire categories of patterns that are common in standard .NET development:
|
||||||
|
|
||||||
|
- **No Entity Framework Core** — EF Core relies heavily on runtime reflection and expression compilation. It is not AOT-compatible. This project uses the MongoDB driver directly instead.
|
||||||
|
|
||||||
|
- **No runtime reflection** — `Type.GetProperties()`, `Activator.CreateInstance()`, dynamic proxies, and similar patterns do not work (or produce warnings/errors) under AOT. If a pattern depends on inspecting types at runtime, it will not survive.
|
||||||
|
|
||||||
|
- **Many NuGet packages are incompatible** — Any package that uses reflection internally (serializers, mappers, validators, ORMs, DI containers with convention scanning, etc.) may break. Check a package's AOT compatibility before adding it.
|
||||||
|
|
||||||
|
- **Source generator-based serialization required** — Rather than `JsonSerializer.Serialize(myObject)` discovering properties at runtime, you must register types with a `JsonSerializerContext` subclass (see `AppJsonSerializerContext.cs`). The serializer then uses generated code instead of reflection.
|
||||||
|
|
||||||
|
- **Route handler code generation** — ASP.NET Core's Minimal API generator produces code for request binding and response writing. Some third-party packages produce code that conflicts with this. If adding a package causes build errors in generated files, AOT incompatibility is the likely cause.
|
||||||
|
|
||||||
|
The practical rule: before reaching for a package or pattern you know from standard ASP.NET Core, check whether it is AOT-compatible. The project will compile normally in Debug mode even with AOT-incompatible code — AOT issues typically only surface during `dotnet publish`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## First-time setup
|
## First-time setup
|
||||||
|
|
||||||
From the repository root:
|
Clone the repo, then install the npm dependencies that the build needs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd Htmx.ApiDemo
|
cd Htmx.ApiDemo
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Why this is required:
|
This installs the Tailwind CSS CLI package. The build runs `npx @tailwindcss/cli` as an MSBuild step, so if this is skipped the build will fail with a missing command error.
|
||||||
|
|
||||||
- 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
|
## Run the app
|
||||||
|
|
||||||
@@ -36,57 +145,62 @@ From the repository root:
|
|||||||
dotnet run --project Htmx.ApiDemo/Htmx.ApiDemo.csproj
|
dotnet run --project Htmx.ApiDemo/Htmx.ApiDemo.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
Default local URL:
|
The app listens on `http://localhost:5120` by default (configured in `Htmx.ApiDemo/Properties/launchSettings.json`).
|
||||||
|
|
||||||
- `http://localhost:5120`
|
---
|
||||||
|
|
||||||
This comes from the launch profile in `Htmx.ApiDemo/Properties/launchSettings.json`.
|
|
||||||
|
|
||||||
## Verify it works
|
## Verify it works
|
||||||
|
|
||||||
1. Open `http://localhost:5120`
|
1. Open `http://localhost:5120` in your browser.
|
||||||
2. If you are not authenticated, middleware redirects to `/login`
|
2. If you are not signed in, the middleware redirects you to `/login` — this is expected.
|
||||||
3. Create an account at `/register`
|
3. Go to `/register` and create an account.
|
||||||
4. Sign in and navigate the app
|
4. Sign in and explore the app.
|
||||||
|
|
||||||
## What startup config does
|
---
|
||||||
|
|
||||||
`Htmx.ApiDemo/Program.cs` configures:
|
## What happens at startup
|
||||||
|
|
||||||
- MongoDB DI and index initialization (`EnsureIndexesAsync`)
|
`Program.cs` wires up the following in order:
|
||||||
- Cookie authentication + authorization
|
|
||||||
|
- MongoDB service registration and index initialization (`EnsureIndexesAsync`)
|
||||||
|
- Cookie-based authentication and authorization
|
||||||
- Antiforgery middleware
|
- Antiforgery middleware
|
||||||
- AOT-friendly JSON resolver chain using `AppJsonSerializerContext`
|
- AOT-compatible JSON serialization via `AppJsonSerializerContext`
|
||||||
- Endpoint registration via generated mapping call:
|
- All generated HTMX endpoints via `app.MapHtmxApiDemoEndpoints()`
|
||||||
- `app.MapHtmxApiDemoEndpoints();`
|
|
||||||
|
|
||||||
## Build behavior worth knowing
|
---
|
||||||
|
|
||||||
- Tailwind CSS is compiled before build into `Htmx.ApiDemo/wwwroot/css/output.css`
|
## Build details
|
||||||
- `.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
|
- **Tailwind** is compiled into `wwwroot/css/output.css` as a pre-build step.
|
||||||
|
- **`.htmx` files** are passed to the source generator as `<AdditionalFiles>`. The generator reads them and produces the abstract base classes and routing code.
|
||||||
|
- **AOT** is active on publish. Run a publish build early and often to catch incompatibilities before they accumulate.
|
||||||
|
|
||||||
|
### Publish (AOT)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
|
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
|
||||||
```
|
```
|
||||||
|
|
||||||
Use this early to catch AOT issues while developing features.
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Build fails on Tailwind command
|
### Build fails — Tailwind command not found
|
||||||
|
|
||||||
- Run `npm install` inside `Htmx.ApiDemo`
|
Run `npm install` inside the `Htmx.ApiDemo` directory. Confirm `node` and `npm` are on your `PATH`.
|
||||||
- Confirm `node -v` and `npm -v` are available
|
|
||||||
|
|
||||||
### Mongo connection errors
|
### MongoDB connection errors at startup
|
||||||
|
|
||||||
- Confirm MongoDB is running on `localhost:27017`
|
- Confirm the MongoDB service is running (`mongod`).
|
||||||
- Confirm `ConnectionStrings:DefaultConnection` in `Htmx.ApiDemo/appsettings.json`
|
- Check that `ConnectionStrings:DefaultConnection` in `appsettings.json` points to `mongodb://localhost:27017`.
|
||||||
|
|
||||||
### App keeps redirecting to login
|
### App always redirects to `/login`
|
||||||
|
|
||||||
- This is expected for unauthenticated routes
|
This is intentional. Unauthenticated requests are redirected by the auth middleware. Register at `/register` first.
|
||||||
- Register at `/register` or sign in at `/login`
|
|
||||||
|
### AOT warnings or errors on publish
|
||||||
|
|
||||||
|
- Look at the warning message — it usually names the type or method causing the issue.
|
||||||
|
- Remove or replace the offending package or pattern with an AOT-compatible alternative.
|
||||||
|
- Run `dotnet publish` regularly during development so issues do not pile up.
|
||||||
|
|||||||
+130
-112
@@ -1,58 +1,32 @@
|
|||||||
# Creating a New Page
|
# 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.
|
Think of a page as a **form letter** — the template is the letter with blanks left for personalisation, and your C# class is the person who fills those blanks in before the letter is sent. The build system generates all the plumbing between the two; you just write the template and the class.
|
||||||
|
|
||||||
## How pages work
|
---
|
||||||
|
|
||||||
Every page is a pair of files:
|
## What you want to achieve
|
||||||
|
|
||||||
| File | Purpose |
|
By the end of this guide you will have a new page at a URL like `/dashboard` that:
|
||||||
|
|
||||||
|
- Shows your own custom HTML
|
||||||
|
- Loads instantly as a full page when you visit the URL directly
|
||||||
|
- Swaps in as a smooth partial update when navigated to from the sidebar
|
||||||
|
- Accepts data you pass to it from C#
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The two files every page needs
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Templates/MyPage.htmx` | HTML markup with `$$SlotName$$` slots |
|
| `Templates/MyPage.htmx` | The letter template — HTML with `$$Slot$$` blanks |
|
||||||
| `Templates/MyPage.htmx.cs` | C# class that fills slots + declares the Minimal API handler |
|
| `Templates/MyPage.htmx.cs` | The person filling in the blanks — C# class + route 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.
|
The build system (`Htmx.SourceGenerator`) reads your `.htmx` file and generates an abstract C# class with one `RenderXxx()` method per `$$Slot$$`. Your job is to inherit that class and implement each method.
|
||||||
|
|
||||||
## How `$$SlotName$$` becomes code
|
---
|
||||||
|
|
||||||
Take this simple template:
|
## Step 1 — Write the 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`:
|
Create `Htmx.ApiDemo/Templates/MyPage.htmx`:
|
||||||
|
|
||||||
@@ -63,119 +37,163 @@ Create `Htmx.ApiDemo/Templates/MyPage.htmx`:
|
|||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules for slots:
|
||||||
- Slot names are **PascalCase** and surrounded by `$$` — e.g. `$$MySlot$$`
|
- Names are **PascalCase** surrounded by `$$` — e.g. `$$MySlot$$`
|
||||||
- A slot can hold plain text, HTML, or another rendered component
|
- A slot can contain plain text, HTML, or a rendered component
|
||||||
- The file must be saved in `Templates/` (or a subfolder) so the `.csproj` `AdditionalFiles` glob picks it up
|
- The file must live inside `Templates/` so the build picks it up automatically
|
||||||
|
|
||||||
## Step 2 — Create the `.htmx.cs` code-behind
|
After saving this file and building, the generator emits `MyPageBase` — a class you will never edit but will inherit from.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Write the code-behind
|
||||||
|
|
||||||
Create `Htmx.ApiDemo/Templates/MyPage.htmx.cs`:
|
Create `Htmx.ApiDemo/Templates/MyPage.htmx.cs`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Immediate.Apis.Shared;
|
|
||||||
using Immediate.Handlers.Shared;
|
|
||||||
|
|
||||||
namespace Htmx.ApiDemo.Templates;
|
namespace Htmx.ApiDemo.Templates;
|
||||||
|
|
||||||
// Concrete template — inherits from the generated base
|
|
||||||
public sealed class MyPage : MyPageBase
|
public sealed class MyPage : MyPageBase
|
||||||
{
|
{
|
||||||
private byte[] _headingData = [];
|
private readonly byte[] _headingData;
|
||||||
private byte[] _descriptionData = [];
|
private readonly byte[] _descriptionData;
|
||||||
|
|
||||||
// Use `init`-only setters to pre-encode strings to UTF-8 bytes once
|
public MyPage(string heading, string description)
|
||||||
public required string Heading { init => _headingData = value.ToUtf8Bytes(); }
|
{
|
||||||
public required string Description { init => _descriptionData = value.ToUtf8Bytes(); }
|
// Convert strings to UTF-8 bytes once in the constructor.
|
||||||
|
// The Render methods then just write those bytes — no allocations at request time.
|
||||||
|
_headingData = heading.ToUtf8Bytes();
|
||||||
|
_descriptionData = description.ToUtf8Bytes();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void RenderHeading(HtmxRenderContext context)
|
protected override void RenderHeading(HtmxRenderContext ctx)
|
||||||
=> context.Writer.WriteUtf8(_headingData);
|
=> ctx.Writer.WriteUtf8(_headingData);
|
||||||
|
|
||||||
protected override void RenderDescription(HtmxRenderContext context)
|
protected override void RenderDescription(HtmxRenderContext ctx)
|
||||||
=> context.Writer.WriteUtf8(_descriptionData);
|
=> ctx.Writer.WriteUtf8(_descriptionData);
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
// Minimal API handler — discovered and registered by the source generator
|
The pattern here is deliberate: do all string work (formatting, encoding) in the constructor, so that `Render` is nothing but memory writes. This keeps request handling fast.
|
||||||
[Handler]
|
|
||||||
[MapGet("/my-page")]
|
---
|
||||||
public static partial class GetMyPageHandler
|
|
||||||
|
## Step 3 — Write the route handler
|
||||||
|
|
||||||
|
Route handlers live in the same `.htmx.cs` file. They are plain static methods registered with Minimal API — no special framework, no base class, no attributes from removed packages:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Htmx.ApiDemo.Templates;
|
||||||
|
|
||||||
|
public static class MyPageEndpoints
|
||||||
{
|
{
|
||||||
public record Query; // add route/query parameters here if needed
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
|
|
||||||
private static ValueTask HandleAsync(
|
|
||||||
Query query,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
var ctx = httpContextAccessor.HttpContext
|
app.MapGet("/my-page", Handle);
|
||||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
}
|
||||||
|
|
||||||
var page = new MyPage
|
private static IResult Handle(HttpContext ctx)
|
||||||
{
|
{
|
||||||
Heading = "My New Page",
|
var page = new MyPage(
|
||||||
Description = "This is a minimal example page."
|
heading: "My New Page",
|
||||||
};
|
description: "This is a minimal example."
|
||||||
|
);
|
||||||
|
|
||||||
// WriteHtmxPage: full HTML shell for direct browser loads,
|
ctx.WriteHtmxPage(page, title: "My Page");
|
||||||
// bare fragment for HTMX partial swaps (HX-Request header present)
|
return Results.Empty;
|
||||||
ctx.WriteHtmxPage(page, title: "My Page", appName: "HtmxApp", pageTitle: "My Page");
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 3 — Add a sidebar link (optional but typical)
|
Then register it in `Program.cs` alongside the other endpoint registrations:
|
||||||
|
|
||||||
Open `Templates/MainLayout.htmx` and add a nav entry inside the `<nav>` block. Existing entries look like this:
|
```csharp
|
||||||
|
MyPageEndpoints.Map(app);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Add a sidebar link (optional but typical)
|
||||||
|
|
||||||
|
Open `Templates/MainLayout.htmx` and add a nav entry inside the `<nav>` block:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<a href="/my-page"
|
<a href="/my-page"
|
||||||
hx-get="/my-page" hx-target="#main-view" hx-push-url="true"
|
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
|
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">
|
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||||
<!-- inline SVG icon here -->
|
|
||||||
My Page
|
My Page
|
||||||
</a>
|
</a>
|
||||||
```
|
```
|
||||||
|
|
||||||
Key HTMX attributes:
|
The three HTMX attributes do the heavy lifting:
|
||||||
- `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
|
| Attribute | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `hx-get="/my-page"` | Fetches the page as a partial instead of a full reload |
|
||||||
|
| `hx-target="#main-view"` | Drops the response into the content area, leaving the sidebar untouched |
|
||||||
|
| `hx-push-url="true"` | Updates the browser URL bar so bookmarks and back-button still work |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the app knows whether to send a full page or just the fragment
|
||||||
|
|
||||||
|
When `WriteHtmxPage` is called it checks for the `HX-Request` header that HTMX sends on every HTMX-triggered request:
|
||||||
|
|
||||||
```
|
```
|
||||||
Request has HX-Request header?
|
Direct browser visit (no HX-Request header)
|
||||||
YES → render bare fragment + set HX-Title response header (browser tab title updates)
|
→ full HTML: <html><head>...</head><body><sidebar/><main>YOUR PAGE</main></body></html>
|
||||||
NO → wrap fragment in MainLayout (full HTML page with sidebar, navbar, etc.)
|
|
||||||
|
HTMX sidebar click (HX-Request: true)
|
||||||
|
→ just your fragment: <div class="p-6 space-y-4">...</div>
|
||||||
|
→ plus an HX-Title header so the browser tab title still updates
|
||||||
```
|
```
|
||||||
|
|
||||||
The logic lives in `HtmxPageExtensions.WriteHtmxPage`. You never need to fork on this yourself.
|
You never need to branch on this yourself. `WriteHtmxPage` handles it.
|
||||||
|
|
||||||
## 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:
|
## Embedding a component inside a page
|
||||||
|
|
||||||
|
Slots are not limited to text. If you want to place a reusable component inside a slot, store it as a field and call `Render` from the override:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public IHtmxComponent MyCard { get; }
|
public sealed class MyPage : MyPageBase
|
||||||
|
|
||||||
public MyPage(IHtmxComponent myCard)
|
|
||||||
{
|
{
|
||||||
MyCard = myCard;
|
private readonly byte[] _headingData;
|
||||||
}
|
private readonly IHtmxComponent _statusBadge;
|
||||||
|
|
||||||
protected override void RenderMyCard(HtmxRenderContext context)
|
public MyPage(string heading, string status)
|
||||||
=> MyCard.Render(context);
|
{
|
||||||
|
_headingData = heading.ToUtf8Bytes();
|
||||||
|
_statusBadge = new Badge(status, variant: "secondary");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void RenderHeading(HtmxRenderContext ctx)
|
||||||
|
=> ctx.Writer.WriteUtf8(_headingData);
|
||||||
|
|
||||||
|
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
||||||
|
=> _statusBadge.Render(ctx.Next()); // ctx.Next() tracks nesting depth
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See [03-creating-a-component.md](03-creating-a-component.md) for the full component pattern.
|
In the template:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h1>$$Heading$$</h1>
|
||||||
|
$$StatusBadge$$
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
See [03-creating-a-component.md](03-creating-a-component.md) for how to build your own components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] `MyPage.htmx` created in `Templates/`
|
- [ ] `MyPage.htmx` saved in `Templates/` with `$$PascalCase$$` slots
|
||||||
- [ ] `MyPage.htmx.cs` created with a class inheriting `MyPageBase`
|
- [ ] `MyPage.htmx.cs` has a class inheriting `MyPageBase` with all `RenderXxx` overrides
|
||||||
- [ ] Each `$$Slot$$` has a matching `RenderSlot` override
|
- [ ] Route handler registered in `Program.cs`
|
||||||
- [ ] `[Handler]` + `[MapGet(...)]` (or `MapPost` etc.) on the handler class
|
- [ ] Builds cleanly — the compiler will error if any slot override is missing
|
||||||
- [ ] `ctx.WriteHtmxPage(...)` called from `HandleAsync`
|
- [ ] Sidebar link added to `MainLayout.htmx` if the page needs to be in the nav
|
||||||
- [ ] Build once — if a slot is missing its override, the compiler will tell you
|
|
||||||
|
|||||||
+103
-54
@@ -1,16 +1,31 @@
|
|||||||
# Creating a New Component
|
# 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.
|
A component is a **reusable stamp**. You design the stamp once (the `.htmx` template + `.htmx.cs` class), and then press it anywhere you need that piece of UI — on multiple pages, inside other components, even multiple times on the same page.
|
||||||
|
|
||||||
## The three component patterns
|
Components are identical in structure to pages, with two key differences:
|
||||||
|
|
||||||
All existing components fall into one of three shapes. Pick the one that fits what you are building.
|
1. They live in `Templates/Components/` instead of `Templates/`
|
||||||
|
2. They are never responsible for HTTP routing — they just render HTML
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pattern A — Simple slot component
|
## What you want to achieve
|
||||||
|
|
||||||
Use this when every piece of output is a plain string set from outside.
|
By the end of this guide you will be able to build any reusable UI piece — a styled label, a card, a form field, or a wrapper that holds other components — and drop it anywhere on a page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three patterns
|
||||||
|
|
||||||
|
All components fit one of three shapes. Pick the one that matches what you are building.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pattern A — A simple label or display element
|
||||||
|
|
||||||
|
Use this when the component just renders a styled string. It is the simplest case.
|
||||||
|
|
||||||
|
**Goal:** a coloured status badge you can reuse in tables, cards, and headers.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Templates/Components/Badge.htmx -->
|
<!-- Templates/Components/Badge.htmx -->
|
||||||
@@ -26,13 +41,11 @@ public sealed class Badge : BadgeBase
|
|||||||
private readonly byte[] _labelData;
|
private readonly byte[] _labelData;
|
||||||
private readonly byte[] _classesData;
|
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")
|
public Badge(string label, string variant = "default")
|
||||||
{
|
{
|
||||||
_labelData = label.ToUtf8Bytes();
|
_labelData = label.ToUtf8Bytes();
|
||||||
|
|
||||||
var variantClass = variant switch
|
var variantClasses = variant switch
|
||||||
{
|
{
|
||||||
"secondary" => "bg-secondary text-secondary-foreground",
|
"secondary" => "bg-secondary text-secondary-foreground",
|
||||||
"destructive" => "bg-destructive text-destructive-foreground",
|
"destructive" => "bg-destructive text-destructive-foreground",
|
||||||
@@ -40,7 +53,7 @@ public sealed class Badge : BadgeBase
|
|||||||
_ => "bg-primary text-primary-foreground",
|
_ => "bg-primary text-primary-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClass}"
|
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClasses}"
|
||||||
.ToUtf8Bytes();
|
.ToUtf8Bytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,15 +62,19 @@ public sealed class Badge : BadgeBase
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The key principle: **all computation happens in the constructor**. By the time `RenderLabel` is called during a request, it is just writing pre-computed bytes — no string formatting, no allocations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pattern B — Conditionally built sections
|
### Pattern B — A container with optional 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.
|
Use this when parts of the component are optional — for example a card that shows a header only when a title is provided.
|
||||||
|
|
||||||
|
**Goal:** a card that always shows its body, but optionally shows a header and a footer.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Templates/Components/Card.htmx -->
|
<!-- Templates/Components/Card.htmx -->
|
||||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow-sm $$ExtraClasses$$">
|
<div class="rounded-lg border border-border bg-card shadow-sm $$ExtraClasses$$">
|
||||||
$$Header$$
|
$$Header$$
|
||||||
<div class="p-6 pt-0">$$Content$$</div>
|
<div class="p-6 pt-0">$$Content$$</div>
|
||||||
$$Footer$$
|
$$Footer$$
|
||||||
@@ -85,7 +102,8 @@ public sealed class Card : CardBase
|
|||||||
_extraClassesData = extraClasses.ToUtf8Bytes();
|
_extraClassesData = extraClasses.ToUtf8Bytes();
|
||||||
_contentData = content.ToUtf8Bytes();
|
_contentData = content.ToUtf8Bytes();
|
||||||
|
|
||||||
// Header is only rendered when a title or description is supplied
|
// Build header HTML in the constructor. If there's no title/description,
|
||||||
|
// store an empty array — writing empty bytes is a no-op.
|
||||||
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
|
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
|
||||||
? []
|
? []
|
||||||
: BuildHeader(title, description);
|
: BuildHeader(title, description);
|
||||||
@@ -116,88 +134,119 @@ public sealed class Card : CardBase
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pattern C — Component slots (embedding other components)
|
### Pattern C — A wrapper that holds 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.
|
Use this when a slot should be filled by another component rather than a string.
|
||||||
|
|
||||||
|
**Goal:** a tooltip wrapper — the trigger is any component, and the tooltip text floats above it on hover.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Templates/Components/MyWrapper.htmx -->
|
<!-- Templates/Components/Tooltip.htmx -->
|
||||||
<div class="wrapper p-4">
|
<span class="relative inline-flex items-center group">
|
||||||
$$Inner$$
|
$$Trigger$$
|
||||||
</div>
|
<span class="absolute bottom-full mb-1.5 ... opacity-0 group-hover:opacity-100">$$Text$$</span>
|
||||||
|
</span>
|
||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Templates/Components/MyWrapper.htmx.cs
|
// Templates/Components/Tooltip.htmx.cs
|
||||||
namespace Htmx.ApiDemo.Templates.Components;
|
namespace Htmx.ApiDemo.Templates.Components;
|
||||||
|
|
||||||
public sealed class MyWrapper : MyWrapperBase
|
public sealed class Tooltip : TooltipBase
|
||||||
{
|
{
|
||||||
private readonly IHtmxComponent _inner;
|
private readonly IHtmxComponent _trigger;
|
||||||
|
private readonly byte[] _textData;
|
||||||
|
|
||||||
public MyWrapper(IHtmxComponent inner)
|
public Tooltip(string text, IHtmxComponent trigger)
|
||||||
{
|
{
|
||||||
_inner = inner;
|
_textData = text.ToUtf8Bytes();
|
||||||
|
_trigger = trigger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass context.Next() so the recursion depth counter increments;
|
protected override void RenderText(HtmxRenderContext ctx)
|
||||||
// the runtime throws if nesting exceeds 512 levels
|
=> ctx.Writer.WriteUtf8(_textData);
|
||||||
protected override void RenderInner(HtmxRenderContext ctx)
|
|
||||||
=> _inner.Render(ctx.Next());
|
// ctx.Next() increments the nesting depth counter.
|
||||||
|
// The runtime throws if nesting exceeds 512 levels — this is the guard against infinite loops.
|
||||||
|
protected override void RenderTrigger(HtmxRenderContext ctx)
|
||||||
|
=> _trigger.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
|
## Using a component inside 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`:
|
Once you have a component, use it from a page's code-behind. The page stores the component as a field and delegates `Render` from its slot override:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// inside MyPage.htmx.cs
|
// MyPage.htmx.cs
|
||||||
public IHtmxComponent MyBadge { get; }
|
public sealed class MyPage : MyPageBase
|
||||||
|
|
||||||
public MyPage(...)
|
|
||||||
{
|
{
|
||||||
MyBadge = new Badge("New", variant: "secondary");
|
private readonly byte[] _headingData;
|
||||||
}
|
private readonly IHtmxComponent _statusBadge;
|
||||||
|
|
||||||
protected override void RenderMyBadge(HtmxRenderContext ctx)
|
public MyPage(string heading, string status)
|
||||||
=> MyBadge.Render(ctx.Next());
|
{
|
||||||
|
_headingData = heading.ToUtf8Bytes();
|
||||||
|
_statusBadge = new Badge(status, variant: "secondary");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void RenderHeading(HtmxRenderContext ctx)
|
||||||
|
=> ctx.Writer.WriteUtf8(_headingData);
|
||||||
|
|
||||||
|
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
||||||
|
=> _statusBadge.Render(ctx.Next());
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The corresponding slot in `MyPage.htmx`:
|
Template:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div class="flex gap-2">
|
<!-- MyPage.htmx -->
|
||||||
<span>Status:</span>
|
<div class="flex items-center gap-3">
|
||||||
$$MyBadge$$
|
<h1>$$Heading$$</h1>
|
||||||
|
$$StatusBadge$$
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## File naming and namespace rules
|
## A note on HTML safety
|
||||||
|
|
||||||
| File location | Generated namespace |
|
`WriteUtf8` writes raw bytes directly to the HTTP response. It does **not** HTML-encode anything.
|
||||||
|
|
||||||
|
- Strings you write in the constructor that come from your own code are fine — you control them.
|
||||||
|
- Any value that comes from user input (a form field, a database value, a query parameter) **must be HTML-encoded before calling `ToUtf8Bytes()`**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Safe — encodes characters like < > " &
|
||||||
|
_nameData = System.Web.HttpUtility.HtmlEncode(userInput).ToUtf8Bytes();
|
||||||
|
```
|
||||||
|
|
||||||
|
Skipping this step is a cross-site scripting (XSS) vulnerability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File location and namespace
|
||||||
|
|
||||||
|
| File location | C# namespace |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Templates/Components/MyComp.htmx` | `Htmx.ApiDemo.Templates.Components` |
|
| `Templates/Components/MyComp.htmx` | `Htmx.ApiDemo.Templates.Components` |
|
||||||
| `Templates/MyPage.htmx` | `Htmx.ApiDemo.Templates` |
|
| `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.
|
The source generator derives the namespace from the folder path relative to the project root. Always keep components in `Templates/Components/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML user content safety
|
## Checklist
|
||||||
|
|
||||||
The `WriteUtf8` method writes raw bytes directly to the response. **It does not HTML-encode.**
|
- [ ] `.htmx` template created in `Templates/Components/` with `$$PascalCase$$` slots
|
||||||
|
- [ ] `.htmx.cs` class inherits the generated `XxxBase` class
|
||||||
- Static strings you write in the constructor are trusted — you control them.
|
- [ ] All `RenderXxx` overrides implemented
|
||||||
- Any value that comes from user input (e.g. a form field, a database string) **must be HTML-encoded before calling `ToUtf8Bytes()`**.
|
- [ ] Computation (string building, class selection) done in the constructor
|
||||||
|
- [ ] User-provided strings HTML-encoded before `ToUtf8Bytes()`
|
||||||
```csharp
|
- [ ] Sub-component `Render` calls use `ctx.Next()` not bare `ctx`
|
||||||
// Safe — user-supplied string is encoded first
|
// Safe — user-supplied string is encoded first
|
||||||
_displayNameData = System.Web.HttpUtility.HtmlEncode(userDisplayName).ToUtf8Bytes();
|
_displayNameData = System.Web.HttpUtility.HtmlEncode(userDisplayName).ToUtf8Bytes();
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
# Data Models and AOT Safety
|
# Data Models and AOT
|
||||||
|
|
||||||
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.
|
Think of your data model as a **passport**. When a document leaves MongoDB and enters your C# code (or vice versa), it needs to be checked against an explicit, pre-declared format. In a regular .NET app, the runtime reads the passport on the fly using reflection. Under AOT, that border crossing has to be pre-approved at build time — every field declared up front, no surprises allowed.
|
||||||
|
|
||||||
## Why AOT matters
|
This guide covers how to define models, register them safely for AOT, and avoid the patterns that quietly break in production.
|
||||||
|
|
||||||
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
|
## What you want to achieve
|
||||||
- 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.
|
By the end of this guide you will know how to:
|
||||||
|
|
||||||
|
- Define a MongoDB document class
|
||||||
|
- Register it so it survives AOT compilation
|
||||||
|
- Add a new collection to the app
|
||||||
|
- Create an index on startup
|
||||||
|
- Spot and fix the most common AOT mistakes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Defining a document model
|
## Defining a document model
|
||||||
|
|
||||||
A document class is a plain C# class annotated with BSON attribute hints. Keep it simple:
|
A document class is a plain C# class. Keep it simple — just properties, no logic.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
// Data/AppUser.cs
|
||||||
using MongoDB.Bson;
|
using MongoDB.Bson;
|
||||||
using MongoDB.Bson.Serialization.Attributes;
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
@@ -45,19 +51,20 @@ public sealed class AppUser
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Two rules:
|
||||||
- Always annotate the primary key with `[BsonId]`
|
- `[BsonId]` marks the primary key — always required
|
||||||
- Always annotate every persisted property with `[BsonElement("fieldName")]` — this makes the MongoDB field name explicit and independent of C# naming conventions
|
- `[BsonElement("fieldName")]` names the MongoDB field explicitly — always use this, otherwise renaming a C# property breaks existing documents
|
||||||
- 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)
|
## Registering the class map
|
||||||
|
|
||||||
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`:
|
MongoDB's default behaviour is to scan your class at runtime using reflection and figure out the fields automatically. That does not work under AOT.
|
||||||
|
|
||||||
|
Instead, you declare the mapping explicitly in `Program.cs` before the app builder is created:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Program.cs — must appear before builder construction
|
// Program.cs — at the very top, before WebApplication.CreateSlimBuilder
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<AppUser>(cm =>
|
BsonClassMap.RegisterClassMap<AppUser>(cm =>
|
||||||
{
|
{
|
||||||
@@ -67,97 +74,133 @@ BsonClassMap.RegisterClassMap<AppUser>(cm =>
|
|||||||
cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash");
|
cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash");
|
||||||
cm.MapProperty(u => u.DisplayName).SetElementName("displayName");
|
cm.MapProperty(u => u.DisplayName).SetElementName("displayName");
|
||||||
cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc");
|
cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc");
|
||||||
|
cm.SetIgnoreExtraElements(true); // old documents with extra fields won't crash
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Every property you want stored must be listed here. If it is not in the class map, it will not be read or written.
|
||||||
|
|
||||||
|
`SetIgnoreExtraElements(true)` is important: as your model evolves, old documents in the database may have fields that no longer exist in your class. Without this, deserializing them throws an exception.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new model — step by step
|
||||||
|
|
||||||
|
### 1. Create the class in `Data/`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Data/Post.cs
|
||||||
|
public sealed class Post
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
|
||||||
|
|
||||||
|
[BsonElement("title")]
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
|
||||||
|
[BsonElement("authorId")]
|
||||||
|
public ObjectId AuthorId { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("createdAtUtc")]
|
||||||
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register the class map in `Program.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
BsonClassMap.RegisterClassMap<Post>(cm =>
|
||||||
|
{
|
||||||
|
cm.MapIdProperty(p => p.Id).SetSerializer(new ObjectIdSerializer());
|
||||||
|
cm.MapProperty(p => p.Title).SetElementName("title");
|
||||||
|
cm.MapProperty(p => p.AuthorId).SetElementName("authorId");
|
||||||
|
cm.MapProperty(p => p.CreatedAtUtc).SetElementName("createdAtUtc");
|
||||||
cm.SetIgnoreExtraElements(true);
|
cm.SetIgnoreExtraElements(true);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
This replaces AutoMap entirely. Every property you want persisted must be listed here.
|
### 3. Add the collection to `MongoDbService`
|
||||||
|
|
||||||
### 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
|
```csharp
|
||||||
// Data/MongoDbService.cs
|
// Data/MongoDbService.cs
|
||||||
public sealed class MongoDbService
|
public sealed class MongoDbService
|
||||||
{
|
{
|
||||||
private readonly IMongoCollection<AppUser> _users;
|
private readonly IMongoCollection<AppUser> _users;
|
||||||
// add more collections here
|
private readonly IMongoCollection<Post> _posts; // ← add this
|
||||||
|
|
||||||
public MongoDbService(IMongoClient client, IConfiguration configuration)
|
public MongoDbService(IMongoClient client, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
|
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
|
||||||
_users = db.GetCollection<AppUser>("users");
|
_users = db.GetCollection<AppUser>("users");
|
||||||
// _posts = db.GetCollection<Post>("posts");
|
_posts = db.GetCollection<Post>("posts"); // ← add this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All queries use the strongly-typed `Builders<T>` API:
|
---
|
||||||
|
|
||||||
|
## Querying data
|
||||||
|
|
||||||
|
Use the `Builders<T>` API — not LINQ, not EF-style expressions. The `Builders<T>` API generates BSON queries at compile time using source generators built into the MongoDB driver, so no reflection is needed at runtime.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Exact-match lookup — no LINQ translation, no reflection at runtime
|
// Good — AOT-safe
|
||||||
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
|
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
|
||||||
return await _users.Find(filter).FirstOrDefaultAsync(ct);
|
var user = 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.
|
// Bad — uses LINQ translation that requires runtime reflection
|
||||||
|
var user = await _users.AsQueryable().FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Index management
|
## Index management
|
||||||
|
|
||||||
Indexes are created via `EnsureIndexesAsync`, called once at startup from `Program.cs`:
|
Indexes are declared in `EnsureIndexesAsync` inside `MongoDbService` and called once at startup from `Program.cs`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync();
|
await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync();
|
||||||
```
|
```
|
||||||
|
|
||||||
Add new indexes to `EnsureIndexesAsync`:
|
Add new indexes here:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public async Task EnsureIndexesAsync(CancellationToken ct = default)
|
public async Task EnsureIndexesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var indexKeys = Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail);
|
// Unique index on email for fast login lookups
|
||||||
var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" };
|
var emailIndex = new CreateIndexModel<AppUser>(
|
||||||
var model = new CreateIndexModel<AppUser>(indexKeys, indexOptions);
|
Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail),
|
||||||
await _users.Indexes.CreateOneAsync(model, cancellationToken: ct);
|
new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" });
|
||||||
|
|
||||||
// add more index creation calls here — CreateOneAsync is idempotent
|
await _users.Indexes.CreateOneAsync(emailIndex, cancellationToken: ct);
|
||||||
|
|
||||||
|
// Add more indexes here — CreateOneAsync is idempotent (safe to call every startup)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The call is idempotent: if the index already exists with the same definition, MongoDB silently succeeds.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AOT anti-patterns to avoid
|
## AOT anti-patterns to avoid
|
||||||
|
|
||||||
| Pattern | Why it breaks AOT | Safe alternative |
|
These patterns compile and run fine in `dotnet run` (Debug mode with the JIT), but fail silently or throw at runtime in a published AOT binary.
|
||||||
|
|
||||||
|
| Pattern | Why it breaks | What to do instead |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `BsonClassMap.RegisterClassMap<T>()` without explicit mapping | Uses AutoMap reflection | Explicit `cm.MapProperty(...)` for every field |
|
| `BsonClassMap.RegisterClassMap<T>()` without explicit property mapping | Uses AutoMap reflection internally | List every property with `cm.MapProperty(...)` |
|
||||||
| `collection.AsQueryable().Where(...)` | EF-style LINQ translation uses reflection | `Builders<T>.Filter.Eq(...)` |
|
| `collection.AsQueryable().Where(...)` | LINQ translation requires runtime reflection | Use `Builders<T>.Filter.Eq(...)` etc. |
|
||||||
| `JsonSerializer.Deserialize<T>(json)` without a type resolver | Reflects on T at runtime | Register T in `AppJsonSerializerContext` |
|
| `JsonSerializer.Deserialize<T>(json)` without registering T | Reflects on T at runtime | Add T to `AppJsonSerializerContext` (see guide 05) |
|
||||||
| `Activator.CreateInstance(type)` | Requires reflection metadata | Use `new T()` directly |
|
| `Activator.CreateInstance(type)` | Requires reflection metadata stripped by AOT trimmer | Use `new T()` directly |
|
||||||
| `typeof(T).GetProperties()` | Stripped by trimmer | Not needed with explicit class maps |
|
| Packages that internally use AutoMapper, EF Core, or convention-scanning DI | Reflection-heavy at runtime | Find AOT-compatible alternatives |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Checking for AOT warnings
|
## Checking your work
|
||||||
|
|
||||||
Run a Release publish and watch the output:
|
Run a Release publish regularly — do not wait until you are done with a feature:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj -c Release
|
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.
|
AOT warnings in the build output are potential runtime failures. Each warning names the exact type or method causing the issue. Fix them as they appear rather than letting them accumulate.
|
||||||
|
|||||||
+177
-146
@@ -1,191 +1,221 @@
|
|||||||
# Form Submission and AppJsonSerializerContext
|
# Form Submission
|
||||||
|
|
||||||
This guide explains how to wire a form POST endpoint, why `AppJsonSerializerContext` must be kept up to date, and what happens if you forget.
|
Think of a form submission as **sending a letter with a security seal**. The form is your letter, the fields are the contents, and the antiforgery token is the wax seal that proves the letter genuinely came from your site and not from a malicious third party trying to impersonate the user.
|
||||||
|
|
||||||
## How form POST endpoints work
|
This guide walks through wiring up a form POST from start to finish.
|
||||||
|
|
||||||
Form submissions use standard HTML `method="post"` forms. The server-side handler receives the posted fields as a strongly-typed `record` using `[FromForm]` bindings:
|
---
|
||||||
|
|
||||||
|
## What you want to achieve
|
||||||
|
|
||||||
|
By the end of this guide you will have a working form that:
|
||||||
|
|
||||||
|
- Posts data to a typed C# handler
|
||||||
|
- Is protected against CSRF attacks with an antiforgery token
|
||||||
|
- Registers its types with `AppJsonSerializerContext` so AOT compilation does not break it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How a form POST works
|
||||||
|
|
||||||
|
Forms use standard HTML `method="post"`. The browser serialises the form fields and sends them as a URL-encoded body. On the server, each field is read from that body and bound to a strongly-typed C# `record`.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
[Handler]
|
// A handler for POST /contact
|
||||||
[MapPost("/login")]
|
public static class PostContactHandler
|
||||||
public static partial class PostLoginHandler
|
|
||||||
{
|
{
|
||||||
|
// The Command record maps exactly to your form field names
|
||||||
public record Command(
|
public record Command(
|
||||||
[property: FromForm] string Email,
|
[property: FromForm] string Name,
|
||||||
[property: FromForm] string Password
|
[property: FromForm] string Message
|
||||||
);
|
);
|
||||||
|
|
||||||
private static async ValueTask HandleAsync(
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
[AsParameters] Command command,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
IAntiforgery antiforgery,
|
|
||||||
AuthService authService,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
// ...
|
app.MapPost("/contact", Handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult Handle(
|
||||||
|
[AsParameters] Command command,
|
||||||
|
HttpContext ctx)
|
||||||
|
{
|
||||||
|
// command.Name and command.Message are already populated
|
||||||
|
ctx.Response.Redirect("/");
|
||||||
|
return Results.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Key parts:
|
Key points:
|
||||||
- `[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
|
- `[property: FromForm]` on each record property tells the binder to read that value from the form body, not from the URL or route
|
||||||
- `[AsParameters]` on the `Command` argument tells Minimal API to bind the record's properties individually rather than deserializing the whole body as JSON
|
- `[AsParameters]` on the `Command` argument tells Minimal API to bind each property individually instead of trying to deserialize the whole body as a single JSON object
|
||||||
- The handler is discovered and registered by the `Immediate.Apis` source generator — no `app.MapPost(...)` call is needed
|
- The handler is a plain static method — no special base class, no framework magic
|
||||||
|
|
||||||
|
Register it in `Program.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PostContactHandler.Map(app);
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Antiforgery tokens
|
## 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.
|
Every mutating form (POST, PUT, DELETE) must include an antiforgery token. This is the wax seal that proves the form was generated by your server — not forged by a third-party page trying to submit on the user's behalf (a CSRF attack).
|
||||||
|
|
||||||
To include the token in a form rendered server-side:
|
The middleware `app.UseAntiforgery()` in `Program.cs` validates this token automatically on every mutating request. If the token is missing or wrong, the request is rejected before your handler even runs.
|
||||||
|
|
||||||
|
To include the token in a form, inject `IAntiforgery` into the GET handler that renders the page, then pass the token to the template:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Inside a page/component constructor, inject IAntiforgery:
|
// GET handler that renders the form page
|
||||||
var afTokens = antiforgery.GetAndStoreTokens(ctx);
|
private static IResult HandleGet(
|
||||||
var tokenField = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afTokens.RequestToken)}" />""";
|
HttpContext ctx,
|
||||||
```
|
IAntiforgery antiforgery)
|
||||||
|
|
||||||
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
|
|
||||||
{
|
{
|
||||||
|
var tokens = antiforgery.GetAndStoreTokens(ctx);
|
||||||
|
var page = new ContactPage(antiforgeryToken: tokens.RequestToken ?? "");
|
||||||
|
ctx.WriteHtmxPage(page, title: "Contact");
|
||||||
|
return Results.Empty;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
In the template or component, render the token as a hidden field:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
```csharp
|
||||||
[Handler]
|
// ContactPage.htmx.cs
|
||||||
[MapPost("/contact")]
|
public ContactPage(string antiforgeryToken)
|
||||||
public static partial class PostContactHandler
|
{
|
||||||
|
// HTML-encode the token value — it is user-visible in the source
|
||||||
|
_tokenFieldData = $"""
|
||||||
|
<input type="hidden"
|
||||||
|
name="__RequestVerificationToken"
|
||||||
|
value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />
|
||||||
|
""".ToUtf8Bytes();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AppJsonSerializerContext — the AOT requirement
|
||||||
|
|
||||||
|
The project uses `WebApplication.CreateSlimBuilder`, which generates endpoint binding code at compile time instead of using runtime reflection. For this to work, the serializer must know about every type it might encounter at compile time too.
|
||||||
|
|
||||||
|
Every `Command` record you create must be registered in `AppJsonSerializerContext.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// AppJsonSerializerContext.cs
|
||||||
|
[JsonSerializable(typeof(string))]
|
||||||
|
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
|
||||||
|
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
|
||||||
|
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")] // ← add this
|
||||||
|
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
|
||||||
|
```
|
||||||
|
|
||||||
|
`TypeInfoPropertyName` gives the generated property a readable name and prevents collisions if two commands happen to share the same type name.
|
||||||
|
|
||||||
|
### What happens if you forget
|
||||||
|
|
||||||
|
- `dotnet run` (Debug, JIT) — works fine, JIT fills in the gaps at runtime
|
||||||
|
- `dotnet publish -c Release` (AOT) — either emits a trim warning during build, or throws `NotSupportedException` at runtime the first time the endpoint is hit
|
||||||
|
|
||||||
|
This is the single most common mistake when adding a new form endpoint. Add the `[JsonSerializable]` entry at the same time as you write the `Command` record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete example — a contact form
|
||||||
|
|
||||||
|
### 1. The template
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Templates/ContactPage.htmx -->
|
||||||
|
<form method="post" action="/contact" class="space-y-4 max-w-md">
|
||||||
|
$$Token$$
|
||||||
|
<div>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="Name" type="text" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="message">Message</label>
|
||||||
|
<textarea id="message" name="Message" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. The page class
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Templates/ContactPage.htmx.cs
|
||||||
|
namespace Htmx.ApiDemo.Templates;
|
||||||
|
|
||||||
|
public sealed class ContactPage : ContactPageBase
|
||||||
|
{
|
||||||
|
private readonly byte[] _tokenData;
|
||||||
|
|
||||||
|
public ContactPage(string antiforgeryToken)
|
||||||
|
{
|
||||||
|
_tokenData = string.IsNullOrEmpty(antiforgeryToken)
|
||||||
|
? []
|
||||||
|
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />"""
|
||||||
|
.ToUtf8Bytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void RenderToken(HtmxRenderContext ctx)
|
||||||
|
=> ctx.Writer.WriteUtf8(_tokenData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. The GET handler (renders the form)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static class GetContactHandler
|
||||||
|
{
|
||||||
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
|
=> app.MapGet("/contact", Handle);
|
||||||
|
|
||||||
|
private static IResult Handle(HttpContext ctx, IAntiforgery antiforgery)
|
||||||
|
{
|
||||||
|
var tokens = antiforgery.GetAndStoreTokens(ctx);
|
||||||
|
ctx.WriteHtmxPage(new ContactPage(tokens.RequestToken ?? ""), title: "Contact");
|
||||||
|
return Results.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. The POST handler (processes the form)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static class PostContactHandler
|
||||||
{
|
{
|
||||||
public record Command(
|
public record Command(
|
||||||
[property: FromForm] string Name,
|
[property: FromForm] string Name,
|
||||||
[property: FromForm] string Message
|
[property: FromForm] string Message
|
||||||
);
|
);
|
||||||
|
|
||||||
private static ValueTask HandleAsync(
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
[AsParameters] Command command,
|
=> app.MapPost("/contact", Handle);
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
CancellationToken token)
|
private static IResult Handle([AsParameters] Command command, HttpContext ctx)
|
||||||
{
|
{
|
||||||
// handle the form submission
|
// process command.Name and command.Message here
|
||||||
var ctx = httpContextAccessor.HttpContext!;
|
|
||||||
ctx.Response.Redirect("/");
|
ctx.Response.Redirect("/");
|
||||||
return ValueTask.CompletedTask;
|
return Results.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Register the command in AppJsonSerializerContext
|
### 5. Register both in `Program.cs`
|
||||||
|
|
||||||
Open `Htmx.ApiDemo/AppJsonSerializerContext.cs` and add a `[JsonSerializable]` entry:
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
[JsonSerializable(typeof(string))]
|
GetContactHandler.Map(app);
|
||||||
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
|
PostContactHandler.Map(app);
|
||||||
[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.
|
### 6. Register the Command in `AppJsonSerializerContext.cs`
|
||||||
|
|
||||||
### 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
|
```csharp
|
||||||
// Templates/Contact.htmx.cs
|
[JsonSerializable(typeof(PostContactHandler.Command), TypeInfoPropertyName = "ContactCommand")]
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -193,8 +223,9 @@ public static partial class GetContactHandler
|
|||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] `Command` record properties use `[property: FromForm]`
|
- [ ] `Command` record properties use `[property: FromForm]`
|
||||||
- [ ] Handler uses `[AsParameters]` on the command
|
- [ ] Handler method uses `[AsParameters]` on the `Command` parameter
|
||||||
- [ ] `Command` type is registered in `AppJsonSerializerContext` with `[JsonSerializable]`
|
- [ ] `Command` type added to `AppJsonSerializerContext` with `[JsonSerializable]`
|
||||||
- [ ] Form template includes the antiforgery hidden input
|
- [ ] Form template includes the antiforgery hidden input
|
||||||
- [ ] GET handler resolves `IAntiforgery`, calls `GetAndStoreTokens`, and passes the token to the template
|
- [ ] GET handler calls `antiforgery.GetAndStoreTokens(ctx)` and passes the token to the page
|
||||||
- [ ] Tested with `dotnet publish -c Release` (not just `dotnet run`) before considering it done
|
- [ ] Both GET and POST handlers registered in `Program.cs`
|
||||||
|
- [ ] Tested with `dotnet publish -c Release` before merging
|
||||||
|
|||||||
@@ -1,35 +1,88 @@
|
|||||||
# Component Reference
|
# Component Reference
|
||||||
|
|
||||||
All components live in `Htmx.ApiDemo/Templates/Components/`. Each is a `.htmx` + `.htmx.cs` pair implementing `IHtmxComponent`.
|
All components live in `Htmx.ApiDemo/Templates/Components/`. Each is a `.htmx` template and a `.htmx.cs` class pair. You create one with `new ComponentName(...)` and drop it into any page slot.
|
||||||
|
|
||||||
| Component | JS? | File |
|
If you have not read [03-creating-a-component.md](03-creating-a-component.md) yet, start there — it explains how components work under the hood.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to read the component docs
|
||||||
|
|
||||||
|
Each component doc is structured the same way:
|
||||||
|
|
||||||
|
1. **What it does** — a one-line description
|
||||||
|
2. **Quick example** — the shortest possible usage that produces a result
|
||||||
|
3. **All the options** — every parameter explained
|
||||||
|
4. **Real-world examples** — usage in context (inside a form, inside a page, with HTMX attributes)
|
||||||
|
5. **How it works** — CSS mechanics and any JavaScript, for when you need to customise it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Display components
|
||||||
|
|
||||||
|
These render static visual elements. No JavaScript required.
|
||||||
|
|
||||||
|
| Component | What it is | Doc |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Accordion | Yes | [Components/Accordion.md](Components/Accordion.md) |
|
| Alert | Coloured callout box for messages and errors | [Alert.md](Components/Alert.md) |
|
||||||
| Alert | No | [Components/Alert.md](Components/Alert.md) |
|
| Avatar | Circular user icon — image or initials fallback | [Avatar.md](Components/Avatar.md) |
|
||||||
| Avatar | No | [Components/Avatar.md](Components/Avatar.md) |
|
| Badge | Small coloured label pill | [Badge.md](Components/Badge.md) |
|
||||||
| Badge | No | [Components/Badge.md](Components/Badge.md) |
|
| Breadcrumb | Navigation trail showing current location | [Breadcrumb.md](Components/Breadcrumb.md) |
|
||||||
| Breadcrumb | No | [Components/Breadcrumb.md](Components/Breadcrumb.md) |
|
| Card | Bordered container with optional header and footer | [Card.md](Components/Card.md) |
|
||||||
| Button | No | [Components/Button.md](Components/Button.md) |
|
| Progress | Horizontal fill bar showing a percentage | [Progress.md](Components/Progress.md) |
|
||||||
| Calendar | Yes | [Components/Calendar.md](Components/Calendar.md) |
|
| Separator | Horizontal or vertical divider line | [Separator.md](Components/Separator.md) |
|
||||||
| CalendarRange | Yes | [Components/CalendarRange.md](Components/CalendarRange.md) |
|
| Skeleton | Animated loading placeholder | [Skeleton.md](Components/Skeleton.md) |
|
||||||
| Card | No | [Components/Card.md](Components/Card.md) |
|
| Table | Styled data table with headers and rows | [Table.md](Components/Table.md) |
|
||||||
| Checkbox | No | [Components/Checkbox.md](Components/Checkbox.md) |
|
| Tooltip | Hover hint above any element (pure CSS) | [Tooltip.md](Components/Tooltip.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) |
|
## Form components
|
||||||
| Pagination | No | [Components/Pagination.md](Components/Pagination.md) |
|
|
||||||
| Progress | No | [Components/Progress.md](Components/Progress.md) |
|
These are used inside `<form>` elements and submit their values with the form.
|
||||||
| RadioGroup | No | [Components/RadioGroup.md](Components/RadioGroup.md) |
|
|
||||||
| Select | No | [Components/Select.md](Components/Select.md) |
|
| Component | What it is | Doc |
|
||||||
| Separator | No | [Components/Separator.md](Components/Separator.md) |
|
|---|---|---|
|
||||||
| Skeleton | No | [Components/Skeleton.md](Components/Skeleton.md) |
|
| Button | Styled button — six visual variants | [Button.md](Components/Button.md) |
|
||||||
| Slider | No | [Components/Slider.md](Components/Slider.md) |
|
| Checkbox | Single on/off tick box | [Checkbox.md](Components/Checkbox.md) |
|
||||||
| Switch | Yes | [Components/Switch.md](Components/Switch.md) |
|
| FileInput | File upload field | [FileInput.md](Components/FileInput.md) |
|
||||||
| Table | No | [Components/Table.md](Components/Table.md) |
|
| Input | Single-line text field | [Input.md](Components/Input.md) |
|
||||||
| Tabs | Yes | [Components/Tabs.md](Components/Tabs.md) |
|
| RadioGroup | Group of mutually exclusive options | [RadioGroup.md](Components/RadioGroup.md) |
|
||||||
| Textarea | No | [Components/Textarea.md](Components/Textarea.md) |
|
| Select | Dropdown list | [Select.md](Components/Select.md) |
|
||||||
| TimePicker | Yes | [Components/TimePicker.md](Components/TimePicker.md) |
|
| Slider | Range input for numeric values | [Slider.md](Components/Slider.md) |
|
||||||
| Toast | Yes | [Components/Toast.md](Components/Toast.md) |
|
| Switch | On/off toggle | [Switch.md](Components/Switch.md) |
|
||||||
| ToastViewport | Paired with Toast | [Components/ToastViewport.md](Components/ToastViewport.md) |
|
| Textarea | Multi-line text field | [Textarea.md](Components/Textarea.md) |
|
||||||
| Tooltip | No (pure CSS) | [Components/Tooltip.md](Components/Tooltip.md) |
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interactive components (require JavaScript)
|
||||||
|
|
||||||
|
These components initialise client-side behaviour on `DOMContentLoaded` and `htmx:afterSwap`. The JS lives in `wwwroot/js/components.js`.
|
||||||
|
|
||||||
|
| Component | What it is | Doc |
|
||||||
|
|---|---|---|
|
||||||
|
| Accordion | Expand/collapse panel list | [Accordion.md](Components/Accordion.md) |
|
||||||
|
| Calendar | Single-date picker | [Calendar.md](Components/Calendar.md) |
|
||||||
|
| CalendarRange | Date range picker | [CalendarRange.md](Components/CalendarRange.md) |
|
||||||
|
| Dialog | Modal overlay using native `<dialog>` | [Dialog.md](Components/Dialog.md) |
|
||||||
|
| DropdownMenu | Button that opens a floating action list | [DropdownMenu.md](Components/DropdownMenu.md) |
|
||||||
|
| Tabs | Tabbed panel switcher | [Tabs.md](Components/Tabs.md) |
|
||||||
|
| TimePicker | Hour/minute selector | [TimePicker.md](Components/TimePicker.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification components
|
||||||
|
|
||||||
|
| Component | What it is | Doc |
|
||||||
|
|---|---|---|
|
||||||
|
| ToastViewport | Fixed container that holds toast notifications — place once in `MainLayout` | [ToastViewport.md](Components/ToastViewport.md) |
|
||||||
|
| Toast | Transient notification triggered from JavaScript via `window.showToast(...)` | [Toast.md](Components/Toast.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation components
|
||||||
|
|
||||||
|
| Component | What it is | Doc |
|
||||||
|
|---|---|---|
|
||||||
|
| Breadcrumb | Location trail | [Breadcrumb.md](Components/Breadcrumb.md) |
|
||||||
|
| Pagination | Numbered page navigation row | [Pagination.md](Components/Pagination.md) |
|
||||||
|
|||||||
+90
-108
@@ -1,72 +1,10 @@
|
|||||||
# Accordion
|
# 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`.
|
An expand/collapse panel list — like a FAQ section or a step-by-step guide where the user reveals each answer one at a time.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
|
||||||
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
|
```csharp
|
||||||
new Accordion(
|
new Accordion(
|
||||||
@@ -75,79 +13,123 @@ new Accordion(
|
|||||||
{
|
{
|
||||||
("What is this?", "A fast HTMX app framework."),
|
("What is this?", "A fast HTMX app framework."),
|
||||||
("Is it AOT-safe?", "Yes, fully."),
|
("Is it AOT-safe?", "Yes, fully."),
|
||||||
("Do I need Node?", "Only to run the Tailwind build step."),
|
("Do I need Node?", "Only for the Tailwind build step."),
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### One pre-expanded
|
That's it. Drop this into a page slot and you have a working FAQ section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All the options
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Accordion(
|
||||||
|
string id,
|
||||||
|
IEnumerable<(string Title, string Content)> items,
|
||||||
|
int openIndex = -1)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `id` | A unique identifier for this accordion on the page. If you have two accordions on the same page, they need different ids. |
|
||||||
|
| `items` | The list of panels. Each item is a pair: the header text (`Title`) and the body content (`Content`). |
|
||||||
|
| `openIndex` | Which panel should start open. `0` = first panel, `1` = second, `-1` = all closed (default). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-world examples
|
||||||
|
|
||||||
|
### FAQ page with the first item pre-opened
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Accordion(
|
new Accordion(
|
||||||
id: "setup-steps",
|
id: "faq",
|
||||||
items: new[]
|
items: new[]
|
||||||
{
|
{
|
||||||
("Step 1 — Install", "Run <code>npm install</code> in the project folder."),
|
("How do I reset my password?", "Go to Settings → Security → Reset Password."),
|
||||||
("Step 2 — Configure", "Edit <code>appsettings.json</code> with your connection string."),
|
("How do I cancel my account?", "Contact support from the Help page."),
|
||||||
("Step 3 — Run", "Use <code>dotnet run</code> to start the server."),
|
("Where are my invoices?", "Under Billing in your account dashboard."),
|
||||||
|
},
|
||||||
|
openIndex: 0) // first answer visible on load
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step-by-step guide with HTML content inside items
|
||||||
|
|
||||||
|
Item `Content` is rendered as raw HTML, so you can use markup inside it:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Accordion(
|
||||||
|
id: "setup-guide",
|
||||||
|
items: new[]
|
||||||
|
{
|
||||||
|
("Step 1 — Install dependencies",
|
||||||
|
"Run <code>npm install</code> inside the <code>Htmx.ApiDemo</code> folder."),
|
||||||
|
("Step 2 — Start MongoDB",
|
||||||
|
"<p>Start the MongoDB service, then confirm it is running on <code>localhost:27017</code>.</p>"),
|
||||||
|
("Step 3 — Run the app",
|
||||||
|
"Run <code>dotnet run --project Htmx.ApiDemo</code> and open <code>http://localhost:5120</code>."),
|
||||||
},
|
},
|
||||||
openIndex: 0)
|
openIndex: 0)
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTML content in items
|
> **Important:** `Title` and `Content` are inserted as raw HTML. If either value comes from user input or a database, HTML-encode it first:
|
||||||
|
> ```csharp
|
||||||
|
> System.Web.HttpUtility.HtmlEncode(userTitle)
|
||||||
|
> ```
|
||||||
|
|
||||||
```csharp
|
### Inside a page
|
||||||
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
|
```html
|
||||||
|
<!-- Templates/FaqPage.htmx -->
|
||||||
<div class="max-w-2xl mx-auto py-10">
|
<div class="max-w-2xl mx-auto py-10">
|
||||||
<h1 class="text-2xl font-bold mb-2">Frequently Asked Questions</h1>
|
<h1 class="text-2xl font-bold mb-6">Frequently Asked Questions</h1>
|
||||||
<p class="text-muted-foreground mb-8">Everything you need to know about BeepBoop.</p>
|
|
||||||
$$FaqAccordion$$
|
$$FaqAccordion$$
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
**`Templates/FaqPage.htmx.cs`**
|
|
||||||
```csharp
|
```csharp
|
||||||
namespace Htmx.ApiDemo.Templates;
|
// Templates/FaqPage.htmx.cs
|
||||||
|
|
||||||
public sealed class FaqPage : FaqPageBase
|
public sealed class FaqPage : FaqPageBase
|
||||||
{
|
{
|
||||||
private readonly IHtmxComponent _faq;
|
private readonly IHtmxComponent _faqAccordion;
|
||||||
|
|
||||||
public FaqPage()
|
public FaqPage()
|
||||||
{
|
{
|
||||||
_faq = new Components.Accordion(
|
_faqAccordion = new Components.Accordion(
|
||||||
id: "faq",
|
id: "faq",
|
||||||
items: new[]
|
items: new[]
|
||||||
{
|
{
|
||||||
("What is BeepBoop?",
|
("What is BeepBoop?", "A fast, AOT-safe HTMX web framework built on .NET 10."),
|
||||||
"A fast, AOT-safe HTMX web framework built on .NET 10."),
|
("Do I need Node.js?", "Only to run the Tailwind CSS build step."),
|
||||||
("Do I need Node.js?",
|
("Is MongoDB required?", "No — swap in any data store you prefer."),
|
||||||
"Only to run the Tailwind CSS build step during development."),
|
});
|
||||||
("Is MongoDB required?",
|
}
|
||||||
"No — swap in any data store you prefer."),
|
|
||||||
|
protected override void RenderFaqAccordion(HtmxRenderContext ctx)
|
||||||
|
=> _faqAccordion.Render(ctx.Next());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The server renders all panels into the HTML. Closed panels are given `height: 0; opacity: 0` inline styles so they are invisible immediately — no layout flash. The JavaScript in `components.js` (`initAccordion`) then attaches click listeners.
|
||||||
|
|
||||||
|
When a panel is opened, JS reads the panel's `scrollHeight` (its natural height) and animates the inline `height` from `0` to that value alongside the opacity, giving a smooth slide-down. The chevron icon rotates 180° to point down when open.
|
||||||
|
|
||||||
|
If the page content is updated by HTMX, `htmx:afterSwap` re-runs the initialisation so newly swapped-in accordions also get click behaviour.
|
||||||
|
|
||||||
|
Users can open multiple panels simultaneously — there is no "only one open at a time" constraint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- The `id` must be unique if you place more than one accordion on a page.
|
||||||
|
- `openIndex` only controls the initial server-rendered state — the user can freely open or close any panel after that.
|
||||||
|
- To listen for accordion interactions from another script, add a `click` listener to the parent container and check `event.target.closest('.accordion-trigger')`.
|
||||||
("How do I deploy?",
|
("How do I deploy?",
|
||||||
"Run <code>dotnet publish -c Release</code> for a native AOT binary."),
|
"Run <code>dotnet publish -c Release</code> for a native AOT binary."),
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-59
@@ -1,35 +1,20 @@
|
|||||||
# Alert
|
# 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.
|
A coloured callout box that draws the user's attention — like a sticky note placed on top of the page. Use it to show errors, warnings, or helpful information.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div[role=alert].{variant classes}
|
new Alert(
|
||||||
{icon SVG} ← positioned absolute top-left via Tailwind arbitrary selectors
|
title: "Heads up",
|
||||||
div
|
description: "Your session expires in 5 minutes.")
|
||||||
h5.font-medium ← title (always rendered)
|
|
||||||
div.text-sm ← description (omitted when empty)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Alert(
|
public Alert(
|
||||||
@@ -39,66 +24,63 @@ public Alert(
|
|||||||
string icon = "")
|
string icon = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `title` | Required heading text |
|
| `title` | The bold heading line — always shown |
|
||||||
| `description` | Optional body text below the title |
|
| `description` | A second line of detail below the title — optional |
|
||||||
| `variant` | `"default"` or `"destructive"` |
|
| `variant` | `"default"` (neutral, grey border) or `"destructive"` (red) |
|
||||||
| `icon` | Raw SVG string; omit for a text-only alert |
|
| `icon` | A raw SVG string placed to the left of the text — omit for no icon |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Informational (no icon)
|
### Login error
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Alert(
|
new Alert(
|
||||||
title: "Heads up",
|
title: "Sign in failed",
|
||||||
description: "Your session expires in 5 minutes.")
|
description: "The email or password you entered is incorrect.",
|
||||||
```
|
|
||||||
|
|
||||||
### Destructive
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Alert(
|
|
||||||
title: "Error",
|
|
||||||
description: "Invalid email or password.",
|
|
||||||
variant: "destructive")
|
variant: "destructive")
|
||||||
```
|
```
|
||||||
|
|
||||||
### With an icon
|
### Success confirmation
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Alert(title: "Changes saved successfully.")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Info notice with a link in the description
|
||||||
|
|
||||||
|
`description` is raw HTML, so you can embed links:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Alert(
|
new Alert(
|
||||||
title: "New message",
|
title: "Maintenance scheduled",
|
||||||
description: "You have 3 unread messages.",
|
description: "The system will be offline on Saturday. <a href='/status' class='underline'>View status page</a>.")
|
||||||
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
|
### Conditional error slot on a form page
|
||||||
|
|
||||||
|
A common pattern is to render the alert only when there is something to show. In the page constructor:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Alert(title: "Changes saved successfully.", variant: "default")
|
// Store empty bytes when there's no error — WriteUtf8 on empty bytes is a no-op
|
||||||
|
_errorAlertData = string.IsNullOrEmpty(errorMessage)
|
||||||
|
? []
|
||||||
|
: new Alert(title: "Error", description: errorMessage, variant: "destructive")
|
||||||
|
.ToRenderedBytes();
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- The icon SVG should be `h-4 w-4` — larger sizes will push text out of alignment.
|
The alert is a `<div role="alert">` — this tells screen readers to announce its content immediately when it appears on the page.
|
||||||
- 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.
|
If you pass an `icon`, it is placed as a direct child SVG. Tailwind's arbitrary selector `[&>svg]:absolute` positions it at the top-left corner automatically, and `[&>svg~*]:pl-7` shifts all the text to the right so nothing overlaps. You do not need any wrapper divs around your SVG.
|
||||||
- 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.
|
|
||||||
|
The icon should be `class="h-4 w-4"` — larger icons will misalign the text.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+39
-48
@@ -1,32 +1,22 @@
|
|||||||
# Avatar
|
# Avatar
|
||||||
|
|
||||||
A circular user avatar. Shows an image when a `src` URL is provided; falls back to a text/initials span otherwise.
|
A circular user icon. Shows a profile photo when a URL is provided, or falls back to text (typically initials) on a neutral background.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
span.relative.flex.{size classes}.shrink-0.overflow-hidden.rounded-full
|
// Initials only
|
||||||
img[src, alt, class] ← when src is provided
|
new Avatar(fallback: "JD")
|
||||||
span.flex.items-center... ← fallback when no src
|
|
||||||
{fallback text}
|
// With a profile photo
|
||||||
|
new Avatar(fallback: "Jane Doe", src: "/avatars/jane.jpg")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Avatar(
|
public Avatar(
|
||||||
@@ -35,56 +25,57 @@ public Avatar(
|
|||||||
string size = "default")
|
string size = "default")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `fallback` | Text shown when no `src` is given; also used as `alt` text on the image |
|
| `fallback` | The text shown when no image is available — typically initials like `"JD"`. Also used as the `alt` attribute on the image for screen readers. |
|
||||||
| `src` | Optional image URL |
|
| `src` | URL of the profile photo. If omitted, the fallback is shown. |
|
||||||
| `size` | `"sm"` / `"default"` / `"lg"` / `"xl"` |
|
| `size` | How big the circle is: `"sm"` (32px), `"default"` (40px), `"lg"` (56px), `"xl"` (80px) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Initials avatar
|
### Initials in different sizes
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Avatar(fallback: "JD")
|
new Avatar(fallback: "SM", size: "sm") // 32×32 — good for compact lists
|
||||||
new Avatar(fallback: "JD", size: "lg")
|
new Avatar(fallback: "JD", size: "default") // 40×40 — standard nav bar
|
||||||
|
new Avatar(fallback: "LG", size: "lg") // 56×56 — profile card header
|
||||||
|
new Avatar(fallback: "XL", size: "xl") // 80×80 — full profile page
|
||||||
```
|
```
|
||||||
|
|
||||||
### Image avatar with fallback
|
### Profile page header
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Avatar(fallback: "Jane Doe", src: "/avatars/jane.jpg", size: "default")
|
new Avatar(
|
||||||
|
fallback: user.DisplayName ?? "?",
|
||||||
|
src: user.AvatarUrl,
|
||||||
|
size: "xl")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sizes
|
The `fallback` is always required — even with a `src` — because it becomes the `alt` text on the `<img>` tag.
|
||||||
|
|
||||||
```csharp
|
### Overlapping avatar stack (e.g. "3 team members")
|
||||||
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
|
Wrap multiple avatars in a flex container with negative spacing:
|
||||||
|
|
||||||
```csharp
|
```html
|
||||||
var avatar = new Avatar(fallback: user.Initials, src: user.AvatarUrl, size: "lg");
|
<div class="flex -space-x-2">
|
||||||
|
$$Avatar1$$
|
||||||
// In a page's RenderUserCard override:
|
$$Avatar2$$
|
||||||
protected override void RenderUserAvatar(HtmxRenderContext ctx)
|
$$Avatar3$$
|
||||||
=> avatar.Render(ctx.Next());
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- 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.
|
The avatar is a `<span>` clipped to a circle with `rounded-full overflow-hidden`. When a `src` is given, an `<img>` fills the circle using `object-cover` so the photo does not stretch. When there is no `src`, a `<span>` with a muted background shows the fallback text centred inside the circle.
|
||||||
- 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.
|
The component does not handle broken image URLs. If you need a fallback when an image 404s, add an `onerror` attribute in the surrounding HTML.
|
||||||
- For a group of overlapping avatars (avatar stack), wrap several Avatars in a flex container with negative margin: `<div class="flex -space-x-2">`.
|
|
||||||
|
The Avatar does not extract initials from full names — do that yourself before constructing it. `"Jane Doe"` → `"JD"` is two lines of C# and is better kept in your own code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+56
-62
@@ -1,108 +1,102 @@
|
|||||||
# Badge
|
# Badge
|
||||||
|
|
||||||
A small inline label pill. Used to indicate status, category, or count. Four variants cover most use-cases.
|
A small coloured pill label — the kind you see next to a status field that says "Active", "Pending", or "Error".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
span.{base classes + variant classes}
|
new Badge("Active")
|
||||||
{text}
|
new Badge("Pending", variant: "secondary")
|
||||||
|
new Badge("Error", variant: "destructive")
|
||||||
|
new Badge("Draft", variant: "outline")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Badge(string text, string variant = "default")
|
public Badge(string text, string variant = "default")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `text` | Label displayed inside the badge |
|
| `text` | The label inside the pill |
|
||||||
| `variant` | `"default"` / `"secondary"` / `"destructive"` / `"outline"` |
|
| `variant` | The colour scheme: `"default"` (primary colour), `"secondary"` (muted), `"destructive"` (red), `"outline"` (border only) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic badges
|
### Status column in a user table
|
||||||
|
|
||||||
|
When you need a Badge inside a table cell (which takes a raw HTML string), render it to a string first:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Badge("New")
|
static string RenderBadge(string text, string variant)
|
||||||
new Badge("Beta", variant: "secondary")
|
{
|
||||||
new Badge("Error", variant: "destructive")
|
var writer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||||
new Badge("Pending", variant: "outline")
|
new Badge(text, variant).Render(new HtmxRenderContext(writer));
|
||||||
```
|
return System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
|
||||||
|
}
|
||||||
### 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(
|
new Table(
|
||||||
headers: new[] { "Name", "Status" },
|
headers: new[] { "Name", "Role", "Status" },
|
||||||
rows: users.Select(u => new[] { u.DisplayName ?? "", badgeHtml }))
|
rows: users.Select(u => new[]
|
||||||
|
{
|
||||||
|
u.DisplayName ?? "",
|
||||||
|
u.Role,
|
||||||
|
RenderBadge(u.IsActive ? "Active" : "Suspended",
|
||||||
|
u.IsActive ? "default" : "destructive"),
|
||||||
|
}))
|
||||||
```
|
```
|
||||||
|
|
||||||
### Embedding in a page slot
|
### Dynamic variant based on data
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var badge = order.Status switch
|
||||||
|
{
|
||||||
|
"complete" => new Badge("Complete"),
|
||||||
|
"pending" => new Badge("Pending", variant: "secondary"),
|
||||||
|
"cancelled" => new Badge("Cancelled", variant: "destructive"),
|
||||||
|
_ => new Badge(order.Status, variant: "outline"),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inside a page slot
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- MyPage.htmx -->
|
<!-- MyPage.htmx -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm">Status:</span>
|
<span>Status:</span>
|
||||||
$$StatusBadge$$
|
$$StatusBadge$$
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// MyPage.htmx.cs
|
// MyPage.htmx.cs
|
||||||
public IHtmxComponent StatusBadge { get; }
|
public sealed class MyPage : MyPageBase
|
||||||
|
|
||||||
public MyPage(string status)
|
|
||||||
{
|
{
|
||||||
StatusBadge = status == "active"
|
private readonly IHtmxComponent _statusBadge;
|
||||||
? new Badge("Active")
|
|
||||||
: new Badge("Inactive", variant: "secondary");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
public MyPage(string status)
|
||||||
=> StatusBadge.Render(ctx.Next());
|
{
|
||||||
|
_statusBadge = new Badge(status == "active" ? "Active" : "Inactive",
|
||||||
|
status == "active" ? "default" : "secondary");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
||||||
|
=> _statusBadge.Render(ctx.Next());
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- Badge does not have a click handler — wrap it in an `<a>` or a `Button` if you need interactivity.
|
Badge is a `<span>` with `rounded-full` giving it the pill shape. The four variants are just different combinations of background and text colour classes. Badge is a purely server-rendered display element — it has no JavaScript and no click behaviour. If you need a clickable badge, wrap it in an `<a>` tag or use a `Button` component with a `link` variant.
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,56 @@
|
|||||||
# Breadcrumb
|
# 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.
|
A "you are here" trail — a row of links showing how the user got to the current page. Like breadcrumbs leading back through a forest.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
|
||||||
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
|
```csharp
|
||||||
new Breadcrumb(new[]
|
new Breadcrumb(new[]
|
||||||
{
|
{
|
||||||
("Home", "/"),
|
("Home", "/"),
|
||||||
("Settings", "/settings"),
|
("Settings", "/settings"),
|
||||||
("Profile", ""), // current page — href is ignored for the last item
|
("Profile", ""), // current page
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dynamic breadcrumb from a data path
|
The last item is always the current page. Its link is ignored — the component automatically renders it as plain text with full colour instead of a dimmed link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All the options
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `items` | An ordered list of `(Label, Href)` pairs from root to current page. |
|
||||||
|
|
||||||
|
Two rules:
|
||||||
|
- The **last item** is always rendered as plain text (current page). Its `Href` is ignored.
|
||||||
|
- Any **non-last item** with an empty `Href` renders as a plain `<span>` — useful for non-navigable category labels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-world examples
|
||||||
|
|
||||||
|
### Three-level app navigation
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Breadcrumb(new[]
|
||||||
|
{
|
||||||
|
("Home", "/"),
|
||||||
|
("Reports", "/reports"),
|
||||||
|
("Monthly", ""), // current — href not needed
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built dynamically from a category tree
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Build items from a category tree
|
|
||||||
var crumbs = categoryPath
|
var crumbs = categoryPath
|
||||||
.Select((cat, i) => (cat.Name, i < categoryPath.Count - 1 ? cat.Url : ""))
|
.Select((cat, i) => (cat.Name, i < categoryPath.Count - 1 ? cat.Url : ""))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -68,41 +58,45 @@ var crumbs = categoryPath
|
|||||||
new Breadcrumb(crumbs)
|
new Breadcrumb(crumbs)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Embedded in a page
|
### Inside a page
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- MyPage.htmx -->
|
<!-- ArticlePage.htmx -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">$$Breadcrumb$$</div>
|
||||||
$$Breadcrumb$$
|
<h1 class="text-3xl font-bold">$$ArticleTitle$$</h1>
|
||||||
</div>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// MyPage.htmx.cs
|
// ArticlePage.htmx.cs
|
||||||
public IHtmxComponent Breadcrumb { get; }
|
public sealed class ArticlePage : ArticlePageBase
|
||||||
|
|
||||||
public MyPage()
|
|
||||||
{
|
{
|
||||||
Breadcrumb = new Breadcrumb(new[]
|
private readonly IHtmxComponent _breadcrumb;
|
||||||
|
private readonly byte[] _titleData;
|
||||||
|
|
||||||
|
public ArticlePage(string articleTitle, string categoryName, string categoryUrl)
|
||||||
|
{
|
||||||
|
_titleData = articleTitle.ToUtf8Bytes();
|
||||||
|
_breadcrumb = new Breadcrumb(new[]
|
||||||
{
|
{
|
||||||
("Home", "/"),
|
("Home", "/"),
|
||||||
("Reports", "/reports"),
|
(categoryName, categoryUrl),
|
||||||
("Monthly", ""),
|
(articleTitle, ""),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
||||||
=> Breadcrumb.Render(ctx.Next());
|
=> _breadcrumb.Render(ctx.Next());
|
||||||
|
|
||||||
|
protected override void RenderArticleTitle(HtmxRenderContext ctx)
|
||||||
|
=> ctx.Writer.WriteUtf8(_titleData);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- Always make the last item the current page — its href is ignored anyway, and it gets the visual "active" treatment automatically.
|
Each item renders as a `<li>` inside an `<ol>` inside a `<nav aria-label="Breadcrumb">`. All items except the last are rendered as `<a>` links; the last is a `<span>`. Between items the component inserts a small SVG chevron that is marked `aria-hidden` so screen readers skip it and only announce the text labels.
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+70
-72
@@ -1,52 +1,20 @@
|
|||||||
# Button
|
# Button
|
||||||
|
|
||||||
A styled `<button>` element. Supports six visual variants and four sizes. HTMX attributes can be injected directly via the `hxAttrs` parameter.
|
A styled clickable button. Use it for form submissions, navigation actions, or triggering HTMX requests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
button[type=$$Type$$, class=$$Classes$$, $$HxAttrs$$]
|
new Button("Save changes", type: "submit")
|
||||||
$$Label$$
|
new Button("Cancel", variant: "outline")
|
||||||
|
new Button("Delete", variant: "destructive")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
**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
|
```csharp
|
||||||
public Button(
|
public Button(
|
||||||
@@ -57,67 +25,97 @@ public Button(
|
|||||||
string hxAttrs = "")
|
string hxAttrs = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `label` | Button text (raw HTML — can include inline SVG) |
|
| `label` | The button text. Can include raw HTML — useful for icons. |
|
||||||
| `variant` | Visual style; see table above |
|
| `variant` | Visual style. See table below. |
|
||||||
| `size` | Physical size; see table above |
|
| `size` | How big the button is. See table below. |
|
||||||
| `type` | HTML button type: `"button"` / `"submit"` / `"reset"` |
|
| `type` | HTML button type. Use `"submit"` for form submit buttons. Defaults to `"button"`. |
|
||||||
| `hxAttrs` | Verbatim string appended as extra HTML attributes (HTMX, data-*, etc.) |
|
| `hxAttrs` | Extra HTML attributes added verbatim — use this for HTMX, `disabled`, `data-*`, etc. |
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
| Variant | Looks like |
|
||||||
|
|---|---|
|
||||||
|
| `default` | Filled with the primary colour — use for the main action on a page |
|
||||||
|
| `destructive` | Red — use for irreversible actions like delete |
|
||||||
|
| `outline` | Transparent with a border — use for secondary actions |
|
||||||
|
| `secondary` | Muted fill — use for tertiary actions |
|
||||||
|
| `ghost` | Invisible until hovered — use for toolbar buttons and icon actions |
|
||||||
|
| `link` | Looks like a hyperlink with an underline on hover |
|
||||||
|
|
||||||
|
**Sizes:**
|
||||||
|
|
||||||
|
| Size | Dimensions |
|
||||||
|
|---|---|
|
||||||
|
| `sm` | Compact (36px tall) — good for dense UI |
|
||||||
|
| `default` | Standard (40px tall) |
|
||||||
|
| `lg` | Large (44px tall) — good for prominent CTAs |
|
||||||
|
| `icon` | Square (40×40) — for icon-only buttons |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Standard actions
|
### Form submit button
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Button("Save changes", type: "submit")
|
new Button("Sign in", type: "submit")
|
||||||
new Button("Cancel", variant: "outline")
|
|
||||||
new Button("Delete", variant: "destructive")
|
|
||||||
new Button("Learn more", variant: "link")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sizes
|
### Confirm and cancel in a dialog footer
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Button("Small", size: "sm")
|
var footer = """
|
||||||
new Button("Default", size: "default")
|
{cancelButton}
|
||||||
new Button("Large", size: "lg")
|
{deleteButton}
|
||||||
new Button("⚙", size: "icon") // icon-only square button
|
""";
|
||||||
|
|
||||||
|
// Pre-render each to HTML string and embed:
|
||||||
|
string Render(IHtmxComponent c)
|
||||||
|
{
|
||||||
|
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||||
|
c.Render(new HtmxRenderContext(w));
|
||||||
|
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Card(
|
||||||
|
content: "<p>Are you sure you want to delete this item?</p>",
|
||||||
|
footer: Render(new Button("Cancel", variant: "outline"))
|
||||||
|
+ Render(new Button("Delete", variant: "destructive", type: "submit")))
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTMX trigger
|
### HTMX load more button
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Button(
|
new Button(
|
||||||
"Load more",
|
"Load more",
|
||||||
|
variant: "outline",
|
||||||
hxAttrs: """hx-get="/items?page=2" hx-target="#item-list" hx-swap="beforeend"""")
|
hxAttrs: """hx-get="/items?page=2" hx-target="#item-list" hx-swap="beforeend"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Submit button inside a form
|
### Icon-only ghost button (e.g. a refresh icon in a toolbar)
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Button("Sign in", variant: "default", type: "submit", size: "default")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ghost button with inline SVG icon
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Button(
|
new Button(
|
||||||
label: """
|
label: "<svg class='h-4 w-4' ...fill or stroke SVG here/>",
|
||||||
<svg class="h-4 w-4" .../>
|
variant: "ghost",
|
||||||
<span>Refresh</span>
|
size: "icon")
|
||||||
""",
|
|
||||||
variant: "ghost")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Disabled appearance (via HTML)
|
### Disabled state
|
||||||
|
|
||||||
The Button component does not have a `disabled` constructor parameter. Set it via `hxAttrs` if needed:
|
Button does not have a `disabled` parameter. Pass it through `hxAttrs`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Button("Processing...", variant: "default", hxAttrs: "disabled aria-disabled='true'")
|
new Button("Processing...", hxAttrs: "disabled aria-disabled='true'")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Button is a `<button>` element — straightforward HTML with Tailwind classes. The `hxAttrs` string is appended verbatim inside the opening `<button>` tag, so any valid HTML attribute works there. The `label` is inserted as raw HTML, which is how inline SVG icons are supported.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+53
-95
@@ -1,68 +1,20 @@
|
|||||||
# Calendar
|
# 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.
|
A date picker that lets the user click to select a single date. The selected date is stored in a hidden form input and submitted with the form. Think of it as a fancy `<input type="date">` that looks the same on every browser.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.calendar-root[id=cal-{id}, data-year, data-month, data-sel-day,
|
new Calendar(id: "dob", name: "dateOfBirth")
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
That renders a calendar starting at today's date. When the user clicks a day, the hidden input is updated and the date is included in the form submission.
|
||||||
|
|
||||||
## 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`)
|
## All the options
|
||||||
|
|
||||||
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
|
```csharp
|
||||||
public Calendar(
|
public Calendar(
|
||||||
@@ -71,83 +23,89 @@ public Calendar(
|
|||||||
DateOnly? selected = null)
|
DateOnly? selected = null)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Logical id; element gets `id="cal-{id}"` |
|
| `id` | A unique identifier for this calendar. The root element gets `id="cal-{id}"`. |
|
||||||
| `name` | Form field name for the hidden input |
|
| `name` | The form field name for the hidden input. Use this name in your `Command` record on the server. |
|
||||||
| `selected` | Pre-selected date; defaults to today |
|
| `selected` | The date to pre-select on render. Defaults to today. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic date picker
|
### Appointment booking form
|
||||||
|
|
||||||
```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
|
```html
|
||||||
<!-- Templates/BookingForm.htmx -->
|
<!-- Templates/BookingForm.htmx -->
|
||||||
<form method="post" action="/book">
|
<form method="post" action="/book" class="space-y-6">
|
||||||
$$AntiforgeryToken$$
|
$$Token$$
|
||||||
|
<div>
|
||||||
<label class="text-sm font-medium">Pick a date</label>
|
<label class="text-sm font-medium">Pick a date</label>
|
||||||
$$DatePicker$$
|
$$DatePicker$$
|
||||||
<button type="submit">Book</button>
|
</div>
|
||||||
|
<button type="submit">Book appointment</button>
|
||||||
</form>
|
</form>
|
||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Templates/BookingForm.htmx.cs
|
// Templates/BookingForm.htmx.cs
|
||||||
public IHtmxComponent DatePicker { get; }
|
public sealed class BookingForm : BookingFormBase
|
||||||
|
|
||||||
public BookingForm(string? afToken = null)
|
|
||||||
{
|
{
|
||||||
DatePicker = new Calendar(id: "booking", name: "bookingDate");
|
private readonly IHtmxComponent _datePicker;
|
||||||
_afTokenData = /* antiforgery hidden input */;
|
private readonly byte[] _tokenData;
|
||||||
}
|
|
||||||
|
|
||||||
protected override void RenderDatePicker(HtmxRenderContext ctx)
|
public BookingForm(string antiforgeryToken)
|
||||||
=> DatePicker.Render(ctx.Next());
|
{
|
||||||
|
_tokenData = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />""".ToUtf8Bytes();
|
||||||
|
_datePicker = new Calendar(id: "booking", name: "bookingDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void RenderToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tokenData);
|
||||||
|
protected override void RenderDatePicker(HtmxRenderContext ctx) => _datePicker.Render(ctx.Next());
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reading the submitted value on the server:**
|
**Reading the submitted date on the server:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public record Command(
|
public record Command(
|
||||||
[property: FromForm] string BookingDate // "yyyy-MM-dd"
|
[property: FromForm] string BookingDate // arrives as "yyyy-MM-dd"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse:
|
|
||||||
var date = DateOnly.ParseExact(command.BookingDate, "yyyy-MM-dd");
|
var date = DateOnly.ParseExact(command.BookingDate, "yyyy-MM-dd");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Listening for selection changes client-side
|
### Pre-selected date (e.g. editing an existing booking)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Calendar(
|
||||||
|
id: "appointment",
|
||||||
|
name: "appointmentDate",
|
||||||
|
selected: existingBooking.Date)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reacting to date selection in JavaScript
|
||||||
|
|
||||||
|
When the user picks a date, the calendar fires a `calendarChange` custom event:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
document.getElementById('cal-appointment').addEventListener('calendarChange', e => {
|
document.getElementById('cal-booking').addEventListener('calendarChange', e => {
|
||||||
console.log(e.detail.date); // "2026-09-15"
|
console.log(e.detail.date); // "2026-09-15"
|
||||||
// update other UI elements based on selection
|
// update price estimates, availability, etc.
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- The hidden input is always named with the `name` parameter — use this as the form field name when reading the submitted POST.
|
The calendar is rendered as static HTML by the server, with the current month's grid pre-built as `<button>` elements. JavaScript in `components.js` (`initCalendar`) takes over after the page loads:
|
||||||
- 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.
|
|
||||||
|
- Clicking a day updates the hidden input and highlights the selected date.
|
||||||
|
- Clicking the month/year label in the navigation row drills down: days → months → years. This lets the user jump to a different year quickly without clicking through months one at a time.
|
||||||
|
- Prev/Next arrows move through the current view (month by month, year by year, or decade by decade).
|
||||||
|
|
||||||
|
All state is stored in `data-*` attributes on the root element — not in JavaScript closures. This means the calendar is fully re-initialised correctly when HTMX swaps it in.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -1,68 +1,20 @@
|
|||||||
# CalendarRange
|
# 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.
|
A date-range picker. The user clicks once to set a start date and clicks again to set an end date. While hovering, the range between start and the cursor is shaded as a preview. Great for booking forms, report filters, or anything that needs a "from / to" date pair.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.calr-root[id=calr-{id}, data-year, data-month, data-start, data-end, data-view="days"]
|
new CalendarRange(id: "vacation", name: "vacation")
|
||||||
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]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
This renders an empty picker. The user clicks two dates to form a range. Both dates are submitted with the form as `vacation-start` and `vacation-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`)
|
## All the options
|
||||||
|
|
||||||
### 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
|
```csharp
|
||||||
public CalendarRange(
|
public CalendarRange(
|
||||||
@@ -72,46 +24,32 @@ public CalendarRange(
|
|||||||
DateOnly? selectedEnd = null)
|
DateOnly? selectedEnd = null)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Logical id; element gets `id="calr-{id}"` |
|
| `id` | A unique identifier. The root element gets `id="calr-{id}"`. |
|
||||||
| `name` | Base form field name; hidden inputs are `{name}-start` and `{name}-end` |
|
| `name` | Base form field name. The two hidden inputs become `{name}-start` and `{name}-end`. |
|
||||||
| `selectedStart` | Pre-selected start date |
|
| `selectedStart` | Pre-selected start date. |
|
||||||
| `selectedEnd` | Pre-selected end date |
|
| `selectedEnd` | Pre-selected end date. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Empty picker
|
### Vacation request form
|
||||||
|
|
||||||
```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
|
```html
|
||||||
<!-- Templates/VacationForm.htmx -->
|
<!-- Templates/VacationForm.htmx -->
|
||||||
<form method="post" action="/vacation">
|
<form method="post" action="/vacation" class="space-y-6">
|
||||||
$$AntiforgeryToken$$
|
$$Token$$
|
||||||
|
<div>
|
||||||
<label class="text-sm font-medium">Select vacation dates</label>
|
<label class="text-sm font-medium">Select vacation dates</label>
|
||||||
$$RangePicker$$
|
$$RangePicker$$
|
||||||
<button type="submit">Request</button>
|
</div>
|
||||||
|
<button type="submit">Submit request</button>
|
||||||
</form>
|
</form>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reading the submitted values:**
|
**Reading the submitted values on the server:**
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public record Command(
|
public record Command(
|
||||||
@@ -119,34 +57,48 @@ public record Command(
|
|||||||
[property: FromForm] string VacationEnd // "yyyy-MM-dd"
|
[property: FromForm] string VacationEnd // "yyyy-MM-dd"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Validate they are not empty before parsing
|
||||||
|
if (string.IsNullOrEmpty(command.VacationStart) || string.IsNullOrEmpty(command.VacationEnd))
|
||||||
|
return; // user did not complete the selection
|
||||||
|
|
||||||
var start = DateOnly.ParseExact(command.VacationStart, "yyyy-MM-dd");
|
var start = DateOnly.ParseExact(command.VacationStart, "yyyy-MM-dd");
|
||||||
var end = DateOnly.ParseExact(command.VacationEnd, "yyyy-MM-dd");
|
var end = DateOnly.ParseExact(command.VacationEnd, "yyyy-MM-dd");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Listening for range changes client-side
|
### Pre-selected range (e.g. editing an existing request)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new CalendarRange(
|
||||||
|
id: "vacation",
|
||||||
|
name: "vacation",
|
||||||
|
selectedStart: existingRequest.StartDate,
|
||||||
|
selectedEnd: existingRequest.EndDate)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reacting to selection changes in JavaScript
|
||||||
|
|
||||||
```js
|
```js
|
||||||
document.getElementById('calr-vacation').addEventListener('rangeChange', e => {
|
document.getElementById('calr-vacation').addEventListener('rangeChange', e => {
|
||||||
console.log(e.detail.start, e.detail.end);
|
console.log(e.detail.start, e.detail.end);
|
||||||
// e.g. "2026-07-01", "2026-07-14"
|
// Update a price estimate, nights count, etc.
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Showing a summary label elsewhere on the page
|
The `.calr-label` element inside the calendar automatically updates to show `start → end` (or `start → pick end date` while mid-selection). You do not need custom JS for the label.
|
||||||
|
|
||||||
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
|
## How it works
|
||||||
|
|
||||||
- Both hidden inputs are always submitted with the form. An empty string means the date was not selected — validate server-side before parsing.
|
The click logic follows three states:
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
1. **Nothing selected** — first click sets the start date, clears the end
|
||||||
|
2. **Start selected, no end** — next click after start sets the end and fires `rangeChange`; clicking before start moves the start; clicking on start again clears both
|
||||||
|
3. **Both selected** — any click resets and starts again from step 1
|
||||||
|
|
||||||
|
The hover preview does not rebuild the grid. It only toggles CSS classes on the day buttons, so it is fast even for long ranges.
|
||||||
|
|
||||||
|
All state is stored in `data-start` and `data-end` attributes on the root element, not in closures, so HTMX-swapped calendars re-initialise correctly.
|
||||||
|
|
||||||
## Complete page example
|
## Complete page example
|
||||||
|
|
||||||
|
|||||||
+54
-68
@@ -1,36 +1,21 @@
|
|||||||
# Card
|
# Card
|
||||||
|
|
||||||
A styled container with optional header (title + description) and footer sections. The body content is always rendered; header and footer are conditionally included.
|
A bordered box for grouping related content — like a physical card you might hold in your hand. It has three distinct zones: a header (title + subtitle), a body (your content), and a footer (usually actions).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.rounded-lg.border.border-border.bg-card.text-card-foreground.shadow-sm.{extraClasses}
|
new Card(
|
||||||
div.flex.flex-col.space-y-1.5.p-6 ← header (omitted when no title/description)
|
content: "<p>Your subscription renews on July 1.</p>",
|
||||||
h3.text-2xl.font-semibold ← title
|
title: "Billing",
|
||||||
p.text-sm.text-muted-foreground ← description
|
description: "Current plan: Pro")
|
||||||
div.p-6.pt-0 ← content (always present)
|
|
||||||
{content}
|
|
||||||
div.flex.items-center.p-6.pt-0 ← footer (omitted when empty)
|
|
||||||
{footer}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Card(
|
public Card(
|
||||||
@@ -41,77 +26,78 @@ public Card(
|
|||||||
string extraClasses = "")
|
string extraClasses = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `content` | Raw HTML for the card body (always rendered) |
|
| `content` | The body of the card — always shown. Raw HTML. |
|
||||||
| `title` | Optional heading in the header area |
|
| `title` | Optional bold heading at the top of the card. |
|
||||||
| `description` | Optional subheading below the title |
|
| `description` | Optional smaller subtitle below the title. |
|
||||||
| `footer` | Optional raw HTML in the footer area |
|
| `footer` | Optional section at the bottom, typically holding action buttons. Raw HTML. |
|
||||||
| `extraClasses` | Additional Tailwind classes on the outer `div` |
|
| `extraClasses` | Additional Tailwind classes on the outer `div` — useful for `max-w-sm`, `col-span-2`, etc. |
|
||||||
|
|
||||||
|
The header section (title + description) is omitted entirely when both are empty. Same for the footer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Simple content card
|
### A stats card on a dashboard
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Card(content: "<p>Your subscription renews on July 1.</p>")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Card with title and description
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Card(
|
new Card(
|
||||||
content: "<p>Manage your billing details and invoices.</p>",
|
title: "Total Users",
|
||||||
title: "Billing",
|
description: "All registered accounts",
|
||||||
description: "Your current plan: Pro")
|
content: $"<p class=\"text-4xl font-bold\">{userCount:N0}</p>")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Card with footer actions
|
### A confirmation card with footer buttons
|
||||||
|
|
||||||
|
Buttons and other components need to be pre-rendered to HTML strings when used inside `content` or `footer`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
string ToHtml(IHtmxComponent c)
|
||||||
|
{
|
||||||
|
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||||
|
c.Render(new HtmxRenderContext(w));
|
||||||
|
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||||
|
}
|
||||||
|
|
||||||
new Card(
|
new Card(
|
||||||
content: "<p>Are you sure you want to cancel your account?</p>",
|
|
||||||
title: "Delete account",
|
title: "Delete account",
|
||||||
description: "This action cannot be undone.",
|
description: "This action cannot be undone.",
|
||||||
footer: """
|
content: "<p>All your data will be permanently removed.</p>",
|
||||||
<button class="inline-flex h-9 rounded-md border border-input px-4 text-sm mr-2">Cancel</button>
|
footer: ToHtml(new Button("Cancel", variant: "outline"))
|
||||||
<button class="inline-flex h-9 rounded-md bg-destructive text-destructive-foreground px-4 text-sm">Delete</button>
|
+ ToHtml(new Button("Delete", variant: "destructive", type: "submit")))
|
||||||
""")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Constrained width
|
### A grid of cards
|
||||||
|
|
||||||
|
Cards are most commonly placed in a CSS grid in the page template:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
$$Card1$$
|
||||||
|
$$Card2$$
|
||||||
|
$$Card3$$
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constrained width (e.g. a login card)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Card(
|
new Card(
|
||||||
content: "<p>Hello world</p>",
|
content: "...login form HTML...",
|
||||||
title: "Welcome",
|
title: "Welcome back",
|
||||||
|
description: "Sign in to your account",
|
||||||
extraClasses: "max-w-sm mx-auto")
|
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
|
## How it works
|
||||||
|
|
||||||
- `content`, `footer`, title, and description are inserted as raw HTML — HTML-encode any user-supplied strings before passing them in.
|
Card uses CSS variables (`bg-card`, `text-card-foreground`, `border-border`) which automatically adapt to dark mode. The header and footer sections are skipped entirely in the rendered HTML when they are not needed — they do not leave empty divs behind.
|
||||||
- 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.
|
All strings passed to `content` and `footer` are raw HTML. HTML-encode any user-supplied values before passing them in.
|
||||||
- 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">`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+43
-57
@@ -1,32 +1,21 @@
|
|||||||
# Checkbox
|
# 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.
|
A styled checkbox with an optional text label. Use it in forms when you want the user to opt in or out of something — "Remember me", "I agree to the terms", or selecting items in a list.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.items-center.space-x-2
|
new Checkbox(
|
||||||
input[type=checkbox, id, name, value, class, $$Checked$$]
|
id: "newsletter",
|
||||||
label[for={id}] ← omitted when label is empty
|
label: "Subscribe to newsletter",
|
||||||
{label text}
|
name: "newsletter")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Checkbox(
|
public Checkbox(
|
||||||
@@ -37,28 +26,41 @@ public Checkbox(
|
|||||||
bool @checked = false)
|
bool @checked = false)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Element id and the `for` attribute on the label |
|
| `id` | The element id. Also used by the `<label for="...">` so clicking the label toggles the box. |
|
||||||
| `label` | Visible text next to the checkbox; omit for a standalone checkbox |
|
| `label` | Visible text next to the checkbox. Leave empty for a standalone checkbox with no label. |
|
||||||
| `name` | Form field name (required when used in a form) |
|
| `name` | Form field name — required if you want the value submitted with the form. |
|
||||||
| `value` | Submitted value when checked (default: `"true"`) |
|
| `value` | The string that gets submitted when the box is checked. Defaults to `"true"`. |
|
||||||
| `checked` | Pre-checked state |
|
| `checked` | Pre-tick the checkbox on render. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic opt-in checkbox
|
### Terms and conditions on a registration form
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Checkbox(
|
new Checkbox(
|
||||||
id: "newsletter",
|
id: "terms",
|
||||||
label: "Subscribe to newsletter",
|
label: "I agree to the terms of service",
|
||||||
name: "newsletter")
|
name: "terms",
|
||||||
|
value: "accepted")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pre-checked
|
Reading it on the server:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record Command(
|
||||||
|
[property: FromForm] string? Terms = null // null when unchecked, "accepted" when checked
|
||||||
|
);
|
||||||
|
|
||||||
|
bool agreedToTerms = command.Terms == "accepted";
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** HTML forms only submit checkboxes that are *checked*. An unchecked checkbox sends nothing — the field is simply absent. Always use a nullable string (`string?`) or give it a default of `null`.
|
||||||
|
|
||||||
|
### Remember me (pre-ticked by default)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Checkbox(
|
new Checkbox(
|
||||||
@@ -68,45 +70,29 @@ new Checkbox(
|
|||||||
checked: true)
|
checked: true)
|
||||||
```
|
```
|
||||||
|
|
||||||
### No visible label
|
### Multiple checkboxes in a preferences form
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Checkbox(id: "select-all", name: "selectAll")
|
new Checkbox(id: "email-alerts", label: "Email alerts", name: "emailAlerts")
|
||||||
|
new Checkbox(id: "sms-alerts", label: "SMS alerts", name: "smsAlerts")
|
||||||
|
new Checkbox(id: "weekly-summary", label: "Weekly summary", name: "weeklySummary")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom submitted value
|
Server-side:
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Checkbox(
|
|
||||||
id: "agree",
|
|
||||||
label: "I agree to the terms",
|
|
||||||
name: "terms",
|
|
||||||
value: "accepted")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reading in a form handler
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public record Command(
|
public record Command(
|
||||||
[property: FromForm] string? Newsletter = null, // null when unchecked
|
[property: FromForm] string? EmailAlerts = null,
|
||||||
[property: FromForm] string? RememberMe = null
|
[property: FromForm] string? SmsAlerts = null,
|
||||||
|
[property: FromForm] string? WeeklySummary = 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
|
## How it works
|
||||||
|
|
||||||
- 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.
|
Checkbox renders a native `<input type="checkbox">` styled with `accent-primary` so the checkmark colour matches your theme's primary colour. The label is a separate `<label for="{id}">` element — clicking anywhere on the label text toggles the checkbox.
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+51
-92
@@ -1,53 +1,32 @@
|
|||||||
# Dialog
|
# 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.
|
A modal pop-up window that appears on top of the page. Think of it like a small piece of paper sliding onto the desk — the rest of the page dims and you have to deal with the dialog before you can go back to work.
|
||||||
|
|
||||||
|
Opening and closing is handled entirely with `data-dialog-open` and `data-dialog-close` HTML attributes — no custom JavaScript needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
dialog[id, class=...]
|
new Dialog(
|
||||||
div.dialog-panel.relative.bg-background.p-6.rounded-lg.shadow-xl.w-full.max-w-md...
|
id: "about-dialog",
|
||||||
button.absolute.top-4.right-4[data-dialog-close={id}] ← × close button
|
title: "About this app",
|
||||||
h2.text-lg.font-semibold ← title (omitted when empty)
|
content: "<p>Version 1.0 — built with .NET 10.</p>",
|
||||||
p.text-sm.text-muted-foreground.mt-1 ← description (omitted when empty)
|
footer: """<button data-dialog-close="about-dialog">Close</button>""")
|
||||||
div.mt-4 ← body content
|
|
||||||
{content}
|
|
||||||
div.mt-6.flex.justify-end.gap-2 ← footer (omitted when empty)
|
|
||||||
{footer}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Then somewhere on the page, add a trigger:
|
||||||
|
|
||||||
## CSS mechanics
|
```html
|
||||||
|
<button data-dialog-open="about-dialog">About</button>
|
||||||
|
```
|
||||||
|
|
||||||
| Class | Effect |
|
That's it. No JavaScript needed in your templates.
|
||||||
|---|---|
|
|
||||||
| `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`)
|
## All the options
|
||||||
|
|
||||||
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
|
```csharp
|
||||||
public Dialog(
|
public Dialog(
|
||||||
@@ -58,96 +37,76 @@ public Dialog(
|
|||||||
string footer = "")
|
string footer = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Element id — must be unique per page; also used by `data-dialog-open` |
|
| `id` | A unique identifier. Used both on the `<dialog>` element and in `data-dialog-open`. |
|
||||||
| `content` | Raw HTML for the dialog body |
|
| `content` | The body of the dialog. Raw HTML. |
|
||||||
| `title` | Optional heading at the top of the panel |
|
| `title` | Optional bold heading at the top of the dialog panel. |
|
||||||
| `description` | Optional subheading below the title |
|
| `description` | Optional smaller text below the title. |
|
||||||
| `footer` | Optional raw HTML for the bottom button row |
|
| `footer` | Optional button row at the bottom. Raw HTML. |
|
||||||
|
|
||||||
|
The title, description, and footer sections are omitted entirely from the HTML when not provided.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Simple information dialog
|
### Confirmation before a destructive action
|
||||||
|
|
||||||
```csharp
|
Place the dialog in the page template, then trigger it from a button:
|
||||||
// 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
|
```html
|
||||||
<button data-dialog-open="about-dialog" class="...">About</button>
|
<!-- Templates/ItemsPage.htmx -->
|
||||||
|
$$DeleteDialog$$
|
||||||
|
<!-- ... rest of page ... -->
|
||||||
|
<button data-dialog-open="confirm-delete">Delete item</button>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Confirmation dialog
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Dialog(
|
// Templates/ItemsPage.htmx.cs
|
||||||
|
_deleteDialog = new Dialog(
|
||||||
id: "confirm-delete",
|
id: "confirm-delete",
|
||||||
title: "Delete item",
|
title: "Delete item",
|
||||||
content: "<p>This action cannot be undone.</p>",
|
content: "<p>This action cannot be undone.</p>",
|
||||||
footer: """
|
footer: """
|
||||||
<button data-dialog-close="confirm-delete" class="...">Cancel</button>
|
<button data-dialog-close="confirm-delete">Cancel</button>
|
||||||
<button hx-delete="/items/42" hx-confirm="" data-dialog-close="confirm-delete"
|
<button hx-delete="/items/42" data-dialog-close="confirm-delete"
|
||||||
class="bg-destructive text-destructive-foreground ...">
|
class="bg-destructive text-destructive-foreground ...">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
""")
|
""");
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTMX-powered content reload
|
### Dialog that loads content on demand
|
||||||
|
|
||||||
|
Use HTMX's `revealed` trigger to load the dialog body only when it opens:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Dialog(
|
new Dialog(
|
||||||
id: "user-detail",
|
id: "user-detail",
|
||||||
|
title: "User details",
|
||||||
content: """<div hx-get="/users/42/detail" hx-trigger="revealed"></div>""")
|
content: """<div hx-get="/users/42/detail" hx-trigger="revealed"></div>""")
|
||||||
```
|
```
|
||||||
|
|
||||||
The `revealed` trigger fires when the dialog becomes visible, loading content on demand.
|
### Closing from outside the dialog
|
||||||
|
|
||||||
### Embedding inside a page slot
|
Any element anywhere on the page can close a dialog by setting `data-dialog-close`:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- MyPage.htmx -->
|
<button data-dialog-close="confirm-delete">Never mind</button>
|
||||||
$$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
|
## How it works
|
||||||
|
|
||||||
- The `id` is used both on the `<dialog>` element and in `data-dialog-open`/`data-dialog-close` — keep it unique per page.
|
Dialog uses the native HTML `<dialog>` element with `showModal()`. A backdrop (the dark overlay) comes from the browser's built-in `::backdrop` pseudo-element, styled in `input.css`.
|
||||||
- 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)`.
|
JavaScript in `components.js` listens for clicks anywhere on the page. If the clicked element has `data-dialog-open`, it calls `showModal()` on the matching dialog. If it has `data-dialog-close`, it calls `close()`. Clicking outside the dialog panel (on the backdrop) also closes it.
|
||||||
- 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.
|
Because the listener is on `document`, dialogs that are HTMX-swapped in work automatically without any re-initialisation.
|
||||||
- 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.
|
|
||||||
|
All `content` and `footer` strings are raw HTML — HTML-encode any user-supplied values before passing them in.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,10 @@
|
|||||||
# DropdownMenu
|
# 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.
|
A button that, when clicked, opens a small floating menu of links. Like a folder label on a filing cabinet — pull it and a list of options drops down. Closes automatically when you click elsewhere or press Escape.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
|
||||||
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
|
```csharp
|
||||||
new DropdownMenu(
|
new DropdownMenu(
|
||||||
@@ -69,24 +13,70 @@ new DropdownMenu(
|
|||||||
{
|
{
|
||||||
("Profile", "/profile", false),
|
("Profile", "/profile", false),
|
||||||
("Settings", "/settings", false),
|
("Settings", "/settings", false),
|
||||||
("", "", true), // separator
|
("", "", true), // separator line
|
||||||
("Sign out", "/logout", false),
|
("Sign out", "/logout", false),
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Icon-button dropdown
|
---
|
||||||
|
|
||||||
|
## All the options
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public DropdownMenu(
|
||||||
|
IHtmxComponent trigger,
|
||||||
|
IEnumerable<(string Label, string Href, bool IsSeparator)> items,
|
||||||
|
string position = "right")
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `trigger` | The visible button that opens the menu. Any `IHtmxComponent` — usually a `Button`. |
|
||||||
|
| `items` | The list of menu items. Each is a `(Label, Href, IsSeparator)` tuple. |
|
||||||
|
| `position` | `"right"` aligns the menu to the right edge of the trigger (default). `"left"` aligns it to the left. |
|
||||||
|
|
||||||
|
**Item tuple fields:**
|
||||||
|
|
||||||
|
| Field | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `Label` | The text shown in the menu. |
|
||||||
|
| `Href` | The URL to navigate to when clicked. |
|
||||||
|
| `IsSeparator` | Set to `true` to render a divider line instead of a link. `Label` and `Href` are ignored. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-world examples
|
||||||
|
|
||||||
|
### User account menu in the header
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new DropdownMenu(
|
||||||
|
trigger: new Button("My account", variant: "ghost"),
|
||||||
|
items: new[]
|
||||||
|
{
|
||||||
|
("Profile", "/profile", false),
|
||||||
|
("Billing", "/billing", false),
|
||||||
|
("", "", true),
|
||||||
|
("Sign out", "/logout", false),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Row action menu in a table (three-dot icon button)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new DropdownMenu(
|
new DropdownMenu(
|
||||||
trigger: new Button("⋯", size: "icon", variant: "ghost"),
|
trigger: new Button("⋯", size: "icon", variant: "ghost"),
|
||||||
items: new[]
|
items: new[]
|
||||||
{
|
{
|
||||||
("Edit", "/items/42/edit", false),
|
("Edit", $"/items/{item.Id}/edit", false),
|
||||||
("Delete", "/items/42/delete", false),
|
("Delete", $"/items/{item.Id}/delete", false),
|
||||||
})
|
},
|
||||||
|
position: "left") // avoid overflow near the right edge of the screen
|
||||||
```
|
```
|
||||||
|
|
||||||
### Left-aligned dropdown (useful when near the right edge of the viewport)
|
### Left-aligned menu (near right side of viewport)
|
||||||
|
|
||||||
|
Use `position: "left"` when the trigger is close to the right edge of the screen to prevent the menu from clipping off-screen:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new DropdownMenu(
|
new DropdownMenu(
|
||||||
@@ -95,26 +85,15 @@ new DropdownMenu(
|
|||||||
position: "left")
|
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
|
## How it works
|
||||||
|
|
||||||
- The `trigger` is any `IHtmxComponent` — pass a `Button`, an `Avatar`, or any custom component.
|
The menu panel is always present in the HTML but hidden with a `hidden` class. When the trigger is clicked, JavaScript toggles the `hidden` class to show it. Clicking anything outside — or pressing Escape — adds `hidden` back.
|
||||||
- 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.
|
Because the click listener is attached to `document`, dropdown menus that are HTMX-swapped in work automatically.
|
||||||
- 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.
|
All items are rendered as `<a href="...">` links. If you need an action that POSTs data (like a delete), the cleanest approach is to route it through a confirmation Dialog.
|
||||||
- Multiple dropdowns on the same page are handled independently — clicking one will close others.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,23 @@
|
|||||||
# FileInput
|
# 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.
|
A styled file upload field. Use it when you need users to attach files to a form — profile pictures, documents, CSV imports, and so on.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.flex-col.gap-1.5
|
new FileInput(
|
||||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
id: "avatar",
|
||||||
{label text}
|
name: "avatar",
|
||||||
input[type=file, id, name, accept, class, $$Multiple$$, $$HxAttrs$$]
|
accept: "image/*",
|
||||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
label: "Profile picture",
|
||||||
{description text}
|
description: "PNG, JPG or GIF up to 2 MB")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public FileInput(
|
public FileInput(
|
||||||
@@ -43,33 +31,22 @@ public FileInput(
|
|||||||
string hxAttrs = "")
|
string hxAttrs = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Element id and label `for` target |
|
| `id` | The element id. Also used by the `<label for="...">`. |
|
||||||
| `name` | Form field name |
|
| `name` | Form field name — required if you want the file submitted with the form. |
|
||||||
| `accept` | MIME types or file extensions, e.g. `"image/*"` or `".pdf,.docx"` |
|
| `accept` | MIME types or extensions to filter the picker, e.g. `"image/*"` or `".pdf,.docx"`. Does not validate server-side. |
|
||||||
| `multiple` | Allows selecting more than one file |
|
| `multiple` | Allow selecting more than one file at a time. |
|
||||||
| `label` | Visible label above the field |
|
| `label` | Visible text label above the field. |
|
||||||
| `description` | Helper text below the field |
|
| `description` | Hint text below the field (e.g. "Max 5 MB"). |
|
||||||
| `extraClasses` | Additional Tailwind classes on the input |
|
| `extraClasses` | Additional Tailwind classes on the `<input>` element. |
|
||||||
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
|
| `hxAttrs` | Extra HTML attributes appended verbatim (HTMX, `data-*`, etc.). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic single file
|
### Multiple document attachments
|
||||||
|
|
||||||
```csharp
|
|
||||||
new FileInput(
|
|
||||||
id: "avatar",
|
|
||||||
name: "avatar",
|
|
||||||
accept: "image/*",
|
|
||||||
label: "Profile picture",
|
|
||||||
description: "PNG, JPG or GIF up to 2 MB")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple files
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new FileInput(
|
new FileInput(
|
||||||
@@ -81,7 +58,7 @@ new FileInput(
|
|||||||
description: "Select one or more documents")
|
description: "Select one or more documents")
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTMX auto-upload on change
|
### Auto-upload on file selection (HTMX)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new FileInput(
|
new FileInput(
|
||||||
@@ -92,22 +69,32 @@ new FileInput(
|
|||||||
hxAttrs: """hx-post="/import" hx-encoding="multipart/form-data" hx-target="#result" hx-trigger="change"""")
|
hxAttrs: """hx-post="/import" hx-encoding="multipart/form-data" hx-target="#result" hx-trigger="change"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
### No label
|
When using HTMX for file uploads, always include `hx-encoding="multipart/form-data"` — HTMX does not infer it from the input type.
|
||||||
|
|
||||||
|
### Reading uploaded files in a handler
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new FileInput(id: "doc", name: "document", accept: ".pdf")
|
public static IResult Handle(HttpContext ctx, IFormFile? avatar)
|
||||||
|
{
|
||||||
|
if (avatar is null || avatar.Length == 0)
|
||||||
|
return Results.BadRequest("No file uploaded");
|
||||||
|
|
||||||
|
// validate file type server-side (accept= only filters in the browser)
|
||||||
|
var allowed = new[] { "image/jpeg", "image/png", "image/gif" };
|
||||||
|
if (!allowed.Contains(avatar.ContentType))
|
||||||
|
return Results.BadRequest("Invalid file type");
|
||||||
|
|
||||||
|
using var stream = avatar.OpenReadStream();
|
||||||
|
// save the file...
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- `accept` filters in the browser's file picker dialog but does not validate server-side — always validate the uploaded file type in your handler.
|
FileInput renders a standard `<input type="file">`. The browser's built-in "Choose file" button is styled using `::file-selector-button` CSS pseudo-element (via Tailwind's `file:` prefix) so it matches the rest of the UI.
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+39
-52
@@ -1,36 +1,23 @@
|
|||||||
# Input
|
# Input
|
||||||
|
|
||||||
A styled text input with optional label and description. Supports all standard HTML input types and HTMX attributes.
|
A styled single-line text field with an optional label and hint text below it. The workhorse of any form — use it for names, emails, passwords, search queries, or any other short text value.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.flex-col.gap-1.5
|
new Input(
|
||||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
id: "email",
|
||||||
{label text}
|
name: "email",
|
||||||
input[type, id, name, placeholder, class, $$HxAttrs$$]
|
inputType: "email",
|
||||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
placeholder: "you@example.com",
|
||||||
{description text}
|
label: "Email address")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Input(
|
public Input(
|
||||||
@@ -44,22 +31,22 @@ public Input(
|
|||||||
string hxAttrs = "")
|
string hxAttrs = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Element id and label `for` target |
|
| `id` | Element id. Also used by the `<label for="...">` so clicking the label focuses the input. |
|
||||||
| `name` | Form field name |
|
| `name` | Form field name — required if you want the value submitted with the form. |
|
||||||
| `inputType` | HTML type attribute: `text` / `email` / `password` / `number` / `search` / `tel` / `url` / `date` / `time` |
|
| `inputType` | HTML type: `text`, `email`, `password`, `number`, `search`, `tel`, `url`, `date`, `time`. |
|
||||||
| `placeholder` | Placeholder text |
|
| `placeholder` | Greyed-out hint inside the field before the user types. |
|
||||||
| `label` | Visible label above the field |
|
| `label` | Visible text label above the field. |
|
||||||
| `description` | Helper text below the field |
|
| `description` | Small hint text below the field (e.g. "At least 8 characters"). |
|
||||||
| `extraClasses` | Additional Tailwind classes on the input |
|
| `extraClasses` | Additional Tailwind classes on the `<input>` element. |
|
||||||
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
|
| `hxAttrs` | Extra HTML attributes appended verbatim. Use for HTMX, `min`/`max`, `autocomplete`, etc. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Email and password fields
|
### Login form fields
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Input(
|
new Input(
|
||||||
@@ -78,7 +65,18 @@ new Input(
|
|||||||
description: "At least 8 characters")
|
description: "At least 8 characters")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Search with HTMX live search
|
Reading on the server:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record Command(
|
||||||
|
[property: FromForm] string Email,
|
||||||
|
[property: FromForm] string Password
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Live search with HTMX
|
||||||
|
|
||||||
|
This fires a GET request 300ms after the user stops typing and swaps the results in:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Input(
|
new Input(
|
||||||
@@ -89,7 +87,9 @@ new Input(
|
|||||||
hxAttrs: """hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:300ms"""")
|
hxAttrs: """hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:300ms"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Number input with constraints (via extraClasses / hxAttrs)
|
### Number input with min/max constraints
|
||||||
|
|
||||||
|
Extra HTML attributes like `min` and `max` can be passed through `hxAttrs`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Input(
|
new Input(
|
||||||
@@ -100,24 +100,11 @@ new Input(
|
|||||||
hxAttrs: """min="1" max="100" step="1"""")
|
hxAttrs: """min="1" max="100" step="1"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
### URL input
|
---
|
||||||
|
|
||||||
```csharp
|
## How it works
|
||||||
new Input(
|
|
||||||
id: "website",
|
|
||||||
name: "websiteUrl",
|
|
||||||
inputType: "url",
|
|
||||||
placeholder: "https://example.com",
|
|
||||||
label: "Website",
|
|
||||||
description: "Include https://")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reading in a form handler
|
Input renders a `<div>` wrapper containing an optional `<label>`, the `<input>`, and an optional description `<p>`. The label and description elements are omitted entirely from the HTML when not provided. The `hxAttrs` string is appended verbatim inside the `<input>` tag, so any valid HTML attribute can be passed through it.
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record Command(
|
|
||||||
[property: FromForm] string Email,
|
|
||||||
[property: FromForm] string Password
|
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,20 @@
|
|||||||
# Pagination
|
# 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.
|
A row of numbered page links — Previous, 1, 2, 3…, Next. Use it at the bottom of a list or table when there are too many items to show all at once. You give it the current page number, the total number of pages, and a URL pattern; it builds all the links automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
nav[aria-label=Pagination].flex.items-center.justify-center.gap-1
|
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
|
||||||
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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
This renders a navigation row with links to pages 1–10 (with ellipsis for interior pages) and the Previous/Next arrows.
|
||||||
|
|
||||||
## 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
|
## All the options
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public Pagination(
|
public Pagination(
|
||||||
@@ -41,60 +23,61 @@ public Pagination(
|
|||||||
string urlPattern)
|
string urlPattern)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `current` | 1-based current page number |
|
| `current` | The currently active page. 1-based (the first page is `1`). |
|
||||||
| `total` | Total number of pages |
|
| `total` | The total number of pages. |
|
||||||
| `urlPattern` | URL template with `{0}` replaced by the page number, e.g. `"/items?page={0}"` |
|
| `urlPattern` | A URL with `{0}` where the page number goes. E.g. `"/items?page={0}"`. |
|
||||||
|
|
||||||
|
The visible page window is at most 7 buttons. For large page counts, interior pages collapse into ellipsis (`…`) while the first page, last page, and pages close to `current` stay visible.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic pagination
|
### Basic list with pagination
|
||||||
|
|
||||||
```csharp
|
```html
|
||||||
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
|
<!-- Templates/BlogPage.htmx -->
|
||||||
|
<div class="space-y-6 mb-10">$$Posts$$</div>
|
||||||
|
$$Pager$$
|
||||||
```
|
```
|
||||||
|
|
||||||
### Preserving query parameters
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Build the URL pattern from the current request
|
// Templates/BlogPage.htmx.cs
|
||||||
var query = HttpUtility.ParseQueryString(Request.QueryString.ToString());
|
_pager = new Pagination(
|
||||||
query["page"] = "{0}";
|
current: page,
|
||||||
var pattern = "/search?" + query.ToString();
|
total: totalPages,
|
||||||
|
urlPattern: "/blog?page={0}");
|
||||||
new Pagination(current: page, total: totalPages, urlPattern: pattern)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTMX-powered pagination (swap content without full navigation)
|
### Preserving filters and sort order across pages
|
||||||
|
|
||||||
The links are standard `<a>` tags. To intercept them with HTMX, use `hx-boost` on the container or wrap in a boosted `<div>`:
|
Build the URL pattern to include any query parameters that should survive page navigation:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var urlPattern = $"/users?role={role}&sort={sort}&page={{0}}";
|
||||||
|
new Pagination(current: page, total: totalPages, urlPattern: urlPattern)
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note the double braces `{{0}}` to produce a literal `{0}` after string interpolation.
|
||||||
|
|
||||||
|
### HTMX-powered pagination (no full page reload)
|
||||||
|
|
||||||
|
Wrap the pagination (and the content it controls) in a `hx-boost` container:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div hx-boost="true" hx-target="#item-list" hx-push-url="true">
|
<div hx-boost="true" hx-target="#item-list" hx-push-url="true">
|
||||||
$$Pagination$$
|
<div id="item-list">$$Items$$</div>
|
||||||
|
$$Pager$$
|
||||||
</div>
|
</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
|
## How it works
|
||||||
|
|
||||||
- The `urlPattern` uses `string.Format`-style `{0}` — do not use `{page}` or other named placeholders.
|
All links are plain `<a href="...">` elements — no JavaScript required. The URL for each page is built by calling `string.Format(urlPattern, pageNumber)`. When `current == 1`, the Previous link is styled as disabled (pointer-events removed, opacity reduced); same for Next when `current == total`.
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+31
-50
@@ -1,98 +1,79 @@
|
|||||||
# Progress
|
# Progress
|
||||||
|
|
||||||
A horizontal progress bar. Value is clamped to 0–100. Three sizes control the bar height.
|
A horizontal bar that fills from left to right to show how complete something is. Use it for upload progress, onboarding checklists, storage usage, or anything that has a percentage value.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.w-full.bg-secondary.rounded-full.overflow-hidden.{size class}
|
new Progress(value: 72)
|
||||||
div.bg-primary.rounded-full.h-full.transition-all[style="width: {value}%"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Progress(int value, string size = "default")
|
public Progress(int value, string size = "default")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `value` | Fill percentage; clamped to 0–100 |
|
| `value` | How filled the bar is, from 0 to 100. Values outside this range are clamped automatically. |
|
||||||
| `size` | `"sm"` / `"default"` / `"lg"` |
|
| `size` | Height of the bar: `"sm"` (6px), `"default"` (10px), or `"lg"` (16px). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Inline usage
|
### Disk usage inside a Card
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Progress(value: 72)
|
// Pre-render the Progress bar to HTML
|
||||||
new Progress(value: 40, size: "sm")
|
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||||
new Progress(value: 100, size: "lg")
|
new Progress(value: usedPercent, size: "lg").Render(new HtmxRenderContext(w));
|
||||||
```
|
var progressHtml = System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||||
|
|
||||||
### Inside a Card
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Card(
|
new Card(
|
||||||
title: "Disk usage",
|
title: "Storage",
|
||||||
content: $"""
|
content: $"""
|
||||||
<div class="mb-2 flex justify-between text-sm">
|
<div class="mb-2 flex justify-between text-sm">
|
||||||
<span>Used</span>
|
<span>Used</span>
|
||||||
<span>{used} GB / {total} GB</span>
|
<span>{usedGb} GB / {totalGb} GB</span>
|
||||||
</div>
|
</div>
|
||||||
{progressHtml}
|
{progressHtml}
|
||||||
""")
|
""")
|
||||||
```
|
```
|
||||||
|
|
||||||
(Pre-render the `Progress` to a string using `HtmxRenderContext` and `ArrayBufferWriter<byte>`.)
|
### Live progress bar (HTMX polling)
|
||||||
|
|
||||||
### HTMX live update
|
Wrap the component in a polling `<div>` that swaps the fragment every second:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div id="progress-bar"
|
<div id="job-progress"
|
||||||
hx-get="/job/42/progress"
|
hx-get="/jobs/42/progress"
|
||||||
hx-trigger="every 1s"
|
hx-trigger="every 1s"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
$$ProgressBar$$
|
$$ProgressBar$$
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
The endpoint returns a partial re-render of this fragment with the updated `value`.
|
The handler returns a fresh render of the component with the updated value. The `transition-all` CSS on the fill makes the change smooth.
|
||||||
|
|
||||||
|
### Three sizes side by side
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Progress(value: 40, size: "sm") // compact, good for table rows
|
||||||
|
new Progress(value: 60) // standard
|
||||||
|
new Progress(value: 80, size: "lg") // prominent
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- Values below 0 are treated as 0; values above 100 are treated as 100 — no manual clamping needed.
|
Progress is two nested `<div>` elements. The outer one is the grey track; the inner one is the filled bar. The fill width is set as an inline `style="width: {value}%"` so no JavaScript is required. The `transition-all` class makes the bar animate smoothly when the value changes via an HTMX swap.
|
||||||
- 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,26 @@
|
|||||||
# RadioGroup
|
# RadioGroup
|
||||||
|
|
||||||
A group of radio buttons sharing the same `name` attribute. Supports horizontal or vertical layout. One option can be pre-selected.
|
A set of radio buttons where only one option can be selected at a time. Use it when you want the user to pick exactly one value from a short list — pricing plans, delivery options, account types.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.flex-col.gap-1.5
|
new RadioGroup(
|
||||||
label.text-sm.font-medium ← group label (omitted when empty)
|
name: "plan",
|
||||||
{label}
|
label: "Select a plan",
|
||||||
div.flex.{direction}.gap-3 ← flex-col or flex-row
|
options: new[]
|
||||||
label.flex.items-center.gap-2.cursor-pointer ← one per option
|
{
|
||||||
input[type=radio, name, value, class, $$Checked$$]
|
("free", "Free", true), // pre-selected
|
||||||
span.text-sm
|
("pro", "Pro", false),
|
||||||
{option label}
|
("teams", "Teams", false),
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public RadioGroup(
|
public RadioGroup(
|
||||||
@@ -41,32 +30,47 @@ public RadioGroup(
|
|||||||
string direction = "flex-col")
|
string direction = "flex-col")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `name` | Shared `name` attribute for all radio inputs in the group |
|
| `name` | The shared form field name for all radio buttons in the group. |
|
||||||
| `options` | List of `(Value, Label, Selected)` tuples |
|
| `options` | The list of choices. Each is a `(Value, Label, Selected)` tuple. |
|
||||||
| `label` | Optional visible group heading above the options |
|
| `label` | Optional heading displayed above the options. |
|
||||||
| `direction` | `"flex-col"` (vertical, default) or `"flex-row"` (horizontal) |
|
| `direction` | `"flex-col"` stacks options vertically (default). `"flex-row"` places them side by side. |
|
||||||
|
|
||||||
|
**Option tuple fields:**
|
||||||
|
|
||||||
|
| Field | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `Value` | The string submitted when this option is selected. |
|
||||||
|
| `Label` | The text shown next to the radio button. |
|
||||||
|
| `Selected` | Pre-select this option on render. Only one should be `true`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Vertical list
|
### Pricing plan selector
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new RadioGroup(
|
new RadioGroup(
|
||||||
name: "plan",
|
name: "plan",
|
||||||
label: "Select a plan",
|
label: "Choose your plan",
|
||||||
options: new[]
|
options: new[]
|
||||||
{
|
{
|
||||||
("free", "Free", true),
|
("free", "Free — up to 3 projects", true),
|
||||||
("pro", "Pro", false),
|
("pro", "Pro — unlimited projects", false),
|
||||||
("teams", "Teams", false),
|
("enterprise", "Enterprise — custom pricing", false),
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Horizontal inline options
|
Reading on the server:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record Command([property: FromForm] string Plan);
|
||||||
|
// command.Plan == "free" | "pro" | "enterprise"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Horizontal size selector
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new RadioGroup(
|
new RadioGroup(
|
||||||
@@ -82,19 +86,11 @@ new RadioGroup(
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reading in a form handler
|
### Options built dynamically from the database
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record Command([property: FromForm] string Plan);
|
|
||||||
|
|
||||||
// command.Plan == "free" | "pro" | "teams"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic options from database
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var options = categories
|
var options = categories
|
||||||
.Select((cat, i) => (cat.Slug, cat.Name, i == 0))
|
.Select((cat, i) => (cat.Slug, cat.Name, i == 0)) // first option pre-selected
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
new RadioGroup(name: "category", label: "Category", options: options)
|
new RadioGroup(name: "category", label: "Category", options: options)
|
||||||
@@ -102,6 +98,15 @@ new RadioGroup(name: "category", label: "Category", options: options)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Each option is a `<label>` element containing a native `<input type="radio">` and a `<span>` with the label text. Because the `<input>` is inside the `<label>`, clicking anywhere on the label text selects the option. All radio buttons in the group share the same `name` attribute — the browser ensures only one can be selected at a time.
|
||||||
|
|
||||||
|
The radio dot colour follows your primary theme colour via `accent-primary`.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## 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).
|
- Only one option in the group can have `Selected = true`; if multiple are marked selected the last one wins (standard HTML behavior).
|
||||||
|
|||||||
+66
-62
@@ -1,37 +1,28 @@
|
|||||||
# Select
|
# Select
|
||||||
|
|
||||||
A styled `<select>` dropdown. Supports a pre-selected value, optional label, and optional description text. HTMX attributes can be added.
|
A styled dropdown that lets the user pick one option from a list. Use it for things like country selection, category filters, or anything where the user chooses from a fixed set of values.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.flex-col.gap-1.5
|
new Select(
|
||||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
id: "country",
|
||||||
{label}
|
name: "country",
|
||||||
select[id, name, class, $$HxAttrs$$]
|
label: "Country",
|
||||||
option[value, $$Selected$$] ← one per option; selected="selected" when matched
|
options: new[]
|
||||||
{display}
|
{
|
||||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
("us", "United States"),
|
||||||
{description}
|
("gb", "United Kingdom"),
|
||||||
|
("ca", "Canada"),
|
||||||
|
},
|
||||||
|
selectedValue: "us")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Select(
|
public Select(
|
||||||
@@ -45,59 +36,72 @@ public Select(
|
|||||||
string hxAttrs = "")
|
string hxAttrs = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Element id and label `for` target |
|
| `id` | The element id. Also used by the `<label for="...">`. |
|
||||||
| `options` | List of `(Value, Display)` tuples |
|
| `options` | The list of choices. Each is a `(Value, Display)` tuple. |
|
||||||
| `selectedValue` | Pre-selected option value; `null` = no pre-selection (first option shown) |
|
| `selectedValue` | The `Value` of the option to pre-select. Leave `null` to show the first option. |
|
||||||
| `name` | Form field name |
|
| `name` | Form field name — required if you want the value submitted. |
|
||||||
| `label` | Optional visible label |
|
| `label` | Visible text label above the dropdown. |
|
||||||
| `description` | Optional helper text below the field |
|
| `description` | Small hint text below the field. |
|
||||||
| `extraClasses` | Additional Tailwind classes on the `<select>` element |
|
| `extraClasses` | Additional Tailwind classes on the `<select>` element. |
|
||||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
| `hxAttrs` | Extra HTML attributes appended verbatim — use for HTMX and `data-*`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Country selector
|
### Category filter that reloads the list on change
|
||||||
|
|
||||||
```csharp
|
```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(
|
new Select(
|
||||||
id: "category",
|
id: "category",
|
||||||
name: "category",
|
name: "category",
|
||||||
label: "Category",
|
label: "Filter by category",
|
||||||
options: options,
|
options: categories.Select(c => (c.Slug, c.Name)),
|
||||||
selectedValue: existingCategory)
|
selectedValue: currentCategory,
|
||||||
|
hxAttrs: """hx-get="/products" hx-target="#product-list" hx-trigger="change"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTMX on-change reload
|
### Dynamic options from the database (with current value pre-selected)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var options = roles.Select(r => (r.Id.ToString(), r.Name));
|
||||||
|
|
||||||
|
new Select(
|
||||||
|
id: "role",
|
||||||
|
name: "roleId",
|
||||||
|
label: "Role",
|
||||||
|
options: options,
|
||||||
|
selectedValue: user.RoleId.ToString())
|
||||||
|
```
|
||||||
|
|
||||||
|
Reading on the server:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record Command([property: FromForm] string RoleId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simple yes/no choice
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Select(
|
new Select(
|
||||||
id: "region",
|
id: "active",
|
||||||
name: "region",
|
name: "isActive",
|
||||||
label: "Region",
|
label: "Status",
|
||||||
options: regions,
|
options: new[]
|
||||||
|
{
|
||||||
|
("true", "Active"),
|
||||||
|
("false", "Inactive"),
|
||||||
|
},
|
||||||
|
selectedValue: user.IsActive ? "true" : "false")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Select renders a standard `<select>` element — no custom dropdown JavaScript. The browser's native dropdown is used, which is the most accessible and reliable approach. The selected option is matched by `Value` and has `selected="selected"` set on render.
|
||||||
hxAttrs: """hx-get="/cities" hx-target="#city-select" hx-trigger="change" hx-include="[name='region']"""")
|
hxAttrs: """hx-get="/cities" hx-target="#city-select" hx-trigger="change" hx-include="[name='region']"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,19 @@
|
|||||||
# Separator
|
# Separator
|
||||||
|
|
||||||
A thin divider line. Renders as a horizontal `<hr>` or a vertical bar depending on orientation.
|
A thin dividing line. Use it to visually separate sections of a page or items in a toolbar. Like a ruled line on a notepad, it gives your layout breathing room and clarity.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
**Horizontal:**
|
```csharp
|
||||||
```
|
new Separator() // horizontal rule
|
||||||
hr.border-t.border-border.my-4.{extraClasses}
|
new Separator(orientation: "vertical") // vertical bar
|
||||||
```
|
|
||||||
|
|
||||||
**Vertical:**
|
|
||||||
```
|
|
||||||
span.inline-block.border-l.border-border.mx-2.h-4.{extraClasses}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Separator(
|
public Separator(
|
||||||
@@ -38,46 +21,57 @@ public Separator(
|
|||||||
string extraClasses = "")
|
string extraClasses = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `orientation` | `"horizontal"` (default) or `"vertical"` |
|
| `orientation` | `"horizontal"` (default) renders an `<hr>`. `"vertical"` renders an inline bar. |
|
||||||
| `extraClasses` | Additional Tailwind classes on the element |
|
| `extraClasses` | Additional Tailwind classes to override spacing or colour. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Horizontal divider
|
### Between sections on a settings page
|
||||||
|
|
||||||
```csharp
|
```html
|
||||||
new Separator()
|
<h2 class="text-lg font-semibold">Account</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">Manage your account details.</p>
|
||||||
|
$$Sep1$$
|
||||||
|
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vertical divider in a flex toolbar
|
```csharp
|
||||||
|
_sep1 = new Separator();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vertical bar in a text editor toolbar
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button>Bold</button>
|
<button>Bold</button>
|
||||||
$$VertSep$$
|
$$Sep$$
|
||||||
<button>Italic</button>
|
<button>Italic</button>
|
||||||
$$VertSep$$
|
$$Sep$$
|
||||||
<button>Underline</button>
|
<button>Underline</button>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var VertSep = new Separator(orientation: "vertical");
|
_sep = new Separator(orientation: "vertical");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom margin
|
### More or less spacing
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Separator(extraClasses: "my-8") // extra vertical space
|
new Separator(extraClasses: "my-8") // extra breathing room above and below
|
||||||
new Separator(extraClasses: "my-0 mt-2") // override default margin
|
new Separator(extraClasses: "my-2") // tighter spacing
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
A horizontal separator is an `<hr>` element with a top border. A vertical separator is an inline `<span>` with a left border and a fixed height of 16px. Both use `border-border` which follows the theme's CSS variable and adapts to dark mode automatically.
|
||||||
|
|
||||||
## Tips and tricks
|
## Tips and tricks
|
||||||
|
|
||||||
- The horizontal `Separator` is an `<hr>` element — it carries semantic meaning as a thematic break. Use it between content sections.
|
- The horizontal `Separator` is an `<hr>` element — it carries semantic meaning as a thematic break. Use it between content sections.
|
||||||
|
|||||||
+48
-56
@@ -1,78 +1,58 @@
|
|||||||
# Skeleton
|
# 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.
|
An animated grey placeholder that pulsates while real content is loading. Think of it as a rough pencil sketch of your UI — it shows the user where something will appear so the page feels responsive even before the data is ready.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.animate-pulse.rounded-md.bg-muted.{classes}
|
new Skeleton("h-4 w-3/4") // a loading line of text
|
||||||
|
new Skeleton("h-10 w-full") // a loading input field
|
||||||
|
new Skeleton("rounded-full h-12 w-12") // a loading avatar
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Skeleton(string classes = "")
|
public Skeleton(string classes = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `classes` | Tailwind classes controlling size, shape, and spacing |
|
| `classes` | Tailwind classes that control the size and shape of the placeholder. |
|
||||||
|
|
||||||
|
There are no other parameters. The component itself is just an animated `<div>` — you shape it entirely through CSS classes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Text line placeholders
|
### A card loading state (avatar + two text lines)
|
||||||
|
|
||||||
```csharp
|
```html
|
||||||
new Skeleton("h-4 w-3/4 mb-2")
|
<div class="flex items-center gap-4 p-6">
|
||||||
new Skeleton("h-4 w-1/2")
|
$$AvatarSkeleton$$
|
||||||
```
|
<div class="space-y-2 flex-1">
|
||||||
|
$$Line1$$
|
||||||
### Avatar placeholder
|
$$Line2$$
|
||||||
|
</div>
|
||||||
```csharp
|
</div>
|
||||||
new Skeleton("rounded-full h-12 w-12")
|
```
|
||||||
```
|
|
||||||
|
```csharp
|
||||||
### Card skeleton loader
|
_avatarSkeleton = new Skeleton("rounded-full h-10 w-10");
|
||||||
|
_line1 = new Skeleton("h-4 w-1/2");
|
||||||
```csharp
|
_line2 = new Skeleton("h-4 w-3/4");
|
||||||
new Card(
|
```
|
||||||
content: """
|
|
||||||
<div class="flex items-center gap-4">
|
### HTMX swap: show skeleton immediately, replace with real content
|
||||||
<!-- Render each Skeleton eagerly to a string or use slot injection -->
|
|
||||||
</div>
|
Render the skeleton into a slot. HTMX fires immediately on page load and swaps it with the real content:
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
</div>
|
|
||||||
""")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full-width block placeholder
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Skeleton("h-10 w-full")
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTMX skeleton swap pattern
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Shown immediately; HTMX replaces with real content -->
|
|
||||||
<div id="user-list"
|
<div id="user-list"
|
||||||
hx-get="/users"
|
hx-get="/users"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
@@ -81,13 +61,25 @@ new Skeleton("h-10 w-full")
|
|||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
The page renders the skeleton on initial load; the HTMX request fires immediately and replaces it once the data arrives.
|
The skeleton appears instantly; the data loads in the background and replaces it.
|
||||||
|
|
||||||
|
### A full table loading state
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Stack five skeleton rows to simulate a loading table
|
||||||
|
var rows = string.Concat(Enumerable.Range(0, 5).Select(_ =>
|
||||||
|
{
|
||||||
|
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||||
|
new Skeleton("h-8 w-full mb-2").Render(new HtmxRenderContext(w));
|
||||||
|
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## How it works
|
||||||
|
|
||||||
- Multiple `Skeleton` elements stacked in a `div.space-y-2` create a convincing text-block placeholder.
|
Skeleton is a single `<div>` with `animate-pulse` (Tailwind's built-in pulsing animation) and `bg-muted`. You control the shape entirely through the `classes` parameter — use `h-*` and `w-*` for size, and `rounded-full` for circular shapes like avatars.
|
||||||
- `rounded-full` makes a circle — useful for avatar skeletons. Combine with equal `h-*` and `w-*` values.
|
- `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.
|
- 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>`).
|
- For table skeletons, render a `Table` with each cell containing a Skeleton HTML string (pre-rendered to a string via `ArrayBufferWriter<byte>`).
|
||||||
|
|||||||
+38
-46
@@ -1,33 +1,23 @@
|
|||||||
# Slider
|
# Slider
|
||||||
|
|
||||||
A styled `<input type="range">` with optional label and description. Supports min/max/step/value and HTMX attributes.
|
A draggable range control. Use it when you want the user to pick a numeric value within a range — volume, brightness, price range, font size.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.flex-col.gap-1.5
|
new Slider(
|
||||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
id: "volume",
|
||||||
{label}
|
name: "volume",
|
||||||
input[type=range, id, name, min, max, step, value, class, $$HxAttrs$$]
|
label: "Volume")
|
||||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
|
||||||
{description}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Defaults to a 0–100 range with a starting value of 50.
|
||||||
|
|
||||||
## 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
|
## All the options
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public Slider(
|
public Slider(
|
||||||
@@ -43,33 +33,24 @@ public Slider(
|
|||||||
string hxAttrs = "")
|
string hxAttrs = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Element id and label `for` target |
|
| `id` | The element id. Also used by `<label for="...">`. |
|
||||||
| `name` | Form field name |
|
| `name` | Form field name. |
|
||||||
| `min` | Minimum value (default: 0) |
|
| `min` | Lowest selectable value. |
|
||||||
| `max` | Maximum value (default: 100) |
|
| `max` | Highest selectable value. |
|
||||||
| `step` | Increment step (default: 1) |
|
| `step` | How much the value changes per tick. |
|
||||||
| `value` | Initial value (default: 50) |
|
| `value` | Starting position of the thumb. |
|
||||||
| `label` | Optional visible label |
|
| `label` | Visible text label above the slider. |
|
||||||
| `description` | Optional helper text |
|
| `description` | Small hint text below the slider. |
|
||||||
| `extraClasses` | Additional Tailwind classes on the input |
|
| `extraClasses` | Additional Tailwind classes on the `<input>`. |
|
||||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
| `hxAttrs` | Extra HTML attributes appended verbatim. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic 0–100 slider
|
### Brightness setting (stepped)
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Slider(
|
|
||||||
id: "volume",
|
|
||||||
name: "volume",
|
|
||||||
label: "Volume")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fixed range with step
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Slider(
|
new Slider(
|
||||||
@@ -80,23 +61,34 @@ new Slider(
|
|||||||
step: 10,
|
step: 10,
|
||||||
value: 70,
|
value: 70,
|
||||||
label: "Brightness",
|
label: "Brightness",
|
||||||
description: "10–100")
|
description: "10 to 100")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Live HTMX update
|
### Font size with live HTMX update
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Slider(
|
new Slider(
|
||||||
id: "fontSize",
|
id: "fontSize",
|
||||||
name: "fontSize",
|
name: "fontSize",
|
||||||
min: 12,
|
min: 12,
|
||||||
max: 24,
|
max: 32,
|
||||||
value: 16,
|
value: 16,
|
||||||
label: "Font size",
|
label: "Font size",
|
||||||
hxAttrs: """hx-post="/settings/font-size" hx-trigger="change" hx-include="[name='fontSize']"""")
|
hxAttrs: """hx-post="/settings/font-size" hx-trigger="change"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reading in a form handler
|
### Reading the value in a form handler
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record Command([property: FromForm] int Volume);
|
||||||
|
// command.Volume == 0..100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Slider renders a native `<input type="range">`. The thumb and active track colour follow your primary theme colour via `accent-primary`. No JavaScript is needed — the browser handles the drag interaction natively.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public record Command([property: FromForm] int Volume);
|
public record Command([property: FromForm] int Volume);
|
||||||
|
|||||||
+44
-72
@@ -1,67 +1,10 @@
|
|||||||
# Switch
|
# 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.
|
An on/off toggle that looks like a physical light switch. Use it for settings where the effect is immediate or where a simple checked/unchecked checkbox would feel too plain — "Enable notifications", "Dark mode", "Maintenance mode".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
|
||||||
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
|
```csharp
|
||||||
new Switch(
|
new Switch(
|
||||||
@@ -71,33 +14,62 @@ new Switch(
|
|||||||
isChecked: true)
|
isChecked: true)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Toggle without label
|
---
|
||||||
|
|
||||||
|
## All the options
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Switch(id: "darkMode", name: "darkMode")
|
public Switch(
|
||||||
|
string id,
|
||||||
|
string label = "",
|
||||||
|
string name = "",
|
||||||
|
bool isChecked = false)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reading in a form handler
|
| Parameter | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `id` | The element id for the hidden checkbox. |
|
||||||
|
| `label` | Optional text shown to the right of the toggle. |
|
||||||
|
| `name` | Form field name — required if you want the value submitted. |
|
||||||
|
| `isChecked` | Whether the switch is on by default. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-world examples
|
||||||
|
|
||||||
|
### Preferences form with multiple toggles
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Switch(id: "email-alerts", label: "Email alerts", name: "emailAlerts", isChecked: prefs.EmailAlerts)
|
||||||
|
new Switch(id: "push-notifs", label: "Push notifications", name: "pushNotifs", isChecked: prefs.PushNotifs)
|
||||||
|
new Switch(id: "weekly-summary", label: "Weekly digest", name: "weeklySummary", isChecked: prefs.WeeklySummary)
|
||||||
|
```
|
||||||
|
|
||||||
|
Reading on the server:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public record Command(
|
public record Command(
|
||||||
[property: FromForm] string? EnableNotifications = null
|
[property: FromForm] string? EmailAlerts = null, // null = off
|
||||||
|
[property: FromForm] string? PushNotifs = null,
|
||||||
|
[property: FromForm] string? WeeklySummary = null
|
||||||
);
|
);
|
||||||
|
|
||||||
bool notificationsOn = command.EnableNotifications != null;
|
bool emailAlerts = command.EmailAlerts != null;
|
||||||
```
|
```
|
||||||
|
|
||||||
> Like all checkboxes, an unchecked switch is not included in the form submission. Use `null` as the default in your command record.
|
> **Important:** Like a checkbox, an unchecked switch is not included in the form submission. Always use `string?` (nullable) with a default of `null`.
|
||||||
|
|
||||||
### HTMX auto-save on change
|
### Toggle without a label (e.g. in a table row)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// The hidden checkbox is named, so wrap in a form or use hx-include:
|
new Switch(id: $"active-{user.Id}", name: "isActive", isChecked: user.IsActive)
|
||||||
new Switch(
|
```
|
||||||
id: "maintenance",
|
|
||||||
name: "maintenanceMode",
|
---
|
||||||
label: "Maintenance mode",
|
|
||||||
isChecked: currentState)
|
## How it works
|
||||||
|
|
||||||
|
Switch is a styled `<label>` wrapping a hidden `<input type="checkbox">`. JavaScript in `components.js` listens for clicks and animates the visible track and thumb. The hidden checkbox holds the actual state and is what gets submitted with a form. Because it is a real checkbox under the hood, the form submission behaviour is identical to a plain Checkbox component.
|
||||||
```
|
```
|
||||||
|
|
||||||
```html
|
```html
|
||||||
|
|||||||
+40
-59
@@ -1,45 +1,25 @@
|
|||||||
# Table
|
# 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.
|
A styled HTML table with a header row, data rows, and optional caption and footer. Use it when you have a list of items with multiple columns — user lists, order history, product inventories.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.overflow-auto.rounded-md.border.border-border
|
new Table(
|
||||||
table.w-full.text-sm.caption-bottom
|
headers: new[] { "Name", "Email", "Role" },
|
||||||
caption.mt-4.text-sm.text-muted-foreground ← omitted when empty
|
rows: users.Select(u => new[]
|
||||||
{caption}
|
{
|
||||||
thead
|
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||||
tr.border-b.bg-muted/50
|
System.Web.HttpUtility.HtmlEncode(u.Email),
|
||||||
th.h-12.px-4.text-left.font-medium ← one per header
|
u.Role
|
||||||
{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
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Table(
|
public Table(
|
||||||
@@ -49,33 +29,27 @@ public Table(
|
|||||||
string footer = "")
|
string footer = "")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `headers` | Column heading strings |
|
| `headers` | Column heading strings. |
|
||||||
| `rows` | Each inner `IEnumerable<string>` is one row; cells are raw HTML |
|
| `rows` | Each inner collection is one table row. Each string in it is a cell. Cells are raw HTML. |
|
||||||
| `caption` | Optional caption below the table |
|
| `caption` | Optional summary text displayed below the table. |
|
||||||
| `footer` | Optional footer cell (spans all columns) |
|
| `footer` | Optional footer text that spans all columns. |
|
||||||
|
|
||||||
|
> **HTML safety:** Cell values are inserted as raw HTML. Always use `System.Web.HttpUtility.HtmlEncode()` on any user-supplied strings before passing them in.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic data table
|
### Table with a count caption and footer note
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Table(
|
|
||||||
headers: new[] { "Name", "Email", "Role" },
|
|
||||||
rows: users.Select(u => new[] { u.DisplayName ?? "", u.Email, u.Role }))
|
|
||||||
```
|
|
||||||
|
|
||||||
### With caption and footer
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Table(
|
new Table(
|
||||||
headers: new[] { "Product", "Price", "Stock" },
|
headers: new[] { "Product", "Price", "Stock" },
|
||||||
rows: products.Select(p => new[]
|
rows: products.Select(p => new[]
|
||||||
{
|
{
|
||||||
p.Name,
|
System.Web.HttpUtility.HtmlEncode(p.Name),
|
||||||
$"${p.Price:F2}",
|
$"${p.Price:F2}",
|
||||||
p.Stock.ToString()
|
p.Stock.ToString()
|
||||||
}),
|
}),
|
||||||
@@ -83,14 +57,15 @@ new Table(
|
|||||||
footer: "Prices include VAT")
|
footer: "Prices include VAT")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cells with HTML content (e.g. badges)
|
### Status column with a Badge
|
||||||
|
|
||||||
|
Pre-render the badge to an HTML string and embed it in the cell:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Pre-render a Badge to HTML string
|
string RenderBadge(string label, string variant = "default")
|
||||||
string ActiveBadge()
|
|
||||||
{
|
{
|
||||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||||
new Badge("Active").Render(new HtmxRenderContext(buf));
|
new Badge(label, variant).Render(new HtmxRenderContext(buf));
|
||||||
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,28 +74,34 @@ new Table(
|
|||||||
rows: users.Select(u => new[]
|
rows: users.Select(u => new[]
|
||||||
{
|
{
|
||||||
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||||
u.IsActive ? ActiveBadge() : ""
|
u.IsActive ? RenderBadge("Active", "default") : RenderBadge("Inactive", "secondary")
|
||||||
}))
|
}))
|
||||||
```
|
```
|
||||||
|
|
||||||
### With action buttons per row
|
### Row actions with HTMX edit button
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
string EditBtn(string id) => $"""
|
string EditLink(string id) =>
|
||||||
<button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>
|
$"""<button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>""";
|
||||||
""";
|
|
||||||
|
|
||||||
new Table(
|
new Table(
|
||||||
headers: new[] { "Name", "Actions" },
|
headers: new[] { "Name", "" },
|
||||||
rows: users.Select(u => new[]
|
rows: users.Select(u => new[]
|
||||||
{
|
{
|
||||||
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||||
EditBtn(u.Id!)
|
EditLink(u.Id!)
|
||||||
}))
|
}))
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Table wraps a standard `<table>` in an `overflow-auto` container so it scrolls horizontally on small screens. Header cells use `<th>` and data cells use `<td>`. The `caption` is rendered inside a `<caption>` element below the table; the `footer` spans all columns in a `<tfoot>` row.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tips and tricks
|
## Tips and tricks
|
||||||
|
|
||||||
- Cells are raw HTML — HTML-encode any user-supplied string values before inserting them to prevent XSS.
|
- Cells are raw HTML — HTML-encode any user-supplied string values before inserting them to prevent XSS.
|
||||||
|
|||||||
+62
-63
@@ -1,50 +1,27 @@
|
|||||||
# Tabs
|
# 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.
|
A row of clickable tabs that each reveal different content. Only one tab is visible at a time. Think of it like a filing cabinet with labelled dividers — you flip between sections without leaving the page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div[id].tabs-root
|
new Tabs(
|
||||||
div.tabs-list.flex.gap-1.border-b.mb-4 ← tab button strip
|
id: "settings-tabs",
|
||||||
button.tabs-trigger[data-tab={tabId}] ← one per tab; ACTIVE/INACTIVE variant
|
tabs: new[]
|
||||||
{label}
|
{
|
||||||
div.tabs-panel[data-tab={tabId}] ← one per tab; hidden or visible
|
("general", "General", "<p>General settings here.</p>"),
|
||||||
{content}
|
("security", "Security", "<p>Password and 2FA here.</p>"),
|
||||||
|
("billing", "Billing", "<p>Payment details here.</p>"),
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
The first tab is active by default.
|
||||||
|
|
||||||
## 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`)
|
## All the options
|
||||||
|
|
||||||
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
|
```csharp
|
||||||
public Tabs(
|
public Tabs(
|
||||||
@@ -52,52 +29,74 @@ public Tabs(
|
|||||||
IEnumerable<(string Id, string Label, string Content)> tabs)
|
IEnumerable<(string Id, string Label, string Content)> tabs)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Root element id — must be unique per page if multiple Tabs are rendered |
|
| `id` | A unique identifier for this tabs widget. Required if you have more than one `Tabs` on the same page. |
|
||||||
| `tabs` | List of `(Id, Label, Content)` tuples; `Id` must be unique within this instance |
|
| `tabs` | The list of tabs. Each is a `(Id, Label, Content)` tuple. |
|
||||||
|
|
||||||
|
**Tab tuple fields:**
|
||||||
|
|
||||||
|
| Field | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `Id` | A unique identifier for this tab within the widget. Used internally to link the trigger to the panel. |
|
||||||
|
| `Label` | The text shown on the tab button. |
|
||||||
|
| `Content` | The HTML content shown when this tab is active. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Simple tabbed content
|
### User profile page with tabbed sections
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Tabs(
|
new Tabs(
|
||||||
id: "settings-tabs",
|
id: "profile-tabs",
|
||||||
tabs: new[]
|
tabs: new[]
|
||||||
{
|
{
|
||||||
("general", "General", "<p>General settings content here.</p>"),
|
("overview", "Overview", $"<p>Joined {user.CreatedAt:MMMM yyyy}</p>"),
|
||||||
("security", "Security", "<p>Security settings content here.</p>"),
|
("activity", "Activity", activityHtml),
|
||||||
("billing", "Billing", "<p>Billing details here.</p>"),
|
("settings", "Settings", settingsFormHtml),
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTML-rich content in a tab
|
### Tab containing a full component
|
||||||
|
|
||||||
|
Pre-render inner components to HTML strings before embedding them:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Tabs(
|
string Render(IHtmxComponent c)
|
||||||
id: "code-tabs",
|
{
|
||||||
tabs: new[]
|
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||||
{
|
c.Render(new HtmxRenderContext(buf));
|
||||||
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
|
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||||
("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(
|
new Tabs(
|
||||||
id: "report",
|
id: "report",
|
||||||
tabs: new[]
|
tabs: new[]
|
||||||
|
{
|
||||||
|
("table", "Table", Render(new Table(headers: cols, rows: rows))),
|
||||||
|
("summary", "Summary", summaryHtml),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code samples in multiple languages
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Tabs(
|
||||||
|
id: "code-example",
|
||||||
|
tabs: new[]
|
||||||
|
{
|
||||||
|
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
|
||||||
|
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
All tab panels are present in the HTML on page load. JavaScript in `components.js` hides all but the first using the HTML `hidden` attribute. When a tab button is clicked, its matching panel has `hidden` removed and all others get it added back. No server request is made — this is pure client-side switching.
|
||||||
{
|
{
|
||||||
("summary", "Summary", "<p>High level numbers.</p>"),
|
("summary", "Summary", "<p>High level numbers.</p>"),
|
||||||
("detail", "Detail", tableHtml),
|
("detail", "Detail", tableHtml),
|
||||||
|
|||||||
+39
-51
@@ -1,37 +1,23 @@
|
|||||||
# Textarea
|
# Textarea
|
||||||
|
|
||||||
A styled multi-line text input with optional label, description, default value, and HTMX attributes.
|
A styled multi-line text input. Use it when you need more than a single line of text — comments, descriptions, notes, bio fields, or message composition.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.flex-col.gap-1.5
|
new Textarea(
|
||||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
id: "comment",
|
||||||
{label}
|
name: "comment",
|
||||||
textarea[id, name, placeholder, rows, class, $$HxAttrs$$]
|
placeholder: "Write a comment…",
|
||||||
{defaultValue}
|
label: "Comment",
|
||||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
rows: 5)
|
||||||
{description}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CSS mechanics
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public Textarea(
|
public Textarea(
|
||||||
@@ -46,45 +32,37 @@ public Textarea(
|
|||||||
int rows = 3)
|
int rows = 3)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `id` | Element id and label `for` target |
|
| `id` | The element id. Also used by the `<label for="...">`. |
|
||||||
| `name` | Form field name |
|
| `name` | Form field name. |
|
||||||
| `placeholder` | Placeholder text |
|
| `placeholder` | Greyed-out hint inside the field when it is empty. |
|
||||||
| `label` | Optional visible label |
|
| `label` | Visible text label above the field. |
|
||||||
| `description` | Optional helper text below the field |
|
| `description` | Small hint below the field (e.g. character limits). |
|
||||||
| `defaultValue` | Pre-filled content of the textarea |
|
| `defaultValue` | Pre-filled content. |
|
||||||
| `extraClasses` | Additional Tailwind classes on the textarea |
|
| `extraClasses` | Additional Tailwind classes on the `<textarea>`. |
|
||||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
| `hxAttrs` | Extra HTML attributes appended verbatim. |
|
||||||
| `rows` | Number of visible rows (default: 3) |
|
| `rows` | How many lines tall the field is initially. Default is 3. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Comment field
|
### Bio field (editing an existing value)
|
||||||
|
|
||||||
```csharp
|
|
||||||
new Textarea(
|
|
||||||
id: "comment",
|
|
||||||
name: "comment",
|
|
||||||
placeholder: "Write a comment…",
|
|
||||||
label: "Comment",
|
|
||||||
rows: 5)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bio field with default value
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Textarea(
|
new Textarea(
|
||||||
id: "bio",
|
id: "bio",
|
||||||
name: "bio",
|
name: "bio",
|
||||||
label: "Bio",
|
label: "Bio",
|
||||||
description: "Tell us about yourself (max 280 characters)",
|
description: "Max 280 characters",
|
||||||
defaultValue: user.Bio ?? "")
|
defaultValue: System.Web.HttpUtility.HtmlEncode(user.Bio ?? ""),
|
||||||
|
rows: 4)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auto-expand with HTMX
|
### Auto-growing field (expands as the user types)
|
||||||
|
|
||||||
|
Pass a small `oninput` handler through `hxAttrs`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Textarea(
|
new Textarea(
|
||||||
@@ -95,9 +73,19 @@ new Textarea(
|
|||||||
hxAttrs: """oninput="this.style.height=''; this.style.height=this.scrollHeight+'px'"""")
|
hxAttrs: """oninput="this.style.height=''; this.style.height=this.scrollHeight+'px'"""")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auto-save on input
|
### Reading on the server
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
public record Command(
|
||||||
|
[property: FromForm] string Bio
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Textarea renders a standard HTML `<textarea>` element. The `defaultValue` is placed between the opening and closing tags (not in `value` like an `<input>`). Always HTML-encode any user-supplied `defaultValue` before passing it in.
|
||||||
new Textarea(
|
new Textarea(
|
||||||
id: "draft",
|
id: "draft",
|
||||||
name: "content",
|
name: "content",
|
||||||
|
|||||||
@@ -1,53 +1,18 @@
|
|||||||
# TimePicker
|
# 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.
|
A styled time selector with separate dropdowns for hours and minutes (and optionally AM/PM). The selected time is always stored in a hidden input as `HH:MM` in 24-hour format, regardless of whether you show the 12-hour display mode.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
div.flex.flex-col.gap-1.5
|
new TimePicker(name: "startTime", label: "Start time")
|
||||||
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
|
## All the options
|
||||||
|
|
||||||
| 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
|
```csharp
|
||||||
public TimePicker(
|
public TimePicker(
|
||||||
@@ -58,49 +23,24 @@ public TimePicker(
|
|||||||
bool use12h = false)
|
bool use12h = false)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `name` | Form field name; hidden input gets this name, visible selects get `{name}-h`, `{name}-m`, `{name}-ampm` |
|
| `name` | Form field name. The hidden input gets this name and always holds a `HH:MM` value. The visible selects get `{name}-h`, `{name}-m`, `{name}-ampm`. |
|
||||||
| `selected` | Pre-selected time as `"HH:MM"` (24h format); defaults to current time |
|
| `selected` | Pre-selected time as `"HH:MM"` in 24-hour format. Defaults to the current time. |
|
||||||
| `label` | Optional visible label |
|
| `label` | Visible text label above the picker. |
|
||||||
| `description` | Optional helper text |
|
| `description` | Small hint text below the picker. |
|
||||||
| `use12h` | If `true`, shows AM/PM select and hour range 1–12 |
|
| `use12h` | Show 12-hour mode with an AM/PM dropdown. The hidden input still stores 24h format. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Basic time picker (24h)
|
### Appointment booking with start and end times
|
||||||
|
|
||||||
```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
|
```html
|
||||||
<!-- ScheduleForm.htmx -->
|
<!-- ScheduleForm.htmx -->
|
||||||
<form method="post" action="/schedule">
|
<form method="post" action="/schedule" class="space-y-4">
|
||||||
$$AntiforgeryToken$$
|
$$Token$$
|
||||||
$$StartTime$$
|
$$StartTime$$
|
||||||
$$EndTime$$
|
$$EndTime$$
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
@@ -108,16 +48,40 @@ new TimePicker(
|
|||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public ScheduleForm()
|
// ScheduleForm.htmx.cs
|
||||||
{
|
_startTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
|
||||||
StartTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
|
_endTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
|
||||||
EndTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reading the submitted values:**
|
Reading on the server:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
public record Command(
|
||||||
|
[property: FromForm] string StartTime, // "HH:MM"
|
||||||
|
[property: FromForm] string EndTime
|
||||||
|
);
|
||||||
|
|
||||||
|
var start = TimeOnly.ParseExact(command.StartTime, "HH:mm");
|
||||||
|
var end = TimeOnly.ParseExact(command.EndTime, "HH:mm");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12-hour display mode with a pre-selected time
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new TimePicker(
|
||||||
|
name: "alarmTime",
|
||||||
|
selected: "07:30",
|
||||||
|
label: "Alarm time",
|
||||||
|
use12h: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The user sees `7:30 AM` in the dropdowns, but `07:30` is what gets submitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
TimePicker renders three `<select>` elements (hours, minutes, and optionally AM/PM) styled to look like a single field, plus a hidden `<input>` that holds the combined value. JavaScript in `components.js` listens for changes on any of the three selects and writes the correctly formatted `HH:MM` value to the hidden input, converting from 12h to 24h when needed.
|
||||||
public record Command(
|
public record Command(
|
||||||
[property: FromForm] string StartTime, // "09:00"
|
[property: FromForm] string StartTime, // "09:00"
|
||||||
[property: FromForm] string EndTime // "17:00"
|
[property: FromForm] string EndTime // "17:00"
|
||||||
|
|||||||
+39
-72
@@ -1,104 +1,73 @@
|
|||||||
# Toast
|
# 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.
|
A small pop-up notification that appears in the corner of the screen, stays briefly, and then fades out on its own. Use it to give users confirmation after an action — "Saved!", "Error: could not connect", "Profile updated".
|
||||||
|
|
||||||
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.
|
Unlike most components, toasts are triggered from **JavaScript**, not from the server-rendered template.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure (dynamically created by JS)
|
## Quick example
|
||||||
|
|
||||||
```
|
|
||||||
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
|
```js
|
||||||
window.showToast({
|
window.showToast({
|
||||||
title: "Operation complete", // required
|
title: "Saved!",
|
||||||
description: "All items saved.", // optional
|
variant: "success",
|
||||||
variant: "success", // "default" | "destructive" | "success"
|
duration: 3000
|
||||||
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
|
## All the options
|
||||||
|
|
||||||
```csharp
|
```js
|
||||||
public Toast(
|
window.showToast({
|
||||||
string title,
|
title: string, // required
|
||||||
string description = "",
|
description: string, // optional — shown below the title
|
||||||
string variant = "default")
|
variant: string, // "default" | "destructive" | "success"
|
||||||
|
duration: number // milliseconds before auto-dismiss (default: 4000)
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
The constructor builds a static initial toast element. Most use-cases call `window.showToast(...)` from JS instead.
|
| Option | What it does |
|
||||||
|
|
||||||
| Parameter | Description |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `title` | Required notification heading |
|
| `title` | The main notification text. |
|
||||||
| `description` | Optional body text |
|
| `description` | Optional secondary text below the title. |
|
||||||
| `variant` | `"default"` / `"destructive"` / `"success"` |
|
| `variant` | `"default"` = neutral; `"destructive"` = red border (errors); `"success"` = green border. |
|
||||||
|
| `duration` | How long the toast stays visible before fading out. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Trigger from JavaScript after an HTMX event
|
### Show a toast after an HTMX request completes
|
||||||
|
|
||||||
```js
|
```js
|
||||||
document.body.addEventListener('htmx:afterRequest', function (e) {
|
document.body.addEventListener('htmx:afterRequest', function (e) {
|
||||||
if (e.detail.successful) {
|
if (e.detail.successful) {
|
||||||
window.showToast({ title: 'Saved', variant: 'success', duration: 3000 });
|
window.showToast({ title: 'Changes saved', variant: 'success', duration: 3000 });
|
||||||
} else {
|
} else {
|
||||||
window.showToast({ title: 'Error', description: 'Could not save.', variant: 'destructive' });
|
window.showToast({ title: 'Something went wrong', description: 'Please try again.', variant: 'destructive' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Trigger from a server response header
|
### Trigger from the server via a response header
|
||||||
|
|
||||||
Add a response header `HX-Trigger` in your handler:
|
Add an `HX-Trigger` response header in your handler to fire a custom event:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
ctx.Response.Headers.Append("HX-Trigger",
|
ctx.Response.Headers.Append("HX-Trigger",
|
||||||
"""{"showToast":{"title":"Saved!","variant":"success","duration":3000}}""");
|
"""{
|
||||||
|
"showToast": {
|
||||||
|
"title": "Profile updated",
|
||||||
|
"variant": "success",
|
||||||
|
"duration": 3000
|
||||||
|
}
|
||||||
|
}""");
|
||||||
```
|
```
|
||||||
|
|
||||||
Client-side listener:
|
Then listen for it on the client:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
document.body.addEventListener('showToast', function (e) {
|
document.body.addEventListener('showToast', function (e) {
|
||||||
@@ -106,17 +75,15 @@ document.body.addEventListener('showToast', function (e) {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server-rendered initial toast (rare)
|
This is the cleanest pattern for server-triggered toasts — the server decides the message and variant, the client handles the display.
|
||||||
|
|
||||||
```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
|
## How it works
|
||||||
|
|
||||||
|
`window.showToast` creates a new `<div>` with the toast content and appends it to the `ToastViewport` container. A CSS animation slides it in. After `duration` ms, a fade-out animation plays and then the element is removed from the DOM. The dismiss button (×) triggers the same fade-out immediately.
|
||||||
|
|
||||||
|
You must have a `ToastViewport` component in your layout for toasts to appear. See [ToastViewport.md](./ToastViewport.md).
|
||||||
|
|
||||||
- Always place a single `ToastViewport` in your main layout so toasts have a container to render into. See [ToastViewport.md](ToastViewport.md).
|
- 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.
|
- Use the `HX-Trigger` header pattern to trigger toasts from HTMX responses — it keeps toast logic on the server without requiring extra HTMX endpoints.
|
||||||
|
|||||||
@@ -1,48 +1,14 @@
|
|||||||
# ToastViewport
|
# 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.
|
The fixed container where toast notifications appear. Place exactly one `ToastViewport` in your main layout — it sits in the corner of the screen and is invisible when no toasts are showing. New toasts stack upward as they are added.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
|
||||||
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
|
```html
|
||||||
<!-- MainLayout.htmx -->
|
<!-- MainLayout.htmx -->
|
||||||
<body class="...">
|
<body>
|
||||||
<main>$$Body$$</main>
|
<main>$$Body$$</main>
|
||||||
$$ToastViewport$$
|
$$ToastViewport$$
|
||||||
</body>
|
</body>
|
||||||
@@ -50,34 +16,30 @@ public ToastViewport(string id = "toast-viewport")
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// MainLayout.htmx.cs
|
// MainLayout.htmx.cs
|
||||||
public IHtmxComponent ToastViewport { get; } = new ToastViewport();
|
_toastViewport = new ToastViewport();
|
||||||
|
|
||||||
protected override void RenderToastViewport(HtmxRenderContext ctx)
|
|
||||||
=> ToastViewport.Render(ctx.Next());
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom id (advanced)
|
That's all. Every call to `window.showToast(...)` will now display in the bottom-right corner of the screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All the options
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new ToastViewport(id: "notifications-container")
|
public ToastViewport(string id = "toast-viewport")
|
||||||
```
|
```
|
||||||
|
|
||||||
Then update the JS lookup:
|
| Parameter | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `id` | The element id. `components.js` looks for `#toast-viewport` by default. Only change this if you also update the JavaScript. |
|
||||||
|
|
||||||
```js
|
---
|
||||||
// In components.js or a custom script:
|
|
||||||
const viewport = document.getElementById('notifications-container');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom position (bottom-left)
|
## How it works
|
||||||
|
|
||||||
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`:
|
ToastViewport renders a single fixed `<div>` anchored to the bottom-right of the screen. It has `pointer-events: none` so it doesn't block clicks on the page behind it. Individual toasts set `pointer-events: auto` so their dismiss buttons are still clickable.
|
||||||
|
|
||||||
```css
|
Toasts are appended to this element by `window.showToast()` and removed after their duration expires.
|
||||||
.toast-viewport {
|
|
||||||
bottom: 1rem;
|
|
||||||
left: 1rem;
|
|
||||||
right: auto;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+38
-47
@@ -1,48 +1,24 @@
|
|||||||
# Tooltip
|
# Tooltip
|
||||||
|
|
||||||
A text hint that appears on hover. Implemented entirely in CSS using Tailwind's `group` and `group-hover` utilities — no JavaScript required.
|
A small text hint that appears when the user hovers over an element. Use it to label icon buttons, clarify abbreviations, or explain options that don't have visible text.
|
||||||
|
|
||||||
|
Tooltips are implemented entirely in CSS — no JavaScript required.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTML structure
|
## Quick example
|
||||||
|
|
||||||
```
|
```csharp
|
||||||
span.relative.inline-flex.items-center.group
|
new Tooltip(
|
||||||
{trigger component rendered inline}
|
text: "Delete item",
|
||||||
span.tooltip-text.absolute.z-50.px-2.py-1.text-xs.rounded.bg-foreground.text-background
|
trigger: new Button("🗑", size: "icon", variant: "ghost"))
|
||||||
.whitespace-nowrap.pointer-events-none
|
|
||||||
.opacity-0.group-hover:opacity-100.transition-opacity.duration-150
|
|
||||||
.{position classes}
|
|
||||||
{tooltip text}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Hover over the button and the label "Delete item" appears above it.
|
||||||
|
|
||||||
## 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
|
## All the options
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public Tooltip(
|
public Tooltip(
|
||||||
@@ -51,33 +27,48 @@ public Tooltip(
|
|||||||
string position = "top")
|
string position = "top")
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `text` | Tooltip label (plain text; HTML not supported) |
|
| `text` | The tooltip text. Plain text only — no HTML. |
|
||||||
| `trigger` | Any `IHtmxComponent` that acts as the hover target |
|
| `trigger` | Any `IHtmxComponent` — this is the element the user hovers over. |
|
||||||
| `position` | `"top"` / `"bottom"` / `"left"` / `"right"` |
|
| `position` | Where the tooltip appears: `"top"` (default), `"bottom"`, `"left"`, or `"right"`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage examples
|
## Real-world examples
|
||||||
|
|
||||||
### Icon button with tooltip
|
### Icon buttons in a toolbar
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new Tooltip(text: "Bold", trigger: new Button("B", size: "icon", variant: "ghost"))
|
||||||
|
new Tooltip(text: "Italic", trigger: new Button("I", size: "icon", variant: "ghost"))
|
||||||
|
new Tooltip(text: "Save", trigger: new Button("💾", size: "icon", variant: "ghost"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Right-aligned tooltip (near the left edge of the UI)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Tooltip(
|
new Tooltip(
|
||||||
text: "Delete item",
|
text: "View help documentation",
|
||||||
trigger: new Button("🗑", size: "icon", variant: "ghost"))
|
trigger: new Button("?", size: "icon", variant: "outline"),
|
||||||
|
position: "right")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Top/bottom/left/right positions
|
### Below the element
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
new Tooltip(text: "Above", trigger: new Button("Hover me"), position: "top")
|
new Tooltip(
|
||||||
new Tooltip(text: "Below", trigger: new Button("Hover me"), position: "bottom")
|
text: "This cannot be undone",
|
||||||
new Tooltip(text: "Left", trigger: new Button("Hover me"), position: "left")
|
trigger: new Button("Delete", variant: "destructive"),
|
||||||
new Tooltip(text: "Right", trigger: new Button("Hover me"), position: "right")
|
position: "bottom")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Tooltip wraps the trigger in a `<span class="group">`. The tooltip text is an absolutely positioned `<span>` inside that wrapper with `opacity-0` by default and `group-hover:opacity-100` to fade it in. Because this is pure Tailwind CSS, there is no JavaScript involved and no initialisation needed for HTMX-swapped content.
|
||||||
|
|
||||||
### Tooltip on an Avatar
|
### Tooltip on an Avatar
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|||||||
Reference in New Issue
Block a user