Added docs
This commit was merged in pull request #1.
This commit is contained in:
@@ -1 +1,51 @@
|
||||
# Blazor UI Components
|
||||
# Enciphered.Blazor.UIComponents
|
||||
|
||||
A **pure static SSR** Blazor component library styled with **Tailwind CSS v4** and **shadcn/ui** design tokens. All interactivity is powered by vanilla JavaScript — no SignalR, no WebAssembly, no `InteractiveServer` render mode required. Form validation and submission are handled entirely through **htmx**.
|
||||
|
||||
## Features
|
||||
|
||||
- **Zero Blazor interactivity** — components work with `AddRazorComponents()` alone
|
||||
- **Tailwind CSS v4** with oklch color tokens (light + dark mode)
|
||||
- **htmx form validation** — per-field blur validation and full form submission with a fluent `FormValidator` API
|
||||
- **Strongly-typed model binding** — bind submitted form data to POCOs automatically
|
||||
- **Sidebar layout** — collapsible, responsive, cookie-persisted
|
||||
- **Card components** — composable header/content/footer/image/action slots
|
||||
- **Dark mode** — toggle with localStorage persistence, FOUC-free
|
||||
- **Date/Time pickers** — calendar and time picker popovers with hidden native inputs
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
dotnet add package Enciphered.Blazor.UIComponents
|
||||
```
|
||||
|
||||
> See the full [Getting Started guide](docs/getting-started.md) for setup instructions.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|---|---|
|
||||
| [Getting Started](docs/getting-started.md) | Installation, prerequisites, and first-app setup |
|
||||
| [Sidebar](docs/components/sidebar.md) | Collapsible sidebar layout system |
|
||||
| [Card](docs/components/card.md) | Composable card components |
|
||||
| [Button](docs/components/button.md) | Button with variants and sizes |
|
||||
| [Form Inputs](docs/components/form-inputs.md) | TextInput, NumberInput, DateInput, TimeInput, DateTimeInput |
|
||||
| [Form Validation](docs/forms/validation.md) | htmx-powered validation with FormValidator |
|
||||
| [Form Submission](docs/forms/submission.md) | Handling form submit with model binding |
|
||||
| [Theme Toggle](docs/components/theme-toggle.md) | Dark/light mode toggle |
|
||||
|
||||
## Architecture
|
||||
|
||||
All interactive behavior (popovers, sidebar collapse, calendar navigation, number input steppers, form resets) is implemented via three vanilla JS modules that use `data-*` attribute selectors and event delegation:
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `darkmode.js` | Theme toggle, localStorage persistence, SVG icon sync |
|
||||
| `sidebar.js` | Expand/collapse, mobile responsiveness, cookie persistence |
|
||||
| `forms.js` | Popover, calendar, time picker, number stepper, form reset |
|
||||
|
||||
These modules survive Blazor enhanced navigation via `MutationObserver` patterns.
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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">
|
||||
© 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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user