Added docs

This commit was merged in pull request #1.
This commit is contained in:
2026-04-13 18:57:47 +05:00
parent b323862e03
commit 5668cf20d9
9 changed files with 1421 additions and 1 deletions
+148
View File
@@ -0,0 +1,148 @@
# Button
A styled button component with variant and size presets built from Tailwind utility classes.
---
## Basic Usage
```razor
<Button>Default Button</Button>
<Button Type="submit">Submit</Button>
<Button Disabled="true">Can't Click</Button>
```
---
## Variants
Use the `Variant` parameter with constants from `ButtonVariant`:
```razor
<Button Variant="@ButtonVariant.Default">Default</Button>
<Button Variant="@ButtonVariant.Secondary">Secondary</Button>
<Button Variant="@ButtonVariant.Destructive">Destructive</Button>
<Button Variant="@ButtonVariant.Outline">Outline</Button>
<Button Variant="@ButtonVariant.Ghost">Ghost</Button>
<Button Variant="@ButtonVariant.Link">Link</Button>
```
| Constant | Tailwind Classes |
|---|---|
| `ButtonVariant.Default` | `bg-primary text-primary-foreground shadow hover:bg-primary/90` |
| `ButtonVariant.Destructive` | `bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90` |
| `ButtonVariant.Outline` | `border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground` |
| `ButtonVariant.Secondary` | `bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80` |
| `ButtonVariant.Ghost` | `hover:bg-accent hover:text-accent-foreground` |
| `ButtonVariant.Link` | `text-primary underline-offset-4 hover:underline` |
You can also pass any custom Tailwind class string directly:
```razor
<Button Variant="bg-blue-600 text-white hover:bg-blue-700">Custom</Button>
```
### Creating Custom Variants
Since `Variant` and `Size` are plain strings (not enums), you can create your own variant constants without modifying the library. Define a static class in your app with any Tailwind utility combinations you need:
```csharp
public static class AppButtonVariant
{
public const string Success =
"bg-green-600 text-white shadow-sm hover:bg-green-700";
public const string Warning =
"bg-amber-500 text-white shadow-sm hover:bg-amber-600";
public const string Info =
"bg-sky-500 text-white shadow-sm hover:bg-sky-600";
public const string OutlineDestructive =
"border border-destructive text-destructive bg-transparent shadow-sm hover:bg-destructive/10";
public const string Gradient =
"bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-sm hover:from-purple-600 hover:to-pink-600";
}
```
Then use them exactly like the built-in variants:
```razor
<Button Variant="@AppButtonVariant.Success">Save Changes</Button>
<Button Variant="@AppButtonVariant.Warning">Proceed with Caution</Button>
<Button Variant="@AppButtonVariant.Gradient">Upgrade Plan</Button>
```
You can do the same for custom sizes:
```csharp
public static class AppButtonSize
{
public const string Xs = "h-7 rounded-md px-2 text-xs";
public const string Xl = "h-12 rounded-lg px-10 text-base";
public const string Wide = "h-9 px-12 py-2";
}
```
```razor
<Button Size="@AppButtonSize.Xl" Variant="@AppButtonVariant.Gradient">
Get Started
</Button>
```
This approach works because the `Button` component simply concatenates the variant and size strings into the element's `class` attribute — there is no closed set of allowed values.
---
## Sizes
Use the `Size` parameter with constants from `ButtonSize`:
```razor
<Button Size="@ButtonSize.Sm">Small</Button>
<Button Size="@ButtonSize.Default">Default</Button>
<Button Size="@ButtonSize.Lg">Large</Button>
<Button Size="@ButtonSize.Icon">🔔</Button>
```
| Constant | Tailwind Classes |
|---|---|
| `ButtonSize.Default` | `h-9 px-4 py-2` |
| `ButtonSize.Sm` | `h-8 rounded-md px-3 text-xs` |
| `ButtonSize.Lg` | `h-10 rounded-md px-8` |
| `ButtonSize.Icon` | `h-9 w-9` |
---
## Button with Icon
Use the `Icon` render fragment to prepend an icon:
```razor
<Button>
<Icon>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</Icon>
<ChildContent>Continue</ChildContent>
</Button>
```
---
## Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Button label content |
| `Icon` | `RenderFragment?` | — | Icon slot rendered before the label |
| `Type` | `string` | `"button"` | HTML button type (`button`, `submit`, `reset`) |
| `Disabled` | `bool` | `false` | Disables the button |
| `Variant` | `string` | `ButtonVariant.Default` | Visual style classes |
| `Size` | `string` | `ButtonSize.Default` | Size classes |
| `Class` | `string?` | — | Additional CSS classes appended |
All unmatched HTML attributes (`data-testid`, `aria-*`, etc.) are passed through via `@attributes`.
+103
View File
@@ -0,0 +1,103 @@
# Card
A composable card component suite for displaying grouped content in a bordered container with optional header, footer, image, and action slots.
---
## Components
| Component | Description |
|---|---|
| `Card` | Root container with border, shadow, and rounded corners |
| `CardHeader` | Top section — contains title, description, and optional action |
| `CardTitle` | `<h3>` heading styled for cards |
| `CardDescription` | Muted paragraph below the title |
| `CardAction` | Trailing action element (button/link) aligned to the header's right edge |
| `CardContent` | Main body area |
| `CardFooter` | Bottom area for buttons or metadata |
| `CardImage` | Full-width image with optional wrapper styling |
---
## Basic Card
```razor
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>You have 3 unread messages.</CardDescription>
</CardHeader>
<CardContent>
<p>Here is the main content of the card.</p>
</CardContent>
<CardFooter>
<Button>View All</Button>
</CardFooter>
</Card>
```
---
## Card with Action
The `CardAction` renders at the trailing edge of the header using CSS grid:
```razor
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
<CardAction>
<Button Variant="@ButtonVariant.Outline" Size="@ButtonSize.Sm">
Add Member
</Button>
</CardAction>
</CardHeader>
<CardContent>
<!-- member list -->
</CardContent>
</Card>
```
---
## Card with Image
```razor
<Card Class="max-w-sm">
<CardImage Src="/images/hero.jpg" Alt="Hero image" WrapperClass="h-48" />
<CardHeader>
<CardTitle>Beautiful Scenery</CardTitle>
<CardDescription>A mountain landscape at sunset.</CardDescription>
</CardHeader>
<CardContent>
<p>The image fills the card width and crops via object-cover.</p>
</CardContent>
</Card>
```
---
## Parameters
### Card
| Parameter | Type | Default | Description |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Card inner content |
| `Class` | `string?` | — | Additional CSS classes |
Base classes: `rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden`
### CardImage
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Src` | `string` | **required** | Image source URL |
| `Alt` | `string` | `""` | Alt text for accessibility |
| `Class` | `string?` | — | Additional classes on the `<img>` |
| `WrapperClass` | `string?` | — | Additional classes on the wrapper `<div>` |
### CardTitle, CardDescription, CardContent, CardFooter, CardAction
All accept `ChildContent` and `Class` parameters. All support unmatched HTML attributes via `@attributes`.
+192
View File
@@ -0,0 +1,192 @@
# Form Inputs
All input components extend a shared `InputBase<T>` class that provides consistent styling, htmx validation integration, and parameter unification. When placed inside an `HtmxForm` with a `FormField`, inputs automatically attach `hx-post`, `hx-trigger="blur"`, and `hx-target` attributes for real-time per-field validation.
---
## TextInput
A standard text input for strings. Supports all HTML input types (`text`, `email`, `password`, `search`, etc.).
```razor
<FormField Label="Full Name" For="name">
<TextInput Id="name" Name="name" Placeholder="Jane Doe" />
</FormField>
<FormField Label="Email" For="email">
<TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" />
</FormField>
<FormField Label="Password" For="password">
<TextInput Id="password" Name="password" Type="password" Placeholder="••••••••" />
</FormField>
```
### Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Type` | `string` | `"text"` | HTML input type |
| `Id` | `string?` | — | Input element ID |
| `Name` | `string?` | — | Form field name (submitted to the server) |
| `Value` | `string?` | — | Current value |
| `Placeholder` | `string?` | — | Placeholder text |
| `Disabled` | `bool` | `false` | Disables the input |
| `ReadOnly` | `bool` | `false` | Makes the input read-only |
| `Class` | `string?` | — | Additional CSS classes |
---
## NumberInput
A numeric input with built-in increment/decrement stepper buttons. Hides the browser's native spinner.
```razor
<FormField Label="Age" For="age">
<NumberInput Id="age" Name="age" Placeholder="25" Min="0" Max="150" />
</FormField>
<FormField Label="Quantity" For="quantity">
<NumberInput Id="quantity" Name="quantity" Value="1" Min="1" Max="99" Step="1" />
</FormField>
```
### Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Min` | `string?` | — | Minimum allowed value |
| `Max` | `string?` | — | Maximum allowed value |
| `Step` | `string?` | — | Step increment |
| `Value` | `double?` | — | Current numeric value |
Inherits all parameters from `InputBase<double?>` (`Id`, `Name`, `Placeholder`, `Disabled`, `ReadOnly`, `Class`).
The stepper buttons are disabled when the value reaches `Min` or `Max`. They are hidden when `Disabled` or `ReadOnly` is true.
---
## DateInput
A date picker that combines a hidden `<input type="date">` with a popover calendar. Users select dates through the calendar UI — the native date picker chrome is hidden.
```razor
<FormField Label="Birth Date" For="birthdate">
<DateInput Id="birthdate" Name="birthdate" Placeholder="Select your birth date" />
</FormField>
```
### How it works
1. A hidden `<input type="date">` holds the actual form value in `yyyy-MM-dd` format
2. A styled button trigger shows the selected date (or placeholder)
3. Clicking the trigger opens a `Popover` containing a `Calendar` component
4. Selecting a day updates the hidden input and closes the popover
### Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Value` | `DateOnly?` | — | Currently selected date |
| `Min` | `string?` | — | Minimum date |
| `Max` | `string?` | — | Maximum date |
| `Placeholder` | `string?` | `"Select date"` | Trigger button placeholder |
Inherits all parameters from `InputBase<DateOnly?>`.
---
## TimeInput
A time picker that combines a hidden `<input type="time">` with a popover time picker. Features scrollable hour/minute columns and AM/PM toggle.
```razor
<FormField Label="Preferred Time" For="preferredtime">
<TimeInput Id="preferredtime" Name="preferredtime" />
</FormField>
```
### Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Value` | `TimeOnly?` | — | Currently selected time |
| `Step` | `string?` | — | Minute step interval |
| `Use12Hour` | `bool` | `true` | 12-hour format with AM/PM |
| `Placeholder` | `string?` | `"Select time"` | Trigger button placeholder |
Inherits all parameters from `InputBase<TimeOnly?>`.
---
## DateTimeInput
Combines a date picker and time picker side by side for selecting both date and time. Internally manages a hidden `<input type="datetime-local">` plus two helper hidden inputs for the date and time parts.
```razor
<FormField Label="Appointment" For="appointment">
<DateTimeInput Id="appointment" Name="appointment" />
</FormField>
```
### How it works
1. A hidden `<input type="datetime-local">` holds the combined value in `yyyy-MM-ddTHH:mm` format
2. Two helper hidden inputs hold the date-part and time-part separately
3. Two popover triggers (calendar + time picker) are displayed side by side
4. The `forms.js` module automatically combines the date-part and time-part values into the main hidden input
### Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Value` | `DateTime?` | — | Currently selected date and time |
| `Min` | `string?` | — | Minimum datetime |
| `Max` | `string?` | — | Maximum datetime |
| `Step` | `string?` | — | Minute step interval |
| `Placeholder` | `string?` | `"Select date"` | Date trigger placeholder |
Inherits all parameters from `InputBase<DateTime?>`.
---
## FormField
Wraps an input with a label, description, and error placeholder. The error element is used by htmx to display server-side validation messages.
```razor
<FormField Label="Email" For="email" Description="We'll never share your email.">
<TextInput Id="email" Name="email" Type="email" />
</FormField>
```
### Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Label` | `string?` | — | Label text rendered as `<label>` |
| `For` | `string?` | — | Links the label to the input, and identifies the field for validation errors |
| `Description` | `string?` | — | Helper text below the input |
| `Error` | `string?` | — | Pre-set error message (for server-rendered errors) |
| `Class` | `string?` | — | Additional CSS classes on the wrapper |
| `LabelClass` | `string?` | — | Additional CSS classes on the label |
### How validation errors are displayed
`FormField` renders a `<p data-field-error="fieldname">` element. When htmx receives a validation response, it swaps this element with the server's HTML fragment (which includes or hides the error message). This is the core mechanism for per-field validation.
---
## InputBase
All input components inherit from `InputBase<TValue>`, which provides:
- **Common parameters**: `Id`, `Name`, `Value`, `Placeholder`, `Disabled`, `ReadOnly`, `Class`
- **Computed CSS**: A consistent input style using Tailwind classes
- **htmx auto-wiring**: When `ValidationEndpoint` and `FieldName` cascading values are present (provided by `HtmxForm` and `FormField`), the input automatically gets:
- `hx-post` pointing to the validation endpoint
- `hx-trigger="blur"` for on-blur validation
- `hx-target="next [data-field-error]"` to update the error element
- `hx-swap="outerHTML"` for full element replacement
- `hx-include="this"` to send only this input's value
- `hx-vals='{"_field": "fieldname"}'` to identify which field is being validated
+139
View File
@@ -0,0 +1,139 @@
# Sidebar
A collapsible, responsive sidebar layout system. Persists expand/collapse state via cookies and adapts between desktop (collapsible rail) and mobile (overlay drawer) modes automatically.
---
## Components
| Component | Description |
|---|---|
| `SidebarProvider` | Root wrapper — provides collapse state context |
| `Sidebar` | The sidebar panel itself |
| `SidebarHeader` | Top area (logo, app name) — also acts as a collapse trigger |
| `SidebarContent` | Scrollable middle area for navigation groups |
| `SidebarFooter` | Bottom area (copyright, user info) |
| `SidebarGroup` | Groups related menu items |
| `SidebarGroupLabel` | Section heading within a group |
| `SidebarGroupContent` | Container for menu items within a group |
| `SidebarMenuItem` | Navigation link with icon support |
| `SidebarInset` | Main content area adjacent to the sidebar |
| `SidebarSeparator` | Horizontal divider line |
| `SidebarTrigger` | Standalone toggle button (for mobile header bars) |
---
## Basic Layout
```razor
<SidebarProvider DefaultOpen="true">
<Sidebar>
<SidebarHeader>
<div class="flex items-center gap-2 px-1 py-1.5">
<img src="logo.svg" alt="Logo" class="size-8" />
<span class="font-semibold text-sm">My App</span>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel Label="Navigation" />
<SidebarGroupContent>
<SidebarMenuItem Href="/" Tooltip="Home" IsActive="true">
<Icon>
<!-- SVG icon -->
</Icon>
<ChildContent>Home</ChildContent>
</SidebarMenuItem>
<SidebarMenuItem Href="/about" Tooltip="About">
<Icon>
<!-- SVG icon -->
</Icon>
<ChildContent>About</ChildContent>
</SidebarMenuItem>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarSeparator />
<div class="px-3 py-2 text-xs text-sidebar-foreground/50">
&copy; 2026 My Company
</div>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header class="flex h-14 items-center gap-2 border-b border-border px-4">
<SidebarTrigger Class="md:hidden" />
<h1 class="text-sm font-medium">My App</h1>
<div class="ml-auto">
<ThemeToggle />
</div>
</header>
<div class="flex-1 p-4 md:p-6">
@Body
</div>
</SidebarInset>
</SidebarProvider>
```
---
## Parameters
### SidebarProvider
| Parameter | Type | Default | Description |
|---|---|---|---|
| `DefaultOpen` | `bool` | `true` | Initial sidebar state on first visit |
| `ChildContent` | `RenderFragment` | — | Must contain `Sidebar` + `SidebarInset` |
### SidebarMenuItem
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Href` | `string?` | — | Navigation URL |
| `Tooltip` | `string?` | — | Tooltip text (shown on hover when collapsed) |
| `IsActive` | `bool` | `false` | Highlights the item with accent styling |
| `Icon` | `RenderFragment?` | — | Icon slot (typically an SVG) |
| `Class` | `string?` | — | Additional CSS classes |
### SidebarGroupLabel
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Label` | `string?` | — | Label text (alternative to ChildContent) |
| `ChildContent` | `RenderFragment?` | — | Custom label markup |
| `Class` | `string?` | — | Additional CSS classes |
### All sidebar components
Every sidebar component accepts:
- `ChildContent` — slot for nested content
- `Class` — additional CSS classes to append
---
## Behavior
### Desktop (≥ 768px)
- Clicking the **SidebarHeader** or **SidebarTrigger** toggles between expanded and collapsed (icon rail) states
- Collapsed state shrinks to `var(--sidebar-width-icon)` (3rem) showing only icons
- State persists via a `sidebar:state` cookie (1 year)
### Mobile (< 768px)
- Sidebar renders as an off-screen drawer
- Opens with a semi-transparent backdrop overlay
- Clicking the overlay or trigger closes it
- Navigation link clicks auto-close the sidebar
### CSS Variables
| Variable | Default | Description |
|---|---|---|
| `--sidebar-width` | `16rem` | Expanded sidebar width |
| `--sidebar-width-icon` | `3rem` | Collapsed (icon rail) width |
These can be overridden in your `@theme` block.
+97
View File
@@ -0,0 +1,97 @@
# Theme Toggle
A dark/light mode toggle button that persists the user's preference to `localStorage` and applies it instantly without a page reload.
---
## Usage
```razor
<ThemeToggle />
```
Place it anywhere in your layout — typically in a header or toolbar:
```razor
<header class="flex h-14 items-center px-4">
<h1 class="text-sm font-medium">My App</h1>
<div class="ml-auto">
<ThemeToggle />
</div>
</header>
```
---
## Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Class` | `string?` | — | Additional CSS classes appended to the button |
---
## How It Works
1. **Toggle button** — renders a `<button>` with moon (light mode) and sun (dark mode) SVG icons
2. **Click handler**`darkmode.js` toggles the `dark` class on `<html>` and persists to `localStorage`
3. **Icon sync** — shows the appropriate icon based on the current theme
4. **FOUC prevention** — a synchronous inline script in `App.razor` checks `localStorage` before first paint
### Required Setup in `App.razor`
Add this script in the `<head>` before any stylesheets:
```html
<script>
(function () {
try {
if (localStorage.getItem('theme') === 'dark')
document.documentElement.classList.add('dark');
} catch (e) { }
})();
</script>
```
And initialize the module in the `<body>`:
```html
<script type="module">
import { init as initDarkMode } from '/_content/Enciphered.Blazor.UIComponents/js/darkmode.js';
initDarkMode();
</script>
```
---
## CSS Custom Variant
The library uses Tailwind CSS v4's custom variant for dark mode:
```css
@custom-variant dark (&:where(.dark, .dark *));
```
This means dark mode is class-based (`.dark` on `<html>`) rather than media-query-based, giving users manual control.
---
## Design Tokens
All color tokens have light and dark variants defined in the library's `Styles/app.css`:
```css
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* ... */
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* ... */
}
```
When the `dark` class is toggled, all components automatically switch to dark colors through these CSS custom properties.
+240
View File
@@ -0,0 +1,240 @@
# Form Submission & Model Binding
Handle validated form submissions with strongly-typed models — no manual dictionary access required.
---
## Basic Submit (No Model Binding)
The simplest approach uses an `onSuccess` callback with direct `HttpContext` access:
```csharp
app.MapFormValidation<ContactFormValidator>("/api/forms/contact",
onSuccess: async ctx =>
{
var form = ctx.Request.Form;
var name = form["name"].ToString();
var email = form["email"].ToString();
// Save to database, send email, etc.
await SaveContactAsync(name, email);
});
```
The `onSuccess` callback fires only after all validation rules pass. If validation fails, the callback is never invoked.
---
## Strongly-Typed Model Binding
Define a POCO model and let `FormModelBinder` handle the mapping automatically:
### Step 1: Create a Model
```csharp
public class ContactFormModel
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string Password { get; set; } = "";
public int? Age { get; set; }
public DateOnly? Birthdate { get; set; }
public TimeOnly? Preferredtime { get; set; }
public DateTime? Appointment { get; set; }
public string Confirmation { get; set; } = "";
}
```
### Step 2: Use the Typed Overload
```csharp
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
onSuccess: async model =>
{
Console.WriteLine($"Name: {model.Name}");
Console.WriteLine($"Email: {model.Email}");
Console.WriteLine($"Age: {model.Age}");
Console.WriteLine($"Birth Date: {model.Birthdate}");
Console.WriteLine($"Preferred Time: {model.Preferredtime}");
Console.WriteLine($"Appointment: {model.Appointment}");
await SaveToDbAsync(model);
});
```
### Step 3: Customize the Success Message (Optional)
```csharp
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
onSuccess: async model => { /* ... */ },
successMessage: "Thank you! Your form has been submitted.");
```
---
## FormModelBinder
`FormModelBinder.Bind<T>()` maps form fields to model properties using reflection with these rules:
- **Case-insensitive matching** — form field `name` matches property `Name`
- **Automatic type conversion** for all common types
- **Nullable support** — empty values become `null` for nullable types
### Supported Types
| Type | Format Expected |
|---|---|
| `string` | Any text |
| `int`, `long` | Integer text |
| `float`, `double`, `decimal` | Numeric text (invariant culture) |
| `bool` | `true`/`false`, `on`, `1` |
| `DateTime` | Parseable datetime (e.g. `2025-12-25T10:30`) |
| `DateOnly` | Parseable date (e.g. `2025-12-25`) |
| `TimeOnly` | Parseable time (e.g. `14:30`) |
| `Guid` | Standard GUID format |
| `Enum` | Case-insensitive enum member name |
All types support their `Nullable<T>` equivalents (`int?`, `DateTime?`, etc.).
---
## API Reference
### MapFormValidation (without model binding)
```csharp
public static RouteGroupBuilder MapFormValidation<TValidator>(
this IEndpointRouteBuilder endpoints,
string basePath,
string successMessage = "✓ Form submitted successfully!",
Func<HttpContext, Task>? onSuccess = null)
where TValidator : FormValidator, new();
```
### MapFormValidation (with model binding)
```csharp
public static RouteGroupBuilder MapFormValidation<TValidator, TModel>(
this IEndpointRouteBuilder endpoints,
string basePath,
Func<TModel, Task> onSuccess,
string successMessage = "✓ Form submitted successfully!")
where TValidator : FormValidator, new()
where TModel : new();
```
### Parameters
| Parameter | Type | Description |
|---|---|---|
| `basePath` | `string` | URL prefix (e.g. `/api/forms/contact`) |
| `successMessage` | `string` | HTML text shown on successful submission |
| `onSuccess` | `Func<HttpContext, Task>?` or `Func<TModel, Task>` | Callback invoked after validation passes |
### Return Value
Returns a `RouteGroupBuilder` for further endpoint configuration if needed.
---
## Registered Endpoints
Both overloads register the same endpoint structure:
| Method | Path | Purpose |
|---|---|---|
| `POST` | `{basePath}/validate` | Per-field validation (called on input blur) |
| `POST` | `{basePath}/submit` | Full form validation and submission |
Both endpoints have `.DisableAntiforgery()` applied since htmx sends raw form data.
---
## Response Format
### Validation Error Response
When validation fails, the `/submit` endpoint returns HTML with OOB (out-of-band) swap fragments:
```html
<p data-field-error="email" hx-swap-oob="outerHTML:[data-field-error='email']"
class="text-[0.8rem] font-medium text-destructive">
Please enter a valid email address.
</p>
<p data-field-error="name" hx-swap-oob="outerHTML:[data-field-error='name']"
class="text-[0.8rem] font-medium text-destructive hidden"></p>
<!-- ... one fragment per field ... -->
<div id="form-result" class="hidden"></div>
```
htmx processes each OOB fragment, updating every field's error element in a single response.
### Success Response
```html
<p data-field-error="email" hx-swap-oob="outerHTML:[data-field-error='email']"
class="text-[0.8rem] font-medium text-destructive hidden"></p>
<!-- ... clears all error elements ... -->
<div id="form-result">
<div data-testid="success-message"
class="rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400">
✓ Form submitted successfully!
</div>
</div>
```
---
## Complete Example
```csharp
// ContactFormValidator.cs
using Enciphered.Blazor.UIComponents.Validation;
public class ContactFormValidator : FormValidator
{
public ContactFormValidator()
{
RuleFor("name", required: true, minLength: 2);
RuleFor("email", required: true,
pattern: @".+@.+\..+",
message: "Please enter a valid email address.");
}
}
// ContactFormModel.cs
public class ContactFormModel
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}
// Program.cs
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/contact",
onSuccess: async model =>
{
await db.Contacts.AddAsync(new Contact
{
Name = model.Name,
Email = model.Email
});
await db.SaveChangesAsync();
});
```
```razor
@* ContactForm.razor *@
@page "/contact"
<HtmxForm Endpoint="/api/forms/contact">
<FormField Label="Name" For="name">
<TextInput Id="name" Name="name" />
</FormField>
<FormField Label="Email" For="email">
<TextInput Id="email" Name="email" Type="email" />
</FormField>
<Button Type="submit">Send</Button>
</HtmxForm>
```
+242
View File
@@ -0,0 +1,242 @@
# Form Validation
htmx-powered server-side validation that provides real-time per-field feedback on blur and full-form validation on submit — all without Blazor interactivity.
---
## Architecture Overview
```
┌──────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌─────────────┐ blur ┌──────────────────────────┐ │
│ │ <TextInput> │ ──────► │ htmx POST /validate │ │
│ └─────────────┘ │ { _field: "email", │ │
│ │ email: "bad" } │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ┌─────────────────┐ ◄──────────┘ │
│ │ <p data-field- │ HTML fragment: │
│ │ error="email"> │ <p class="text-destructive"> │
│ │ swapped by │ Please enter a valid email. │
│ │ htmx │ </p> │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Server (Minimal API) │
│ │
│ FormValidator.ValidateField("email", "bad") │
│ → "Please enter a valid email address." │
│ │
│ HtmxFormValidationRenderer.FieldErrorFragment(...) │
│ → HTML <p> element with error text │
└──────────────────────────────────────────────────────────┘
```
---
## Step 1: Create a Validator
Define your validation rules by extending `FormValidator` and calling `RuleFor()` in the constructor:
```csharp
using Enciphered.Blazor.UIComponents.Validation;
public class ContactFormValidator : FormValidator
{
public ContactFormValidator()
{
RuleFor("name",
displayName: "Name",
required: true,
minLength: 2);
RuleFor("email",
displayName: "Email",
required: true,
pattern: @".+@.+\..+",
message: "Please enter a valid email address.");
RuleFor("password",
displayName: "Password",
required: true,
minLength: 6);
RuleFor("age",
displayName: "Age",
min: 0,
max: 150);
RuleFor("birthdate",
displayName: "Birth Date",
custom: value => !DateOnly.TryParse(value, out _)
? "Please enter a valid date."
: null);
RuleFor("preferredtime",
displayName: "Preferred Time",
custom: value => !TimeOnly.TryParse(value, out _)
? "Please enter a valid time."
: null);
RuleFor("appointment",
displayName: "Appointment",
custom: value => !DateTime.TryParse(value, out _)
? "Please enter a valid date and time."
: null);
RuleFor("confirmation",
displayName: "Confirmation",
required: true,
custom: value => value != "CONFIRM"
? "You must type CONFIRM to proceed."
: null);
}
}
```
---
## Step 2: Register Validation Endpoints
In `Program.cs`, call `MapFormValidation<T>()`:
```csharp
app.MapFormValidation<ContactFormValidator>("/api/forms/contact");
```
This registers two endpoints:
| Endpoint | Method | Purpose |
|---|---|---|
| `POST /api/forms/contact/validate` | Per-field | Validates a single field on blur |
| `POST /api/forms/contact/submit` | Full form | Validates all fields on submit |
Both endpoints have antiforgery disabled (via `.DisableAntiforgery()`) since htmx sends form data directly.
---
## Step 3: Build the Form
Use `HtmxForm`, `FormField`, and input components:
```razor
<HtmxForm Endpoint="/api/forms/contact">
<FormField Label="Full Name" For="name">
<TextInput Id="name" Name="name" Placeholder="Jane Doe" />
</FormField>
<FormField Label="Email" For="email">
<TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" />
</FormField>
<FormField Label="Password" For="password">
<TextInput Id="password" Name="password" Type="password" />
</FormField>
<FormField Label="Age" For="age">
<NumberInput Id="age" Name="age" Min="0" Max="150" />
</FormField>
<FormField Label="Birth Date" For="birthdate">
<DateInput Id="birthdate" Name="birthdate" />
</FormField>
<div class="flex gap-2 pt-2">
<Button Type="submit">Submit</Button>
<Button Type="reset" Variant="@ButtonVariant.Outline">Reset</Button>
</div>
</HtmxForm>
```
---
## RuleFor API Reference
```csharp
protected void RuleFor(
string field, // Form field name (must match the input's Name)
string? displayName, // Human-readable label (auto-generated from field if omitted)
bool required, // Whether the field is required
int? minLength, // Minimum string length
int? maxLength, // Maximum string length
string? pattern, // Regex pattern for format validation
double? min, // Minimum numeric value
double? max, // Maximum numeric value
string? message, // Custom error message for pattern failures
Func<string, string?>? custom // Custom validation function
);
```
### Validation Order
Rules are evaluated in this order — the first failure stops evaluation:
1. **Required** — empty/whitespace check
2. **Empty skip** — if not required and value is empty, the field passes (skips remaining rules)
3. **MinLength** — minimum character count
4. **MaxLength** — maximum character count
5. **Pattern** — regex match (uses `message` if provided, else default format error)
6. **Min/Max** — numeric range (attempts to parse as `double`)
7. **Custom** — arbitrary validation function returning an error string or `null`
### Custom Validators
The `custom` parameter accepts a `Func<string, string?>` — receive the trimmed value, return an error message or `null`:
```csharp
RuleFor("confirmation",
required: true,
custom: value => value != "CONFIRM"
? "You must type CONFIRM to proceed."
: null);
```
For date/time/datetime fields, use `TryParse`:
```csharp
RuleFor("birthdate",
custom: value => !DateOnly.TryParse(value, out _)
? "Please enter a valid date."
: null);
```
> **Note**: Custom validators only run when the value is non-empty. If the field is not required and left blank, the custom function is never called.
---
## How It Works
### On Blur (Per-Field)
1. `InputBase<T>` auto-injects htmx attributes when inside `HtmxForm` + `FormField`
2. When the user leaves an input, htmx fires `POST /validate` with the field name and value
3. The server calls `FormValidator.ValidateField()` and returns an HTML `<p>` fragment
4. htmx replaces the existing `<p data-field-error="...">` element with the response
### On Submit (Full Form)
1. `HtmxForm` adds `hx-post="/submit"` to the `<form>` element
2. htmx sends all form fields
3. The server calls `FormValidator.ValidateAll()` and returns:
- **If errors**: OOB (out-of-band) swap fragments for each field's error element
- **If valid**: Success message + OOB swaps to clear all errors
### HtmxForm Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `Endpoint` | `string` | **required** | Base path (e.g. `/api/forms/contact`) |
| `ResultId` | `string` | `"form-result"` | ID of the result div for success/error messages |
| `Class` | `string?` | — | Additional CSS classes on the `<form>` |
### Form Reset
Clicking a `<Button Type="reset">` triggers the browser's native form reset. The `forms.js` module listens for the `reset` event and:
- Clears all visible input values
- Hides all `[data-field-error]` elements
- Hides the result div
- Resets date/time trigger button text to their placeholders
+209
View File
@@ -0,0 +1,209 @@
# Getting Started
This guide walks you through adding **Enciphered.Blazor.UIComponents** to a new or existing Blazor Web App and building a fully static SSR application with htmx-powered form validation.
---
## Prerequisites
| Tool | Version |
|---|---|
| .NET SDK | 9.0+ |
| Node.js | 18+ (for Tailwind CSS CLI) |
---
## 1. Create a Blazor Web App
```bash
dotnet new blazor -n MyApp --interactivity None
cd MyApp
```
> The `--interactivity None` flag creates a pure static SSR app — no SignalR or WebAssembly.
---
## 2. Install the Library
```bash
dotnet add reference path/to/Enciphered.Blazor.UIComponents.csproj
```
Or, if published as a NuGet package:
```bash
dotnet add package Enciphered.Blazor.UIComponents
```
---
## 3. Install Tailwind CSS v4
From your solution root, initialize npm and install Tailwind:
```bash
npm init -y
npm install tailwindcss @tailwindcss/cli
```
Create `Styles/app.css` in your app project:
```css
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
```
Add the library's design tokens by importing or copying the token definitions from `Enciphered.Blazor.UIComponents/Styles/app.css`. At minimum you need the `:root` and `.dark` token blocks and the `@theme` mapping.
Add a build step to your `.csproj`:
```xml
<Target Name="TailwindBuild" BeforeTargets="Build">
<Exec Command="npx @tailwindcss/cli -i Styles/app.css -o wwwroot/css/app.css --minify" />
</Target>
```
---
## 4. Configure `App.razor`
Your root `App.razor` needs three things:
1. **Stylesheet references** — the library CSS and your app CSS
2. **htmx CDN** — loaded after `blazor.web.js`
3. **JS module imports** — initialize the library's interactive modules
```razor
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<!-- Dark-mode bootstrap: prevents flash of wrong theme -->
<script>
(function () {
try {
if (localStorage.getItem('theme') === 'dark')
document.documentElement.classList.add('dark');
} catch (e) { }
})();
</script>
<!-- Library stylesheet (design tokens + component styles) -->
<link rel="stylesheet" href="_content/Enciphered.Blazor.UIComponents/css/app.css" />
<!-- Your app stylesheet -->
<link rel="stylesheet" href="css/app.css" />
<HeadOutlet />
</head>
<body class="min-h-svh antialiased bg-background text-foreground">
<Routes />
<!-- Blazor framework script -->
<script src="_framework/blazor.web.js"></script>
<!-- htmx (required for form validation/submission) -->
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
<!-- Initialize library JS modules -->
<script type="module">
import { init as initDarkMode } from '/_content/Enciphered.Blazor.UIComponents/js/darkmode.js';
import { init as initSidebar } from '/_content/Enciphered.Blazor.UIComponents/js/sidebar.js';
import { init as initForms } from '/_content/Enciphered.Blazor.UIComponents/js/forms.js';
initDarkMode();
initSidebar();
initForms();
</script>
</body>
</html>
```
> **Note**: Only import the modules you need. If you don't use the sidebar, skip `initSidebar()`. If you don't use forms, skip `initForms()`. If you don't need dark mode, skip `initDarkMode()` and the bootstrap script.
---
## 5. Configure `Program.cs`
Register Razor Components and map the library assembly:
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents();
builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddAdditionalAssemblies(
typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly);
app.Run();
```
> The `AddAdditionalAssemblies` call registers the library's components for routing discovery.
---
## 6. Add `_Imports.razor`
In your app's `Components/_Imports.razor`, add:
```razor
@using Enciphered.Blazor.UIComponents
```
This makes all library components available without per-file `@using` directives.
---
## 7. Verify the Setup
Create a simple page to test:
```razor
@page "/test"
<Card>
<CardHeader>
<CardTitle>It Works!</CardTitle>
<CardDescription>The library is installed correctly.</CardDescription>
</CardHeader>
<CardContent>
<p>If you can see this styled card, everything is set up.</p>
</CardContent>
<CardFooter>
<Button>Click Me</Button>
</CardFooter>
</Card>
```
Run the app:
```bash
dotnet run
```
You should see a styled card with a button. If the styles aren't applied, verify:
- The Tailwind build step ran (check `wwwroot/css/app.css` exists)
- The stylesheet links in `App.razor` are correct
- The design tokens are present in your `Styles/app.css`
---
## Next Steps
- [Set up a sidebar layout →](components/sidebar.md)
- [Add form validation with htmx →](forms/validation.md)
- [Browse all components →](components/button.md)