Migrating all validation to use HTMX and endpoints instead of WASM/Websocket connections

This commit is contained in:
2026-04-13 18:40:17 +05:00
parent d1f0967a0c
commit b323862e03
24 changed files with 1203 additions and 188 deletions
@@ -26,6 +26,9 @@
<body class="min-h-svh antialiased bg-background text-foreground">
<Routes />
<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">
import { init as initDarkMode } from '/_content/Enciphered.Blazor.UIComponents/js/darkmode.js';
import { init as initSidebar } from '/_content/Enciphered.Blazor.UIComponents/js/sidebar.js';
@@ -5,45 +5,46 @@
<div class="space-y-6 max-w-lg">
<div>
<h1 class="text-3xl font-bold tracking-tight">Forms Demo</h1>
<p class="text-muted-foreground">All input components — fully static SSR with JS interactivity.</p>
<p class="text-muted-foreground">All input components — fully static SSR with htmx validation.</p>
</div>
<form>
<HtmxForm Endpoint="/api/forms/contact">
<FormField Label="Full Name" For="name">
<TextInput Id="name" Name="name" Placeholder="Jane Doe" data-testid="input-name" />
</FormField>
<div class="space-y-4">
<FormField Label="Full Name" For="name">
<TextInput Id="name" Name="name" Placeholder="Jane Doe" data-testid="input-name" />
</FormField>
<FormField Label="Email" For="email">
<TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" data-testid="input-email" />
</FormField>
<FormField Label="Email" For="email">
<TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" data-testid="input-email" />
</FormField>
<FormField Label="Password" For="password">
<TextInput Id="password" Name="password" Type="password" Placeholder="••••••••" data-testid="input-password" />
</FormField>
<FormField Label="Password" For="password">
<TextInput Id="password" Name="password" Type="password" Placeholder="••••••••" data-testid="input-password" />
</FormField>
<FormField Label="Age" For="age">
<NumberInput Id="age" Name="age" Placeholder="25" Min="0" Max="150" data-testid="input-age" />
</FormField>
<FormField Label="Age" For="age">
<NumberInput Id="age" Name="age" Placeholder="25" Min="0" Max="150" data-testid="input-age" />
</FormField>
<FormField Label="Birth Date" For="birthdate">
<DateInput Id="birthdate" Name="birthdate" data-testid="input-birthdate" />
</FormField>
<FormField Label="Birth Date" For="birthdate">
<DateInput Id="birthdate" Name="birthdate" data-testid="input-birthdate" />
</FormField>
<FormField Label="Preferred Time" For="preferredtime">
<TimeInput Id="preferredtime" Name="preferredtime" data-testid="input-time" />
</FormField>
<FormField Label="Preferred Time" For="preferredtime">
<TimeInput Id="preferredtime" Name="preferredtime" data-testid="input-time" />
</FormField>
<FormField Label="Appointment" For="appointment">
<DateTimeInput Id="appointment" Name="appointment" data-testid="input-appointment" />
</FormField>
<FormField Label="Appointment" For="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>
<div class="flex gap-2 pt-2">
<Button Type="submit" data-testid="btn-submit">Submit</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>
</div>
<div class="flex gap-2 pt-2">
<Button Type="submit" data-testid="btn-submit">Submit</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>
</div>
</form>
</HtmxForm>
</div>
@@ -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);
}
}
+18 -4
View File
@@ -1,27 +1,41 @@
using Enciphered.Blazor.UIComponents.Demo;
using Enciphered.Blazor.UIComponents.Demo.Components;
using Enciphered.Blazor.UIComponents.Validation;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents();
builder.Services.AddAntiforgery();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
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.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddAdditionalAssemblies(typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly);
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/forms/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();
@@ -266,7 +266,7 @@ public class FormsTests : PlaywrightTestBase
var labels = Page.Locator("label");
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]
@@ -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();
}
}
@@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.14" />
</ItemGroup>
@@ -17,16 +17,7 @@
[Parameter] public string Type { get; set; } = "button";
[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;
/// <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? Class { get; set; }
@@ -1,7 +1,6 @@
@namespace Enciphered.Blazor.UIComponents
@inherits InputBase<DateOnly?>
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
<input type="date"
id="@Id"
name="@Name"
@@ -12,7 +11,7 @@
tabindex="-1"
aria-hidden="true"
disabled="@Disabled"
@attributes="AdditionalAttributes" />
@attributes="MergedAttributes" />
<Popover>
<Trigger>
@@ -20,7 +19,6 @@
disabled="@Disabled"
data-testid="@($"trigger-{Id}")"
class="@TriggerClass">
@* Lucide calendar 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"
class="mr-2 shrink-0 text-muted-foreground">
@@ -1,7 +1,6 @@
@namespace Enciphered.Blazor.UIComponents
@inherits InputBase<DateTime?>
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
<input type="datetime-local"
id="@Id"
name="@Name"
@@ -10,9 +9,8 @@
tabindex="-1"
aria-hidden="true"
disabled="@Disabled"
@attributes="AdditionalAttributes" />
@attributes="MergedAttributes" />
@* Hidden helper inputs for JS to write date/time portions separately *@
<input type="hidden" id="@($"{Id}-date-part")"
value="@(SelectedDateOnly?.ToString("yyyy-MM-dd") ?? "")"
data-trigger-id="@($"trigger-{Id}-date")"
@@ -26,16 +24,13 @@
data-datetime-part="time"
data-datetime-input-id="@Id" />
@* ── Two side-by-side triggers: date field + time field ── *@
<div class="flex gap-2">
@* ── Date portion ── *@
<Popover>
<Trigger>
<button type="button"
disabled="@Disabled"
data-testid="@($"trigger-{Id}-date")"
class="@TriggerClass">
@* Lucide calendar 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"
class="mr-2 shrink-0 text-muted-foreground">
@@ -53,14 +48,12 @@
</Content>
</Popover>
@* ── Time portion ── *@
<Popover>
<Trigger>
<button type="button"
disabled="@Disabled"
data-testid="@($"trigger-{Id}-time")"
class="@TriggerClass">
@* Lucide clock 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"
class="mr-2 shrink-0 text-muted-foreground">
@@ -82,9 +75,6 @@
[Parameter] public string? Min { 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; }
private DateOnly? SelectedDateOnly =>
@@ -1,6 +1,6 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass">
<div class="@ComputedClass" data-form-field="@For">
@if (!string.IsNullOrEmpty(Label))
{
<label for="@For"
@@ -9,7 +9,9 @@
</label>
}
@ChildContent
<CascadingValue Value="@For" Name="FieldName">
@ChildContent
</CascadingValue>
@if (!string.IsNullOrEmpty(Description))
{
@@ -18,33 +20,23 @@
@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>
@code {
/// <summary>Label text displayed above the input.</summary>
[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; }
/// <summary>Help text displayed below the input.</summary>
[Parameter] public string? Description { get; set; }
/// <summary>Validation error message displayed below the input.</summary>
[Parameter] public string? Error { get; set; }
/// <summary>The input component(s) to render inside the field.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes for the wrapper div.</summary>
[Parameter] public string? Class { get; set; }
/// <summary>Additional CSS classes for the label element.</summary>
[Parameter] public string? LabelClass { get; set; }
private const string BaseClass = "space-y-2";
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 ?? "";
}
@@ -2,19 +2,10 @@ using Microsoft.AspNetCore.Components;
namespace Enciphered.Blazor.UIComponents;
/// <summary>
/// Abstract base for all form input components. Provides parameter declarations
/// for value, id, name, disabled state, and CSS class computation.
/// All interactivity is handled by the forms.js module.
/// </summary>
public abstract class InputBase<TValue> : ComponentBase
{
// ── Value (for initial SSR render) ───────────────────────────────────────
[Parameter] public TValue? Value { get; set; }
// ── Common parameters ────────────────────────────────────────────────────
[Parameter] public string? Id { get; set; }
[Parameter] public string? Name { get; set; }
[Parameter] public bool Disabled { get; set; }
@@ -22,15 +13,14 @@ public abstract class InputBase<TValue> : ComponentBase
[Parameter] public string? Placeholder { 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)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
// ── CSS helpers ──────────────────────────────────────────────────────────
[CascadingParameter(Name = "ValidationEndpoint")]
public string? ValidationEndpoint { get; set; }
[CascadingParameter(Name = "FieldName")]
public string? FieldName { get; set; }
private const string BaseInputClass =
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm " +
@@ -38,16 +28,31 @@ public abstract class InputBase<TValue> : ComponentBase
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
"disabled:cursor-not-allowed disabled:opacity-50";
/// <summary>
/// Computes the full CSS class string: base + consumer override.
/// </summary>
protected string ComputedClass
protected string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseInputClass : $"{BaseInputClass} {Class}";
protected Dictionary<string, object> MergedAttributes
{
get
{
return string.IsNullOrEmpty(Class)
? BaseInputClass
: $"{BaseInputClass} {Class}";
var attrs = AdditionalAttributes is not null
? new Dictionary<string, object>(AdditionalAttributes)
: new Dictionary<string, object>();
if (!string.IsNullOrEmpty(ValidationEndpoint) &&
!string.IsNullOrEmpty(FieldName) &&
!attrs.ContainsKey("hx-post"))
{
attrs["hx-post"] = ValidationEndpoint;
attrs["hx-trigger"] = "blur";
attrs["hx-target"] = "next [data-field-error]";
attrs["hx-swap"] = "outerHTML";
attrs["hx-include"] = "this";
attrs["hx-vals"] = $"{{\"_field\": \"{FieldName}\"}}";
}
return attrs;
}
}
}
@@ -13,7 +13,7 @@
min="@Min"
max="@Max"
class="@ComputedClass [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none pr-8"
@attributes="AdditionalAttributes" />
@attributes="MergedAttributes" />
@if (!Disabled && !ReadOnly)
{
@@ -9,12 +9,8 @@
disabled="@Disabled"
readonly="@ReadOnly"
class="@ComputedClass"
@attributes="AdditionalAttributes" />
@attributes="MergedAttributes" />
@code {
/// <summary>
/// HTML input type. Defaults to "text".
/// Supports: text, email, password, url, tel, search.
/// </summary>
[Parameter] public string Type { get; set; } = "text";
}
@@ -1,7 +1,6 @@
@namespace Enciphered.Blazor.UIComponents
@inherits InputBase<TimeOnly?>
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
<input type="time"
id="@Id"
name="@Name"
@@ -12,7 +11,7 @@
tabindex="-1"
aria-hidden="true"
disabled="@Disabled"
@attributes="AdditionalAttributes" />
@attributes="MergedAttributes" />
<Popover>
<Trigger>
@@ -20,7 +19,6 @@
disabled="@Disabled"
data-testid="@($"trigger-{Id}")"
class="@TriggerClass">
@* Lucide clock 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"
class="mr-2 shrink-0 text-muted-foreground">
@@ -38,12 +36,7 @@
</Popover>
@code {
/// <summary>
/// Step in seconds. Use "1" for second precision, "60" (default) for minutes only.
/// </summary>
[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;
private int ParsedMinuteStep => int.TryParse(Step, out var s) && s >= 60 ? s / 60 : 1;
@@ -0,0 +1,55 @@
using System.Globalization;
using System.Reflection;
using Microsoft.AspNetCore.Http;
namespace Enciphered.Blazor.UIComponents.Validation;
public static class FormModelBinder
{
public static TModel Bind<TModel>(IFormCollection form) where TModel : new()
{
var model = new TModel();
var props = typeof(TModel).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite);
foreach (var prop in props)
{
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,79 @@
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, 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;
}
}
File diff suppressed because one or more lines are too long
@@ -1,17 +1,6 @@
/* ═══════════════════════════════════════════════════════════════════════════
* Forms Module — handles all interactive form behaviour via vanilla JS.
* Manages: Popover open/close, Calendar navigation & day selection,
* TimePicker hour/minute/period selection, NumberInput +/- stepping.
* Survives Blazor enhanced-navigation via MutationObserver.
* ═══════════════════════════════════════════════════════════════════════════ */
let initialized = false;
let bodyObserver = null;
// ─────────────────────────────────────────────────────────────────────────────
// ── Popover ──────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
function openPopover(wrapper) {
const panel = wrapper.querySelector('[data-popover-panel]');
const backdrop = wrapper.querySelector('[data-popover-backdrop]');
@@ -36,13 +25,8 @@ function togglePopover(wrapper) {
else openPopover(wrapper);
}
// ─────────────────────────────────────────────────────────────────────────────
// ── Calendar ─────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
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'];
const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function getCalendarState(cal) {
return {
@@ -103,7 +87,6 @@ function renderCalendar(cal) {
const contentEl = cal.querySelector('[data-calendar-content]');
if (!contentEl) return;
// Update header labels
const monthLabel = cal.querySelector('[data-calendar-month]');
const yearLabel = cal.querySelector('[data-calendar-year]');
if (monthLabel) monthLabel.textContent = MONTH_NAMES_SHORT[state.displayMonth - 1];
@@ -211,10 +194,6 @@ function calendarNext(cal) {
setCalendarState(cal, state);
}
// ─────────────────────────────────────────────────────────────────────────────
// ── TimePicker ───────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
function getTimePickerState(tp) {
return {
hour: parseInt(tp.getAttribute('data-selected-hour') || '0'),
@@ -234,7 +213,6 @@ function setTimePickerState(tp, state) {
function renderTimePicker(tp) {
const state = getTimePickerState(tp);
// Update selected states on buttons
tp.querySelectorAll('[data-tp-hour]').forEach(btn => {
const val = parseInt(btn.getAttribute('data-tp-hour'));
updateTimeItemSelected(btn, val === state.hour);
@@ -278,10 +256,6 @@ function formatTime12(hour, minute) {
return String(h12).padStart(2, '0') + ':' + String(minute).padStart(2, '0') + ' ' + (isPm ? 'PM' : 'AM');
}
// ─────────────────────────────────────────────────────────────────────────────
// ── NumberInput ──────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
function handleNumberIncrement(wrapper, direction) {
const input = wrapper.querySelector('input[type="number"]');
if (!input) return;
@@ -293,16 +267,13 @@ function handleNumberIncrement(wrapper, direction) {
let current = parseFloat(input.value) || 0;
current += step * direction;
// Clamp
if (min !== null && current < min) current = min;
if (max !== null && current > max) current = max;
input.value = current;
// Fire native input event so forms pick it up
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
// Update disabled state on buttons
updateStepperDisabledState(wrapper);
}
@@ -325,10 +296,6 @@ function updateStepperDisabledState(wrapper) {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ── Trigger text sync (DateInput / TimeInput / DateTimeInput) ────────────────
// ─────────────────────────────────────────────────────────────────────────────
function syncDateTriggerText(hiddenInput) {
const triggerId = hiddenInput.getAttribute('data-trigger-id');
if (!triggerId) return;
@@ -341,8 +308,6 @@ function syncDateTriggerText(hiddenInput) {
if (val) {
const [y, m, d] = val.split('-').map(Number);
const date = new Date(y, m - 1, d);
// DateTime date-parts use short month ("Dec 25, 2025"),
// standalone DateInput uses long month ("December 25, 2025")
const isDateTimePart = hiddenInput.hasAttribute('data-datetime-part');
const options = isDateTimePart
? { month: 'short', day: 'numeric', year: 'numeric' }
@@ -374,14 +339,9 @@ function syncTimeTriggerText(hiddenInput) {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ── Global click handler ─────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
function handleClick(e) {
const target = e.target;
// ── Popover trigger ──
const popoverTrigger = target.closest('[data-popover-trigger]');
if (popoverTrigger) {
const wrapper = popoverTrigger.closest('[data-popover]');
@@ -392,7 +352,6 @@ function handleClick(e) {
}
}
// ── Popover backdrop (close on outside click) ──
const backdrop = target.closest('[data-popover-backdrop]');
if (backdrop) {
const wrapper = backdrop.closest('[data-popover]');
@@ -403,21 +362,18 @@ function handleClick(e) {
}
}
// ── Calendar: Previous button ──
const prevBtn = target.closest('[data-calendar-prev]');
if (prevBtn) {
const cal = prevBtn.closest('[data-calendar]');
if (cal) { calendarPrev(cal); return; }
}
// ── Calendar: Next button ──
const nextBtn = target.closest('[data-calendar-next]');
if (nextBtn) {
const cal = nextBtn.closest('[data-calendar]');
if (cal) { calendarNext(cal); return; }
}
// ── Calendar: Month header toggle ──
const monthHeader = target.closest('[data-calendar-month]');
if (monthHeader) {
const cal = monthHeader.closest('[data-calendar]');
@@ -429,7 +385,6 @@ function handleClick(e) {
}
}
// ── Calendar: Year header toggle ──
const yearHeader = target.closest('[data-calendar-year]');
if (yearHeader) {
const cal = yearHeader.closest('[data-calendar]');
@@ -441,7 +396,6 @@ function handleClick(e) {
}
}
// ── Calendar: Select month ──
const monthBtn = target.closest('[data-calendar-select-month]');
if (monthBtn) {
const cal = monthBtn.closest('[data-calendar]');
@@ -454,7 +408,6 @@ function handleClick(e) {
}
}
// ── Calendar: Select year ──
const yearBtn = target.closest('[data-calendar-select-year]');
if (yearBtn) {
const cal = yearBtn.closest('[data-calendar]');
@@ -469,7 +422,6 @@ function handleClick(e) {
}
}
// ── Calendar: Select day ──
const dayBtn = target.closest('[data-calendar-day]');
if (dayBtn && !dayBtn.disabled) {
const cal = dayBtn.closest('[data-calendar]');
@@ -482,7 +434,6 @@ function handleClick(e) {
state.displayMonth = m;
setCalendarState(cal, state);
// Update the linked hidden input
const inputId = cal.getAttribute('data-linked-input');
if (inputId) {
const hiddenInput = document.getElementById(inputId);
@@ -493,14 +444,12 @@ function handleClick(e) {
}
}
// Auto-close popover parent
const popover = cal.closest('[data-popover]');
if (popover) closePopover(popover);
return;
}
}
// ── TimePicker: Select hour ──
const hourBtn = target.closest('[data-tp-hour]');
if (hourBtn) {
const tp = hourBtn.closest('[data-timepicker]');
@@ -513,7 +462,6 @@ function handleClick(e) {
}
}
// ── TimePicker: Select minute ──
const minuteBtn = target.closest('[data-tp-minute]');
if (minuteBtn) {
const tp = minuteBtn.closest('[data-timepicker]');
@@ -526,7 +474,6 @@ function handleClick(e) {
}
}
// ── TimePicker: Select AM/PM ──
const periodBtn = target.closest('[data-tp-period]');
if (periodBtn) {
const tp = periodBtn.closest('[data-timepicker]');
@@ -539,14 +486,12 @@ function handleClick(e) {
}
}
// ── NumberInput: Increment ──
const incBtn = target.closest('[data-number-increment]');
if (incBtn) {
const wrapper = incBtn.closest('[data-number-input]');
if (wrapper) { handleNumberIncrement(wrapper, 1); return; }
}
// ── NumberInput: Decrement ──
const decBtn = target.closest('[data-number-decrement]');
if (decBtn) {
const wrapper = decBtn.closest('[data-number-input]');
@@ -566,10 +511,6 @@ function syncTimeToHiddenInput(tp) {
syncTimeTriggerText(hiddenInput);
}
// ─────────────────────────────────────────────────────────────────────────────
// ── Global input handler (for NumberInput stepper disabled state) ─────────────
// ─────────────────────────────────────────────────────────────────────────────
function handleInput(e) {
const target = e.target;
if (target.matches('[data-number-input] input[type="number"]')) {
@@ -578,27 +519,56 @@ function handleInput(e) {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ── Init all static calendars/timepickers on the page ────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
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() {
// Render all calendars
document.querySelectorAll('[data-calendar]').forEach(cal => {
renderCalendar(cal);
});
// Render all timepickers
document.querySelectorAll('[data-timepicker]').forEach(tp => {
renderTimePicker(tp);
});
// Init all number input stepper states
document.querySelectorAll('[data-number-input]').forEach(wrapper => {
updateStepperDisabledState(wrapper);
});
// Ensure all popovers start closed
document.querySelectorAll('[data-popover]').forEach(wrapper => {
if (wrapper.getAttribute('data-popover-open') !== 'true') {
closePopover(wrapper);
@@ -606,30 +576,31 @@ function initComponents() {
});
}
// ─────────────────────────────────────────────────────────────────────────────
// ── Lifecycle ────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
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);
// Watch for Blazor enhanced-nav replacing content
document.addEventListener('blazor:enhanced-load', () => {
initComponents();
document.addEventListener('htmx:afterSwap', () => {
syncValidationStyling();
});
document.addEventListener('blazor:enhanced-load', () => {
initComponents();
if (typeof htmx !== 'undefined') htmx.process(document.body);
});
// Body observer for dynamic content
bodyObserver = new MutationObserver(() => {
// If new calendars/timepickers/popovers appeared, init them
const uninitCals = document.querySelectorAll('[data-calendar]:not([data-initialized])');
uninitCals.forEach(cal => {
cal.setAttribute('data-initialized', 'true');