diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/App.razor b/Enciphered.Blazor.UIComponents.Demo/Components/App.razor index 3e1e747..9d4937b 100644 --- a/Enciphered.Blazor.UIComponents.Demo/Components/App.razor +++ b/Enciphered.Blazor.UIComponents.Demo/Components/App.razor @@ -26,11 +26,16 @@
Current count: @currentCount
Current count: 0
All input components with DataAnnotations validation.
All input components — fully static SSR with htmx validation.
✓ Form submitted successfully
Name: @_submittedName
@Error
{safeError}
Here is the main content of the card.
The image fills the card width and crops via object-cover.
` 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`, 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 diff --git a/docs/components/sidebar.md b/docs/components/sidebar.md new file mode 100644 index 0000000..ac8c488 --- /dev/null +++ b/docs/components/sidebar.md @@ -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 + + + + + + My App + + + + + + + + + + + + Home + + + + + + About + + + + + + + + + © 2026 My Company + + + + + + + + My App + + + + + + + @Body + + + +``` + +--- + +## 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. diff --git a/docs/components/theme-toggle.md b/docs/components/theme-toggle.md new file mode 100644 index 0000000..90a6878 --- /dev/null +++ b/docs/components/theme-toggle.md @@ -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 + +``` + +Place it anywhere in your layout — typically in a header or toolbar: + +```razor + + My App + + + + +``` + +--- + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `Class` | `string?` | — | Additional CSS classes appended to the button | + +--- + +## How It Works + +1. **Toggle button** — renders a `` with moon (light mode) and sun (dark mode) SVG icons +2. **Click handler** — `darkmode.js` toggles the `dark` class on `` 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 `` before any stylesheets: + +```html + +``` + +And initialize the module in the ``: + +```html + +``` + +--- + +## 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 ``) 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. diff --git a/docs/forms/submission.md b/docs/forms/submission.md new file mode 100644 index 0000000..af47272 --- /dev/null +++ b/docs/forms/submission.md @@ -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("/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("/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("/api/forms/contact", + onSuccess: async model => { /* ... */ }, + successMessage: "Thank you! Your form has been submitted."); +``` + +--- + +## FormModelBinder + +`FormModelBinder.Bind()` 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` equivalents (`int?`, `DateTime?`, etc.). + +--- + +## API Reference + +### MapFormValidation (without model binding) + +```csharp +public static RouteGroupBuilder MapFormValidation( + this IEndpointRouteBuilder endpoints, + string basePath, + string successMessage = "✓ Form submitted successfully!", + Func? onSuccess = null) + where TValidator : FormValidator, new(); +``` + +### MapFormValidation (with model binding) + +```csharp +public static RouteGroupBuilder MapFormValidation( + this IEndpointRouteBuilder endpoints, + string basePath, + Func 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?` or `Func` | 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 + + Please enter a valid email address. + + + + +``` + +htmx processes each OOB fragment, updating every field's error element in a single response. + +### Success Response + +```html + + + + + ✓ Form submitted successfully! + + +``` + +--- + +## 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("/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" + + + + + + + + + + + Send + +``` diff --git a/docs/forms/validation.md b/docs/forms/validation.md new file mode 100644 index 0000000..2bb0ead --- /dev/null +++ b/docs/forms/validation.md @@ -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 ┌──────────────────────────┐ │ +│ │ │ ──────► │ htmx POST /validate │ │ +│ └─────────────┘ │ { _field: "email", │ │ +│ │ email: "bad" } │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ┌─────────────────┐ ◄──────────┘ │ +│ │ │ │ +│ │ swapped by │ Please enter a valid email. │ +│ │ htmx │ │ +│ └─────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Server (Minimal API) │ +│ │ +│ FormValidator.ValidateField("email", "bad") │ +│ → "Please enter a valid email address." │ +│ │ +│ HtmxFormValidationRenderer.FieldErrorFragment(...) │ +│ → HTML 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()`: + +```csharp +app.MapFormValidation("/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 + + + + + + + + + + + + + + + + + + + + + + + Submit + Reset + + +``` + +--- + +## 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? 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` — 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` 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 `` fragment +4. htmx replaces the existing `` element with the response + +### On Submit (Full Form) + +1. `HtmxForm` adds `hx-post="/submit"` to the `` 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 Reset + +Clicking a `` 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 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..06fc9d9 --- /dev/null +++ b/docs/getting-started.md @@ -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 + + + +``` + +--- + +## 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +> **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() + .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" + + + + It Works! + The library is installed correctly. + + + If you can see this styled card, everything is set up. + + + Click Me + + +``` + +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)
+ Please enter a valid email address. +
│
│ +│ │ swapped by │ Please enter a valid email. │ +│ │ htmx │
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()`: + +```csharp +app.MapFormValidation("/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 + + + + + + + + + + + + + + + + + + + + + + + Submit + Reset + + +``` + +--- + +## 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? 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` — 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` 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 `` fragment +4. htmx replaces the existing `` element with the response + +### On Submit (Full Form) + +1. `HtmxForm` adds `hx-post="/submit"` to the `` 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 Reset + +Clicking a `` 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 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..06fc9d9 --- /dev/null +++ b/docs/getting-started.md @@ -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 + + + +``` + +--- + +## 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +> **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() + .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" + + + + It Works! + The library is installed correctly. + + + If you can see this styled card, everything is set up. + + + Click Me + + +``` + +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)
` fragment +4. htmx replaces the existing `
` element with the response + +### On Submit (Full Form) + +1. `HtmxForm` adds `hx-post="/submit"` to the `
If you can see this styled card, everything is set up.