Compare commits

..

11 Commits

Author SHA1 Message Date
shaamilahmed b7c75716e8 Added Table Component 2026-04-14 15:44:47 +05:00
shaamilahmed 398d22169d Prepare to publish to nuget. 2026-04-13 20:34:40 +05:00
shaamilahmed a8b13d1120 remove a non existing reference to an invalid css. 2026-04-13 20:34:30 +05:00
shaamilahmed bd3827bb41 Upgrading to net10 and getting ready for AOT even though AOT is currently not available for RazorPages. 2026-04-13 20:11:27 +05:00
shaamilahmed 32cad03088 Fixed issue detecting blazor navigation. (Was not initializing HTMX on navigation when done through blazor UI) 2026-04-13 20:10:43 +05:00
shaamilahmed 4411307383 removed redundant path in endpoint. 2026-04-13 19:06:23 +05:00
shaamilahmed 5668cf20d9 Added docs 2026-04-13 18:57:47 +05:00
shaamilahmed b323862e03 Migrating all validation to use HTMX and endpoints instead of WASM/Websocket connections 2026-04-13 18:40:17 +05:00
shaamilahmed d1f0967a0c Migrate all interactive Blazor components to vanilla JS for full SSR
- Replace server interactivity with vanilla JS (forms.js) for Popover, Calendar, TimePicker, NumberInput, and Counter components

- Rewrite all Razor components to static SSR using data-* attributes for JS hooks

- Simplify InputBase.cs (remove EventCallback, EditContext, SetValueAsync)

- Remove AddInteractiveServerComponents/AddInteractiveServerRenderMode from Program.cs

- Update demo pages: remove @rendermode, replace EditForm with native form

- Add InteractivityGapTests.cs with 30 scoped E2E tests

- Update FormsTests.cs selectors for new static SSR structure

- Fix year picker navigation bug and date format mismatch in forms.js

- All 126 tests passing
2026-04-13 16:45:30 +05:00
shaamilahmed 086917b5aa Removed package-lock.json 2026-04-13 15:41:05 +05:00
shaamilahmed c23c598b0b Card Component Added 2026-04-13 15:39:41 +05:00
56 changed files with 4709 additions and 1934 deletions
+2
View File
@@ -1,3 +1,5 @@
package-lock.json
## .NET ## .NET
bin/ bin/
obj/ obj/
@@ -17,7 +17,6 @@
</script> </script>
<link rel="stylesheet" href="_content/Enciphered.Blazor.UIComponents/css/app.css" /> <link rel="stylesheet" href="_content/Enciphered.Blazor.UIComponents/css/app.css" />
<link rel="stylesheet" href="@Assets["css/app.css"]" /> <link rel="stylesheet" href="@Assets["css/app.css"]" />
<link rel="stylesheet" href="@Assets["Enciphered.Blazor.UIComponents.Demo.styles.css"]" />
<ImportMap /> <ImportMap />
<link rel="icon" type="image/svg+xml" href="enci_white.svg" /> <link rel="icon" type="image/svg+xml" href="enci_white.svg" />
<HeadOutlet /> <HeadOutlet />
@@ -26,11 +25,16 @@
<body class="min-h-svh antialiased bg-background text-foreground"> <body class="min-h-svh antialiased bg-background text-foreground">
<Routes /> <Routes />
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
<script type="module"> <script type="module">
import { init as initDarkMode } from '/_content/Enciphered.Blazor.UIComponents/js/darkmode.js'; 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 initSidebar } from '/_content/Enciphered.Blazor.UIComponents/js/sidebar.js';
import { init as initForms } from '/_content/Enciphered.Blazor.UIComponents/js/forms.js';
initDarkMode(); initDarkMode();
initSidebar(); initSidebar();
initForms();
</script> </script>
</body> </body>
@@ -57,6 +57,16 @@
</Icon> </Icon>
<ChildContent>Forms</ChildContent> <ChildContent>Forms</ChildContent>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem Href="/cards" Tooltip="Cards">
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" />
</svg>
</Icon>
<ChildContent>Cards</ChildContent>
</SidebarMenuItem>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
@@ -0,0 +1,66 @@
@page "/cards"
<PageTitle>Cards</PageTitle>
<div class="space-y-8 max-w-lg">
<div>
<h1 class="text-3xl font-bold tracking-tight">Cards Demo</h1>
<p class="text-muted-foreground">Card components with multiple layouts.</p>
</div>
@* ── Basic card ── *@
<Card data-testid="card-basic">
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent>
<p>Card Content</p>
</CardContent>
<CardFooter>
<p>Card Footer</p>
</CardFooter>
</Card>
@* ── Card with action in header ── *@
<Card data-testid="card-login">
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardAction>
<Button Variant="@ButtonVariant.Link" Class="text-sm">Sign Up</Button>
</CardAction>
<CardDescription>Enter your email below to login to your account</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<FormField Label="Email" For="email">
<TextInput Id="email" Type="email" Placeholder="m@example.com" />
</FormField>
<FormField Label="Password" For="password">
<TextInput Id="password" Type="password" />
</FormField>
</div>
</CardContent>
<CardFooter Class="flex-col gap-2">
<Button Class="w-full">Login</Button>
<Button Variant="@ButtonVariant.Outline" Class="w-full">Login with Google</Button>
</CardFooter>
</Card>
@* ── Image card ── *@
<Card data-testid="card-image">
<CardImage Src="https://placehold.co/600x300/2a2a2a/2a2a2a" Alt="Event cover" WrapperClass="h-48" />
<CardHeader>
<CardTitle>Design systems meetup</CardTitle>
<CardAction>
<span class="rounded-full border border-border px-3 py-0.5 text-xs font-medium text-muted-foreground">
Featured
</span>
</CardAction>
<CardDescription>A practical talk on component APIs, accessibility, and shipping faster.</CardDescription>
</CardHeader>
<CardFooter>
<Button Class="w-full">View Event</Button>
</CardFooter>
</Card>
</div>
@@ -1,19 +1,9 @@
@page "/counter" @page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle> <PageTitle>Counter</PageTitle>
<h1>Counter</h1> <h1>Counter</h1>
<p role="status">Current count: @currentCount</p> <p role="status">Current count: <span id="counter-value">0</span></p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button> <button class="btn btn-primary" id="counter-btn" onclick="document.getElementById('counter-value').textContent = ++window._count || (window._count=1)">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
@@ -1,126 +1,50 @@
@page "/forms" @page "/forms"
@rendermode InteractiveServer
@using System.ComponentModel.DataAnnotations
<PageTitle>Forms</PageTitle> <PageTitle>Forms</PageTitle>
<div class="space-y-6 max-w-lg"> <div class="space-y-6 max-w-lg">
<div> <div>
<h1 class="text-3xl font-bold tracking-tight">Forms Demo</h1> <h1 class="text-3xl font-bold tracking-tight">Forms Demo</h1>
<p class="text-muted-foreground">All input components with DataAnnotations validation.</p> <p class="text-muted-foreground">All input components — fully static SSR with htmx validation.</p>
</div> </div>
<EditForm EditContext="_editContext" OnSubmit="HandleSubmit" FormName="demo-form"> <HtmxForm Endpoint="/api/contact">
<DataAnnotationsValidator /> <FormField Label="Full Name" For="name">
<TextInput Id="name" Name="name" Placeholder="Jane Doe" data-testid="input-name" />
<div class="space-y-4">
<FormField Label="Full Name" For="name" Error="@GetError(nameof(Model.Name))">
<TextInput Id="name" @bind-Value="Model.Name" Placeholder="Jane Doe" data-testid="input-name" />
</FormField> </FormField>
<FormField Label="Email" For="email" Error="@GetError(nameof(Model.Email))"> <FormField Label="Email" For="email">
<TextInput Id="email" Type="email" @bind-Value="Model.Email" Placeholder="jane@example.com" data-testid="input-email" /> <TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" data-testid="input-email" />
</FormField> </FormField>
<FormField Label="Password" For="password" Error="@GetError(nameof(Model.Password))"> <FormField Label="Password" For="password">
<TextInput Id="password" Type="password" @bind-Value="Model.Password" Placeholder="••••••••" data-testid="input-password" /> <TextInput Id="password" Name="password" Type="password" Placeholder="••••••••" data-testid="input-password" />
</FormField> </FormField>
<FormField Label="Age" For="age" Error="@GetError(nameof(Model.Age))"> <FormField Label="Age" For="age">
<NumberInput Id="age" @bind-Value="Model.Age" Placeholder="25" Min="0" Max="150" data-testid="input-age" /> <NumberInput Id="age" Name="age" Placeholder="25" Min="0" Max="150" data-testid="input-age" />
</FormField> </FormField>
<FormField Label="Birth Date" For="birthdate" Error="@GetError(nameof(Model.BirthDate))"> <FormField Label="Birth Date" For="birthdate">
<DateInput Id="birthdate" @bind-Value="Model.BirthDate" data-testid="input-birthdate" /> <DateInput Id="birthdate" Name="birthdate" data-testid="input-birthdate" />
</FormField> </FormField>
<FormField Label="Preferred Time" For="preferredtime" Error="@GetError(nameof(Model.PreferredTime))"> <FormField Label="Preferred Time" For="preferredtime">
<TimeInput Id="preferredtime" @bind-Value="Model.PreferredTime" data-testid="input-time" /> <TimeInput Id="preferredtime" Name="preferredtime" data-testid="input-time" />
</FormField> </FormField>
<FormField Label="Appointment" For="appointment" Error="@GetError(nameof(Model.Appointment))"> <FormField Label="Appointment" For="appointment">
<DateTimeInput Id="appointment" @bind-Value="Model.Appointment" data-testid="input-appointment" /> <DateTimeInput Id="appointment" Name="appointment" data-testid="input-appointment" />
</FormField>
<FormField Label="Confirmation" For="confirmation">
<TextInput Id="confirmation" Name="confirmation" Placeholder='Type "CONFIRM"' data-testid="input-confirmation" />
</FormField> </FormField>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<Button Type="submit" data-testid="btn-submit">Submit</Button> <Button Type="submit" data-testid="btn-submit">Submit</Button>
<Button Variant="@ButtonVariant.Outline" OnClick="HandleReset" data-testid="btn-reset">Reset</Button> <Button Type="reset" Variant="@ButtonVariant.Outline" data-testid="btn-reset">Reset</Button>
<Button Variant="@ButtonVariant.Destructive" Disabled="true" data-testid="btn-disabled">Disabled</Button> <Button Variant="@ButtonVariant.Destructive" Disabled="true" data-testid="btn-disabled">Disabled</Button>
</div> </div>
</HtmxForm>
</div> </div>
</EditForm>
@if (_submitted)
{
<div data-testid="success-message"
class="rounded-md border border-input bg-card p-4 text-sm text-card-foreground">
<p class="font-medium">✓ Form submitted successfully</p>
<p class="text-muted-foreground mt-1">Name: @_submittedName</p>
</div>
}
</div>
@code {
private FormModel Model { get; set; } = new();
private EditContext _editContext = null!;
private bool _submitted;
private string _submittedName = "";
protected override void OnInitialized()
{
_editContext = new EditContext(Model);
}
private string? GetError(string fieldName)
{
var field = _editContext.Field(fieldName);
var messages = _editContext.GetValidationMessages(field);
return messages.FirstOrDefault();
}
private void HandleSubmit()
{
_submitted = false;
if (!_editContext.Validate())
return;
_submittedName = Model.Name!;
_submitted = true;
}
private void HandleReset()
{
Model = new();
_submitted = false;
_editContext = new EditContext(Model);
}
public class FormModel
{
[Required(ErrorMessage = "Name is required.")]
[StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2100 characters.")]
public string? Name { get; set; }
[Required(ErrorMessage = "Email is required.")]
[EmailAddress(ErrorMessage = "Invalid email address.")]
public string? Email { get; set; }
[Required(ErrorMessage = "Password is required.")]
[StringLength(64, MinimumLength = 8, ErrorMessage = "Password must be 864 characters.")]
public string? Password { get; set; }
[Required(ErrorMessage = "Age is required.")]
[Range(1, 150, ErrorMessage = "Age must be between 1 and 150.")]
public double? Age { get; set; }
[Required(ErrorMessage = "Birth date is required.")]
public DateOnly? BirthDate { get; set; }
[Required(ErrorMessage = "Preferred time is required.")]
public TimeOnly? PreferredTime { get; set; }
[Required(ErrorMessage = "Appointment is required.")]
public DateTime? Appointment { get; set; }
}
}
@@ -1,4 +1,5 @@
@page "/weather" @page "/weather"
@using Enciphered.Blazor.UIComponents
@attribute [StreamRendering] @attribute [StreamRendering]
<PageTitle>Weather</PageTitle> <PageTitle>Weather</PageTitle>
@@ -13,27 +14,20 @@
} }
else else
{ {
<table class="table"> <Table Items="forecasts">
<thead> <Columns>
<tr>
<th>Date</th> <th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th> <th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Farenheit">Temp. (F)</th> <th aria-label="Temperature in Farenheit">Temp. (F)</th>
<th>Summary</th> <th>Summary</th>
</tr> </Columns>
</thead> <RowTemplate Context="forecast">
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td> <td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td> <td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td> <td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td> <td>@forecast.Summary</td>
</tr> </RowTemplate>
} </Table>
</tbody>
</table>
} }
@code { @code {
@@ -0,0 +1,13 @@
namespace Enciphered.Blazor.UIComponents.Demo;
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; } = "";
}
@@ -0,0 +1,47 @@
using Enciphered.Blazor.UIComponents.Validation;
namespace Enciphered.Blazor.UIComponents.Demo;
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);
}
}
@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Enciphered.Blazor.UIComponents\Enciphered.Blazor.UIComponents.csproj" /> <ProjectReference Include="..\Enciphered.Blazor.UIComponents\Enciphered.Blazor.UIComponents.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<Target Name="TailwindBuild" BeforeTargets="Build"> <Target Name="TailwindBuild" BeforeTargets="Build">
<Exec Command="npx @tailwindcss/cli -i Styles/app.css -o wwwroot/css/app.css --minify" /> <Exec Command="npx @tailwindcss/cli -i Styles/app.css -o wwwroot/css/app.css --minify" />
</Target> </Target>
+19 -7
View File
@@ -1,29 +1,41 @@
using Enciphered.Blazor.UIComponents.Demo;
using Enciphered.Blazor.UIComponents.Demo.Components; using Enciphered.Blazor.UIComponents.Demo.Components;
using Enciphered.Blazor.UIComponents.Validation;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. builder.Services.AddRazorComponents();
builder.Services.AddRazorComponents() builder.Services.AddAntiforgery();
.AddInteractiveServerComponents();
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts(); app.UseHsts();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly); .AddAdditionalAssemblies(typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly);
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/contact",
onSuccess: async model =>
{
Console.WriteLine("── Form Submitted ──");
Console.WriteLine($" Name: {model.Name}");
Console.WriteLine($" Email: {model.Email}");
Console.WriteLine($" Password: {model.Password}");
Console.WriteLine($" Age: {model.Age}");
Console.WriteLine($" Birth Date: {model.Birthdate}");
Console.WriteLine($" Time: {model.Preferredtime}");
Console.WriteLine($" Appointment: {model.Appointment}");
Console.WriteLine($" Confirmation: {model.Confirmation}");
await Task.CompletedTask;
});
app.Run(); app.Run();
File diff suppressed because one or more lines are too long
@@ -0,0 +1,250 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
[TestFixture]
public class CardTests : PlaywrightTestBase
{
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task GoToCardsAsync()
{
await Page.GotoAsync($"{BaseUrl}/cards", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Page.WaitForSelectorAsync("[data-testid='card-basic']", new PageWaitForSelectorOptions { Timeout = 10_000 });
}
private ILocator Card(string testId) => Page.Locator($"[data-testid='{testId}']");
// ────────────────────────────────────────────
// Basic card structure
// ────────────────────────────────────────────
[Test]
public async Task BasicCard_Is_Visible()
{
await GoToCardsAsync();
await Expect(Card("card-basic")).ToBeVisibleAsync();
}
[Test]
public async Task BasicCard_Has_Rounded_Border_Classes()
{
await GoToCardsAsync();
var card = Card("card-basic");
await Expect(card).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("rounded-xl"));
await Expect(card).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("border"));
}
[Test]
public async Task BasicCard_Renders_Title()
{
await GoToCardsAsync();
var title = Card("card-basic").Locator("h3");
await Expect(title).ToBeVisibleAsync();
await Expect(title).ToHaveTextAsync("Card Title");
}
[Test]
public async Task BasicCard_Renders_Description()
{
await GoToCardsAsync();
var desc = Card("card-basic").Locator("[data-slot='card-description']");
await Expect(desc).ToBeVisibleAsync();
await Expect(desc).ToHaveTextAsync("Card Description");
}
[Test]
public async Task BasicCard_Renders_Content()
{
await GoToCardsAsync();
var content = Card("card-basic").Locator("p:has-text('Card Content')");
await Expect(content).ToBeVisibleAsync();
}
[Test]
public async Task BasicCard_Renders_Footer()
{
await GoToCardsAsync();
var footer = Card("card-basic").Locator("p:has-text('Card Footer')");
await Expect(footer).ToBeVisibleAsync();
}
[Test]
public async Task BasicCard_Description_Appears_Below_Title()
{
await GoToCardsAsync();
var title = Card("card-basic").Locator("h3");
var desc = Card("card-basic").Locator("[data-slot='card-description']");
var titleBox = await title.BoundingBoxAsync();
var descBox = await desc.BoundingBoxAsync();
Assert.That(titleBox, Is.Not.Null, "Title bounding box should exist");
Assert.That(descBox, Is.Not.Null, "Description bounding box should exist");
Assert.That(descBox!.Y, Is.GreaterThan(titleBox!.Y), "Description should be positioned below the title");
}
// ────────────────────────────────────────────
// Login card header with action
// ────────────────────────────────────────────
[Test]
public async Task LoginCard_Is_Visible()
{
await GoToCardsAsync();
await Expect(Card("card-login")).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Renders_Title()
{
await GoToCardsAsync();
var title = Card("card-login").Locator("h3");
await Expect(title).ToHaveTextAsync("Login to your account");
}
[Test]
public async Task LoginCard_Renders_Action()
{
await GoToCardsAsync();
var action = Card("card-login").Locator("[data-slot='card-action']");
await Expect(action).ToBeVisibleAsync();
await Expect(action).ToContainTextAsync("Sign Up");
}
[Test]
public async Task LoginCard_Action_Is_Right_Of_Title()
{
await GoToCardsAsync();
var title = Card("card-login").Locator("h3");
var action = Card("card-login").Locator("[data-slot='card-action']");
var titleBox = await title.BoundingBoxAsync();
var actionBox = await action.BoundingBoxAsync();
Assert.That(titleBox, Is.Not.Null);
Assert.That(actionBox, Is.Not.Null);
Assert.That(actionBox!.X, Is.GreaterThan(titleBox!.X), "Action should be to the right of the title");
}
[Test]
public async Task LoginCard_Renders_Description()
{
await GoToCardsAsync();
var desc = Card("card-login").Locator("[data-slot='card-description']");
await Expect(desc).ToContainTextAsync("Enter your email below");
}
[Test]
public async Task LoginCard_Has_Email_Input()
{
await GoToCardsAsync();
var email = Card("card-login").Locator("input#email");
await Expect(email).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Has_Password_Input()
{
await GoToCardsAsync();
var password = Card("card-login").Locator("input#password");
await Expect(password).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Has_Login_Button()
{
await GoToCardsAsync();
var btn = Card("card-login").Locator("button:has-text('Login')").First;
await Expect(btn).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Has_Google_Button()
{
await GoToCardsAsync();
var btn = Card("card-login").Locator("button:has-text('Login with Google')");
await Expect(btn).ToBeVisibleAsync();
}
// ────────────────────────────────────────────
// Image card
// ────────────────────────────────────────────
[Test]
public async Task ImageCard_Is_Visible()
{
await GoToCardsAsync();
await Expect(Card("card-image")).ToBeVisibleAsync();
}
[Test]
public async Task ImageCard_Renders_Image()
{
await GoToCardsAsync();
var img = Card("card-image").Locator("img");
await Expect(img).ToBeVisibleAsync();
await Expect(img).ToHaveAttributeAsync("alt", "Event cover");
}
[Test]
public async Task ImageCard_Image_Appears_Above_Title()
{
await GoToCardsAsync();
var img = Card("card-image").Locator("img");
var title = Card("card-image").Locator("h3");
var imgBox = await img.BoundingBoxAsync();
var titleBox = await title.BoundingBoxAsync();
Assert.That(imgBox, Is.Not.Null);
Assert.That(titleBox, Is.Not.Null);
Assert.That(titleBox!.Y, Is.GreaterThan(imgBox!.Y), "Title should be below the image");
}
[Test]
public async Task ImageCard_Renders_Title()
{
await GoToCardsAsync();
var title = Card("card-image").Locator("h3");
await Expect(title).ToHaveTextAsync("Design systems meetup");
}
[Test]
public async Task ImageCard_Renders_Featured_Badge()
{
await GoToCardsAsync();
var badge = Card("card-image").Locator("[data-slot='card-action']");
await Expect(badge).ToContainTextAsync("Featured");
}
[Test]
public async Task ImageCard_Renders_Description()
{
await GoToCardsAsync();
var desc = Card("card-image").Locator("[data-slot='card-description']");
await Expect(desc).ToContainTextAsync("A practical talk on component APIs");
}
[Test]
public async Task ImageCard_Has_View_Event_Button()
{
await GoToCardsAsync();
var btn = Card("card-image").Locator("button:has-text('View Event')");
await Expect(btn).ToBeVisibleAsync();
}
// ────────────────────────────────────────────
// All three cards render on the page
// ────────────────────────────────────────────
[Test]
public async Task CardsPage_Renders_Three_Cards()
{
await GoToCardsAsync();
await Expect(Card("card-basic")).ToBeVisibleAsync();
await Expect(Card("card-login")).ToBeVisibleAsync();
await Expect(Card("card-image")).ToBeVisibleAsync();
}
}
@@ -10,7 +10,7 @@ public class FormsTests : PlaywrightTestBase
private async Task GoToFormsAsync() private async Task GoToFormsAsync()
{ {
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
// Wait for Blazor interactive mode to be ready // Wait for the form to be rendered
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 }); await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
} }
@@ -18,6 +18,15 @@ public class FormsTests : PlaywrightTestBase
private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']"); private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']"); private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']");
/// <summary>
/// Get the open popover panel nearest to a trigger.
/// </summary>
private ILocator PopoverPanelFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-panel]");
private ILocator PopoverBackdropFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-backdrop]");
/// <summary> /// <summary>
/// Select a date via the calendar popover. /// Select a date via the calendar popover.
/// Opens the trigger, uses month/year pickers to navigate, then clicks the day. /// Opens the trigger, uses month/year pickers to navigate, then clicks the day.
@@ -26,62 +35,61 @@ public class FormsTests : PlaywrightTestBase
{ {
// Open the popover // Open the popover
await Trigger(triggerId).ClickAsync(); await Trigger(triggerId).ClickAsync();
await Page.WaitForTimeoutAsync(200); await Page.WaitForTimeoutAsync(300);
await NavigateCalendarToDate(target); var panel = PopoverPanelFor(triggerId);
await NavigateCalendarToDate(panel, target);
// Click the target day (only enabled buttons in the calendar day grid) // Click the target day
// The day grid is the last grid-cols-7 div; find the button with matching day text var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
var dayGrid = Page.Locator(".grid.grid-cols-7").Last;
var dayButton = dayGrid.Locator($"button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First; var dayButton = dayGrid.Locator($"button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First;
await dayButton.ClickAsync(); await dayButton.ClickAsync();
await Page.WaitForTimeoutAsync(200); await Page.WaitForTimeoutAsync(300);
} }
/// <summary> /// <summary>
/// Navigate the open calendar to a specific month/year using the month and year pickers. /// Navigate the open calendar to a specific month/year using the month and year pickers.
/// </summary> /// </summary>
private async Task NavigateCalendarToDate(DateOnly target) private async Task NavigateCalendarToDate(ILocator panel, DateOnly target)
{ {
// Click year header to open year picker, then select the year // Click year header to open year picker, then select the year
var yearButton = Page.Locator("[data-calendar-year]"); var yearButton = panel.Locator("[data-calendar-year]");
await yearButton.ClickAsync(); await yearButton.ClickAsync();
await Page.WaitForTimeoutAsync(100); await Page.WaitForTimeoutAsync(150);
// The year picker is a scrollable grid; find and click the target year // The year picker grid is inside the calendar content
var yearGrid = Page.Locator(".grid.grid-cols-4"); var yearGrid = panel.Locator(".grid.grid-cols-4");
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')"); var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
// If the year isn't visible, use prev/next to shift the year range (±20 per click) // If the year isn't visible, use prev/next to shift the year range
var attempts = 0; var attempts = 0;
while (await targetYearBtn.CountAsync() == 0 && attempts < 10) while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
{ {
// Read the first year button text to determine which direction to go
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync(); var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
var firstYear = int.Parse(firstYearText.Trim()); var firstYear = int.Parse(firstYearText.Trim());
if (target.Year < firstYear) if (target.Year < firstYear)
await Page.Locator("button[aria-label='Previous month']").ClickAsync(); await panel.Locator("button[aria-label='Previous month']").ClickAsync();
else else
await Page.Locator("button[aria-label='Next month']").ClickAsync(); await panel.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(50); await Page.WaitForTimeoutAsync(100);
yearGrid = Page.Locator(".grid.grid-cols-4"); yearGrid = panel.Locator(".grid.grid-cols-4");
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')"); targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
attempts++; attempts++;
} }
await targetYearBtn.First.ClickAsync(); await targetYearBtn.First.ClickAsync();
await Page.WaitForTimeoutAsync(100); await Page.WaitForTimeoutAsync(150);
// Now click month header to open month picker, then select the month // Now click month header to open month picker, then select the month
var monthButton = Page.Locator("[data-calendar-month]"); var monthButton = panel.Locator("[data-calendar-month]");
await monthButton.ClickAsync(); await monthButton.ClickAsync();
await Page.WaitForTimeoutAsync(100); await Page.WaitForTimeoutAsync(150);
var monthGrid = Page.Locator(".grid.grid-cols-3"); var monthGrid = panel.Locator(".grid.grid-cols-3");
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM"); var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync(); await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(100); await Page.WaitForTimeoutAsync(150);
} }
/// <summary> /// <summary>
@@ -92,44 +100,42 @@ public class FormsTests : PlaywrightTestBase
{ {
// Open the popover // Open the popover
await Trigger(triggerId).ClickAsync(); await Trigger(triggerId).ClickAsync();
await Page.WaitForTimeoutAsync(200); await Page.WaitForTimeoutAsync(300);
await PickTimeInOpenPopover(hour, minute); var panel = PopoverPanelFor(triggerId);
await PickTimeInOpenPopover(panel, hour, minute);
// Close popover by clicking the backdrop overlay // Close popover by clicking the backdrop overlay
await Page.Locator(".fixed.inset-0.z-40").ClickAsync(new LocatorClickOptions { Force = true }); var backdrop = PopoverBackdropFor(triggerId);
await Page.WaitForTimeoutAsync(100); await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(200);
} }
/// <summary> /// <summary>
/// Pick hour, minute, and AM/PM in an already-open time picker. /// Pick hour, minute, and AM/PM in an already-open time picker.
/// Scopes all locators to the visible popover content to avoid backdrop interception.
/// </summary> /// </summary>
private async Task PickTimeInOpenPopover(int hour, int minute) private async Task PickTimeInOpenPopover(ILocator panel, int hour, int minute)
{ {
// The popover content sits in a z-50 absolutely positioned container
var popoverContent = Page.Locator(".absolute.z-50");
// Convert to 12-hour format // Convert to 12-hour format
var isPm = hour >= 12; var isPm = hour >= 12;
var hour12 = hour % 12; var hour12 = hour % 12;
if (hour12 == 0) hour12 = 12; if (hour12 == 0) hour12 = 12;
// Click the hour in the first scrollable column (within the popover) // Click the hour in the first scrollable column
var hourText = hour12.ToString("D2"); var hourText = hour12.ToString("D2");
var hourColumn = popoverContent.Locator(".scrollbar-thin").First; var hourColumn = panel.Locator(".scrollbar-thin").First;
await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync(); await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50); await Page.WaitForTimeoutAsync(50);
// Click the minute in the second scrollable column (within the popover) // Click the minute in the second scrollable column
var minuteText = minute.ToString("D2"); var minuteText = minute.ToString("D2");
var minuteColumn = popoverContent.Locator(".scrollbar-thin").Nth(1); var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1);
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync(); await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50); await Page.WaitForTimeoutAsync(50);
// Click AM/PM (within the popover) // Click AM/PM
var periodText = isPm ? "PM" : "AM"; var periodText = isPm ? "PM" : "AM";
await popoverContent.Locator($"button:has-text('{periodText}')").First.ClickAsync(); await panel.Locator($"button:has-text('{periodText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50); await Page.WaitForTimeoutAsync(50);
} }
@@ -260,7 +266,7 @@ public class FormsTests : PlaywrightTestBase
var labels = Page.Locator("label"); var labels = Page.Locator("label");
var count = await labels.CountAsync(); var count = await labels.CountAsync();
Assert.That(count, Is.EqualTo(7), "Expected 7 labels (one per form field)"); Assert.That(count, Is.EqualTo(8), "Expected 8 labels (one per form field)");
} }
[Test] [Test]
@@ -276,7 +282,7 @@ public class FormsTests : PlaywrightTestBase
} }
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
// Two-way binding // Value binding (native)
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
[Test] [Test]
@@ -334,137 +340,12 @@ public class FormsTests : PlaywrightTestBase
// Pick the time part via the time trigger // Pick the time part via the time trigger
await SelectTimeAsync("trigger-appointment-time", 10, 0); await SelectTimeAsync("trigger-appointment-time", 10, 0);
// The hidden input should have the combined datetime value // The hidden datetime-local input should have the combined value
var value = await Input("input-appointment").InputValueAsync(); // Note: DateTime hidden input is composed from separate date/time part hidden inputs
Assert.That(value, Does.StartWith("2025-12-25T10:00")); var datePartVal = await Page.Locator("#appointment-date-part").InputValueAsync();
} var timePartVal = await Page.Locator("#appointment-time-part").InputValueAsync();
Assert.That(datePartVal, Is.EqualTo("2025-12-25"));
// ════════════════════════════════════════════════════════════════════════ Assert.That(timePartVal, Is.EqualTo("10:00"));
// Validation — empty submit shows errors
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Empty_Submit_Shows_Validation_Errors()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
// Wait for at least one error message to appear
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var errors = Page.Locator("p.text-destructive");
var count = await errors.CountAsync();
Assert.That(count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 validation errors (one per required field)");
}
[Test]
public async Task Validation_Error_Shows_Name_Required()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var nameError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Name is required" });
await Expect(nameError).ToBeVisibleAsync();
}
[Test]
public async Task Validation_Error_Shows_Email_Required()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var emailError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Email is required" });
await Expect(emailError).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Validation — specific error messages
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Short_Name_Shows_Length_Error()
{
await GoToFormsAsync();
var input = Input("input-name");
await input.FillAsync("A");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "2100 characters" });
await Expect(error).ToBeVisibleAsync();
}
[Test]
public async Task Invalid_Email_Shows_Error()
{
await GoToFormsAsync();
var input = Input("input-email");
await input.FillAsync("not-an-email");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Invalid email" });
await Expect(error).ToBeVisibleAsync();
}
[Test]
public async Task Short_Password_Shows_Error()
{
await GoToFormsAsync();
var input = Input("input-password");
await input.FillAsync("123");
await Btn("btn-submit").ClickAsync();
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "864 characters" });
await Expect(error).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Valid submission
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Valid_Form_Shows_Success_Message()
{
await GoToFormsAsync();
await Input("input-name").FillAsync("Jane Doe");
await Input("input-email").FillAsync("jane@example.com");
await Input("input-password").FillAsync("securepassword123");
await Input("input-age").FillAsync("30");
// Use popover pickers for date/time fields
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
await SelectTimeAsync("trigger-preferredtime", 9, 30);
// DateTime: pick date and time via separate triggers
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
await SelectTimeAsync("trigger-appointment-time", 10, 0);
await Btn("btn-submit").ClickAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
await Expect(success).ToContainTextAsync("Form submitted successfully");
await Expect(success).ToContainTextAsync("Jane Doe");
}
[Test]
public async Task No_Success_Message_Before_Submit()
{
await GoToFormsAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeHiddenAsync();
} }
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
@@ -512,7 +393,7 @@ public class FormsTests : PlaywrightTestBase
} }
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
// Reset // Reset (native HTML reset)
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
[Test] [Test]
@@ -524,7 +405,7 @@ public class FormsTests : PlaywrightTestBase
await Input("input-name").FillAsync("Alice"); await Input("input-name").FillAsync("Alice");
await Input("input-email").FillAsync("alice@test.com"); await Input("input-email").FillAsync("alice@test.com");
// Reset // Reset (native form reset)
await Btn("btn-reset").ClickAsync(); await Btn("btn-reset").ClickAsync();
// Fields should be empty // Fields should be empty
@@ -532,35 +413,6 @@ public class FormsTests : PlaywrightTestBase
await Expect(Input("input-email")).ToHaveValueAsync(""); await Expect(Input("input-email")).ToHaveValueAsync("");
} }
[Test]
public async Task Reset_Button_Clears_Success_Message()
{
await GoToFormsAsync();
// Submit valid form
await Input("input-name").FillAsync("Jane Doe");
await Input("input-email").FillAsync("jane@example.com");
await Input("input-password").FillAsync("securepassword123");
await Input("input-age").FillAsync("30");
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
await SelectTimeAsync("trigger-preferredtime", 9, 30);
// DateTime picker — date and time via separate triggers
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
await SelectTimeAsync("trigger-appointment-time", 10, 0);
await Btn("btn-submit").ClickAsync();
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
// Reset
await Btn("btn-reset").ClickAsync();
await Expect(success).ToBeHiddenAsync();
}
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
// Input styling (base CSS classes present) // Input styling (base CSS classes present)
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,718 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
/// <summary>
/// Tests that cover interactive behavior gaps to ensure safe JS migration.
/// Covers: NumberInput +/- buttons & min/max clamping, Popover open/close mechanics,
/// Calendar arrow navigation, and trigger text updates for Date/Time/DateTime inputs.
/// </summary>
[TestFixture]
public class InteractivityTests : PlaywrightTestBase
{
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task GoToFormsAsync()
{
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
}
private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']");
private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
/// <summary>
/// Get the popover panel scoped to the popover containing a trigger.
/// </summary>
private ILocator PopoverPanelFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-panel]");
private ILocator PopoverBackdropFor(string triggerId) =>
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-backdrop]");
/// <summary>
/// Navigate the open calendar to a specific month/year using the month and year pickers.
/// </summary>
private async Task NavigateCalendarToDate(ILocator panel, DateOnly target)
{
var yearButton = panel.Locator("[data-calendar-year]");
await yearButton.ClickAsync();
await Page.WaitForTimeoutAsync(150);
var yearGrid = panel.Locator(".grid.grid-cols-4");
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
var attempts = 0;
while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
{
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
var firstYear = int.Parse(firstYearText.Trim());
if (target.Year < firstYear)
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
else
await panel.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(100);
yearGrid = panel.Locator(".grid.grid-cols-4");
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
attempts++;
}
await targetYearBtn.First.ClickAsync();
await Page.WaitForTimeoutAsync(150);
var monthButton = panel.Locator("[data-calendar-month]");
await monthButton.ClickAsync();
await Page.WaitForTimeoutAsync(150);
var monthGrid = panel.Locator(".grid.grid-cols-3");
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(150);
}
/// <summary>
/// Pick hour, minute, and AM/PM in an already-open time picker popover.
/// </summary>
private async Task PickTimeInOpenPopover(ILocator panel, int hour, int minute)
{
var isPm = hour >= 12;
var hour12 = hour % 12;
if (hour12 == 0) hour12 = 12;
var hourText = hour12.ToString("D2");
var hourColumn = panel.Locator(".scrollbar-thin").First;
await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
var minuteText = minute.ToString("D2");
var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1);
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
var periodText = isPm ? "PM" : "AM";
await panel.Locator($"button:has-text('{periodText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
}
// ════════════════════════════════════════════════════════════════════════
// NumberInput: Increment / Decrement buttons
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task NumberInput_Increment_Button_Increases_Value()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("25");
await Page.WaitForTimeoutAsync(100);
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
await incrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Expect(input).ToHaveValueAsync("26");
}
[Test]
public async Task NumberInput_Decrement_Button_Decreases_Value()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("25");
await Page.WaitForTimeoutAsync(100);
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
await decrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Expect(input).ToHaveValueAsync("24");
}
[Test]
public async Task NumberInput_Increment_Multiple_Times()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("10");
await Page.WaitForTimeoutAsync(100);
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
await incrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(50);
await incrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(50);
await incrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Expect(input).ToHaveValueAsync("13");
}
[Test]
public async Task NumberInput_Increment_From_Empty_Sets_Value_To_One()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("");
await Page.WaitForTimeoutAsync(100);
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
await incrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Expect(input).ToHaveValueAsync("1");
}
[Test]
public async Task NumberInput_Decrement_From_Empty_Sets_Value_To_Negative_One_Or_Min()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("");
await Page.WaitForTimeoutAsync(100);
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
await decrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(100);
// Age has Min=0, so decrement from 0 (default) should clamp to 0
await Expect(input).ToHaveValueAsync("0");
}
// ════════════════════════════════════════════════════════════════════════
// NumberInput: Min / Max clamping
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task NumberInput_Increment_Button_Disabled_At_Max()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("150"); // Max is 150
await Page.WaitForTimeoutAsync(100);
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
await Expect(incrementBtn).ToBeDisabledAsync();
}
[Test]
public async Task NumberInput_Decrement_Button_Disabled_At_Min()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("0"); // Min is 0
await Page.WaitForTimeoutAsync(100);
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
await Expect(decrementBtn).ToBeDisabledAsync();
}
[Test]
public async Task NumberInput_Increment_At_Max_Does_Not_Exceed()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("149");
await Page.WaitForTimeoutAsync(100);
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
await incrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Expect(input).ToHaveValueAsync("150");
await Expect(incrementBtn).ToBeDisabledAsync();
}
[Test]
public async Task NumberInput_Decrement_At_Min_Does_Not_Go_Below()
{
await GoToFormsAsync();
var input = Input("input-age");
await input.FillAsync("1");
await Page.WaitForTimeoutAsync(100);
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
await decrementBtn.ClickAsync();
await Page.WaitForTimeoutAsync(100);
await Expect(input).ToHaveValueAsync("0");
await Expect(decrementBtn).ToBeDisabledAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Popover: explicit open/close mechanics
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Popover_Opens_On_Trigger_Click()
{
await GoToFormsAsync();
// Date input trigger opens a calendar popover
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
// The popover panel scoped to this trigger should be visible
var panel = PopoverPanelFor("trigger-birthdate");
await Expect(panel).ToBeVisibleAsync();
}
[Test]
public async Task Popover_Closes_On_Backdrop_Click()
{
await GoToFormsAsync();
// Open the time input popover
await Trigger("trigger-preferredtime").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-preferredtime");
await Expect(panel).ToBeVisibleAsync();
// Click the backdrop overlay to close
var backdrop = PopoverBackdropFor("trigger-preferredtime");
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(300);
// Popover should no longer be visible
await Expect(panel).ToBeHiddenAsync();
}
[Test]
public async Task Popover_Stays_Open_On_Content_Click()
{
await GoToFormsAsync();
// Open the date input popover
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
await Expect(panel).ToBeVisibleAsync();
// Click inside the popover content (e.g. the month header button) — should NOT close
var monthButton = panel.Locator("[data-calendar-month]");
await monthButton.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// Popover should still be visible (month picker is now showing)
await Expect(panel).ToBeVisibleAsync();
}
[Test]
public async Task Popover_Toggle_Opens_Then_Closes_Via_Backdrop()
{
await GoToFormsAsync();
var trigger = Trigger("trigger-birthdate");
var panel = PopoverPanelFor("trigger-birthdate");
// Open
await trigger.ClickAsync();
await Page.WaitForTimeoutAsync(300);
await Expect(panel).ToBeVisibleAsync();
// Close via backdrop
var backdrop = PopoverBackdropFor("trigger-birthdate");
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(300);
await Expect(panel).ToBeHiddenAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Calendar: Previous / Next arrow buttons
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Calendar_Next_Arrow_Advances_Month()
{
await GoToFormsAsync();
// Open calendar
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
// Read the current displayed month
var monthLabel = panel.Locator("[data-calendar-month]");
var initialMonth = await monthLabel.InnerTextAsync();
// Click the next arrow
await panel.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(200);
// Month should have changed
var newMonth = await monthLabel.InnerTextAsync();
Assert.That(newMonth, Is.Not.EqualTo(initialMonth), "Month label should change after clicking Next");
}
[Test]
public async Task Calendar_Previous_Arrow_Goes_Back_Month()
{
await GoToFormsAsync();
// Open calendar
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
var monthLabel = panel.Locator("[data-calendar-month]");
var initialMonth = await monthLabel.InnerTextAsync();
// Click the previous arrow
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
await Page.WaitForTimeoutAsync(200);
var newMonth = await monthLabel.InnerTextAsync();
Assert.That(newMonth, Is.Not.EqualTo(initialMonth), "Month label should change after clicking Previous");
}
[Test]
public async Task Calendar_Next_Arrow_Wraps_Year()
{
await GoToFormsAsync();
// Open calendar
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
// Navigate to Dec of current year
var target = new DateOnly(DateTime.Today.Year, 12, 1);
await NavigateCalendarToDate(panel, target);
var yearLabel = panel.Locator("[data-calendar-year]");
var initialYear = await yearLabel.InnerTextAsync();
// Click next — should go to Jan of next year
await panel.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(200);
var monthLabel = panel.Locator("[data-calendar-month]");
var newMonth = await monthLabel.InnerTextAsync();
var newYear = await yearLabel.InnerTextAsync();
Assert.That(newMonth.Trim(), Is.EqualTo("Jan"), "Should wrap to January");
Assert.That(int.Parse(newYear.Trim()), Is.EqualTo(int.Parse(initialYear.Trim()) + 1), "Year should increment");
}
[Test]
public async Task Calendar_Previous_Arrow_Wraps_Year()
{
await GoToFormsAsync();
// Open calendar
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
var target = new DateOnly(DateTime.Today.Year, 1, 1);
await NavigateCalendarToDate(panel, target);
var yearLabel = panel.Locator("[data-calendar-year]");
var initialYear = await yearLabel.InnerTextAsync();
// Click previous — should go to Dec of previous year
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
await Page.WaitForTimeoutAsync(200);
var monthLabel = panel.Locator("[data-calendar-month]");
var newMonth = await monthLabel.InnerTextAsync();
var newYear = await yearLabel.InnerTextAsync();
Assert.That(newMonth.Trim(), Is.EqualTo("Dec"), "Should wrap to December");
Assert.That(int.Parse(newYear.Trim()), Is.EqualTo(int.Parse(initialYear.Trim()) - 1), "Year should decrement");
}
[Test]
public async Task Calendar_Selecting_Day_Via_Arrow_Navigation()
{
await GoToFormsAsync();
// Open calendar
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
// Navigate forward one month using arrow
await panel.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(200);
// Read the new month/year
var monthText = (await panel.Locator("[data-calendar-month]").InnerTextAsync()).Trim();
var yearText = (await panel.Locator("[data-calendar-year]").InnerTextAsync()).Trim();
var month = DateTime.ParseExact(monthText, "MMM", null).Month;
var year = int.Parse(yearText);
// Click day 15
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// Verify the hidden input has the correct value
var expected = new DateOnly(year, month, 15).ToString("yyyy-MM-dd");
await Expect(Input("input-birthdate")).ToHaveValueAsync(expected);
}
// ════════════════════════════════════════════════════════════════════════
// DateInput: Trigger text updates after selection
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task DateInput_Trigger_Shows_Placeholder_Initially()
{
await GoToFormsAsync();
var triggerSpan = Trigger("trigger-birthdate").Locator("span");
var text = await triggerSpan.InnerTextAsync();
Assert.That(text.Trim(), Is.EqualTo("Select date"), "Should show placeholder before a date is selected");
}
[Test]
public async Task DateInput_Trigger_Shows_Formatted_Date_After_Selection()
{
await GoToFormsAsync();
// Open and select June 15, 2000
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
await NavigateCalendarToDate(panel, new DateOnly(2000, 6, 15));
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// Trigger button text should now show the formatted date
var triggerSpan = Trigger("trigger-birthdate").Locator("span");
var text = (await triggerSpan.InnerTextAsync()).Trim();
Assert.That(text, Is.EqualTo("June 15, 2000"), "Trigger should display the formatted selected date");
}
[Test]
public async Task DateInput_Trigger_Text_Loses_Placeholder_Class_After_Selection()
{
await GoToFormsAsync();
var triggerSpan = Trigger("trigger-birthdate").Locator("span");
// Before selection — should have muted style
var classBefore = await triggerSpan.GetAttributeAsync("class");
Assert.That(classBefore, Does.Contain("text-muted-foreground"), "Placeholder text should have muted class");
// Select a date
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
await NavigateCalendarToDate(panel, new DateOnly(2000, 6, 15));
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// After selection — should NOT have muted class
var classAfter = await triggerSpan.GetAttributeAsync("class");
Assert.That(classAfter, Does.Not.Contain("text-muted-foreground"), "Selected date text should not have muted class");
}
// ════════════════════════════════════════════════════════════════════════
// TimeInput: Trigger text updates after selection
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task TimeInput_Trigger_Shows_Placeholder_Initially()
{
await GoToFormsAsync();
var triggerSpan = Trigger("trigger-preferredtime").Locator("span");
var text = await triggerSpan.InnerTextAsync();
Assert.That(text.Trim(), Is.EqualTo("Select time"), "Should show placeholder before a time is selected");
}
[Test]
public async Task TimeInput_Trigger_Shows_Formatted_Time_After_Selection()
{
await GoToFormsAsync();
// Open time picker and select 2:30 PM
await Trigger("trigger-preferredtime").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-preferredtime");
await PickTimeInOpenPopover(panel, 14, 30);
// Close by clicking backdrop
var backdrop = PopoverBackdropFor("trigger-preferredtime");
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(300);
var triggerSpan = Trigger("trigger-preferredtime").Locator("span");
var text = (await triggerSpan.InnerTextAsync()).Trim();
Assert.That(text, Is.EqualTo("02:30 PM"), "Trigger should display the formatted selected time");
}
[Test]
public async Task TimeInput_Trigger_Text_Loses_Placeholder_Class_After_Selection()
{
await GoToFormsAsync();
var triggerSpan = Trigger("trigger-preferredtime").Locator("span");
// Before selection
var classBefore = await triggerSpan.GetAttributeAsync("class");
Assert.That(classBefore, Does.Contain("text-muted-foreground"), "Placeholder should have muted class");
// Select a time
await Trigger("trigger-preferredtime").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-preferredtime");
await PickTimeInOpenPopover(panel, 14, 30);
var backdrop = PopoverBackdropFor("trigger-preferredtime");
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(300);
// After selection
var classAfter = await triggerSpan.GetAttributeAsync("class");
Assert.That(classAfter, Does.Not.Contain("text-muted-foreground"), "Selected time text should not have muted class");
}
// ════════════════════════════════════════════════════════════════════════
// DateTimeInput: Trigger text updates after selection
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task DateTimeInput_Date_Trigger_Shows_Placeholder_Initially()
{
await GoToFormsAsync();
var triggerSpan = Trigger("trigger-appointment-date").Locator("span");
var text = await triggerSpan.InnerTextAsync();
Assert.That(text.Trim(), Is.EqualTo("Select date"), "Date trigger should show placeholder initially");
}
[Test]
public async Task DateTimeInput_Time_Trigger_Shows_Placeholder_Initially()
{
await GoToFormsAsync();
var triggerSpan = Trigger("trigger-appointment-time").Locator("span");
var text = await triggerSpan.InnerTextAsync();
Assert.That(text.Trim(), Is.EqualTo("Select time"), "Time trigger should show placeholder initially");
}
[Test]
public async Task DateTimeInput_Date_Trigger_Shows_Formatted_Date_After_Selection()
{
await GoToFormsAsync();
// Open and select Dec 25, 2025
await Trigger("trigger-appointment-date").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-appointment-date");
await NavigateCalendarToDate(panel, new DateOnly(2025, 12, 25));
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "25" }).First.ClickAsync();
await Page.WaitForTimeoutAsync(300);
var triggerSpan = Trigger("trigger-appointment-date").Locator("span");
var text = (await triggerSpan.InnerTextAsync()).Trim();
Assert.That(text, Is.EqualTo("Dec 25, 2025"), "Date trigger should display the formatted selected date");
}
[Test]
public async Task DateTimeInput_Time_Trigger_Shows_Formatted_Time_After_Selection()
{
await GoToFormsAsync();
// Must select a date first so the component has a value
await Trigger("trigger-appointment-date").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var datePanel = PopoverPanelFor("trigger-appointment-date");
await NavigateCalendarToDate(datePanel, new DateOnly(2025, 12, 25));
var dayGrid = datePanel.Locator(".grid.grid-cols-7").Last;
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "25" }).First.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// Now select time 10:00 AM
await Trigger("trigger-appointment-time").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var timePanel = PopoverPanelFor("trigger-appointment-time");
await PickTimeInOpenPopover(timePanel, 10, 0);
var backdrop = PopoverBackdropFor("trigger-appointment-time");
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(300);
var triggerSpan = Trigger("trigger-appointment-time").Locator("span");
var text = (await triggerSpan.InnerTextAsync()).Trim();
Assert.That(text, Is.EqualTo("10:00 AM"), "Time trigger should display the formatted selected time");
}
// ════════════════════════════════════════════════════════════════════════
// Calendar: day selection highlights correctly
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Calendar_Selected_Day_Has_Primary_Styling()
{
await GoToFormsAsync();
// Open and select a date
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
var day15 = dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First;
await day15.ClickAsync();
await Page.WaitForTimeoutAsync(300);
// Re-open the calendar to verify the selected day is highlighted
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
panel = PopoverPanelFor("trigger-birthdate");
var selectedDay = panel.Locator(".grid.grid-cols-7").Last
.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First;
var cls = await selectedDay.GetAttributeAsync("class");
Assert.That(cls, Does.Contain("bg-primary"), "Selected day should have primary background styling");
}
// ════════════════════════════════════════════════════════════════════════
// Popover: Date selection auto-closes popover
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task DateInput_Popover_Closes_After_Day_Selection()
{
await GoToFormsAsync();
await Trigger("trigger-birthdate").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor("trigger-birthdate");
await Expect(panel).ToBeVisibleAsync();
// Select a day
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "10" }).First.ClickAsync();
await Page.WaitForTimeoutAsync(400);
// Popover should auto-close after date selection
await Expect(panel).ToBeHiddenAsync();
}
}
@@ -0,0 +1,648 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
/// <summary>
/// Tests for htmx-powered server-side validation on the Forms Demo page.
/// Covers: inline field validation on blur, full-form submission validation,
/// success message display, error styling, and form reset clearing errors.
/// </summary>
[TestFixture]
public class ValidationTests : PlaywrightTestBase
{
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task GoToFormsAsync()
{
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
}
private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']");
private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']");
private ILocator FieldError(string fieldName) =>
Page.Locator($"[data-field-error='{fieldName}']");
/// <summary>
/// Focus an input, clear it, then blur to trigger htmx validation.
/// </summary>
private async Task BlurEmptyField(string testId)
{
var input = Input(testId);
await input.ClickAsync();
await input.FillAsync("");
await input.BlurAsync();
// Wait for htmx round-trip
await Page.WaitForTimeoutAsync(500);
}
/// <summary>
/// Fill a field and blur it.
/// </summary>
private async Task FillAndBlur(string testId, string value)
{
var input = Input(testId);
await input.ClickAsync();
await input.FillAsync(value);
await input.BlurAsync();
await Page.WaitForTimeoutAsync(500);
}
/// <summary>
/// Fill all required fields with valid data.
/// </summary>
private async Task FillAllValidFields()
{
await Input("input-name").FillAsync("Alice Johnson");
await Input("input-email").FillAsync("alice@example.com");
await Input("input-password").FillAsync("secure123");
await Input("input-confirmation").FillAsync("CONFIRM");
}
// ════════════════════════════════════════════════════════════════════════
// Inline field validation — blur triggers
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Name_Blank_Shows_Required_Error_On_Blur()
{
await GoToFormsAsync();
await BlurEmptyField("input-name");
var error = FieldError("name");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("Name is required.");
}
[Test]
public async Task Name_Too_Short_Shows_Length_Error_On_Blur()
{
await GoToFormsAsync();
await FillAndBlur("input-name", "A");
var error = FieldError("name");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("Name must be at least 2 characters.");
}
[Test]
public async Task Name_Valid_Clears_Error_On_Blur()
{
await GoToFormsAsync();
// Trigger an error first
await BlurEmptyField("input-name");
await Expect(FieldError("name")).ToBeVisibleAsync();
// Now fix it
await FillAndBlur("input-name", "Alice");
var error = FieldError("name");
await Expect(error).ToBeHiddenAsync();
}
[Test]
public async Task Email_Blank_Shows_Required_Error_On_Blur()
{
await GoToFormsAsync();
await BlurEmptyField("input-email");
var error = FieldError("email");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("Email is required.");
}
[Test]
public async Task Email_Invalid_Shows_Format_Error_On_Blur()
{
await GoToFormsAsync();
await FillAndBlur("input-email", "notanemail");
var error = FieldError("email");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("Please enter a valid email address.");
}
[Test]
public async Task Email_Valid_Clears_Error_On_Blur()
{
await GoToFormsAsync();
await BlurEmptyField("input-email");
await Expect(FieldError("email")).ToBeVisibleAsync();
await FillAndBlur("input-email", "alice@example.com");
await Expect(FieldError("email")).ToBeHiddenAsync();
}
[Test]
public async Task Password_Blank_Shows_Required_Error_On_Blur()
{
await GoToFormsAsync();
await BlurEmptyField("input-password");
var error = FieldError("password");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("Password is required.");
}
[Test]
public async Task Password_Too_Short_Shows_Length_Error_On_Blur()
{
await GoToFormsAsync();
await FillAndBlur("input-password", "abc");
var error = FieldError("password");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("Password must be at least 6 characters.");
}
[Test]
public async Task Password_Valid_Clears_Error_On_Blur()
{
await GoToFormsAsync();
await BlurEmptyField("input-password");
await Expect(FieldError("password")).ToBeVisibleAsync();
await FillAndBlur("input-password", "secure123");
await Expect(FieldError("password")).ToBeHiddenAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Input gets destructive border styling on error
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Input_Gets_Destructive_Border_On_Error()
{
await GoToFormsAsync();
await BlurEmptyField("input-name");
var cls = await Input("input-name").GetAttributeAsync("class");
Assert.That(cls, Does.Contain("border-destructive"), "Input should have destructive border on error");
}
[Test]
public async Task Input_Loses_Destructive_Border_When_Valid()
{
await GoToFormsAsync();
await BlurEmptyField("input-name");
// Verify error styling is present
var clsBefore = await Input("input-name").GetAttributeAsync("class");
Assert.That(clsBefore, Does.Contain("border-destructive"));
// Fix the field
await FillAndBlur("input-name", "Alice");
var clsAfter = await Input("input-name").GetAttributeAsync("class");
Assert.That(clsAfter, Does.Not.Contain("border-destructive"), "Input should lose destructive border when valid");
}
// ════════════════════════════════════════════════════════════════════════
// Full form submission validation
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Empty_Submit_Shows_All_Required_Errors()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
// Name, email, and password should show errors
await Expect(FieldError("name")).ToBeVisibleAsync();
await Expect(FieldError("email")).ToBeVisibleAsync();
await Expect(FieldError("password")).ToBeVisibleAsync();
}
[Test]
public async Task Valid_Submit_Shows_Success_Message()
{
await GoToFormsAsync();
await FillAllValidFields();
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync();
await Expect(success).ToContainTextAsync("Form submitted successfully");
}
[Test]
public async Task Valid_Submit_Clears_All_Errors()
{
await GoToFormsAsync();
// First trigger errors
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
await Expect(FieldError("name")).ToBeVisibleAsync();
// Now fill valid data and resubmit
await FillAllValidFields();
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
await Expect(FieldError("name")).ToBeHiddenAsync();
await Expect(FieldError("email")).ToBeHiddenAsync();
await Expect(FieldError("password")).ToBeHiddenAsync();
await Expect(FieldError("confirmation")).ToBeHiddenAsync();
}
[Test]
public async Task Partial_Submit_Shows_Only_Invalid_Errors()
{
await GoToFormsAsync();
// Fill name only
await Input("input-name").FillAsync("Alice");
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
// Name should be clear, email and password should show errors
await Expect(FieldError("name")).ToBeHiddenAsync();
await Expect(FieldError("email")).ToBeVisibleAsync();
await Expect(FieldError("password")).ToBeVisibleAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Reset clears validation state
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Reset_Clears_Validation_Errors()
{
await GoToFormsAsync();
// Trigger errors
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
await Expect(FieldError("name")).ToBeVisibleAsync();
// Reset the form
await Btn("btn-reset").ClickAsync();
await Page.WaitForTimeoutAsync(300);
await Expect(FieldError("name")).ToBeHiddenAsync();
await Expect(FieldError("email")).ToBeHiddenAsync();
await Expect(FieldError("password")).ToBeHiddenAsync();
}
[Test]
public async Task Reset_Clears_Success_Message()
{
await GoToFormsAsync();
// Submit valid form
await FillAllValidFields();
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
await Expect(Page.Locator("[data-testid='success-message']")).ToBeVisibleAsync();
// Reset
await Btn("btn-reset").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var result = Page.Locator("#form-result");
await Expect(result).ToBeHiddenAsync();
}
[Test]
public async Task Reset_Removes_Destructive_Border_Styling()
{
await GoToFormsAsync();
// Trigger error on name
await BlurEmptyField("input-name");
var clsBefore = await Input("input-name").GetAttributeAsync("class");
Assert.That(clsBefore, Does.Contain("border-destructive"));
// Reset
await Btn("btn-reset").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var clsAfter = await Input("input-name").GetAttributeAsync("class");
Assert.That(clsAfter, Does.Not.Contain("border-destructive"));
}
// ════════════════════════════════════════════════════════════════════════
// Error elements exist for htmx targeting
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task FormField_Renders_Hidden_Error_Placeholder()
{
await GoToFormsAsync();
// Each form field should have a hidden [data-field-error] element
var nameError = FieldError("name");
await Expect(nameError).ToHaveCountAsync(1);
await Expect(nameError).ToBeHiddenAsync();
var emailError = FieldError("email");
await Expect(emailError).ToHaveCountAsync(1);
await Expect(emailError).ToBeHiddenAsync();
}
// ════════════════════════════════════════════════════════════════════════
// Custom validator — confirmation field must equal "CONFIRM"
// ════════════════════════════════════════════════════════════════════════
[Test]
public async Task Confirmation_Blank_Shows_Required_Error_On_Blur()
{
await GoToFormsAsync();
await BlurEmptyField("input-confirmation");
var error = FieldError("confirmation");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("Confirmation is required.");
}
[Test]
public async Task Confirmation_Wrong_Value_Shows_Custom_Error_On_Blur()
{
await GoToFormsAsync();
await FillAndBlur("input-confirmation", "nope");
var error = FieldError("confirmation");
await Expect(error).ToBeVisibleAsync();
await Expect(error).ToHaveTextAsync("You must type CONFIRM to proceed.");
}
[Test]
public async Task Confirmation_Correct_Value_Clears_Error_On_Blur()
{
await GoToFormsAsync();
// Trigger error first
await FillAndBlur("input-confirmation", "wrong");
await Expect(FieldError("confirmation")).ToBeVisibleAsync();
// Now fix it
await FillAndBlur("input-confirmation", "CONFIRM");
await Expect(FieldError("confirmation")).ToBeHiddenAsync();
}
[Test]
public async Task Confirmation_Error_Shows_On_Submit()
{
await GoToFormsAsync();
// Fill everything except confirmation
await Input("input-name").FillAsync("Alice Johnson");
await Input("input-email").FillAsync("alice@example.com");
await Input("input-password").FillAsync("secure123");
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
await Expect(FieldError("confirmation")).ToBeVisibleAsync();
await Expect(FieldError("confirmation")).ToHaveTextAsync("Confirmation is required.");
}
// ════════════════════════════════════════════════════════════════════════
// Date/Time/DateTime validation
// ════════════════════════════════════════════════════════════════════════
private async Task SetHiddenInputValue(string inputId, string value)
{
await Page.EvaluateAsync($@"
const el = document.getElementById('{inputId}');
if (el) {{
el.value = '{value}';
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
");
}
private ILocator PopoverPanelFor(string triggerId) =>
Page.Locator($"[data-testid='{triggerId}']")
.Locator("xpath=ancestor::*[@data-popover]")
.Locator("[data-popover-panel]");
private ILocator PopoverBackdropFor(string triggerId) =>
Page.Locator($"[data-testid='{triggerId}']")
.Locator("xpath=ancestor::*[@data-popover]")
.Locator("[data-popover-backdrop]");
private async Task SelectDateAsync(string triggerId, DateOnly target)
{
await Page.Locator($"[data-testid='{triggerId}']").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor(triggerId);
var yearButton = panel.Locator("[data-calendar-year]");
await yearButton.ClickAsync();
await Page.WaitForTimeoutAsync(150);
var yearGrid = panel.Locator(".grid.grid-cols-4");
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
var attempts = 0;
while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
{
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
var firstYear = int.Parse(firstYearText.Trim());
if (target.Year < firstYear)
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
else
await panel.Locator("button[aria-label='Next month']").ClickAsync();
await Page.WaitForTimeoutAsync(100);
yearGrid = panel.Locator(".grid.grid-cols-4");
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
attempts++;
}
await targetYearBtn.First.ClickAsync();
await Page.WaitForTimeoutAsync(150);
var monthButton = panel.Locator("[data-calendar-month]");
await monthButton.ClickAsync();
await Page.WaitForTimeoutAsync(150);
var monthGrid = panel.Locator(".grid.grid-cols-3");
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(150);
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
var dayButton = dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = target.Day.ToString() }).First;
await dayButton.ClickAsync();
await Page.WaitForTimeoutAsync(300);
}
private async Task SelectTimeAsync(string triggerId, int hour, int minute)
{
await Page.Locator($"[data-testid='{triggerId}']").ClickAsync();
await Page.WaitForTimeoutAsync(300);
var panel = PopoverPanelFor(triggerId);
var isPm = hour >= 12;
var hour12 = hour % 12;
if (hour12 == 0) hour12 = 12;
var hourColumn = panel.Locator(".scrollbar-thin").First;
await hourColumn.Locator($"button:has-text('{hour12:D2}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1);
await minuteColumn.Locator($"button:has-text('{minute:D2}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
var periodText = isPm ? "PM" : "AM";
await panel.Locator($"button:has-text('{periodText}')").First.ClickAsync();
await Page.WaitForTimeoutAsync(50);
var backdrop = PopoverBackdropFor(triggerId);
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
await Page.WaitForTimeoutAsync(200);
}
[Test]
public async Task BirthDate_Selected_Via_Popover_Submits_Successfully()
{
await GoToFormsAsync();
await FillAllValidFields();
await SelectDateAsync("trigger-birthdate", new DateOnly(2000, 6, 15));
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync();
await Expect(FieldError("birthdate")).ToBeHiddenAsync();
}
[Test]
public async Task PreferredTime_Selected_Via_Popover_Submits_Successfully()
{
await GoToFormsAsync();
await FillAllValidFields();
await SelectTimeAsync("trigger-preferredtime", 14, 30);
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync();
await Expect(FieldError("preferredtime")).ToBeHiddenAsync();
}
[Test]
public async Task Appointment_Date_And_Time_Selected_Via_Popover_Submits_Successfully()
{
await GoToFormsAsync();
await FillAllValidFields();
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
await SelectTimeAsync("trigger-appointment-time", 10, 30);
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync();
await Expect(FieldError("appointment")).ToBeHiddenAsync();
}
[Test]
public async Task BirthDate_Hidden_Input_Gets_Correct_Value_After_Selection()
{
await GoToFormsAsync();
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 20));
var value = await Page.Locator("#birthdate").InputValueAsync();
Assert.That(value, Is.EqualTo("1995-03-20"));
}
[Test]
public async Task PreferredTime_Hidden_Input_Gets_Correct_Value_After_Selection()
{
await GoToFormsAsync();
await SelectTimeAsync("trigger-preferredtime", 9, 15);
var value = await Page.Locator("#preferredtime").InputValueAsync();
Assert.That(value, Is.EqualTo("09:15"));
}
[Test]
public async Task Submit_With_Valid_Date_Time_DateTime_Via_Hidden_Input_Succeeds()
{
await GoToFormsAsync();
await FillAllValidFields();
await SetHiddenInputValue("birthdate", "2000-06-15");
await SetHiddenInputValue("preferredtime", "14:30");
await SetHiddenInputValue("appointment", "2025-12-25T10:30");
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync();
await Expect(success).ToContainTextAsync("Form submitted successfully");
}
[Test]
public async Task Submit_With_Empty_Optional_DateTime_Fields_Succeeds()
{
await GoToFormsAsync();
await FillAllValidFields();
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync();
await Expect(FieldError("birthdate")).ToBeHiddenAsync();
await Expect(FieldError("preferredtime")).ToBeHiddenAsync();
await Expect(FieldError("appointment")).ToBeHiddenAsync();
}
[Test]
public async Task Submit_All_Fields_Including_DateTime_Shows_Success()
{
await GoToFormsAsync();
await FillAllValidFields();
await SelectDateAsync("trigger-birthdate", new DateOnly(1990, 1, 15));
await SelectTimeAsync("trigger-preferredtime", 16, 0);
await SelectDateAsync("trigger-appointment-date", new DateOnly(2026, 6, 1));
await SelectTimeAsync("trigger-appointment-time", 9, 0);
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
var success = Page.Locator("[data-testid='success-message']");
await Expect(success).ToBeVisibleAsync();
await Expect(success).ToContainTextAsync("Form submitted successfully");
}
[Test]
public async Task Reset_Clears_DateTime_Error_Placeholders()
{
await GoToFormsAsync();
await Btn("btn-submit").ClickAsync();
await Page.WaitForTimeoutAsync(500);
await Btn("btn-reset").ClickAsync();
await Page.WaitForTimeoutAsync(300);
await Expect(FieldError("birthdate")).ToBeHiddenAsync();
await Expect(FieldError("preferredtime")).ToBeHiddenAsync();
await Expect(FieldError("appointment")).ToBeHiddenAsync();
}
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>The card's inner content (CardHeader, CardContent, CardFooter, etc.).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes appended to the card wrapper.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div data-slot="card-action" class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Action content rendered at the trailing edge of the header (e.g. a link or button).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"self-start ml-auto";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,21 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Main body content of the card.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass = "p-6 pt-0";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<p data-slot="card-description" class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</p>
@code {
/// <summary>Description text or markup rendered below the title.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"text-sm text-muted-foreground";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Footer content — typically action buttons.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"flex items-center p-6 pt-0";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Header content — typically CardTitle, CardDescription, and CardAction.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"flex flex-col gap-1.5 p-6 has-[data-slot=card-action]:grid has-[data-slot=card-action]:grid-cols-[1fr_auto] has-[data-slot=card-action]:items-center [&>[data-slot=card-description]]:col-span-full";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,31 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedWrapperClass">
<img src="@Src" alt="@Alt" class="@ComputedImageClass" @attributes="AdditionalAttributes" />
</div>
@code {
/// <summary>Image source URL.</summary>
[Parameter, EditorRequired] public string Src { get; set; } = string.Empty;
/// <summary>Image alt text for accessibility.</summary>
[Parameter] public string Alt { get; set; } = string.Empty;
/// <summary>Additional CSS classes for the image element.</summary>
[Parameter] public string? Class { get; set; }
/// <summary>Additional CSS classes for the wrapper div.</summary>
[Parameter] public string? WrapperClass { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseImageClass = "w-full h-full object-cover";
private const string BaseWrapperClass = "overflow-hidden";
private string ComputedImageClass =>
string.IsNullOrEmpty(Class) ? BaseImageClass : $"{BaseImageClass} {Class}";
private string ComputedWrapperClass =>
string.IsNullOrEmpty(WrapperClass) ? BaseWrapperClass : $"{BaseWrapperClass} {WrapperClass}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<h3 class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</h3>
@code {
/// <summary>Title text or markup.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"text-lg font-semibold leading-none tracking-tight";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -1,9 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Razor"> <Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<IsPackable>true</IsPackable>
<PackageId>Enciphered.Blazor.UIComponents</PackageId>
<Version>0.0.1</Version>
<Authors>shaamilahmed</Authors>
<Company>nciphered</Company>
<Description>Pure static SSR Blazor component library styled with Tailwind CSS v4 and shadcn/ui. All interactivity powered by vanilla JS. htmx-powered form validation.</Description>
<PackageTags>blazor;ui;components;tailwind;htmx;ssr;static</PackageTags>
<RepositoryUrl>https://git.nciphered.com/shaamilahmed/Enciphered.Blazor.UIComponents</RepositoryUrl>
<PackageLicenseExpression></PackageLicenseExpression>
<PackageProjectUrl>https://git.nciphered.com/shaamilahmed/Enciphered.Blazor.UIComponents</PackageProjectUrl>
<PackageIcon>enci_white.png</PackageIcon>
</PropertyGroup> </PropertyGroup>
<Target Name="TailwindBuild" BeforeTargets="Build"> <Target Name="TailwindBuild" BeforeTargets="Build">
@@ -12,10 +25,11 @@
<ItemGroup> <ItemGroup>
<SupportedPlatform Include="browser" /> <SupportedPlatform Include="browser" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.14" /> <None Include="./Logo/enci_white.png" Pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -3,7 +3,6 @@
<button type="@Type" <button type="@Type"
disabled="@Disabled" disabled="@Disabled"
class="@ComputedClass" class="@ComputedClass"
@onclick="OnClick"
@attributes="AdditionalAttributes"> @attributes="AdditionalAttributes">
@if (Icon is not null) @if (Icon is not null)
{ {
@@ -18,19 +17,9 @@
[Parameter] public string Type { get; set; } = "button"; [Parameter] public string Type { get; set; } = "button";
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
/// <summary>
/// Visual variant — accepts any <see cref="ButtonVariant"/> constant
/// or a custom Tailwind class string.
/// </summary>
[Parameter] public string Variant { get; set; } = ButtonVariant.Default; [Parameter] public string Variant { get; set; } = ButtonVariant.Default;
/// <summary>
/// Size preset — accepts any <see cref="ButtonSize"/> constant
/// or a custom Tailwind class string.
/// </summary>
[Parameter] public string Size { get; set; } = ButtonSize.Default; [Parameter] public string Size { get; set; } = ButtonSize.Default;
[Parameter] public EventCallback<Microsoft.AspNetCore.Components.Web.MouseEventArgs> OnClick { get; set; }
[Parameter] public string? Class { get; set; } [Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)] [Parameter(CaptureUnmatchedValues = true)]
@@ -1,7 +1,26 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
@* ── shadcn/ui-style calendar grid ────────────────────────────────────── *@ @* ── shadcn/ui-style calendar grid (JS-driven) ───────────────────────── *@
<div class="p-4" @attributes="AdditionalAttributes"> @{
var displayDate = SelectedDate ?? DateOnly.FromDateTime(DateTime.Today);
var yearRangeStart = displayDate.Year - 10;
var yearRangeEnd = displayDate.Year + 10;
var selectedStr = SelectedDate.HasValue
? SelectedDate.Value.ToString("yyyy-MM-dd")
: "";
}
<div class="p-4"
data-calendar
data-display-year="@displayDate.Year"
data-display-month="@displayDate.Month"
data-selected-date="@selectedStr"
data-view="days"
data-year-range-start="@yearRangeStart"
data-year-range-end="@yearRangeEnd"
data-linked-input="@LinkedInputId"
@attributes="AdditionalAttributes">
@* ── Month / Year navigation ── *@ @* ── Month / Year navigation ── *@
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<button type="button" <button type="button"
@@ -9,7 +28,7 @@
text-muted-foreground hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:bg-accent hover:text-accent-foreground
transition-colors cursor-pointer" transition-colors cursor-pointer"
aria-label="Previous month" aria-label="Previous month"
@onclick="Previous"> data-calendar-prev>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <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"> fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6"/> <path d="m15 18-6-6 6-6"/>
@@ -19,15 +38,13 @@
<div class="flex items-center gap-1.5 text-sm font-medium"> <div class="flex items-center gap-1.5 text-sm font-medium">
<button type="button" <button type="button"
data-calendar-month data-calendar-month
class="px-2 py-0.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer" class="px-2 py-0.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer">
@onclick="ToggleMonthPicker"> @displayDate.ToString("MMM")
@_displayDate.ToString("MMM")
</button> </button>
<button type="button" <button type="button"
data-calendar-year data-calendar-year
class="px-2 py-0.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer" class="px-2 py-0.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer">
@onclick="ToggleYearPicker"> @displayDate.Year
@_displayDate.Year
</button> </button>
</div> </div>
@@ -36,7 +53,7 @@
text-muted-foreground hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:bg-accent hover:text-accent-foreground
transition-colors cursor-pointer" transition-colors cursor-pointer"
aria-label="Next month" aria-label="Next month"
@onclick="Next"> data-calendar-next>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <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"> fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"/> <path d="m9 18 6-6-6-6"/>
@@ -44,215 +61,51 @@
</button> </button>
</div> </div>
@if (_showMonthPicker) @* ── Dynamic content area (rendered by JS) ── *@
{ <div data-calendar-content>
@* ── Month picker grid ── *@ @* Server-rendered initial day grid for SSR — JS will take over *@
<div class="grid grid-cols-3 gap-2 py-2"> @{
@for (int m = 1; m <= 12; m++) var dayHeaders = new[] { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" };
{ var firstOfMonth = new DateOnly(displayDate.Year, displayDate.Month, 1);
var month = m; var startOffset = (int)firstOfMonth.DayOfWeek;
var isCurrentMonth = _displayDate.Month == month; var start = firstOfMonth.AddDays(-startOffset);
<button type="button" var today = DateOnly.FromDateTime(DateTime.Today);
class="@($"h-9 rounded-md text-sm transition-colors cursor-pointer {(isCurrentMonth ? "bg-primary text-primary-foreground" : "hover:bg-accent hover:text-accent-foreground")}")"
@onclick="() => SelectMonth(month)">
@(new DateOnly(2000, month, 1).ToString("MMM"))
</button>
} }
</div>
}
else if (_showYearPicker)
{
@* ── Year picker grid ── *@
<div class="grid grid-cols-4 gap-2 py-2 max-h-52 overflow-y-auto scrollbar-thin pr-1">
@for (int y = _yearRangeStart; y <= _yearRangeEnd; y++)
{
var year = y;
var isCurrentYear = _displayDate.Year == year;
<button type="button"
class="@($"h-9 rounded-md text-sm transition-colors cursor-pointer {(isCurrentYear ? "bg-primary text-primary-foreground" : "hover:bg-accent hover:text-accent-foreground")}")"
@onclick="() => SelectYear(year)">
@year
</button>
}
</div>
}
else
{
@* ── Day-of-week headers ── *@
<div class="grid grid-cols-7 gap-1 mb-1"> <div class="grid grid-cols-7 gap-1 mb-1">
@foreach (var dow in _dayHeaders) @foreach (var dow in dayHeaders)
{ {
<div class="h-9 w-9 flex items-center justify-center text-[0.8rem] text-muted-foreground font-medium"> <div class="h-9 w-9 flex items-center justify-center text-[0.8rem] text-muted-foreground font-medium">@dow</div>
@dow
</div>
} }
</div> </div>
@* ── Day grid ── *@
<div class="grid grid-cols-7 gap-1"> <div class="grid grid-cols-7 gap-1">
@foreach (var day in GetCalendarDays()) @for (int i = 0; i < 42; i++)
{ {
var d = day; var d = start.AddDays(i);
var isOutside = d.Month != displayDate.Month;
var isSelected = SelectedDate.HasValue && d == SelectedDate.Value; var isSelected = SelectedDate.HasValue && d == SelectedDate.Value;
var isToday = d == DateOnly.FromDateTime(DateTime.Today); var isToday = d == today;
var isOutside = d.Month != _displayDate.Month; var dateStr = d.ToString("yyyy-MM-dd");
<button type="button" var cls = "h-9 w-9 inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer";
class="@DayCellClass(isSelected, isToday, isOutside)" if (isSelected) { cls += " bg-primary text-primary-foreground font-semibold"; }
disabled="@isOutside" else if (isOutside) { cls += " text-muted-foreground/40 cursor-default"; }
@onclick="() => SelectDay(d)"> else if (isToday) { cls += " bg-accent text-accent-foreground font-medium"; }
@d.Day else { cls += " hover:bg-accent hover:text-accent-foreground"; }
</button>
<button type="button" class="@cls" disabled="@isOutside" data-calendar-day="@dateStr">@d.Day</button>
} }
</div> </div>
} </div>
</div> </div>
@code { @code {
/// <summary>The currently selected date (two-way bindable).</summary> /// <summary>The currently selected date.</summary>
[Parameter] public DateOnly? SelectedDate { get; set; } [Parameter] public DateOnly? SelectedDate { get; set; }
[Parameter] public EventCallback<DateOnly?> SelectedDateChanged { get; set; }
/// <summary>The id of the linked hidden input to update when a day is selected.</summary>
[Parameter] public string? LinkedInputId { get; set; }
/// <summary>Any extra HTML attributes (data-testid, etc.).</summary> /// <summary>Any extra HTML attributes (data-testid, etc.).</summary>
[Parameter(CaptureUnmatchedValues = true)] [Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; } public Dictionary<string, object>? AdditionalAttributes { get; set; }
private DateOnly _displayDate;
private bool _showMonthPicker;
private bool _showYearPicker;
private int _yearRangeStart;
private int _yearRangeEnd;
private static readonly string[] _dayHeaders = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
protected override void OnInitialized()
{
_displayDate = SelectedDate ?? DateOnly.FromDateTime(DateTime.Today);
UpdateYearRange();
}
protected override void OnParametersSet()
{
// If the selected date changes externally, navigate to that month
if (SelectedDate.HasValue && (SelectedDate.Value.Year != _displayDate.Year || SelectedDate.Value.Month != _displayDate.Month))
{
_displayDate = new DateOnly(SelectedDate.Value.Year, SelectedDate.Value.Month, 1);
UpdateYearRange();
}
}
// ── Navigation ───────────────────────────────────────────────────────
private void Previous()
{
if (_showYearPicker)
{
// Shift year range back by 20
_yearRangeStart -= 20;
_yearRangeEnd -= 20;
}
else if (_showMonthPicker)
{
_displayDate = new DateOnly(_displayDate.Year - 1, _displayDate.Month, 1);
UpdateYearRange();
}
else
{
_displayDate = _displayDate.AddMonths(-1);
UpdateYearRange();
}
}
private void Next()
{
if (_showYearPicker)
{
// Shift year range forward by 20
_yearRangeStart += 20;
_yearRangeEnd += 20;
}
else if (_showMonthPicker)
{
_displayDate = new DateOnly(_displayDate.Year + 1, _displayDate.Month, 1);
UpdateYearRange();
}
else
{
_displayDate = _displayDate.AddMonths(1);
UpdateYearRange();
}
}
private void ToggleMonthPicker()
{
_showMonthPicker = !_showMonthPicker;
_showYearPicker = false;
}
private void ToggleYearPicker()
{
_showYearPicker = !_showYearPicker;
_showMonthPicker = false;
}
private void SelectMonth(int month)
{
_displayDate = new DateOnly(_displayDate.Year, month, 1);
_showMonthPicker = false;
}
private void SelectYear(int year)
{
_displayDate = new DateOnly(year, _displayDate.Month, 1);
_showYearPicker = false;
UpdateYearRange();
}
private void UpdateYearRange()
{
_yearRangeStart = _displayDate.Year - 10;
_yearRangeEnd = _displayDate.Year + 10;
}
// ── Day selection ────────────────────────────────────────────────────
private async Task SelectDay(DateOnly date)
{
SelectedDate = date;
_displayDate = new DateOnly(date.Year, date.Month, 1);
await SelectedDateChanged.InvokeAsync(date);
}
// ── Calendar generation ──────────────────────────────────────────────
private List<DateOnly> GetCalendarDays()
{
var days = new List<DateOnly>();
var firstOfMonth = new DateOnly(_displayDate.Year, _displayDate.Month, 1);
var startOffset = (int)firstOfMonth.DayOfWeek; // Sunday = 0
// Previous month's trailing days
var start = firstOfMonth.AddDays(-startOffset);
// Always show 6 rows (42 cells) for consistent height
for (int i = 0; i < 42; i++)
days.Add(start.AddDays(i));
return days;
}
// ── CSS ──────────────────────────────────────────────────────────────
private static string DayCellClass(bool isSelected, bool isToday, bool isOutside)
{
const string baseClass = "h-9 w-9 inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer";
if (isSelected)
return $"{baseClass} bg-primary text-primary-foreground font-semibold";
if (isOutside)
return $"{baseClass} text-muted-foreground/40 cursor-default";
if (isToday)
return $"{baseClass} bg-accent text-accent-foreground font-medium";
return $"{baseClass} hover:bg-accent hover:text-accent-foreground";
}
} }
@@ -1,25 +1,24 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
@inherits InputBase<DateOnly?> @inherits InputBase<DateOnly?>
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
<input type="date" <input type="date"
id="@Id" id="@Id"
name="@Name" name="@Name"
value="@FormatValue()" value="@FormatValue()"
data-trigger-id="@($"trigger-{Id}")"
data-placeholder="@(Placeholder ?? "Select date")"
class="sr-only" class="sr-only"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
disabled="@Disabled" disabled="@Disabled"
@attributes="AdditionalAttributes" /> @attributes="MergedAttributes" />
<Popover @ref="_popover"> <Popover>
<Trigger> <Trigger>
<button type="button" <button type="button"
disabled="@Disabled" disabled="@Disabled"
data-testid="@($"trigger-{Id}")" data-testid="@($"trigger-{Id}")"
class="@TriggerClass" class="@TriggerClass">
@onclick="() => _popover?.Toggle()">
@* Lucide calendar icon *@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="mr-2 shrink-0 text-muted-foreground"> class="mr-2 shrink-0 text-muted-foreground">
@@ -33,7 +32,7 @@
</button> </button>
</Trigger> </Trigger>
<Content> <Content>
<Calendar SelectedDate="@Value" SelectedDateChanged="OnCalendarDateChanged" /> <Calendar SelectedDate="@Value" LinkedInputId="@Id" />
</Content> </Content>
</Popover> </Popover>
@@ -41,8 +40,6 @@
[Parameter] public string? Min { get; set; } [Parameter] public string? Min { get; set; }
[Parameter] public string? Max { get; set; } [Parameter] public string? Max { get; set; }
private Popover? _popover;
private string? FormatValue() => private string? FormatValue() =>
Value?.ToString("yyyy-MM-dd"); Value?.ToString("yyyy-MM-dd");
@@ -56,26 +53,9 @@
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
"disabled:cursor-not-allowed disabled:opacity-50"; "disabled:cursor-not-allowed disabled:opacity-50";
var validation = GetTriggerValidationClass();
return string.IsNullOrEmpty(Class) return string.IsNullOrEmpty(Class)
? $"{baseClass} {validation}" ? $"{baseClass} border-input"
: $"{baseClass} {validation} {Class}"; : $"{baseClass} border-input {Class}";
} }
} }
[CascadingParameter] private EditContext? _cascadedEditContext { get; set; }
private string GetTriggerValidationClass()
{
if (_cascadedEditContext is null || FieldId is not { } fi) return "border-input";
return _cascadedEditContext.GetValidationMessages(fi).Any()
? "border-destructive focus-visible:ring-destructive"
: "border-input";
}
private async Task OnCalendarDateChanged(DateOnly? date)
{
await SetValueAsync(date);
_popover?.Close();
}
} }
@@ -1,7 +1,6 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
@inherits InputBase<DateTime?> @inherits InputBase<DateTime?>
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
<input type="datetime-local" <input type="datetime-local"
id="@Id" id="@Id"
name="@Name" name="@Name"
@@ -10,19 +9,28 @@
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
disabled="@Disabled" disabled="@Disabled"
@attributes="AdditionalAttributes" /> @attributes="MergedAttributes" />
<input type="hidden" id="@($"{Id}-date-part")"
value="@(SelectedDateOnly?.ToString("yyyy-MM-dd") ?? "")"
data-trigger-id="@($"trigger-{Id}-date")"
data-placeholder="@(Placeholder ?? "Select date")"
data-datetime-part="date"
data-datetime-input-id="@Id" />
<input type="hidden" id="@($"{Id}-time-part")"
value="@(SelectedTimeOnly.HasValue ? SelectedTimeOnly.Value.ToString("HH:mm") : "")"
data-trigger-id="@($"trigger-{Id}-time")"
data-placeholder="Select time"
data-datetime-part="time"
data-datetime-input-id="@Id" />
@* ── Two side-by-side triggers: date field + time field ── *@
<div class="flex gap-2"> <div class="flex gap-2">
@* ── Date portion ── *@ <Popover>
<Popover @ref="_datePopover">
<Trigger> <Trigger>
<button type="button" <button type="button"
disabled="@Disabled" disabled="@Disabled"
data-testid="@($"trigger-{Id}-date")" data-testid="@($"trigger-{Id}-date")"
class="@TriggerClass" class="@TriggerClass">
@onclick="() => _datePopover?.Toggle()">
@* Lucide calendar icon *@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="mr-2 shrink-0 text-muted-foreground"> class="mr-2 shrink-0 text-muted-foreground">
@@ -36,19 +44,16 @@
</button> </button>
</Trigger> </Trigger>
<Content> <Content>
<Calendar SelectedDate="@SelectedDateOnly" SelectedDateChanged="OnDatePartChanged" /> <Calendar SelectedDate="@SelectedDateOnly" LinkedInputId="@($"{Id}-date-part")" />
</Content> </Content>
</Popover> </Popover>
@* ── Time portion ── *@ <Popover>
<Popover @ref="_timePopover">
<Trigger> <Trigger>
<button type="button" <button type="button"
disabled="@Disabled" disabled="@Disabled"
data-testid="@($"trigger-{Id}-time")" data-testid="@($"trigger-{Id}-time")"
class="@TriggerClass" class="@TriggerClass">
@onclick="() => _timePopover?.Toggle()">
@* Lucide clock icon *@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="mr-2 shrink-0 text-muted-foreground"> class="mr-2 shrink-0 text-muted-foreground">
@@ -61,7 +66,7 @@
</button> </button>
</Trigger> </Trigger>
<Content> <Content>
<TimePicker SelectedTime="@SelectedTimeOnly" SelectedTimeChanged="OnTimePartChanged" Use12Hour="true" /> <TimePicker SelectedTime="@SelectedTimeOnly" Use12Hour="true" LinkedInputId="@($"{Id}-time-part")" />
</Content> </Content>
</Popover> </Popover>
</div> </div>
@@ -70,14 +75,8 @@
[Parameter] public string? Min { get; set; } [Parameter] public string? Min { get; set; }
[Parameter] public string? Max { get; set; } [Parameter] public string? Max { get; set; }
/// <summary>
/// Step in seconds. Use "1" for second precision, "60" (default) for minutes only.
/// </summary>
[Parameter] public string? Step { get; set; } [Parameter] public string? Step { get; set; }
private Popover? _datePopover;
private Popover? _timePopover;
private DateOnly? SelectedDateOnly => private DateOnly? SelectedDateOnly =>
Value.HasValue ? DateOnly.FromDateTime(Value.Value) : null; Value.HasValue ? DateOnly.FromDateTime(Value.Value) : null;
@@ -97,35 +96,9 @@
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
"disabled:cursor-not-allowed disabled:opacity-50"; "disabled:cursor-not-allowed disabled:opacity-50";
var validation = GetTriggerValidationClass();
return string.IsNullOrEmpty(Class) return string.IsNullOrEmpty(Class)
? $"{baseClass} {validation}" ? $"{baseClass} border-input"
: $"{baseClass} {validation} {Class}"; : $"{baseClass} border-input {Class}";
} }
} }
[CascadingParameter] private EditContext? _cascadedEditContext { get; set; }
private string GetTriggerValidationClass()
{
if (_cascadedEditContext is null || FieldId is not { } fi) return "border-input";
return _cascadedEditContext.GetValidationMessages(fi).Any()
? "border-destructive focus-visible:ring-destructive"
: "border-input";
}
private async Task OnDatePartChanged(DateOnly? date)
{
if (date is null) return;
var timePart = SelectedTimeOnly ?? new TimeOnly(0, 0);
await SetValueAsync(date.Value.ToDateTime(timePart));
_datePopover?.Close();
}
private async Task OnTimePartChanged(TimeOnly? time)
{
if (time is null) return;
var datePart = SelectedDateOnly ?? DateOnly.FromDateTime(DateTime.Today);
await SetValueAsync(datePart.ToDateTime(time.Value));
}
} }
@@ -1,6 +1,6 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass"> <div class="@ComputedClass" data-form-field="@For">
@if (!string.IsNullOrEmpty(Label)) @if (!string.IsNullOrEmpty(Label))
{ {
<label for="@For" <label for="@For"
@@ -9,7 +9,9 @@
</label> </label>
} }
<CascadingValue Value="@For" Name="FieldName">
@ChildContent @ChildContent
</CascadingValue>
@if (!string.IsNullOrEmpty(Description)) @if (!string.IsNullOrEmpty(Description))
{ {
@@ -18,33 +20,23 @@
@if (!string.IsNullOrEmpty(Error)) @if (!string.IsNullOrEmpty(Error))
{ {
<p class="text-[0.8rem] font-medium text-destructive">@Error</p> <p data-field-error="@For" class="text-[0.8rem] font-medium text-destructive">@Error</p>
}
else
{
<p data-field-error="@For" class="text-[0.8rem] font-medium text-destructive hidden"></p>
} }
</div> </div>
@code { @code {
/// <summary>Label text displayed above the input.</summary>
[Parameter] public string? Label { get; set; } [Parameter] public string? Label { get; set; }
/// <summary>The id of the associated input (for the label's "for" attribute).</summary>
[Parameter] public string? For { get; set; } [Parameter] public string? For { get; set; }
/// <summary>Help text displayed below the input.</summary>
[Parameter] public string? Description { get; set; } [Parameter] public string? Description { get; set; }
/// <summary>Validation error message displayed below the input.</summary>
[Parameter] public string? Error { get; set; } [Parameter] public string? Error { get; set; }
/// <summary>The input component(s) to render inside the field.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes for the wrapper div.</summary>
[Parameter] public string? Class { get; set; } [Parameter] public string? Class { get; set; }
/// <summary>Additional CSS classes for the label element.</summary>
[Parameter] public string? LabelClass { get; set; } [Parameter] public string? LabelClass { get; set; }
private const string BaseClass = "space-y-2"; private const string BaseClass = "space-y-2";
private string ComputedClass => string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}"; private string ComputedClass => string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
} }
@@ -0,0 +1,30 @@
@namespace Enciphered.Blazor.UIComponents
<CascadingValue Value="@ValidationEndpoint" Name="ValidationEndpoint">
<form hx-post="@SubmitUrl"
hx-target="@ResultSelector"
hx-swap="outerHTML"
data-enhance="false"
class="@ComputedClass"
@attributes="AdditionalAttributes">
<div class="space-y-4">
@ChildContent
</div>
<div id="@ResultId" class="hidden"></div>
</form>
</CascadingValue>
@code {
[Parameter, EditorRequired] public string Endpoint { get; set; } = "";
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter] public string ResultId { get; set; } = "form-result";
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }
private string ValidationEndpoint => $"{Endpoint.TrimEnd('/')}/validate";
private string SubmitUrl => $"{Endpoint.TrimEnd('/')}/submit";
private string ResultSelector => $"#{ResultId}";
private string ComputedClass => Class ?? "";
}
@@ -1,25 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
namespace Enciphered.Blazor.UIComponents; namespace Enciphered.Blazor.UIComponents;
/// <summary>
/// Abstract base for all form input components. Provides two-way binding
/// (<see cref="Value"/>/<see cref="ValueChanged"/>/<see cref="ValueExpression"/>),
/// optional <see cref="EditContext"/> integration for validation CSS,
/// and attribute splatting via <see cref="AdditionalAttributes"/>.
/// </summary>
public abstract class InputBase<TValue> : ComponentBase public abstract class InputBase<TValue> : ComponentBase
{ {
// ── Two-way binding triad ────────────────────────────────────────────────
[Parameter] public TValue? Value { get; set; } [Parameter] public TValue? Value { get; set; }
[Parameter] public EventCallback<TValue> ValueChanged { get; set; }
[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }
// ── Common parameters ────────────────────────────────────────────────────
[Parameter] public string? Id { get; set; } [Parameter] public string? Id { get; set; }
[Parameter] public string? Name { get; set; } [Parameter] public string? Name { get; set; }
@@ -28,46 +13,14 @@ public abstract class InputBase<TValue> : ComponentBase
[Parameter] public string? Placeholder { get; set; } [Parameter] public string? Placeholder { get; set; }
[Parameter] public string? Class { get; set; } [Parameter] public string? Class { get; set; }
/// <summary>
/// Arbitrary HTML attributes forwarded to the root element via <c>@attributes</c>.
/// Allows consumers to pass <c>required</c>, <c>aria-*</c>, <c>data-*</c>,
/// <c>maxlength</c>, etc. without the component needing to declare them.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] [Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; } public Dictionary<string, object>? AdditionalAttributes { get; set; }
// ── EditContext integration (optional) ──────────────────────────────────── [CascadingParameter(Name = "ValidationEndpoint")]
public string? ValidationEndpoint { get; set; }
[CascadingParameter] private EditContext? EditContext { get; set; } [CascadingParameter(Name = "FieldName")]
public string? FieldName { get; set; }
private FieldIdentifier? _fieldIdentifier;
protected FieldIdentifier? FieldId => _fieldIdentifier;
// ── Lifecycle ────────────────────────────────────────────────────────────
protected override void OnParametersSet()
{
if (ValueExpression is not null)
_fieldIdentifier = FieldIdentifier.Create(ValueExpression);
}
// ── Value helpers ────────────────────────────────────────────────────────
/// <summary>
/// Called by derived components when the user changes the value.
/// Fires <see cref="ValueChanged"/> and notifies the <see cref="EditContext"/>.
/// </summary>
protected async Task SetValueAsync(TValue? value)
{
Value = value;
await ValueChanged.InvokeAsync(value);
if (EditContext is not null && _fieldIdentifier is FieldIdentifier fi)
EditContext.NotifyFieldChanged(fi);
}
// ── CSS helpers ──────────────────────────────────────────────────────────
private const string BaseInputClass = private const string BaseInputClass =
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " + "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " +
@@ -75,26 +28,31 @@ public abstract class InputBase<TValue> : ComponentBase
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " + "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
"disabled:cursor-not-allowed disabled:opacity-50"; "disabled:cursor-not-allowed disabled:opacity-50";
/// <summary> protected string ComputedClass =>
/// Computes the full CSS class string: base + validation state + consumer override. string.IsNullOrEmpty(Class) ? BaseInputClass : $"{BaseInputClass} {Class}";
/// </summary>
protected string ComputedClass protected Dictionary<string, object> MergedAttributes
{ {
get get
{ {
var validation = GetValidationClass(); var attrs = AdditionalAttributes is not null
return string.IsNullOrEmpty(Class) ? new Dictionary<string, object>(AdditionalAttributes)
? string.IsNullOrEmpty(validation) ? BaseInputClass : $"{BaseInputClass} {validation}" : new Dictionary<string, object>();
: string.IsNullOrEmpty(validation) ? $"{BaseInputClass} {Class}" : $"{BaseInputClass} {validation} {Class}";
}
}
private string? GetValidationClass() if (!string.IsNullOrEmpty(ValidationEndpoint) &&
!string.IsNullOrEmpty(FieldName) &&
!attrs.ContainsKey("hx-post"))
{ {
if (EditContext is null || _fieldIdentifier is not FieldIdentifier fi) attrs["hx-post"] = ValidationEndpoint;
return null; attrs["hx-trigger"] = "blur";
attrs["hx-target"] = "next [data-field-error]";
attrs["hx-swap"] = "outerHTML";
attrs["hx-include"] = "this";
attrs["hx-vals"] = $"{{\"_field\": \"{FieldName}\"}}";
}
var isValid = !EditContext.GetValidationMessages(fi).Any(); return attrs;
return isValid ? "border-input" : "border-destructive focus-visible:ring-destructive";
} }
} }
}
@@ -1,7 +1,7 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
@inherits InputBase<double?> @inherits InputBase<double?>
<div class="relative flex items-center"> <div class="relative flex items-center" data-number-input>
<input id="@Id" <input id="@Id"
name="@Name" name="@Name"
type="number" type="number"
@@ -13,8 +13,7 @@
min="@Min" min="@Min"
max="@Max" max="@Max"
class="@ComputedClass [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none pr-8" class="@ComputedClass [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none pr-8"
@oninput="OnInput" @attributes="MergedAttributes" />
@attributes="AdditionalAttributes" />
@if (!Disabled && !ReadOnly) @if (!Disabled && !ReadOnly)
{ {
@@ -26,7 +25,7 @@
hover:text-foreground hover:bg-accent transition-colors hover:text-foreground hover:bg-accent transition-colors
rounded-tr-md cursor-pointer disabled:cursor-not-allowed disabled:opacity-50" rounded-tr-md cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
disabled="@IsAtMax" disabled="@IsAtMax"
@onclick="Increment"> data-number-increment>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6"/> <path d="m18 15-6-6-6 6"/>
@@ -40,7 +39,7 @@
hover:text-foreground hover:bg-accent transition-colors hover:text-foreground hover:bg-accent transition-colors
rounded-br-md cursor-pointer disabled:cursor-not-allowed disabled:opacity-50" rounded-br-md cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
disabled="@IsAtMin" disabled="@IsAtMin"
@onclick="Decrement"> data-number-decrement>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/> <path d="m6 9 6 6 6-6"/>
@@ -55,38 +54,9 @@
[Parameter] public string? Min { get; set; } [Parameter] public string? Min { get; set; }
[Parameter] public string? Max { get; set; } [Parameter] public string? Max { get; set; }
private double StepValue => double.TryParse(Step, out var s) ? s : 1;
private double? MinValue => double.TryParse(Min, out var m) ? m : null;
private double? MaxValue => double.TryParse(Max, out var m) ? m : null; private double? MaxValue => double.TryParse(Max, out var m) ? m : null;
private double? MinValue => double.TryParse(Min, out var m) ? m : null;
private bool IsAtMax => Value.HasValue && MaxValue.HasValue && Value.Value >= MaxValue.Value; private bool IsAtMax => Value.HasValue && MaxValue.HasValue && Value.Value >= MaxValue.Value;
private bool IsAtMin => Value.HasValue && MinValue.HasValue && Value.Value <= MinValue.Value; private bool IsAtMin => Value.HasValue && MinValue.HasValue && Value.Value <= MinValue.Value;
private async Task OnInput(ChangeEventArgs e)
{
var raw = e.Value?.ToString();
if (double.TryParse(raw, out var parsed))
await SetValueAsync(Clamp(parsed));
else if (string.IsNullOrWhiteSpace(raw))
await SetValueAsync(null);
}
private async Task Increment()
{
var current = Value ?? 0;
await SetValueAsync(Clamp(current + StepValue));
}
private async Task Decrement()
{
var current = Value ?? 0;
await SetValueAsync(Clamp(current - StepValue));
}
private double Clamp(double value)
{
if (MinValue.HasValue && value < MinValue.Value) return MinValue.Value;
if (MaxValue.HasValue && value > MaxValue.Value) return MaxValue.Value;
return value;
}
} }
@@ -1,20 +1,19 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
@* ── Generic popover: trigger + dropdown panel ───────────────────────── *@ @* ── Generic popover: trigger + dropdown panel (JS-driven) ──────────── *@
<div class="relative inline-block w-full" @attributes="AdditionalAttributes"> <div class="relative inline-block w-full" data-popover data-popover-open="false" @attributes="AdditionalAttributes">
<div data-popover-trigger>
@Trigger @Trigger
</div>
@if (_open)
{
@* Backdrop to close on outside click *@ @* Backdrop to close on outside click *@
<div class="fixed inset-0 z-40" @onclick="Close" @onclick:stopPropagation></div> <div class="fixed inset-0 z-40" data-popover-backdrop style="display:none"></div>
<div class="absolute left-0 z-50 mt-1.5 rounded-lg border border-input bg-popover text-popover-foreground shadow-md <div class="absolute left-0 z-50 mt-1.5 rounded-lg border border-input bg-popover text-popover-foreground shadow-md
animate-in fade-in-0 zoom-in-95 origin-top-left" animate-in fade-in-0 zoom-in-95 origin-top-left"
@onclick:stopPropagation> data-popover-panel style="display:none">
@Content @Content
</div> </div>
}
</div> </div>
@code { @code {
@@ -24,35 +23,7 @@
/// <summary>The popover content.</summary> /// <summary>The popover content.</summary>
[Parameter] public RenderFragment? Content { get; set; } [Parameter] public RenderFragment? Content { get; set; }
/// <summary>Any extra HTML attributes.</summary> /// <summary>Any extra HTML attributes (data-testid, etc.).</summary>
[Parameter(CaptureUnmatchedValues = true)] [Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; } public Dictionary<string, object>? AdditionalAttributes { get; set; }
private bool _open;
/// <summary>Toggle the popover open/closed. Call from the trigger button.</summary>
public void Toggle()
{
_open = !_open;
StateHasChanged();
}
/// <summary>Close the popover.</summary>
public void Close()
{
if (!_open) return;
_open = false;
StateHasChanged();
}
/// <summary>Open the popover.</summary>
public void Open()
{
if (_open) return;
_open = true;
StateHasChanged();
}
/// <summary>Whether the popover is currently open.</summary>
public bool IsOpen => _open;
} }
@@ -9,18 +9,8 @@
disabled="@Disabled" disabled="@Disabled"
readonly="@ReadOnly" readonly="@ReadOnly"
class="@ComputedClass" class="@ComputedClass"
@oninput="OnInput" @attributes="MergedAttributes" />
@attributes="AdditionalAttributes" />
@code { @code {
/// <summary>
/// HTML input type. Defaults to "text".
/// Supports: text, email, password, url, tel, search.
/// </summary>
[Parameter] public string Type { get; set; } = "text"; [Parameter] public string Type { get; set; } = "text";
private async Task OnInput(ChangeEventArgs e)
{
await SetValueAsync(e.Value?.ToString());
}
} }
@@ -1,25 +1,24 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
@inherits InputBase<TimeOnly?> @inherits InputBase<TimeOnly?>
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
<input type="time" <input type="time"
id="@Id" id="@Id"
name="@Name" name="@Name"
value="@FormatValue()" value="@FormatValue()"
data-trigger-id="@($"trigger-{Id}")"
data-placeholder="@(Placeholder ?? "Select time")"
class="sr-only" class="sr-only"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
disabled="@Disabled" disabled="@Disabled"
@attributes="AdditionalAttributes" /> @attributes="MergedAttributes" />
<Popover @ref="_popover"> <Popover>
<Trigger> <Trigger>
<button type="button" <button type="button"
disabled="@Disabled" disabled="@Disabled"
data-testid="@($"trigger-{Id}")" data-testid="@($"trigger-{Id}")"
class="@TriggerClass" class="@TriggerClass">
@onclick="() => _popover?.Toggle()">
@* Lucide clock icon *@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" <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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="mr-2 shrink-0 text-muted-foreground"> class="mr-2 shrink-0 text-muted-foreground">
@@ -32,21 +31,14 @@
</button> </button>
</Trigger> </Trigger>
<Content> <Content>
<TimePicker SelectedTime="@Value" SelectedTimeChanged="OnTimeChanged" Use12Hour="true" MinuteStep="@ParsedMinuteStep" /> <TimePicker SelectedTime="@Value" Use12Hour="true" MinuteStep="@ParsedMinuteStep" LinkedInputId="@Id" />
</Content> </Content>
</Popover> </Popover>
@code { @code {
/// <summary>
/// Step in seconds. Use "1" for second precision, "60" (default) for minutes only.
/// </summary>
[Parameter] public string? Step { get; set; } [Parameter] public string? Step { get; set; }
/// <summary>Use 12-hour format with AM/PM. Default is true.</summary>
[Parameter] public bool Use12Hour { get; set; } = true; [Parameter] public bool Use12Hour { get; set; } = true;
private Popover? _popover;
private int ParsedMinuteStep => int.TryParse(Step, out var s) && s >= 60 ? s / 60 : 1; private int ParsedMinuteStep => int.TryParse(Step, out var s) && s >= 60 ? s / 60 : 1;
private string? FormatValue() => private string? FormatValue() =>
@@ -62,25 +54,9 @@
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
"disabled:cursor-not-allowed disabled:opacity-50"; "disabled:cursor-not-allowed disabled:opacity-50";
var validation = GetTriggerValidationClass();
return string.IsNullOrEmpty(Class) return string.IsNullOrEmpty(Class)
? $"{baseClass} {validation}" ? $"{baseClass} border-input"
: $"{baseClass} {validation} {Class}"; : $"{baseClass} border-input {Class}";
} }
} }
[CascadingParameter] private EditContext? _cascadedEditContext { get; set; }
private string GetTriggerValidationClass()
{
if (_cascadedEditContext is null || FieldId is not { } fi) return "border-input";
return _cascadedEditContext.GetValidationMessages(fi).Any()
? "border-destructive focus-visible:ring-destructive"
: "border-input";
}
private async Task OnTimeChanged(TimeOnly? time)
{
await SetValueAsync(time);
}
} }
@@ -1,19 +1,50 @@
@namespace Enciphered.Blazor.UIComponents @namespace Enciphered.Blazor.UIComponents
@* ── shadcn/ui-style time picker with scrollable columns ─────────────── *@ @* ── shadcn/ui-style time picker with scrollable columns (JS-driven) ── *@
<div class="flex items-stretch gap-1 p-4" @attributes="AdditionalAttributes"> @{
var use12 = Use12Hour;
var minStep = MinuteStep < 1 ? 1 : MinuteStep;
var selHour = 0;
var selMinute = 0;
var isPm = false;
if (SelectedTime is { } t)
{
if (use12)
{
isPm = t.Hour >= 12;
selHour = t.Hour % 12;
selMinute = t.Minute;
}
else
{
selHour = t.Hour;
selMinute = t.Minute;
}
}
}
<div class="flex items-stretch gap-1 p-4"
data-timepicker
data-selected-hour="@selHour"
data-selected-minute="@selMinute"
data-selected-pm="@(isPm ? "true" : "false")"
data-use-12-hour="@(use12 ? "true" : "false")"
data-linked-input="@LinkedInputId"
@attributes="AdditionalAttributes">
@* ── Hour column ── *@ @* ── Hour column ── *@
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span class="text-xs font-medium text-muted-foreground mb-2">Hr</span> <span class="text-xs font-medium text-muted-foreground mb-2">Hr</span>
<div class="h-52 w-16 overflow-y-auto flex flex-col gap-1 scrollbar-thin pr-1"> <div class="h-52 w-16 overflow-y-auto flex flex-col gap-1 scrollbar-thin pr-1">
@for (int h = 0; h < (_use12Hour ? 12 : 24); h++) @for (int h = 0; h < (use12 ? 12 : 24); h++)
{ {
var hour = _use12Hour ? (h == 0 ? 12 : h) : h; var hour = use12 ? (h == 0 ? 12 : h) : h;
var hourValue = h; var hourValue = h;
var isSelected = _selectedHour == hourValue; var isSelected = selHour == hourValue;
<button type="button" <button type="button"
class="@TimeItemClass(isSelected)" class="@TimeItemClass(isSelected)"
@onclick="() => SelectHour(hourValue)"> data-tp-hour="@hourValue">
@hour.ToString("D2") @hour.ToString("D2")
</button> </button>
} }
@@ -26,20 +57,20 @@
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span class="text-xs font-medium text-muted-foreground mb-2">Min</span> <span class="text-xs font-medium text-muted-foreground mb-2">Min</span>
<div class="h-52 w-16 overflow-y-auto flex flex-col gap-1 scrollbar-thin pr-1"> <div class="h-52 w-16 overflow-y-auto flex flex-col gap-1 scrollbar-thin pr-1">
@for (int m = 0; m < 60; m += _minuteStep) @for (int m = 0; m < 60; m += minStep)
{ {
var minute = m; var minute = m;
var isSelected = _selectedMinute == minute; var isSel = selMinute == minute;
<button type="button" <button type="button"
class="@TimeItemClass(isSelected)" class="@TimeItemClass(isSel)"
@onclick="() => SelectMinute(minute)"> data-tp-minute="@minute">
@minute.ToString("D2") @minute.ToString("D2")
</button> </button>
} }
</div> </div>
</div> </div>
@if (_use12Hour) @if (use12)
{ {
<div class="flex items-center px-1"></div> <div class="flex items-center px-1"></div>
@@ -48,13 +79,13 @@
<span class="text-xs font-medium text-muted-foreground mb-2"> </span> <span class="text-xs font-medium text-muted-foreground mb-2"> </span>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<button type="button" <button type="button"
class="@TimeItemClass(!_isPm)" class="@TimeItemClass(!isPm)"
@onclick='() => SelectPeriod(false)'> data-tp-period="am">
AM AM
</button> </button>
<button type="button" <button type="button"
class="@TimeItemClass(_isPm)" class="@TimeItemClass(isPm)"
@onclick='() => SelectPeriod(true)'> data-tp-period="pm">
PM PM
</button> </button>
</div> </div>
@@ -63,9 +94,8 @@
</div> </div>
@code { @code {
/// <summary>The currently selected time (two-way bindable).</summary> /// <summary>The currently selected time (for initial render).</summary>
[Parameter] public TimeOnly? SelectedTime { get; set; } [Parameter] public TimeOnly? SelectedTime { get; set; }
[Parameter] public EventCallback<TimeOnly?> SelectedTimeChanged { get; set; }
/// <summary>Use 12-hour format with AM/PM. Default is false (24-hour).</summary> /// <summary>Use 12-hour format with AM/PM. Default is false (24-hour).</summary>
[Parameter] public bool Use12Hour { get; set; } [Parameter] public bool Use12Hour { get; set; }
@@ -73,74 +103,13 @@
/// <summary>Minute step interval. Default is 1.</summary> /// <summary>Minute step interval. Default is 1.</summary>
[Parameter] public int MinuteStep { get; set; } = 1; [Parameter] public int MinuteStep { get; set; } = 1;
/// <summary>The id of the linked hidden input to sync time value to.</summary>
[Parameter] public string? LinkedInputId { get; set; }
/// <summary>Any extra HTML attributes.</summary> /// <summary>Any extra HTML attributes.</summary>
[Parameter(CaptureUnmatchedValues = true)] [Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; } public Dictionary<string, object>? AdditionalAttributes { get; set; }
private int _selectedHour;
private int _selectedMinute;
private bool _isPm;
private bool _use12Hour;
private int _minuteStep = 1;
protected override void OnInitialized()
{
_use12Hour = Use12Hour;
_minuteStep = MinuteStep < 1 ? 1 : MinuteStep;
ApplyFromValue();
}
protected override void OnParametersSet()
{
_use12Hour = Use12Hour;
_minuteStep = MinuteStep < 1 ? 1 : MinuteStep;
ApplyFromValue();
}
private void ApplyFromValue()
{
if (SelectedTime is { } t)
{
if (_use12Hour)
{
_isPm = t.Hour >= 12;
_selectedHour = t.Hour % 12;
_selectedMinute = t.Minute;
}
else
{
_selectedHour = t.Hour;
_selectedMinute = t.Minute;
}
}
}
private async Task SelectHour(int hour)
{
_selectedHour = hour;
await EmitValue();
}
private async Task SelectMinute(int minute)
{
_selectedMinute = minute;
await EmitValue();
}
private async Task SelectPeriod(bool isPm)
{
_isPm = isPm;
await EmitValue();
}
private async Task EmitValue()
{
var hour = _use12Hour ? (_selectedHour % 12) + (_isPm ? 12 : 0) : _selectedHour;
var time = new TimeOnly(hour, _selectedMinute);
SelectedTime = time;
await SelectedTimeChanged.InvokeAsync(time);
}
private static string TimeItemClass(bool isSelected) private static string TimeItemClass(bool isSelected)
{ {
const string baseClass = "h-9 w-full inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer"; const string baseClass = "h-9 w-full inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer";
@@ -0,0 +1,58 @@
using System.Globalization;
using System.Reflection;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Http;
namespace Enciphered.Blazor.UIComponents.Validation;
public static class FormModelBinder
{
public static TModel Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TModel>(IFormCollection form) where TModel : new()
{
var model = new TModel();
PropertyInfo[] props = typeof(TModel).GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in props)
{
if (!prop.CanWrite) continue;
var key = form.Keys.FirstOrDefault(k =>
string.Equals(k, prop.Name, StringComparison.OrdinalIgnoreCase));
if (key is null) continue;
var raw = form[key].ToString();
var value = ConvertValue(raw, prop.PropertyType);
if (value is not null)
prop.SetValue(model, value);
}
return model;
}
private static object? ConvertValue(string raw, Type target)
{
var underlying = Nullable.GetUnderlyingType(target);
var isNullable = underlying is not null;
var type = underlying ?? target;
if (string.IsNullOrWhiteSpace(raw))
return isNullable ? null : (type == typeof(string) ? "" : null);
if (type == typeof(string)) return raw;
if (type == typeof(int)) return int.TryParse(raw, out var i) ? i : null;
if (type == typeof(long)) return long.TryParse(raw, out var l) ? l : null;
if (type == typeof(double)) return double.TryParse(raw, CultureInfo.InvariantCulture, out var d) ? d : null;
if (type == typeof(decimal)) return decimal.TryParse(raw, CultureInfo.InvariantCulture, out var m) ? m : null;
if (type == typeof(float)) return float.TryParse(raw, CultureInfo.InvariantCulture, out var f) ? f : null;
if (type == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "on" || raw == "1";
if (type == typeof(DateTime)) return DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) ? dt : null;
if (type == typeof(DateOnly)) return DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d2) ? d2 : null;
if (type == typeof(TimeOnly)) return TimeOnly.TryParse(raw, CultureInfo.InvariantCulture, out var t) ? t : null;
if (type == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null;
if (type.IsEnum) return Enum.TryParse(type, raw, ignoreCase: true, out var e) ? e : null;
return null;
}
}
@@ -0,0 +1,71 @@
using System.Text.RegularExpressions;
namespace Enciphered.Blazor.UIComponents.Validation;
public sealed class FormValidationRule
{
public required string Field { get; init; }
public string? DisplayName { get; init; }
public bool Required { get; init; }
public int? MinLength { get; init; }
public int? MaxLength { get; init; }
public string? Pattern { get; init; }
public double? Min { get; init; }
public double? Max { get; init; }
public string? Message { get; init; }
public Func<string, string?>? Custom { get; init; }
public string? Validate(string? value)
{
var label = DisplayName ?? ToTitleCase(Field);
var v = value?.Trim() ?? "";
if (Required && string.IsNullOrWhiteSpace(v))
return $"{label} is required.";
if (string.IsNullOrWhiteSpace(v))
return null;
if (MinLength.HasValue && v.Length < MinLength.Value)
return $"{label} must be at least {MinLength.Value} characters.";
if (MaxLength.HasValue && v.Length > MaxLength.Value)
return $"{label} must be at most {MaxLength.Value} characters.";
if (Pattern is not null && !Regex.IsMatch(v, Pattern))
return Message ?? $"{label} is not in the correct format.";
if (Min.HasValue || Max.HasValue)
{
if (!double.TryParse(v, out var num))
return $"{label} must be a number.";
if (Min.HasValue && num < Min.Value)
return $"{label} must be at least {Min.Value}.";
if (Max.HasValue && num > Max.Value)
return $"{label} must be at most {Max.Value}.";
}
if (Custom is not null)
{
var customError = Custom(v);
if (customError is not null)
return customError;
}
return null;
}
private static string ToTitleCase(string field)
{
if (string.IsNullOrEmpty(field)) return field;
var sb = new System.Text.StringBuilder();
sb.Append(char.ToUpper(field[0]));
for (int i = 1; i < field.Length; i++)
{
if (char.IsUpper(field[i]))
sb.Append(' ');
sb.Append(field[i]);
}
return sb.ToString();
}
}
@@ -0,0 +1,55 @@
namespace Enciphered.Blazor.UIComponents.Validation;
public abstract class FormValidator
{
private readonly List<FormValidationRule> _rules = [];
public IReadOnlyList<FormValidationRule> Rules => _rules;
public IEnumerable<string> Fields => _rules.Select(r => r.Field);
protected void RuleFor(
string field,
string? displayName = null,
bool required = false,
int? minLength = null,
int? maxLength = null,
string? pattern = null,
double? min = null,
double? max = null,
string? message = null,
Func<string, string?>? custom = null)
{
_rules.Add(new FormValidationRule
{
Field = field,
DisplayName = displayName,
Required = required,
MinLength = minLength,
MaxLength = maxLength,
Pattern = pattern,
Min = min,
Max = max,
Message = message,
Custom = custom
});
}
public string? ValidateField(string field, string? value)
{
var rule = _rules.FirstOrDefault(r => r.Field == field);
return rule?.Validate(value);
}
public Dictionary<string, string> ValidateAll(Func<string, string?> valueAccessor)
{
var errors = new Dictionary<string, string>();
foreach (var rule in _rules)
{
var value = valueAccessor(rule.Field);
var error = rule.Validate(value);
if (error is not null)
errors[rule.Field] = error;
}
return errors;
}
}
@@ -0,0 +1,80 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Enciphered.Blazor.UIComponents.Validation;
public static class HtmxFormValidationExtensions
{
public static RouteGroupBuilder MapFormValidation<TValidator>(
this IEndpointRouteBuilder endpoints,
string basePath,
string successMessage = "✓ Form submitted successfully!",
Func<HttpContext, Task>? onSuccess = null)
where TValidator : FormValidator, new()
{
var validator = new TValidator();
return MapEndpoints(endpoints, basePath, validator, successMessage, onSuccess);
}
public static RouteGroupBuilder MapFormValidation<TValidator, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TModel>(
this IEndpointRouteBuilder endpoints,
string basePath,
Func<TModel, Task> onSuccess,
string successMessage = "✓ Form submitted successfully!")
where TValidator : FormValidator, new()
where TModel : new()
{
var validator = new TValidator();
return MapEndpoints(endpoints, basePath, validator, successMessage, async ctx =>
{
var model = FormModelBinder.Bind<TModel>(ctx.Request.Form);
await onSuccess(model);
});
}
private static RouteGroupBuilder MapEndpoints(
IEndpointRouteBuilder endpoints,
string basePath,
FormValidator validator,
string successMessage,
Func<HttpContext, Task>? onSuccess)
{
var group = endpoints.MapGroup(basePath);
group.MapPost("/validate", (HttpContext ctx) =>
{
var form = ctx.Request.Form;
var field = form["_field"].ToString();
var value = form.ContainsKey(field) ? form[field].ToString() : "";
var error = validator.ValidateField(field, value);
var html = HtmxFormValidationRenderer.FieldErrorFragment(field, error);
return Results.Content(html, "text/html");
}).DisableAntiforgery();
group.MapPost("/submit", async (HttpContext ctx) =>
{
var form = ctx.Request.Form;
var errors = validator.ValidateAll(f => form.ContainsKey(f) ? form[f].ToString() : "");
ctx.Response.ContentType = "text/html; charset=utf-8";
if (errors.Count > 0)
{
var html = HtmxFormValidationRenderer.ErrorResponse(validator.Fields, errors);
return Results.Content(html, "text/html");
}
if (onSuccess is not null)
await onSuccess(ctx);
var successHtml = HtmxFormValidationRenderer.SuccessResponse(validator.Fields, successMessage);
return Results.Content(successHtml, "text/html");
}).DisableAntiforgery();
return group;
}
}
@@ -0,0 +1,62 @@
using System.Net;
namespace Enciphered.Blazor.UIComponents.Validation;
public static class HtmxFormValidationRenderer
{
private const string ErrorClasses = "text-[0.8rem] font-medium text-destructive";
private const string HiddenClass = "hidden";
private const string SuccessClasses =
"rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400";
public static string FieldErrorFragment(string field, string? error)
{
var safeField = WebUtility.HtmlEncode(field);
if (string.IsNullOrEmpty(error))
return $"<p data-field-error=\"{safeField}\" class=\"{ErrorClasses} {HiddenClass}\"></p>";
var safeError = WebUtility.HtmlEncode(error);
return $"<p data-field-error=\"{safeField}\" class=\"{ErrorClasses}\">{safeError}</p>";
}
public static string OobFieldErrorFragment(string field, string? error)
{
var safeField = WebUtility.HtmlEncode(field);
var oobAttr = $"hx-swap-oob=\"outerHTML:[data-field-error='{safeField}']\"";
if (string.IsNullOrEmpty(error))
return $"<p data-field-error=\"{safeField}\" {oobAttr} class=\"{ErrorClasses} {HiddenClass}\"></p>";
var safeError = WebUtility.HtmlEncode(error);
return $"<p data-field-error=\"{safeField}\" {oobAttr} class=\"{ErrorClasses}\">{safeError}</p>";
}
public static string ErrorResponse(
IEnumerable<string> allFields,
Dictionary<string, string> errors,
string resultDivId = "form-result")
{
var html = "";
foreach (var field in allFields)
{
errors.TryGetValue(field, out var err);
html += OobFieldErrorFragment(field, err) + "\n";
}
html += $"<div id=\"{resultDivId}\" class=\"{HiddenClass}\"></div>";
return html;
}
public static string SuccessResponse(
IEnumerable<string> allFields,
string successMessage,
string resultDivId = "form-result",
string? testId = "success-message")
{
var html = "";
foreach (var field in allFields)
html += OobFieldErrorFragment(field, null) + "\n";
var testIdAttr = testId is not null ? $" data-testid=\"{testId}\"" : "";
html += $"<div id=\"{resultDivId}\">";
html += $"<div{testIdAttr} class=\"{SuccessClasses}\">";
html += WebUtility.HtmlEncode(successMessage);
html += "</div></div>";
return html;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,26 @@
@namespace Enciphered.Blazor.UIComponents
@typeparam TItem
<div class="w-full overflow-auto rounded-md border border-zinc-800">
<table class="w-full caption-bottom text-sm">
<thead class="bg-zinc-900/50">
<tr class="border-b border-zinc-800 transition-colors">
@Columns
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
@foreach (TItem item in Items)
{
<tr class="border-b border-zinc-800 transition-colors hover:bg-zinc-900/50 text-center">
@RowTemplate(item)
</tr>
}
</tbody>
</table>
</div>
@code {
[Parameter] public required IReadOnlyList<TItem> Items { get; set; }
[Parameter] public required RenderFragment Columns { get; set; }
[Parameter] public required RenderFragment<TItem> RowTemplate { get; set; }
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,619 @@
let initialized = false;
let bodyObserver = null;
function openPopover(wrapper) {
const panel = wrapper.querySelector('[data-popover-panel]');
const backdrop = wrapper.querySelector('[data-popover-backdrop]');
if (!panel) return;
panel.style.display = '';
if (backdrop) backdrop.style.display = '';
wrapper.setAttribute('data-popover-open', 'true');
}
function closePopover(wrapper) {
const panel = wrapper.querySelector('[data-popover-panel]');
const backdrop = wrapper.querySelector('[data-popover-backdrop]');
if (!panel) return;
panel.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
wrapper.setAttribute('data-popover-open', 'false');
}
function togglePopover(wrapper) {
const isOpen = wrapper.getAttribute('data-popover-open') === 'true';
if (isOpen) closePopover(wrapper);
else openPopover(wrapper);
}
const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function getCalendarState(cal) {
return {
displayYear: parseInt(cal.getAttribute('data-display-year')),
displayMonth: parseInt(cal.getAttribute('data-display-month')),
selectedDate: cal.getAttribute('data-selected-date') || '',
view: cal.getAttribute('data-view') || 'days', // 'days' | 'months' | 'years'
yearRangeStart: parseInt(cal.getAttribute('data-year-range-start') || '0'),
yearRangeEnd: parseInt(cal.getAttribute('data-year-range-end') || '0'),
};
}
function setCalendarState(cal, state) {
cal.setAttribute('data-display-year', state.displayYear);
cal.setAttribute('data-display-month', state.displayMonth);
if (state.selectedDate !== undefined) cal.setAttribute('data-selected-date', state.selectedDate);
cal.setAttribute('data-view', state.view);
cal.setAttribute('data-year-range-start', state.yearRangeStart);
cal.setAttribute('data-year-range-end', state.yearRangeEnd);
renderCalendar(cal);
}
function getCalendarDays(year, month) {
const days = [];
const firstOfMonth = new Date(year, month - 1, 1);
const startOffset = firstOfMonth.getDay(); // Sunday = 0
const start = new Date(firstOfMonth);
start.setDate(start.getDate() - startOffset);
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(d.getDate() + i);
days.push(d);
}
return days;
}
function formatDateISO(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function parseDateISO(str) {
if (!str) return null;
const [y, m, d] = str.split('-').map(Number);
return new Date(y, m - 1, d);
}
function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
function renderCalendar(cal) {
const state = getCalendarState(cal);
const contentEl = cal.querySelector('[data-calendar-content]');
if (!contentEl) return;
const monthLabel = cal.querySelector('[data-calendar-month]');
const yearLabel = cal.querySelector('[data-calendar-year]');
if (monthLabel) monthLabel.textContent = MONTH_NAMES_SHORT[state.displayMonth - 1];
if (yearLabel) yearLabel.textContent = state.displayYear;
const selectedParsed = parseDateISO(state.selectedDate);
const today = new Date();
if (state.view === 'months') {
contentEl.innerHTML = renderMonthPicker(state);
} else if (state.view === 'years') {
contentEl.innerHTML = renderYearPicker(state);
} else {
contentEl.innerHTML = renderDayGrid(state, selectedParsed, today);
}
}
function renderDayGrid(state, selectedParsed, today) {
const baseBtn = 'h-9 w-9 inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer';
let html = '<div class="grid grid-cols-7 gap-1 mb-1">';
for (const dow of DAY_HEADERS) {
html += `<div class="h-9 w-9 flex items-center justify-center text-[0.8rem] text-muted-foreground font-medium">${dow}</div>`;
}
html += '</div><div class="grid grid-cols-7 gap-1">';
const days = getCalendarDays(state.displayYear, state.displayMonth);
for (const d of days) {
const isOutside = d.getMonth() !== state.displayMonth - 1;
const isSelected = selectedParsed && isSameDay(d, selectedParsed);
const isToday = isSameDay(d, today);
const dateStr = formatDateISO(d);
let cls = baseBtn;
if (isSelected) cls += ' bg-primary text-primary-foreground font-semibold';
else if (isOutside) cls += ' text-muted-foreground/40 cursor-default';
else if (isToday) cls += ' bg-accent text-accent-foreground font-medium';
else cls += ' hover:bg-accent hover:text-accent-foreground';
html += `<button type="button" class="${cls}" ${isOutside ? 'disabled' : ''} data-calendar-day="${dateStr}">${d.getDate()}</button>`;
}
html += '</div>';
return html;
}
function renderMonthPicker(state) {
let html = '<div class="grid grid-cols-3 gap-2 py-2">';
for (let m = 0; m < 12; m++) {
const isCurrent = state.displayMonth === m + 1;
const cls = isCurrent
? 'h-9 rounded-md text-sm transition-colors cursor-pointer bg-primary text-primary-foreground'
: 'h-9 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground';
html += `<button type="button" class="${cls}" data-calendar-select-month="${m + 1}">${MONTH_NAMES_SHORT[m]}</button>`;
}
html += '</div>';
return html;
}
function renderYearPicker(state) {
let html = '<div class="grid grid-cols-4 gap-2 py-2 max-h-52 overflow-y-auto scrollbar-thin pr-1">';
for (let y = state.yearRangeStart; y <= state.yearRangeEnd; y++) {
const isCurrent = state.displayYear === y;
const cls = isCurrent
? 'h-9 rounded-md text-sm transition-colors cursor-pointer bg-primary text-primary-foreground'
: 'h-9 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground';
html += `<button type="button" class="${cls}" data-calendar-select-year="${y}">${y}</button>`;
}
html += '</div>';
return html;
}
function calendarPrev(cal) {
const state = getCalendarState(cal);
if (state.view === 'years') {
state.yearRangeStart -= 20;
state.yearRangeEnd -= 20;
} else if (state.view === 'months') {
state.displayYear--;
state.yearRangeStart = state.displayYear - 10;
state.yearRangeEnd = state.displayYear + 10;
} else {
state.displayMonth--;
if (state.displayMonth < 1) { state.displayMonth = 12; state.displayYear--; }
state.yearRangeStart = state.displayYear - 10;
state.yearRangeEnd = state.displayYear + 10;
}
setCalendarState(cal, state);
}
function calendarNext(cal) {
const state = getCalendarState(cal);
if (state.view === 'years') {
state.yearRangeStart += 20;
state.yearRangeEnd += 20;
} else if (state.view === 'months') {
state.displayYear++;
state.yearRangeStart = state.displayYear - 10;
state.yearRangeEnd = state.displayYear + 10;
} else {
state.displayMonth++;
if (state.displayMonth > 12) { state.displayMonth = 1; state.displayYear++; }
state.yearRangeStart = state.displayYear - 10;
state.yearRangeEnd = state.displayYear + 10;
}
setCalendarState(cal, state);
}
function getTimePickerState(tp) {
return {
hour: parseInt(tp.getAttribute('data-selected-hour') || '0'),
minute: parseInt(tp.getAttribute('data-selected-minute') || '0'),
isPm: tp.getAttribute('data-selected-pm') === 'true',
use12Hour: tp.getAttribute('data-use-12-hour') === 'true',
};
}
function setTimePickerState(tp, state) {
tp.setAttribute('data-selected-hour', state.hour);
tp.setAttribute('data-selected-minute', state.minute);
tp.setAttribute('data-selected-pm', state.isPm);
renderTimePicker(tp);
}
function renderTimePicker(tp) {
const state = getTimePickerState(tp);
tp.querySelectorAll('[data-tp-hour]').forEach(btn => {
const val = parseInt(btn.getAttribute('data-tp-hour'));
updateTimeItemSelected(btn, val === state.hour);
});
tp.querySelectorAll('[data-tp-minute]').forEach(btn => {
const val = parseInt(btn.getAttribute('data-tp-minute'));
updateTimeItemSelected(btn, val === state.minute);
});
tp.querySelectorAll('[data-tp-period]').forEach(btn => {
const isPm = btn.getAttribute('data-tp-period') === 'pm';
updateTimeItemSelected(btn, isPm === state.isPm);
});
}
function updateTimeItemSelected(btn, isSelected) {
const base = 'h-9 w-full inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer';
if (isSelected) {
btn.className = base + ' bg-primary text-primary-foreground font-semibold';
} else {
btn.className = base + ' hover:bg-accent hover:text-accent-foreground';
}
}
function getTimePickerValue(tp) {
const state = getTimePickerState(tp);
let hour = state.hour;
if (state.use12Hour) {
hour = (state.hour % 12) + (state.isPm ? 12 : 0);
}
return { hour, minute: state.minute };
}
function formatTime24(hour, minute) {
return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0');
}
function formatTime12(hour, minute) {
const isPm = hour >= 12;
let h12 = hour % 12;
if (h12 === 0) h12 = 12;
return String(h12).padStart(2, '0') + ':' + String(minute).padStart(2, '0') + ' ' + (isPm ? 'PM' : 'AM');
}
function handleNumberIncrement(wrapper, direction) {
const input = wrapper.querySelector('input[type="number"]');
if (!input) return;
const step = parseFloat(input.getAttribute('step')) || 1;
const min = input.hasAttribute('min') ? parseFloat(input.getAttribute('min')) : null;
const max = input.hasAttribute('max') ? parseFloat(input.getAttribute('max')) : null;
let current = parseFloat(input.value) || 0;
current += step * direction;
if (min !== null && current < min) current = min;
if (max !== null && current > max) current = max;
input.value = current;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
updateStepperDisabledState(wrapper);
}
function updateStepperDisabledState(wrapper) {
const input = wrapper.querySelector('input[type="number"]');
if (!input) return;
const min = input.hasAttribute('min') ? parseFloat(input.getAttribute('min')) : null;
const max = input.hasAttribute('max') ? parseFloat(input.getAttribute('max')) : null;
const val = parseFloat(input.value);
const incBtn = wrapper.querySelector('[data-number-increment]');
const decBtn = wrapper.querySelector('[data-number-decrement]');
if (incBtn) {
incBtn.disabled = (max !== null && !isNaN(val) && val >= max);
}
if (decBtn) {
decBtn.disabled = (min !== null && !isNaN(val) && val <= min);
}
}
function syncDateTriggerText(hiddenInput) {
const triggerId = hiddenInput.getAttribute('data-trigger-id');
if (!triggerId) return;
const trigger = document.querySelector(`[data-testid="${triggerId}"]`);
if (!trigger) return;
const span = trigger.querySelector('span');
if (!span) return;
const val = hiddenInput.value;
if (val) {
const [y, m, d] = val.split('-').map(Number);
const date = new Date(y, m - 1, d);
const isDateTimePart = hiddenInput.hasAttribute('data-datetime-part');
const options = isDateTimePart
? { month: 'short', day: 'numeric', year: 'numeric' }
: { month: 'long', day: 'numeric', year: 'numeric' };
span.textContent = date.toLocaleDateString('en-US', options);
span.classList.remove('text-muted-foreground');
} else {
span.textContent = hiddenInput.getAttribute('data-placeholder') || 'Select date';
span.classList.add('text-muted-foreground');
}
}
function syncTimeTriggerText(hiddenInput) {
const triggerId = hiddenInput.getAttribute('data-trigger-id');
if (!triggerId) return;
const trigger = document.querySelector(`[data-testid="${triggerId}"]`);
if (!trigger) return;
const span = trigger.querySelector('span');
if (!span) return;
const val = hiddenInput.value;
if (val) {
const [h, m] = val.split(':').map(Number);
span.textContent = formatTime12(h, m);
span.classList.remove('text-muted-foreground');
} else {
span.textContent = hiddenInput.getAttribute('data-placeholder') || 'Select time';
span.classList.add('text-muted-foreground');
}
}
function handleClick(e) {
const target = e.target;
const popoverTrigger = target.closest('[data-popover-trigger]');
if (popoverTrigger) {
const wrapper = popoverTrigger.closest('[data-popover]');
if (wrapper) {
e.stopPropagation();
togglePopover(wrapper);
return;
}
}
const backdrop = target.closest('[data-popover-backdrop]');
if (backdrop) {
const wrapper = backdrop.closest('[data-popover]');
if (wrapper) {
e.stopPropagation();
closePopover(wrapper);
return;
}
}
const prevBtn = target.closest('[data-calendar-prev]');
if (prevBtn) {
const cal = prevBtn.closest('[data-calendar]');
if (cal) { calendarPrev(cal); return; }
}
const nextBtn = target.closest('[data-calendar-next]');
if (nextBtn) {
const cal = nextBtn.closest('[data-calendar]');
if (cal) { calendarNext(cal); return; }
}
const monthHeader = target.closest('[data-calendar-month]');
if (monthHeader) {
const cal = monthHeader.closest('[data-calendar]');
if (cal) {
const state = getCalendarState(cal);
state.view = state.view === 'months' ? 'days' : 'months';
setCalendarState(cal, state);
return;
}
}
const yearHeader = target.closest('[data-calendar-year]');
if (yearHeader) {
const cal = yearHeader.closest('[data-calendar]');
if (cal) {
const state = getCalendarState(cal);
state.view = state.view === 'years' ? 'days' : 'years';
setCalendarState(cal, state);
return;
}
}
const monthBtn = target.closest('[data-calendar-select-month]');
if (monthBtn) {
const cal = monthBtn.closest('[data-calendar]');
if (cal) {
const state = getCalendarState(cal);
state.displayMonth = parseInt(monthBtn.getAttribute('data-calendar-select-month'));
state.view = 'days';
setCalendarState(cal, state);
return;
}
}
const yearBtn = target.closest('[data-calendar-select-year]');
if (yearBtn) {
const cal = yearBtn.closest('[data-calendar]');
if (cal) {
const state = getCalendarState(cal);
state.displayYear = parseInt(yearBtn.getAttribute('data-calendar-select-year'));
state.view = 'days';
state.yearRangeStart = state.displayYear - 10;
state.yearRangeEnd = state.displayYear + 10;
setCalendarState(cal, state);
return;
}
}
const dayBtn = target.closest('[data-calendar-day]');
if (dayBtn && !dayBtn.disabled) {
const cal = dayBtn.closest('[data-calendar]');
if (cal) {
const dateStr = dayBtn.getAttribute('data-calendar-day');
const [y, m, d] = dateStr.split('-').map(Number);
const state = getCalendarState(cal);
state.selectedDate = dateStr;
state.displayYear = y;
state.displayMonth = m;
setCalendarState(cal, state);
const inputId = cal.getAttribute('data-linked-input');
if (inputId) {
const hiddenInput = document.getElementById(inputId);
if (hiddenInput) {
hiddenInput.value = dateStr;
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
syncDateTriggerText(hiddenInput);
}
}
const popover = cal.closest('[data-popover]');
if (popover) closePopover(popover);
return;
}
}
const hourBtn = target.closest('[data-tp-hour]');
if (hourBtn) {
const tp = hourBtn.closest('[data-timepicker]');
if (tp) {
const state = getTimePickerState(tp);
state.hour = parseInt(hourBtn.getAttribute('data-tp-hour'));
setTimePickerState(tp, state);
syncTimeToHiddenInput(tp);
return;
}
}
const minuteBtn = target.closest('[data-tp-minute]');
if (minuteBtn) {
const tp = minuteBtn.closest('[data-timepicker]');
if (tp) {
const state = getTimePickerState(tp);
state.minute = parseInt(minuteBtn.getAttribute('data-tp-minute'));
setTimePickerState(tp, state);
syncTimeToHiddenInput(tp);
return;
}
}
const periodBtn = target.closest('[data-tp-period]');
if (periodBtn) {
const tp = periodBtn.closest('[data-timepicker]');
if (tp) {
const state = getTimePickerState(tp);
state.isPm = periodBtn.getAttribute('data-tp-period') === 'pm';
setTimePickerState(tp, state);
syncTimeToHiddenInput(tp);
return;
}
}
const incBtn = target.closest('[data-number-increment]');
if (incBtn) {
const wrapper = incBtn.closest('[data-number-input]');
if (wrapper) { handleNumberIncrement(wrapper, 1); return; }
}
const decBtn = target.closest('[data-number-decrement]');
if (decBtn) {
const wrapper = decBtn.closest('[data-number-input]');
if (wrapper) { handleNumberIncrement(wrapper, -1); return; }
}
}
function syncTimeToHiddenInput(tp) {
const inputId = tp.getAttribute('data-linked-input');
if (!inputId) return;
const hiddenInput = document.getElementById(inputId);
if (!hiddenInput) return;
const { hour, minute } = getTimePickerValue(tp);
hiddenInput.value = formatTime24(hour, minute);
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
syncTimeTriggerText(hiddenInput);
}
function handleInput(e) {
const target = e.target;
if (target.matches('[data-number-input] input[type="number"]')) {
const wrapper = target.closest('[data-number-input]');
if (wrapper) updateStepperDisabledState(wrapper);
}
}
function syncValidationStyling() {
document.querySelectorAll('[data-field-error]').forEach(errEl => {
const fieldName = errEl.getAttribute('data-field-error');
if (!fieldName) return;
const field = document.querySelector(`[data-form-field="${fieldName}"]`);
if (!field) return;
const input = field.querySelector('input, select, textarea');
if (!input) return;
const hasError = !errEl.classList.contains('hidden') && errEl.textContent.trim().length > 0;
if (hasError) {
input.classList.add('border-destructive');
input.classList.remove('border-input');
} else {
input.classList.remove('border-destructive');
input.classList.add('border-input');
}
});
}
function handleFormReset(e) {
if (e.target.tagName !== 'FORM') return;
const form = e.target;
setTimeout(() => {
form.querySelectorAll('[data-field-error]').forEach(errEl => {
errEl.classList.add('hidden');
errEl.textContent = '';
});
form.querySelectorAll('.border-destructive').forEach(input => {
input.classList.remove('border-destructive');
input.classList.add('border-input');
});
const result = form.querySelector('#form-result');
if (result) { result.className = 'hidden'; result.innerHTML = ''; }
}, 0);
}
function initComponents() {
document.querySelectorAll('[data-calendar]').forEach(cal => {
renderCalendar(cal);
});
document.querySelectorAll('[data-timepicker]').forEach(tp => {
renderTimePicker(tp);
});
document.querySelectorAll('[data-number-input]').forEach(wrapper => {
updateStepperDisabledState(wrapper);
});
document.querySelectorAll('[data-popover]').forEach(wrapper => {
if (wrapper.getAttribute('data-popover-open') !== 'true') {
closePopover(wrapper);
}
});
}
export function init() {
if (initialized) {
initComponents();
if (typeof htmx !== 'undefined') htmx.process(document.body);
return;
}
initialized = true;
initComponents();
if (typeof htmx !== 'undefined') htmx.process(document.body);
document.addEventListener('click', handleClick);
document.addEventListener('input', handleInput);
document.addEventListener('reset', handleFormReset, true);
document.addEventListener('htmx:afterSwap', () => {
syncValidationStyling();
});
window.Blazor.addEventListener('enhancedload', () => {
initComponents();
if (typeof htmx !== 'undefined') htmx.process(document.body);
});
bodyObserver = new MutationObserver(() => {
const uninitCals = document.querySelectorAll('[data-calendar]:not([data-initialized])');
uninitCals.forEach(cal => {
cal.setAttribute('data-initialized', 'true');
renderCalendar(cal);
});
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
}
export function dispose() {
document.removeEventListener('click', handleClick);
document.removeEventListener('input', handleInput);
bodyObserver?.disconnect();
bodyObserver = null;
initialized = false;
}
+51 -1
View File
@@ -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
+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)
-995
View File
@@ -1,995 +0,0 @@
{
"name": "enciphered.blazor.uicomponents",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "enciphered.blazor.uicomponents",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@tailwindcss/cli": "^4.2.2",
"tailwindcss": "^4.2.2"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz",
"integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==",
"license": "MIT",
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.2.2",
"@tailwindcss/oxide": "4.2.2",
"enhanced-resolve": "^5.19.0",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.2.2"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-x64": "4.2.2",
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
}
}
}