changing-to-js-from-interactivity #1
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user