Compare commits

..

2 Commits

Author SHA1 Message Date
shaamilahmed 3059c6cc77 Added issues that are going to be tracked and will be deeply considering changes based on DX.
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 00:21:13 +05:00
shaamilahmed b530bb8c97 Rewrote all the docs - more noob friendly now.
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 23:55:26 +05:00
42 changed files with 3377 additions and 2341 deletions
+155 -41
View File
@@ -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.
+133 -115
View File
@@ -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.
protected override void RenderHeading(HtmxRenderContext context) _headingData = heading.ToUtf8Bytes();
=> context.Writer.WriteUtf8(_headingData); _descriptionData = description.ToUtf8Bytes();
protected override void RenderDescription(HtmxRenderContext context)
=> context.Writer.WriteUtf8(_descriptionData);
} }
// Minimal API handler — discovered and registered by the source generator protected override void RenderHeading(HtmxRenderContext ctx)
[Handler] => ctx.Writer.WriteUtf8(_headingData);
[MapGet("/my-page")]
public static partial class GetMyPageHandler
{
public record Query; // add route/query parameters here if needed
private static ValueTask HandleAsync( protected override void RenderDescription(HtmxRenderContext ctx)
Query query, => ctx.Writer.WriteUtf8(_descriptionData);
IHttpContextAccessor httpContextAccessor, }
CancellationToken token) ```
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var page = new MyPage 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.
{
Heading = "My New Page",
Description = "This is a minimal example page."
};
// WriteHtmxPage: full HTML shell for direct browser loads, ---
// bare fragment for HTMX partial swaps (HX-Request header present)
ctx.WriteHtmxPage(page, title: "My Page", appName: "HtmxApp", pageTitle: "My Page"); ## Step 3 — Write the route handler
return ValueTask.CompletedTask;
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 static void Map(IEndpointRouteBuilder app)
{
app.MapGet("/my-page", Handle);
}
private static IResult Handle(HttpContext ctx)
{
var page = new MyPage(
heading: "My New Page",
description: "This is a minimal example."
);
ctx.WriteHtmxPage(page, title: "My Page");
return Results.Empty;
} }
} }
``` ```
## 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;
public MyPage(string heading, string status)
{
_headingData = heading.ToUtf8Bytes();
_statusBadge = new Badge(status, variant: "secondary");
} }
protected override void RenderMyCard(HtmxRenderContext context) protected override void RenderHeading(HtmxRenderContext ctx)
=> MyCard.Render(context); => 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
+102 -53
View File
@@ -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;
public MyPage(string heading, string status)
{
_headingData = heading.ToUtf8Bytes();
_statusBadge = new Badge(status, variant: "secondary");
} }
protected override void RenderMyBadge(HtmxRenderContext ctx) protected override void RenderHeading(HtmxRenderContext ctx)
=> MyBadge.Render(ctx.Next()); => 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();
``` ```
+96 -53
View File
@@ -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
View File
@@ -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
+90 -31
View File
@@ -1,35 +1,94 @@
# 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.
For known API limitations and improvement tracking, also see:
- [Issues/Components/00-API-Limitations-and-Workarounds.md](Issues/Components/00-API-Limitations-and-Workarounds.md)
- [Issues/Components/00-API-Design-Guidelines.md](Issues/Components/00-API-Design-Guidelines.md)
- [Issues/README.md](Issues/README.md)
---
## 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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
--- ---
+51 -57
View File
@@ -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")
new Badge("Pending", variant: "outline")
```
### Status indicator in a table cell
```csharp
// Render to bytes and embed in table cell HTML
var writer = new System.Buffers.ArrayBufferWriter<byte>(); var writer = new System.Buffers.ArrayBufferWriter<byte>();
new Badge("Active", variant: "default").Render(new HtmxRenderContext(writer)); new Badge(text, variant).Render(new HtmxRenderContext(writer));
var badgeHtml = System.Text.Encoding.UTF8.GetString(writer.WrittenSpan); return 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
{
private readonly IHtmxComponent _statusBadge;
public MyPage(string status) public MyPage(string status)
{ {
StatusBadge = status == "active" _statusBadge = new Badge(status == "active" ? "Active" : "Inactive",
? new Badge("Active") status == "active" ? "default" : "secondary");
: new Badge("Inactive", variant: "secondary");
} }
protected override void RenderStatusBadge(HtmxRenderContext ctx) protected override void RenderStatusBadge(HtmxRenderContext ctx)
=> StatusBadge.Render(ctx.Next()); => _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.
--- ---
+59 -65
View File
@@ -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
View File
@@ -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.
``` ```
--- ---
+52 -94
View File
@@ -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 ← SunSat headings (hidden in month/year views)
div.cal-grid.grid.grid-cols-7 ← day/month/year cells, built by JS
input.cal-hidden-input[type=hidden, name] ← holds selected date as yyyy-MM-dd
``` ```
--- 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 JanDec abbreviated buttons in a 3-column grid
- Click drills back to days view for that month
**Years view:**
- Renders 12 consecutive years (decade rounded to nearest 12)
- Click drills back to months view for that year
### Navigation buttons
- Prev/Next adjust month ± 1 (wrapping year), year ± 1, or decade ± 12 depending on `data-view`
- Month-label click drills down: days → months → years (no further drill from years)
### Re-initialization
`initAll` re-queries `.calendar-root` after `htmx:afterSwap`, so HTMX-swapped calendars work correctly.
---
## Constructor signature
```csharp ```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;
public BookingForm(string antiforgeryToken)
{
_tokenData = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />""".ToUtf8Bytes();
_datePicker = new Calendar(id: "booking", name: "bookingDate");
} }
protected override void RenderDatePicker(HtmxRenderContext ctx) protected override void RenderToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tokenData);
=> DatePicker.Render(ctx.Next()); 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.
+45 -93
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
--- ---
+60 -81
View File
@@ -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.
--- ---
+41 -54
View File
@@ -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
View File
@@ -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
); );
``` ```
+40 -57
View File
@@ -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 110 (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 (23 pages), all page buttons are shown with no ellipsis.
- For very small page counts (23 pages), all page buttons are shown with no ellipsis.
--- ---
+31 -50
View File
@@ -1,98 +1,79 @@
# Progress # Progress
A horizontal progress bar. Value is clamped to 0100. 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 0100 | | `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).
--- ---
+51 -46
View File
@@ -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
View File
@@ -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']"""")
``` ```
+31 -37
View File
@@ -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
View File
@@ -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
View File
@@ -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 0100 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 0100 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: "10100") 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
View File
@@ -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
View File
@@ -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.
+60 -61
View File
@@ -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[]
{ {
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
("vb", "VB.NET", "<pre><code>Dim x As Integer = 42</code></pre>"),
})
```
### Embedding a full component in a tab
```csharp
// Pre-render the inner component to HTML string
var buf = new System.Buffers.ArrayBufferWriter<byte>(); var buf = new System.Buffers.ArrayBufferWriter<byte>();
new Table(headers: cols, rows: data).Render(new HtmxRenderContext(buf)); c.Render(new HtmxRenderContext(buf));
var tableHtml = System.Text.Encoding.UTF8.GetString(buf.WrittenSpan); return 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
View File
@@ -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",
+45 -81
View File
@@ -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 (112 or 023)
span.text-muted-foreground :
select.timepicker-m[name={name}-m] ← minute select (0059)
select.timepicker-ampm[name={name}-ampm] ← AM/PM (12h mode only)
input.sr-only[type=hidden, name={name}] ← hidden input holding HH:MM
p.text-sm.text-muted-foreground ← omitted when empty
{description}
``` ```
--- ---
## CSS mechanics ## 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 112 | | `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
View File
@@ -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.
+17 -55
View File
@@ -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
View File
@@ -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
+525
View File
@@ -0,0 +1,525 @@
# Component API Smells
This document catalogs potential complaints about the current component/template API.
Each item includes:
- Problem
- Why it hurts
- Potential solutions
- Consequences/costs
## 1) Magic Strings for Variants, Sizes, Types, Directions
Problem:
- Many components use raw strings for semantic options (`variant`, `size`, `type`, `direction`, `align`, etc.).
- Invalid values often silently fall back to defaults.
Why it hurts:
- No compile-time safety.
- Typos are easy to miss.
- Weak IntelliSense discoverability.
Potential solutions:
- Replace string options with enums for common semantic domains.
- Generate constants classes per component for non-breaking intermediate step.
- Add analyzers that validate allowed literals where strings are retained.
Consequences/costs:
- Enums can be breaking if public signatures change.
- Constants are low cost but do not fully prevent invalid values.
- Analyzer route adds tooling complexity.
## 2) Inconsistent Styling Extensibility (`extraClasses` and wrappers)
Problem:
- Some components have `extraClasses`; others do not.
- Developers often wrap components in outer `div` just to apply layout/styling.
Why it hurts:
- No consistent mental model.
- Extra wrapper markup increases noise and nesting depth.
Potential solutions:
- Add a standard `className` (or `extraClasses`) parameter to every component.
- Support class merging utility behavior in a shared helper.
Consequences/costs:
- Public constructor expansion across many components.
- Need policy for precedence (base classes first vs custom classes first).
## 3) Missing Uniform Attribute Pass-Through
Problem:
- Attribute extensibility is fragmented (`hxAttrs` in some places, none in others).
- No first-class support for arbitrary `aria-*`, `data-*`, test IDs, analytics attributes.
Why it hurts:
- Manual string composition is error-prone.
- Difficult accessibility and testing instrumentation.
Potential solutions:
- Add a shared attributes bag type (`IReadOnlyDictionary<string, string?>`).
- Keep `hxAttrs` temporarily as compatibility shim.
Consequences/costs:
- Larger refactor surface.
- Slight allocation/processing overhead.
- Requires HTML attribute encoding rules in one central place.
## 4) `hxAttrs` Raw String Footgun
Problem:
- Raw attribute strings allow malformed markup or accidental injection.
Why it hurts:
- Hard-to-debug render bugs.
- Security posture depends on each caller doing manual encoding correctly.
Potential solutions:
- Deprecate raw `hxAttrs` in favor of typed/structured attrs.
- Provide safe helper methods to construct HTMX attribute sets.
Consequences/costs:
- Migration needed for existing call sites.
- Potentially breaking unless a gradual fallback is kept.
## 5) Unsafe-by-Default Raw HTML Content Paths
Problem:
- Several components accept string content that is rendered as raw HTML.
- Caller must remember to encode user-provided values.
Why it hurts:
- XSS risk in real application code.
- Easy to misuse when moving quickly.
Potential solutions:
- Safe-by-default encoding for plain string inputs.
- Separate APIs for encoded text vs trusted HTML (explicit escape hatch).
- Introduce a `SafeHtml` wrapper type for intentional raw HTML.
Consequences/costs:
- Safe-by-default may break current behavior for callers relying on raw HTML.
- Trusted-HTML API adds conceptual complexity, but clearer intent.
## 6) Inconsistent Security Guidance in Component Docs
Problem:
- Some docs mention encoding; others do not provide clear warnings.
Why it hurts:
- Security correctness relies on tribal knowledge.
Potential solutions:
- Add a standardized "Security" section to every component doc.
- Include explicit examples: safe input, unsafe input, and fix.
Consequences/costs:
- Documentation maintenance overhead.
- Strong DX/security benefit for low implementation cost.
## 7) No Explicit "Component Props" Model in Markup
Problem:
- Slots replace placeholders, but there is no direct concept of passing typed props in `.htmx` markup itself.
- Dynamic behavior is mostly constructor-centric in `.htmx.cs`.
Why it hurts:
- Feels unlike modern component systems where props are explicit and local.
- New users may expect inline component parameterization and be surprised.
Potential solutions:
- Document this limitation clearly as a design constraint.
- Add a generated props record convention per component/page.
- Explore optional parameterized slot syntax in generator (long-term).
Consequences/costs:
- Props model requires generator design changes.
- Parameterized slot syntax is high complexity and may conflict with AOT simplicity.
## 8) Constructor Bloat and Low Readability
Problem:
- Some components expose many optional parameters, often multiple strings.
Why it hurts:
- Ambiguous calls and poor self-documentation.
- Easy to mis-order arguments.
Potential solutions:
- Favor required args + options record pattern.
- Add fluent builders for complex components.
Consequences/costs:
- Options records improve readability but introduce extra types.
- Builders can increase allocations and complexity.
## 9) Tuple-Based APIs for Complex Components
Problem:
- Components like tabs/accordion/dropdown/table rely on tuple collections.
Why it hurts:
- Tuples are easy to misuse and harder to read than named objects.
- Harder to evolve APIs without breaking all call sites.
Potential solutions:
- Replace tuple parameters with named records (`TabItem`, `AccordionItem`, etc.).
Consequences/costs:
- Migration churn across existing usage.
- Clear long-term maintainability win.
## 10) Missing Validation Feedback for Invalid Inputs
Problem:
- Invalid option values often degrade silently rather than failing fast.
Why it hurts:
- Bugs are hidden and discovered late.
Potential solutions:
- Add debug-time validation with clear exceptions/messages.
- Optionally emit logs/diagnostics in production with safe defaults.
Consequences/costs:
- Strict runtime validation can be breaking for existing invalid usages.
- Diagnostics-only mode is safer for migration.
## 11) Inconsistent Boolean Option Naming
Problem:
- Different components use varying naming styles for booleans and toggles.
Why it hurts:
- Low API predictability.
Potential solutions:
- Define naming conventions (`isX`, `hasX`, `enableX`) and enforce globally.
Consequences/costs:
- Rename churn if normalized retroactively.
## 12) CSS Contract Coupled to JS via Hidden Class Names
Problem:
- Interactive behavior relies on specific class/data selectors that are effectively API contracts.
Why it hurts:
- Refactoring classes can break behavior.
- Coupling is not obvious from constructor APIs.
Potential solutions:
- Document required selectors/events in each interactive component doc.
- Prefer stable `data-component`/`data-role` markers over purely visual class names.
Consequences/costs:
- Markup updates across components and JS.
- Better long-term resilience to style refactors.
## 13) Runtime Behavior Dependencies Not Surfaced in API
Problem:
- Components requiring JS initialization do not expose that requirement in code signatures.
Why it hurts:
- Silent "renders but does not work" failures.
Potential solutions:
- Add `Requires JavaScript` section in docs and XML comments.
- Add lightweight marker interface or metadata attribute for interactive components.
Consequences/costs:
- Minimal runtime cost; mostly documentation/tooling work.
## 14) Accessibility Ergonomics Gaps
Problem:
- No consistent way to pass `aria-*`, `id`, `for`, `describedby` across all components.
Why it hurts:
- Accessibility quality depends on manual wrapper hacks.
Potential solutions:
- Introduce shared accessible options type.
- Provide defaults and enforce required labels where relevant.
Consequences/costs:
- Constructor changes and additional validation logic.
## 15) Testability Friction (No Standard Test IDs)
Problem:
- No consistent `data-testid` or attribute pass-through strategy.
Why it hurts:
- E2E selectors become brittle (class/text-based selectors).
Potential solutions:
- Add standard attributes bag and testing guidance.
- Add `testId` convenience parameter in interactive/form primitives.
Consequences/costs:
- Minor API surface increase.
- Significant test stability benefit.
## 16) Documentation Discoverability Gaps
Problem:
- Component docs focus on usage but under-emphasize known limitations and smell areas.
Why it hurts:
- New contributors re-learn the same constraints repeatedly.
Potential solutions:
- Add dedicated docs for limitations, anti-patterns, and migration strategy.
- Add an index from component reference into Issues docs.
Consequences/costs:
- Ongoing documentation upkeep.
## 17) Inconsistent Naming (`extraClasses` vs alternatives)
Problem:
- Similar concepts have inconsistent parameter names.
Why it hurts:
- Context switching overhead.
Potential solutions:
- Standardize naming dictionary and enforce in reviews.
- Offer temporary backward-compatible aliases.
Consequences/costs:
- Alias support increases short-term complexity.
## 18) Lack of Strongly-Typed Domain Primitives
Problem:
- IDs, route paths, CSS classes, and labels are all plain strings.
Why it hurts:
- Accidental parameter swaps and weak intent signaling.
Potential solutions:
- Introduce lightweight value objects or records for high-value domains (`DialogId`, `CssClassList`, etc.).
Consequences/costs:
- Added type count and conversion code.
- Better readability and safer APIs.
## 19) Missing Centralized Class Composition Policy
Problem:
- Tailwind class strings are composed ad-hoc in constructors.
Why it hurts:
- Risk of duplicate/conflicting classes.
- Hard to audit variant behavior consistency.
Potential solutions:
- Add shared class composition helper utilities.
- Optionally adopt a deterministic merge utility pattern.
Consequences/costs:
- New utility dependency or internal helper maintenance.
## 20) Limited Error Reporting for Misconfigured Interactive Components
Problem:
- Missing/incorrect JS hooks often fail quietly.
Why it hurts:
- Time-consuming debugging.
Potential solutions:
- Development-only console warnings/assertions from `components.js` when expected markers are missing.
Consequences/costs:
- Slight JS complexity increase.
- Better troubleshooting experience.
## 21) Form Component API Inconsistency
Problem:
- Form primitives vary in how they accept value/default/checked/attrs/labels.
Why it hurts:
- Hard to predict usage patterns across components.
Potential solutions:
- Define a shared form control contract:
- `name`, `id`, `label`, `value`, `disabled`, `required`, `className`, `attributes`
Consequences/costs:
- Widespread API harmonization work.
- Major usability win once stabilized.
## 22) No First-Class Validation/Error State Patterns
Problem:
- Error display, invalid styling, and message linkage are largely ad-hoc.
Why it hurts:
- Inconsistent UX and accessibility for validation states.
Potential solutions:
- Add canonical form-field wrapper component and error semantics.
- Add helper patterns in docs for mapping server validation to components.
Consequences/costs:
- Additional abstractions and migration.
## 23) Table API Lacks Strong Cell/Column Models
Problem:
- Table inputs as nested strings are simplistic and rigid.
Why it hurts:
- Hard to represent links, badges, actions, and per-cell semantics safely.
Potential solutions:
- Introduce column and row models with typed cell renderers.
- Support text cell vs trusted HTML cell explicit APIs.
Consequences/costs:
- Significant redesign effort for table API.
- High payoff for real-world usage.
## 24) Potential Over-Eager Precomputation in Constructors
Problem:
- Some components precompute heavy HTML payloads in constructor.
Why it hurts:
- Allocation spikes for large datasets.
- Can surprise developers expecting render-time streaming.
Potential solutions:
- Lazy compute/cache expensive sections.
- Document performance profile and guardrails per component.
Consequences/costs:
- Possible complexity in caching invalidation.
- Better performance transparency.
## 25) No Compatibility Policy for API Evolution
Problem:
- No explicit deprecation policy for parameter renames or behavior changes.
Why it hurts:
- Contributors hesitate to improve APIs due to break risk.
Potential solutions:
- Define semver/deprecation policy in docs.
- Use staged migration with obsolete annotations.
Consequences/costs:
- Process overhead, but critical for long-term maintainability.
## 26) No Unified Component Design Principles Doc
Problem:
- Patterns are documented, but not as enforceable design principles.
Why it hurts:
- New components may diverge in API style.
Potential solutions:
- Publish a component API style guide with mandatory rules and preferred patterns.
Consequences/costs:
- Requires reviewer discipline.
## 27) Internationalization (i18n) Boundaries Not Explicit
Problem:
- Many labels/content are plain strings without explicit localization guidance.
Why it hurts:
- Inconsistent localization strategy across pages/components.
Potential solutions:
- Add docs for localizable boundaries and resource integration patterns.
Consequences/costs:
- Documentation and integration work.
## 28) Missing "Known Limitations" Section in Component Reference Entry Point
Problem:
- The main component reference does not prominently call out systemic limitations.
Why it hurts:
- Developers discover constraints by trial and error.
Potential solutions:
- Add up-front limitations and issue tracker links in component reference.
Consequences/costs:
- Low cost; immediate discoverability gains.
## 29) API Surface Differs Across Similar Components
Problem:
- Similar categories (display/form/interactive) do not expose comparable extension points.
Why it hurts:
- Surprising differences force re-learning per component.
Potential solutions:
- Define a baseline component contract by category:
- Display: `className`, `attributes`
- Form: baseline form control props + attrs
- Interactive: baseline + JS contract notes
Consequences/costs:
- Requires systematic API audit and staged rollout.
## 30) Missing Tooling Support for API Misuse
Problem:
- No analyzers/code fixes for common mistakes (invalid variant, unsafe content, missing encoding).
Why it hurts:
- Review burden remains manual.
Potential solutions:
- Introduce Roslyn analyzers for:
- magic string validation
- unsafe raw HTML from untrusted sources
- missing serialization registration patterns
Consequences/costs:
- Initial tooling investment is medium-high.
- Scales quality across the codebase after adoption.
---
## Cross-Cutting Improvement Patterns
1. Standardized base options record:
- `className`
- `attributes`
- `testId`
- `ariaLabel`
2. Strong typing for semantic options:
- enums/constants/analyzers
3. Safe content model:
- text-safe by default, explicit trusted-html escape hatch
4. Better docs contract:
- every component doc should include:
- security notes
- accessibility notes
- JS dependency notes (if interactive)
- extension points
5. Migration strategy:
- additive changes first
- obsolete old params
- remove deprecated paths in major version bump
@@ -0,0 +1,329 @@
# Component-by-Component Concerns
This matrix captures likely API/DX complaints per component area, including potential improvement directions.
## Display Components
### Alert
Concerns:
- Variant as magic string.
- Content/title may be used as raw HTML without explicit safety boundary.
- Custom classes/attributes may be inconsistent.
Potential improvements:
- Enum for variant.
- Safe text API + explicit trusted HTML path.
- Standard `className` and `attributes`.
### Avatar
Concerns:
- Fallback/shape/size options can become string-heavy.
- Accessibility attributes may be awkward without attr bag.
Potential improvements:
- Typed size/shape options.
- Uniform attributes model.
### Badge
Concerns:
- Variant string typing and typo risk.
- Inconsistent extension points compared with Button/Input.
Potential improvements:
- Variant enum/constants.
- Standardized extensibility surface.
### Breadcrumb
Concerns:
- Item model may be primitive/string-only.
- Accessibility hooks and custom attrs may be limited.
Potential improvements:
- Named item record model.
- Attr bag and aria convenience options.
### Card
Concerns:
- Raw HTML sections can be misused.
- Optional sections create many constructor parameters.
Potential improvements:
- Options record.
- Safe content model.
### Progress
Concerns:
- Value bounds validation may be implicit or absent.
- Class extension inconsistencies.
Potential improvements:
- Explicit min/max validation.
- Standard class and attr extension.
### Separator
Concerns:
- Orientation/type as magic string.
- Thin API extensibility for accessibility semantics.
Potential improvements:
- Enum for orientation.
- Attr bag support.
### Skeleton
Concerns:
- Shape/sizing patterns vary by caller wrappers.
- Lacks compositional guidance for complex placeholders.
Potential improvements:
- Preset variants + className override.
- Pattern docs for loading states.
### Table
Concerns:
- Primitive row/cell string model limits rich content.
- Possible heavy constructor precomputation for large data.
- Safety boundary unclear when rendering rich cell content.
Potential improvements:
- Typed column/cell models.
- Lazy render/cache strategy for large tables.
- Explicit text-vs-html cell APIs.
### Tooltip
Concerns:
- Trigger/content composition can rely on string/slot conventions.
- Accessibility and focus behavior may need clearer guidance.
Potential improvements:
- Better keyboard and aria documentation.
- Attr/class pass-through consistency.
## Form Components
### Button
Concerns:
- Variant/size/type magic strings.
- `hxAttrs` raw string ergonomics/security risk.
- Need for wrapper to add layout classes in some contexts.
Potential improvements:
- Enums/constants.
- Structured attributes.
- Standard `className`.
### Checkbox
Concerns:
- Label/id/checked model may not match other form controls.
- Limited pass-through attributes.
Potential improvements:
- Shared form-control options contract.
- Attr bag and validation state support.
### FileInput
Concerns:
- Accepted file types and attrs may be cumbersome.
- Inconsistent API vs Input/Textarea.
Potential improvements:
- Shared form-control options.
- Better file-specific typed options.
### Input
Concerns:
- Input type as string.
- Validation/aria hooks likely manual.
Potential improvements:
- Input type enum/constants.
- Baseline form options and attr bag.
### RadioGroup
Concerns:
- Tuple options reduce readability.
- Direction/layout often string-based.
- Attr pass-through likely limited.
Potential improvements:
- Named option record.
- Typed direction values.
- Standard form/attr contract.
### Select
Concerns:
- Option model and selected/default semantics may be inconsistent.
- Styling/attrs may differ from Input/Textarea.
Potential improvements:
- Named option record.
- Unified form control API.
### Slider
Concerns:
- Value/min/max/step validation and formatting ergonomics.
- Limited attr/class extensibility in some usages.
Potential improvements:
- Stronger numeric validation and docs.
- Standard extension points.
### Switch
Concerns:
- Checked/value semantics may differ from checkbox.
- JS and accessibility contracts may not be obvious.
Potential improvements:
- Form contract alignment.
- Explicit a11y + JS requirements docs.
### Textarea
Concerns:
- Similar concerns to Input: attrs, validation, and consistency.
Potential improvements:
- Shared baseline form options.
## Interactive Components
### Accordion
Concerns:
- Tuple-based item model.
- JS selector coupling via markup classes/data attributes.
- Rich content safety boundary if strings are HTML.
Potential improvements:
- Named `AccordionItem` model.
- Stable data-role contracts.
- Explicit safe/trusted content APIs.
### Calendar
Concerns:
- JS dependency and date contract coupling.
- Limited custom attributes for instrumentation/a11y.
Potential improvements:
- Explicit JS contract docs in API comments.
- Standard attrs support.
### CalendarRange
Concerns:
- Similar to Calendar plus complexity around range state.
- Validation/error state API may be weak.
Potential improvements:
- Strong state model and docs.
- Better attr/validation contract.
### Dialog
Concerns:
- Open/close semantics rely on data attributes and JS wiring.
- Content sections may use raw HTML strings.
Potential improvements:
- Named trigger/actions patterns in docs.
- Safe content APIs and structured attrs.
### DropdownMenu
Concerns:
- Item model can be tuple-heavy.
- Keyboard and accessibility behavior depends on JS contract.
Potential improvements:
- Named item/action records.
- Explicit interaction/accessibility contract docs.
### Tabs
Concerns:
- Tuple-based tab definitions.
- ID/active state handling as plain strings.
- JS contract may be implicit.
Potential improvements:
- `TabItem` record + typed active key model.
- Explicit JS and accessibility notes.
### TimePicker
Concerns:
- Value format/string handling may be error-prone.
- JS coupling and validation ergonomics.
Potential improvements:
- Typed time value helpers.
- Clear formatting and validation rules.
## Notification Components
### Toast
Concerns:
- Trigger lifecycle and JS coupling may not be obvious.
- Variant/style options likely string-based.
Potential improvements:
- Typed options and JS contract docs.
- Standard attrs/class extension points.
### ToastViewport
Concerns:
- Placement/config likely string-heavy.
- Global singleton usage constraints may be under-documented.
Potential improvements:
- Typed placement/options.
- Clear singleton/layout guidance.
## Navigation Components
### Pagination
Concerns:
- Data model may be primitive and hard to customize.
- Accessibility state semantics need consistency.
Potential improvements:
- Named model for page items and actions.
- Strong accessibility defaults and hooks.
## Cross-Component Cost Notes
Low-cost improvements:
- Better docs for limitations/security/js contracts.
- Add design guidelines and migration policy.
- Constants for common string literals.
Medium-cost improvements:
- Standard `className` and `attributes` options.
- Options records for complex constructors.
- Named item models replacing tuples.
High-cost improvements:
- Safe-by-default content model transition.
- Full enum migration for all semantic options.
- Analyzer suite for API misuse detection.
@@ -0,0 +1,51 @@
# Component API Design Guidelines
Use this when creating or evolving components so the API remains predictable.
## Goals
- Consistency across all components
- Safe defaults for user content
- Low ceremony for common use cases
- Explicit escape hatches for advanced scenarios
## Baseline API Conventions
For every new component, prefer:
- `className` for caller-supplied Tailwind/CSS classes
- `attributes` for arbitrary HTML attributes (`aria-*`, `data-*`, test IDs)
- Strongly typed semantic options where practical (enums or constants)
- Named item records instead of tuples for complex lists
## Safety Rules
1. Plain text input should be encoded by default.
2. Raw HTML should require an explicit trusted path.
3. Never require callers to manually concatenate unsafe attribute strings for normal usage.
## Documentation Rules
Every component doc should include:
1. Quick example
2. All options
3. Security notes
4. Accessibility notes
5. JS dependency notes (if interactive)
6. Extension points (`className`, `attributes`)
## Evolution Rules
1. Prefer additive changes first.
2. Mark old APIs as deprecated with migration examples.
3. Remove deprecated paths only in major release.
## Review Checklist
- Is the API consistent with sibling components?
- Can callers add classes without wrapper divs?
- Can callers pass `aria-*` and `data-*` safely?
- Are semantic options type-safe?
- Are user-provided strings encoded by default?
- Are interactive JS requirements documented?
@@ -0,0 +1,128 @@
# Component API Limitations and Workarounds
This page documents known limitations in the current component API and practical ways to work effectively with them.
## 1) Magic String Parameters
Limitation:
- Semantic options like `variant`, `size`, and similar values are frequently string-based.
What this means:
- Typos may silently fall back to defaults.
Workaround:
- Centralize repeated literals in local constants in your feature code.
- Prefer named arguments for readability.
Example:
```csharp
private const string VariantDestructive = "destructive";
var deleteButton = new Button(
label: "Delete",
variant: VariantDestructive,
size: "sm");
```
## 2) Extra Styling Often Requires Wrappers
Limitation:
- Not every component exposes a class extension parameter.
What this means:
- You may need wrapper elements for layout spacing, sizing, or responsive behavior.
Workaround:
- Use a minimal wrapper pattern and keep wrapper intent obvious.
Example:
```html
<div class="md:max-w-sm w-full">$$SaveButton$$</div>
```
## 3) No Universal Attribute Bag
Limitation:
- Some components expose `hxAttrs`, some do not, and no shared attributes model exists yet.
What this means:
- Passing `aria-*`, `data-*`, or test selectors is inconsistent.
Workaround:
- Prefer wrapper-level attributes where possible.
- If using raw attr strings, keep them static and explicit.
## 4) No Direct "Props in .htmx Markup" Model
Limitation:
- Component/page parameterization is constructor-driven in `.htmx.cs`, not inline-props driven in `.htmx` markup.
What this means:
- Dynamic behavior is assembled in C# code-behind.
Workaround:
- Treat `.htmx` as shape and slot layout.
- Treat `.htmx.cs` as the single source of component input logic.
## 5) Raw HTML Output Requires Discipline
Limitation:
- Several components render provided strings as HTML.
What this means:
- User input must be encoded before rendering.
Workaround:
- Always encode user-provided values before `ToUtf8Bytes()`.
Example:
```csharp
var safeName = System.Web.HttpUtility.HtmlEncode(userDisplayName);
_nameData = safeName.ToUtf8Bytes();
```
## 6) Interactive Components Depend on JS Contracts
Limitation:
- Components like tabs, accordion, dialog, calendar, and toast depend on JavaScript hooks/selectors.
What this means:
- Markup can render but behave incorrectly if expected JS wiring is missing.
Workaround:
- Verify behavior after HTMX swaps.
- Keep required data-role/class markers intact.
## 7) Tuple APIs for Complex Components
Limitation:
- Some components expect tuple arrays for items/options.
What this means:
- Call sites can become harder to read and evolve.
Workaround:
- Build small local records/variables first, then map to tuples.
## 8) Form API Inconsistency
Limitation:
- Form primitives do not all expose the same extension points.
What this means:
- You need per-component familiarity.
Workaround:
- Create feature-local helper methods to normalize usage patterns.
## 9) Recommendation for Teams
If multiple developers are contributing:
1. Define local conventions for allowed variant strings.
2. Standardize wrapper patterns (`layout wrappers`, `a11y wrappers`, `test-id wrappers`).
3. Review for HTML encoding whenever user input is rendered.
4. Track repeated pain points in docs/Issues for future API upgrades.
@@ -0,0 +1,68 @@
# Improvement Options and Costs
This matrix helps prioritize API improvements by DX value, risk, and migration cost.
Legend:
- Effort: S (small), M (medium), L (large), XL (very large)
- Break Risk: Low, Medium, High
- Runtime Impact: Positive, Neutral, Slight Negative
| Proposal | Solves | Effort | Break Risk | Runtime Impact | Notes |
|---|---|---|---|---|---|
| Add standardized `className` to all components | wrapper-div workaround, styling consistency | M | Low | Neutral | Additive change if optional |
| Add standardized attributes bag (`attributes`) | missing `aria-*`, `data-*`, test IDs | L | Medium | Slight Negative | Best long-term extensibility |
| Keep `hxAttrs` as compatibility fallback | migration safety | S | Low | Neutral | Mark as legacy in docs |
| Enums for variant/size/type | magic strings | M | Medium | Neutral | Better compile-time safety |
| Constants classes for allowed string values | magic strings (partial) | S | Low | Neutral | Good transitional step |
| Replace tuple APIs with named records | readability, future extensibility | M | Medium | Neutral | `TabItem`, `AccordionItem`, etc. |
| Add options-record constructors for complex components | constructor bloat | M | Low | Neutral | Improves call-site clarity |
| Safe-by-default text encoding APIs | XSS risk | M | High | Neutral | Breaking if current raw HTML behavior is relied on |
| Explicit trusted HTML wrapper API | intentional raw HTML path | M | Medium | Neutral | Clear security intent |
| Add standardized Security section in every component doc | security docs inconsistency | M | Low | Neutral | High ROI docs work |
| Add standardized Accessibility section in every component doc | a11y gaps | M | Low | Neutral | High ROI docs work |
| Add JS contract metadata/docs for interactive components | hidden JS dependencies | S | Low | Neutral | Immediate debugging benefit |
| Add development-time JS diagnostics in components.js | silent interactive failures | M | Low | Slight Negative | Dev-only checks recommended |
| Add analyzer: invalid variant literals | magic string typos | L | Low | Neutral | Tooling investment pays off |
| Add analyzer: unsafe unencoded user input usage | XSS prevention | L | Medium | Neutral | Requires careful heuristics |
| Add API evolution policy and deprecation plan | change management | S | Low | Neutral | Needed before major refactors |
| Introduce per-category baseline contracts | inconsistency across components | L | Medium | Neutral | Strategic but broad |
| Introduce table column/cell model | table API limitations | L | Medium | Neutral | High payoff for real apps |
| Add form control baseline contract | form component inconsistency | L | Medium | Neutral | Improves predictability |
| Introduce lazy compute in heavy components | precompute allocation concerns | M | Low | Positive | Benchmark before and after |
## Recommended Sequencing
1. Documentation-first, no-break improvements:
- known limitations docs
- security/a11y/js contract sections
- issue tracker links and migration guidance
2. Additive API upgrades:
- `className`
- `attributes`
- options records
- constants for allowed values
3. Strong typing and validation:
- enums
- analyzers
- debug validations
4. Breaking/security-hardening updates:
- safe-by-default content model
- deprecate raw string HTML entry points
## Consequence Summary
Positive consequences:
- More predictable and discoverable API
- Lower bug rate from string typos and attr mistakes
- Better security baseline
- Less wrapper-div boilerplate
Negative/neutral consequences:
- Larger API surface
- Migration overhead in existing usages
- Potentially more allocations for flexible attribute models
- Need for contributor discipline to maintain consistency
+30
View File
@@ -0,0 +1,30 @@
# Issues and API Improvement Tracker
This folder tracks known design/API smells in the current template stack, with proposed fixes and their trade-offs.
Use this section for:
- Capturing developer pain points before they are forgotten
- Aligning on possible API improvements
- Comparing implementation cost vs DX benefit
- Planning staged, low-risk migrations
## Files
- `Component-API-Smells.md` - exhaustive problem catalog and solution options
- `Improvement-Options-and-Costs.md` - decision matrix with cost/consequence analysis
- `Roadmap.md` - phased adoption plan
- `Component-by-Component-Concerns.md` - component-level complaint inventory and improvements
- `Components/00-API-Limitations-and-Workarounds.md` - component API limitations and practical workarounds
- `Components/00-API-Design-Guidelines.md` - component API design principles for future changes
## Scope
This is intentionally broader than bug tracking. Many entries are not defects; they are DX and API design weaknesses (inconsistency, discoverability, ergonomics, safety defaults, and maintainability concerns).
## Ground Rules
1. Keep issues concrete and reproducible.
2. Include at least one realistic solution.
3. Always state consequences (breaking change risk, complexity, perf, AOT constraints).
4. Prefer incremental migration paths over large rewrites.
+81
View File
@@ -0,0 +1,81 @@
# API Improvement Roadmap
This roadmap is designed to improve DX without destabilizing the current template.
## Phase 0: Track and Communicate (Now)
Goals:
- Make limitations explicit.
- Prevent repeated confusion.
Actions:
- Publish issues catalog and cost matrix.
- Link issues from the main component reference.
- Add "Known Limitations" + "Security" notes in component docs.
Success criteria:
- New contributors can find limitations before implementation.
## Phase 1: Low-Risk Additive API Improvements
Goals:
- Improve ergonomics with minimal break risk.
Actions:
- Add optional `className` to all components.
- Add optional `attributes` bag to all components.
- Keep `hxAttrs` as legacy fallback.
- Introduce constants classes for common string domains.
Success criteria:
- Most wrapper-div style workarounds disappear.
- Most custom attribute hacks disappear.
## Phase 2: Standardization and Strong Typing
Goals:
- Reduce error-prone string APIs.
Actions:
- Move high-value components to enums for semantic options.
- Introduce options records where constructors are overloaded.
- Replace tuple-based list inputs with named records.
Success criteria:
- IntelliSense can guide common component usage.
- Fewer runtime surprises from typos.
## Phase 3: Security and Validation Hardening
Goals:
- Safer defaults and clearer intent.
Actions:
- Introduce safe-by-default text paths.
- Keep explicit trusted HTML APIs for advanced usage.
- Add analyzers (or debug validators) for invalid options and unsafe patterns.
Success criteria:
- XSS risk materially reduced.
- Unsafe usage becomes explicit and reviewable.
## Phase 4: Deep API Evolution
Goals:
- Improve advanced composition and maintainability.
Actions:
- Formalize per-category component contracts.
- Improve table/form models for richer data and validation scenarios.
- Consider generator-level support for richer parameter/props models.
Success criteria:
- New component additions follow one predictable design style.
- Complex UIs require less custom glue code.
## Migration Strategy
1. Add new APIs first.
2. Mark old parameters/patterns as deprecated.
3. Provide codemod or migration examples.
4. Remove deprecated surface only in major release.