Compare commits
12 Commits
9bef5813ae
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b7c75716e8 | |||
| 398d22169d | |||
| a8b13d1120 | |||
| bd3827bb41 | |||
| 32cad03088 | |||
| 4411307383 | |||
| 5668cf20d9 | |||
| b323862e03 | |||
| d1f0967a0c | |||
| 086917b5aa | |||
| c23c598b0b | |||
| 06ec22704b |
@@ -1,4 +1,46 @@
|
|||||||
|
package-lock.json
|
||||||
|
|
||||||
|
## .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|
||||||
|
## Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
## User-specific files
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
## Visual Studio
|
||||||
|
.vs/
|
||||||
|
|
||||||
|
## JetBrains Rider
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
## Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
[Bb]in/
|
[Bb]in/
|
||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
package-lock.json
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
## NuGet
|
||||||
|
*.nupkg
|
||||||
|
*.snupkg
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
|
||||||
|
## OS files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<base href="/" />
|
|
||||||
<ResourcePreloader />
|
|
||||||
<link rel="stylesheet" href="@Assets["css/output.css"]" />
|
|
||||||
<ImportMap />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="enci_white.svg" />
|
|
||||||
<HeadOutlet />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<Routes @rendermode="RenderMode.InteractiveServer" />
|
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
@inherits LayoutComponentBase
|
|
||||||
|
|
||||||
<div class="flex min-h-screen">
|
|
||||||
|
|
||||||
@* Desktop sidebar — collapsible *@
|
|
||||||
<aside class="@($"hidden shrink-0 border-r border-sidebar-border bg-sidebar transition-all duration-300 ease-in-out md:block {(sidebarCollapsed ? "w-16" : "w-64")}")">
|
|
||||||
<NavMenu Collapsed="sidebarCollapsed" OnToggleSidebar="ToggleSidebar" />
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
@* Mobile overlay *@
|
|
||||||
@if (mobileOpen)
|
|
||||||
{
|
|
||||||
<div class="fixed inset-0 z-40 flex md:hidden">
|
|
||||||
@* Backdrop *@
|
|
||||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @onclick="CloseMobile"></div>
|
|
||||||
|
|
||||||
@* Drawer *@
|
|
||||||
<aside class="relative z-50 flex w-72 flex-col bg-sidebar shadow-xl">
|
|
||||||
@* Close button *@
|
|
||||||
<button class="absolute right-3 top-3 rounded-md p-1 text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
|
||||||
@onclick="CloseMobile">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<NavMenu OnNavigated="CloseMobile" />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Main content *@
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
|
||||||
@* Top bar *@
|
|
||||||
<header class="sticky top-0 z-10 flex h-14 items-center gap-4 border-b border-border bg-background/80 px-4 backdrop-blur-sm md:px-6">
|
|
||||||
@* Mobile menu button *@
|
|
||||||
<button class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:hidden"
|
|
||||||
@onclick="OpenMobile">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@* Desktop collapse toggle *@
|
|
||||||
<button class="hidden rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:inline-flex"
|
|
||||||
@onclick="ToggleSidebar"
|
|
||||||
title="@(sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar")">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
||||||
<path d="M9 3v18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex-1 text-sm font-medium text-muted-foreground">
|
|
||||||
Enciphered UI Components
|
|
||||||
</div>
|
|
||||||
<a href="https://learn.microsoft.com/aspnet/core/"
|
|
||||||
target="_blank"
|
|
||||||
class="text-sm text-muted-foreground transition-colors hover:text-foreground">
|
|
||||||
About
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@* Page content *@
|
|
||||||
<main class="flex-1 overflow-auto p-4 md:p-6">
|
|
||||||
@Body
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool sidebarCollapsed;
|
|
||||||
private bool mobileOpen;
|
|
||||||
|
|
||||||
private void ToggleSidebar() => sidebarCollapsed = !sidebarCollapsed;
|
|
||||||
private void OpenMobile() => mobileOpen = true;
|
|
||||||
private void CloseMobile() => mobileOpen = false;
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
@* Sidebar header *@
|
|
||||||
<div class="flex h-14 items-center border-b border-sidebar-border px-4">
|
|
||||||
<button type="button"
|
|
||||||
class="flex items-center gap-2 font-semibold text-sidebar-foreground rounded-md transition-colors hover:bg-sidebar-accent px-1 py-1"
|
|
||||||
title="Toggle sidebar"
|
|
||||||
@onclick="HandleToggle">
|
|
||||||
<img src="enci_white.svg" alt="Logo" class="h-6 w-6 shrink-0 transition-transform duration-300" />
|
|
||||||
@if (!Collapsed)
|
|
||||||
{
|
|
||||||
<span class="text-sm tracking-tight">Enciphered</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Navigation *@
|
|
||||||
<div class="flex flex-col gap-1 p-3">
|
|
||||||
@if (!Collapsed)
|
|
||||||
{
|
|
||||||
<p class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
||||||
Navigation
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<NavLink class="@NavLinkClass" href="" Match="NavLinkMatch.All"
|
|
||||||
ActiveClass="bg-sidebar-accent text-accent-foreground"
|
|
||||||
@onclick="HandleNav"
|
|
||||||
title="Home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
|
||||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
||||||
</svg>
|
|
||||||
@if (!Collapsed)
|
|
||||||
{
|
|
||||||
<span>Home</span>
|
|
||||||
}
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
<NavLink class="@NavLinkClass" href="weather"
|
|
||||||
ActiveClass="bg-sidebar-accent text-accent-foreground"
|
|
||||||
@onclick="HandleNav"
|
|
||||||
title="Weather">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
|
|
||||||
</svg>
|
|
||||||
@if (!Collapsed)
|
|
||||||
{
|
|
||||||
<span>Weather</span>
|
|
||||||
}
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public bool Collapsed { get; set; }
|
|
||||||
[Parameter] public EventCallback OnToggleSidebar { get; set; }
|
|
||||||
[Parameter] public EventCallback OnNavigated { get; set; }
|
|
||||||
|
|
||||||
private string NavLinkClass => Collapsed
|
|
||||||
? "nav-link group flex items-center justify-center rounded-md p-2 text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
|
|
||||||
: "nav-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent";
|
|
||||||
|
|
||||||
private async Task HandleToggle()
|
|
||||||
{
|
|
||||||
if (OnToggleSidebar.HasDelegate)
|
|
||||||
await OnToggleSidebar.InvokeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleNav()
|
|
||||||
{
|
|
||||||
if (OnNavigated.HasDelegate)
|
|
||||||
await OnNavigated.InvokeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
@page "/"
|
|
||||||
|
|
||||||
<PageTitle>Home</PageTitle>
|
|
||||||
|
|
||||||
<h1>Hello, world!</h1>
|
|
||||||
|
|
||||||
Welcome to your new app.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@page "/not-found"
|
|
||||||
@layout MainLayout
|
|
||||||
|
|
||||||
<h3>Not Found</h3>
|
|
||||||
<p>Sorry, the content you are looking for does not exist.</p>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
|
||||||
<Found Context="routeData">
|
|
||||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
|
||||||
</Found>
|
|
||||||
</Router>
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
|
<script>
|
||||||
|
// Synchronous dark-mode bootstrap — runs before first paint to prevent FOUC.
|
||||||
|
// Must stay in sync with the 'theme' localStorage key used by darkmode.js.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('theme') === 'dark')
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} catch (e) { }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="_content/Enciphered.Blazor.UIComponents/css/app.css" />
|
||||||
|
<link rel="stylesheet" href="@Assets["css/app.css"]" />
|
||||||
|
<ImportMap />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="enci_white.svg" />
|
||||||
|
<HeadOutlet />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<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';
|
||||||
|
import { init as initForms } from '/_content/Enciphered.Blazor.UIComponents/js/forms.js';
|
||||||
|
initDarkMode();
|
||||||
|
initSidebar();
|
||||||
|
initForms();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@using Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<SidebarProvider DefaultOpen="true">
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<div class="flex items-center gap-2 px-1 py-1.5 overflow-hidden">
|
||||||
|
<div class="flex shrink-0 aspect-square size-8 items-center justify-center">
|
||||||
|
<img src="enci_white.svg" alt="Enciphered" class="size-5 hidden dark:block" />
|
||||||
|
<img src="enci.svg" alt="Enciphered" class="size-5 block dark:hidden" />
|
||||||
|
</div>
|
||||||
|
<span class="truncate font-semibold text-sm group-data-[state=collapsed]:group-data-[mobile=false]:hidden">Enciphered UI</span>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel Label="Navigation" />
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenuItem Href="/" Tooltip="Home">
|
||||||
|
<Icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</svg>
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>Home</ChildContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem Href="/counter" Tooltip="Counter">
|
||||||
|
<Icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||||
|
<line x1="12" x2="12" y1="8" y2="16" />
|
||||||
|
<line x1="8" x2="16" y1="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>Counter</ChildContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem Href="/weather" Tooltip="Weather">
|
||||||
|
<Icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
|
||||||
|
</svg>
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>Weather</ChildContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem Href="/forms" Tooltip="Forms">
|
||||||
|
<Icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
|
||||||
|
</svg>
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>Forms</ChildContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem Href="/cards" Tooltip="Cards">
|
||||||
|
<Icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M3 9h18" />
|
||||||
|
</svg>
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>Cards</ChildContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<div class="group-data-[state=collapsed]:group-data-[mobile=false]:hidden">
|
||||||
|
<SidebarSeparator />
|
||||||
|
<div class="px-3 py-2 text-xs text-sidebar-foreground/50 truncate">
|
||||||
|
© 2026 Enciphered
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<SidebarInset>
|
||||||
|
<header class="flex h-14 items-center gap-2 border-b border-border px-4">
|
||||||
|
<SidebarTrigger Class="md:hidden" />
|
||||||
|
<h1 class="text-sm font-medium">Demo App</h1>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 p-4 md:p-6">
|
||||||
|
@Body
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
@page "/cards"
|
||||||
|
|
||||||
|
<PageTitle>Cards</PageTitle>
|
||||||
|
|
||||||
|
<div class="space-y-8 max-w-lg">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Cards Demo</h1>
|
||||||
|
<p class="text-muted-foreground">Card components with multiple layouts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Basic card ── *@
|
||||||
|
<Card data-testid="card-basic">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Card Title</CardTitle>
|
||||||
|
<CardDescription>Card Description</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>Card Content</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<p>Card Footer</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
@* ── Card with action in header ── *@
|
||||||
|
<Card data-testid="card-login">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Login to your account</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<Button Variant="@ButtonVariant.Link" Class="text-sm">Sign Up</Button>
|
||||||
|
</CardAction>
|
||||||
|
<CardDescription>Enter your email below to login to your account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormField Label="Email" For="email">
|
||||||
|
<TextInput Id="email" Type="email" Placeholder="m@example.com" />
|
||||||
|
</FormField>
|
||||||
|
<FormField Label="Password" For="password">
|
||||||
|
<TextInput Id="password" Type="password" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter Class="flex-col gap-2">
|
||||||
|
<Button Class="w-full">Login</Button>
|
||||||
|
<Button Variant="@ButtonVariant.Outline" Class="w-full">Login with Google</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
@* ── Image card ── *@
|
||||||
|
<Card data-testid="card-image">
|
||||||
|
<CardImage Src="https://placehold.co/600x300/2a2a2a/2a2a2a" Alt="Event cover" WrapperClass="h-48" />
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Design systems meetup</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<span class="rounded-full border border-border px-3 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
</CardAction>
|
||||||
|
<CardDescription>A practical talk on component APIs, accessibility, and shipping faster.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter>
|
||||||
|
<Button Class="w-full">View Event</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@page "/counter"
|
||||||
|
|
||||||
|
<PageTitle>Counter</PageTitle>
|
||||||
|
|
||||||
|
<h1>Counter</h1>
|
||||||
|
|
||||||
|
<p role="status">Current count: <span id="counter-value">0</span></p>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" id="counter-btn" onclick="document.getElementById('counter-value').textContent = ++window._count || (window._count=1)">Click me</button>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
@page "/forms"
|
||||||
|
|
||||||
|
<PageTitle>Forms</PageTitle>
|
||||||
|
|
||||||
|
<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 htmx validation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HtmxForm Endpoint="/api/contact">
|
||||||
|
<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="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="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="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>
|
||||||
|
</HtmxForm>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@page "/"
|
||||||
|
|
||||||
|
<PageTitle>Home</PageTitle>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Welcome</h1>
|
||||||
|
<p class="text-muted-foreground">This is the Enciphered Blazor UI Components demo app.</p>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/weather"
|
@page "/weather"
|
||||||
|
@using Enciphered.Blazor.UIComponents
|
||||||
@attribute [StreamRendering]
|
@attribute [StreamRendering]
|
||||||
|
|
||||||
<PageTitle>Weather</PageTitle>
|
<PageTitle>Weather</PageTitle>
|
||||||
@@ -13,27 +14,20 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<table class="table">
|
<Table Items="forecasts">
|
||||||
<thead>
|
<Columns>
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th aria-label="Temperature in Celsius">Temp. (C)</th>
|
<th aria-label="Temperature in Celsius">Temp. (C)</th>
|
||||||
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
|
<th aria-label="Temperature in Farenheit">Temp. (F)</th>
|
||||||
<th>Summary</th>
|
<th>Summary</th>
|
||||||
</tr>
|
</Columns>
|
||||||
</thead>
|
<RowTemplate Context="forecast">
|
||||||
<tbody>
|
|
||||||
@foreach (var forecast in forecasts)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@forecast.Date.ToShortDateString()</td>
|
<td>@forecast.Date.ToShortDateString()</td>
|
||||||
<td>@forecast.TemperatureC</td>
|
<td>@forecast.TemperatureC</td>
|
||||||
<td>@forecast.TemperatureF</td>
|
<td>@forecast.TemperatureF</td>
|
||||||
<td>@forecast.Summary</td>
|
<td>@forecast.Summary</td>
|
||||||
</tr>
|
</RowTemplate>
|
||||||
}
|
</Table>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly }">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
@@ -6,6 +6,6 @@
|
|||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
|
@using Enciphered.Blazor.UIComponents.Demo
|
||||||
|
@using Enciphered.Blazor.UIComponents.Demo.Components
|
||||||
@using Enciphered.Blazor.UIComponents
|
@using Enciphered.Blazor.UIComponents
|
||||||
@using Enciphered.Blazor.UIComponents.Components
|
|
||||||
@using Enciphered.Blazor.UIComponents.Components.Layout
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RunAOTCompilation>true</RunAOTCompilation>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Enciphered.Blazor.UIComponents\Enciphered.Blazor.UIComponents.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="TailwindBuild" BeforeTargets="Build">
|
||||||
|
<Exec Command="npx @tailwindcss/cli -i Styles/app.css -o wwwroot/css/app.css --minify" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Enciphered.Blazor.UIComponents.Demo;
|
||||||
|
using Enciphered.Blazor.UIComponents.Demo.Components;
|
||||||
|
using Enciphered.Blazor.UIComponents.Validation;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddRazorComponents();
|
||||||
|
builder.Services.AddAntiforgery();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapStaticAssets();
|
||||||
|
app.MapRazorComponents<App>()
|
||||||
|
.AddAdditionalAssemblies(typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly);
|
||||||
|
|
||||||
|
app.MapFormValidation<ContactFormValidator, ContactFormModel>("/api/contact",
|
||||||
|
onSuccess: async model =>
|
||||||
|
{
|
||||||
|
Console.WriteLine("── Form Submitted ──");
|
||||||
|
Console.WriteLine($" Name: {model.Name}");
|
||||||
|
Console.WriteLine($" Email: {model.Email}");
|
||||||
|
Console.WriteLine($" Password: {model.Password}");
|
||||||
|
Console.WriteLine($" Age: {model.Age}");
|
||||||
|
Console.WriteLine($" Birth Date: {model.Birthdate}");
|
||||||
|
Console.WriteLine($" Time: {model.Preferredtime}");
|
||||||
|
Console.WriteLine($" Appointment: {model.Appointment}");
|
||||||
|
Console.WriteLine($" Confirmation: {model.Confirmation}");
|
||||||
|
await Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "http://localhost:5182",
|
"applicationUrl": "http://localhost:5146",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "https://localhost:7009;http://localhost:5182",
|
"applicationUrl": "https://localhost:7065;http://localhost:5146",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* shadcn/ui design tokens.
|
||||||
|
* These must be defined in every project that uses Tailwind classes with
|
||||||
|
* the Enciphered.Blazor.UIComponents design system. The library ships a
|
||||||
|
* pre-built CSS (via _content/.../css/app.css) that covers all component
|
||||||
|
* classes, but if your own Razor files use token-based utilities like
|
||||||
|
* bg-background, text-foreground, etc., you need these tokens in your
|
||||||
|
* own Tailwind source file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ---------- light tokens (neutral) ---------- */
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border-color: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar-background: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- dark tokens (neutral) ---------- */
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border-color: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
--sidebar-background: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Map CSS vars → Tailwind theme ---------- */
|
||||||
|
@theme {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border-color);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-sidebar: var(--sidebar-background);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius: var(--radius);
|
||||||
|
--sidebar-width: 16rem;
|
||||||
|
--sidebar-width-icon: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Date / Time input native chrome overrides ---------- */
|
||||||
|
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||||
|
input[type="time"]::-webkit-calendar-picker-indicator,
|
||||||
|
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="date"]::-webkit-date-and-time-value,
|
||||||
|
input[type="time"]::-webkit-date-and-time-value,
|
||||||
|
input[type="datetime-local"]::-webkit-date-and-time-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="date"],
|
||||||
|
input[type="time"],
|
||||||
|
input[type="datetime-local"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input[type="date"],
|
||||||
|
.dark input[type="time"],
|
||||||
|
.dark input[type="datetime-local"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Custom scrollbar for picker columns ---------- */
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--muted) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--muted);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RunSettings>
|
||||||
|
<Playwright>
|
||||||
|
<BrowserName>chromium</BrowserName>
|
||||||
|
<LaunchOptions>
|
||||||
|
<Headless>true</Headless>
|
||||||
|
</LaunchOptions>
|
||||||
|
</Playwright>
|
||||||
|
</RunSettings>
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class CardTests : PlaywrightTestBase
|
||||||
|
{
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task GoToCardsAsync()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync($"{BaseUrl}/cards", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
await Page.WaitForSelectorAsync("[data-testid='card-basic']", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILocator Card(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Basic card – structure
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BasicCard_Is_Visible()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
await Expect(Card("card-basic")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BasicCard_Has_Rounded_Border_Classes()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var card = Card("card-basic");
|
||||||
|
await Expect(card).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("rounded-xl"));
|
||||||
|
await Expect(card).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("border"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BasicCard_Renders_Title()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var title = Card("card-basic").Locator("h3");
|
||||||
|
await Expect(title).ToBeVisibleAsync();
|
||||||
|
await Expect(title).ToHaveTextAsync("Card Title");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BasicCard_Renders_Description()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var desc = Card("card-basic").Locator("[data-slot='card-description']");
|
||||||
|
await Expect(desc).ToBeVisibleAsync();
|
||||||
|
await Expect(desc).ToHaveTextAsync("Card Description");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BasicCard_Renders_Content()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var content = Card("card-basic").Locator("p:has-text('Card Content')");
|
||||||
|
await Expect(content).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BasicCard_Renders_Footer()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var footer = Card("card-basic").Locator("p:has-text('Card Footer')");
|
||||||
|
await Expect(footer).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task BasicCard_Description_Appears_Below_Title()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var title = Card("card-basic").Locator("h3");
|
||||||
|
var desc = Card("card-basic").Locator("[data-slot='card-description']");
|
||||||
|
|
||||||
|
var titleBox = await title.BoundingBoxAsync();
|
||||||
|
var descBox = await desc.BoundingBoxAsync();
|
||||||
|
|
||||||
|
Assert.That(titleBox, Is.Not.Null, "Title bounding box should exist");
|
||||||
|
Assert.That(descBox, Is.Not.Null, "Description bounding box should exist");
|
||||||
|
Assert.That(descBox!.Y, Is.GreaterThan(titleBox!.Y), "Description should be positioned below the title");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Login card – header with action
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Is_Visible()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
await Expect(Card("card-login")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Renders_Title()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var title = Card("card-login").Locator("h3");
|
||||||
|
await Expect(title).ToHaveTextAsync("Login to your account");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Renders_Action()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var action = Card("card-login").Locator("[data-slot='card-action']");
|
||||||
|
await Expect(action).ToBeVisibleAsync();
|
||||||
|
await Expect(action).ToContainTextAsync("Sign Up");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Action_Is_Right_Of_Title()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var title = Card("card-login").Locator("h3");
|
||||||
|
var action = Card("card-login").Locator("[data-slot='card-action']");
|
||||||
|
|
||||||
|
var titleBox = await title.BoundingBoxAsync();
|
||||||
|
var actionBox = await action.BoundingBoxAsync();
|
||||||
|
|
||||||
|
Assert.That(titleBox, Is.Not.Null);
|
||||||
|
Assert.That(actionBox, Is.Not.Null);
|
||||||
|
Assert.That(actionBox!.X, Is.GreaterThan(titleBox!.X), "Action should be to the right of the title");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Renders_Description()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var desc = Card("card-login").Locator("[data-slot='card-description']");
|
||||||
|
await Expect(desc).ToContainTextAsync("Enter your email below");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Has_Email_Input()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var email = Card("card-login").Locator("input#email");
|
||||||
|
await Expect(email).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Has_Password_Input()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var password = Card("card-login").Locator("input#password");
|
||||||
|
await Expect(password).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Has_Login_Button()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var btn = Card("card-login").Locator("button:has-text('Login')").First;
|
||||||
|
await Expect(btn).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoginCard_Has_Google_Button()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var btn = Card("card-login").Locator("button:has-text('Login with Google')");
|
||||||
|
await Expect(btn).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Image card
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImageCard_Is_Visible()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
await Expect(Card("card-image")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImageCard_Renders_Image()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var img = Card("card-image").Locator("img");
|
||||||
|
await Expect(img).ToBeVisibleAsync();
|
||||||
|
await Expect(img).ToHaveAttributeAsync("alt", "Event cover");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImageCard_Image_Appears_Above_Title()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var img = Card("card-image").Locator("img");
|
||||||
|
var title = Card("card-image").Locator("h3");
|
||||||
|
|
||||||
|
var imgBox = await img.BoundingBoxAsync();
|
||||||
|
var titleBox = await title.BoundingBoxAsync();
|
||||||
|
|
||||||
|
Assert.That(imgBox, Is.Not.Null);
|
||||||
|
Assert.That(titleBox, Is.Not.Null);
|
||||||
|
Assert.That(titleBox!.Y, Is.GreaterThan(imgBox!.Y), "Title should be below the image");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImageCard_Renders_Title()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var title = Card("card-image").Locator("h3");
|
||||||
|
await Expect(title).ToHaveTextAsync("Design systems meetup");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImageCard_Renders_Featured_Badge()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var badge = Card("card-image").Locator("[data-slot='card-action']");
|
||||||
|
await Expect(badge).ToContainTextAsync("Featured");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImageCard_Renders_Description()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var desc = Card("card-image").Locator("[data-slot='card-description']");
|
||||||
|
await Expect(desc).ToContainTextAsync("A practical talk on component APIs");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImageCard_Has_View_Event_Button()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
var btn = Card("card-image").Locator("button:has-text('View Event')");
|
||||||
|
await Expect(btn).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// All three cards render on the page
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task CardsPage_Renders_Three_Cards()
|
||||||
|
{
|
||||||
|
await GoToCardsAsync();
|
||||||
|
|
||||||
|
await Expect(Card("card-basic")).ToBeVisibleAsync();
|
||||||
|
await Expect(Card("card-login")).ToBeVisibleAsync();
|
||||||
|
await Expect(Card("card-image")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launches the demo Blazor app as a separate process on a random free port.
|
||||||
|
/// Shared across all tests in the assembly via [SetUpFixture].
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DemoServerFixture : IDisposable
|
||||||
|
{
|
||||||
|
private Process? _process;
|
||||||
|
|
||||||
|
public string BaseUrl { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
var port = GetFreePort();
|
||||||
|
BaseUrl = $"http://localhost:{port}";
|
||||||
|
|
||||||
|
// Resolve the demo project directory (navigate up from test bin output)
|
||||||
|
var testDir = AppContext.BaseDirectory; // …Tests/bin/Debug/net9.0
|
||||||
|
var solutionRoot = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", ".."));
|
||||||
|
var demoProjectDir = Path.Combine(solutionRoot, "Enciphered.Blazor.UIComponents.Demo");
|
||||||
|
|
||||||
|
if (!Directory.Exists(demoProjectDir))
|
||||||
|
throw new DirectoryNotFoundException($"Demo project not found at: {demoProjectDir}");
|
||||||
|
|
||||||
|
_process = new Process
|
||||||
|
{
|
||||||
|
StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
Arguments = $"run --no-build --urls {BaseUrl}",
|
||||||
|
WorkingDirectory = demoProjectDir,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
Environment =
|
||||||
|
{
|
||||||
|
["ASPNETCORE_ENVIRONMENT"] = "Development",
|
||||||
|
["DOTNET_NOLOGO"] = "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_process.Start();
|
||||||
|
|
||||||
|
// Wait for the server to be ready by polling the URL
|
||||||
|
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetAsync(BaseUrl);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Server not ready yet
|
||||||
|
}
|
||||||
|
await Task.Delay(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"Demo server did not start within 30 seconds at {BaseUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_process is not null && !_process.HasExited)
|
||||||
|
{
|
||||||
|
_process.Kill(entireProcessTree: true);
|
||||||
|
_process.WaitForExit(5000);
|
||||||
|
_process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.59.0" />
|
||||||
|
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||||
|
<PackageReference Include="NUnit.Analyzers" Version="4.4.0" />
|
||||||
|
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="NUnit.Framework" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class FormsTests : PlaywrightTestBase
|
||||||
|
{
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task GoToFormsAsync()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
// Wait for the form to be rendered
|
||||||
|
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||||
|
private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||||
|
private ILocator Btn(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the open popover panel nearest to a trigger.
|
||||||
|
/// </summary>
|
||||||
|
private ILocator PopoverPanelFor(string triggerId) =>
|
||||||
|
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-panel]");
|
||||||
|
|
||||||
|
private ILocator PopoverBackdropFor(string triggerId) =>
|
||||||
|
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-backdrop]");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Select a date via the calendar popover.
|
||||||
|
/// Opens the trigger, uses month/year pickers to navigate, then clicks the day.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SelectDateAsync(string triggerId, DateOnly target)
|
||||||
|
{
|
||||||
|
// Open the popover
|
||||||
|
await Trigger(triggerId).ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor(triggerId);
|
||||||
|
await NavigateCalendarToDate(panel, target);
|
||||||
|
|
||||||
|
// Click the target day
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigate the open calendar to a specific month/year using the month and year pickers.
|
||||||
|
/// </summary>
|
||||||
|
private async Task NavigateCalendarToDate(ILocator panel, DateOnly target)
|
||||||
|
{
|
||||||
|
// Click year header to open year picker, then select the year
|
||||||
|
var yearButton = panel.Locator("[data-calendar-year]");
|
||||||
|
await yearButton.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(150);
|
||||||
|
|
||||||
|
// The year picker grid is inside the calendar content
|
||||||
|
var yearGrid = panel.Locator(".grid.grid-cols-4");
|
||||||
|
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
|
||||||
|
|
||||||
|
// If the year isn't visible, use prev/next to shift the year range
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Now click month header to open month picker, then select the month
|
||||||
|
var monthButton = panel.Locator("[data-calendar-month]");
|
||||||
|
await monthButton.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(150);
|
||||||
|
|
||||||
|
var monthGrid = panel.Locator(".grid.grid-cols-3");
|
||||||
|
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
|
||||||
|
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Select a time via the time picker popover.
|
||||||
|
/// Opens the trigger, clicks the hour, minute, and AM/PM.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SelectTimeAsync(string triggerId, int hour, int minute)
|
||||||
|
{
|
||||||
|
// Open the popover
|
||||||
|
await Trigger(triggerId).ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor(triggerId);
|
||||||
|
await PickTimeInOpenPopover(panel, hour, minute);
|
||||||
|
|
||||||
|
// Close popover by clicking the backdrop overlay
|
||||||
|
var backdrop = PopoverBackdropFor(triggerId);
|
||||||
|
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pick hour, minute, and AM/PM in an already-open time picker.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PickTimeInOpenPopover(ILocator panel, int hour, int minute)
|
||||||
|
{
|
||||||
|
// Convert to 12-hour format
|
||||||
|
var isPm = hour >= 12;
|
||||||
|
var hour12 = hour % 12;
|
||||||
|
if (hour12 == 0) hour12 = 12;
|
||||||
|
|
||||||
|
// Click the hour in the first scrollable column
|
||||||
|
var hourText = hour12.ToString("D2");
|
||||||
|
var hourColumn = panel.Locator(".scrollbar-thin").First;
|
||||||
|
await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
|
||||||
|
// Click the minute in the second scrollable column
|
||||||
|
var minuteText = minute.ToString("D2");
|
||||||
|
var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1);
|
||||||
|
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
|
||||||
|
// Click AM/PM
|
||||||
|
var periodText = isPm ? "PM" : "AM";
|
||||||
|
await panel.Locator($"button:has-text('{periodText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Rendering
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task FormsPage_Loads_Successfully()
|
||||||
|
{
|
||||||
|
var response = await Page.GotoAsync($"{BaseUrl}/forms");
|
||||||
|
Assert.That(response!.Status, Is.EqualTo(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task FormsPage_Has_Title()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
|
||||||
|
await Expect(heading).ToHaveTextAsync("Forms Demo");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task All_Inputs_Are_Rendered()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
await Expect(Input("input-name")).ToBeVisibleAsync();
|
||||||
|
await Expect(Input("input-email")).ToBeVisibleAsync();
|
||||||
|
await Expect(Input("input-password")).ToBeVisibleAsync();
|
||||||
|
await Expect(Input("input-age")).ToBeVisibleAsync();
|
||||||
|
// Date/Time/DateTime use popover triggers instead of visible native inputs
|
||||||
|
await Expect(Trigger("trigger-birthdate")).ToBeVisibleAsync();
|
||||||
|
await Expect(Trigger("trigger-preferredtime")).ToBeVisibleAsync();
|
||||||
|
await Expect(Trigger("trigger-appointment-date")).ToBeVisibleAsync();
|
||||||
|
await Expect(Trigger("trigger-appointment-time")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task All_Buttons_Are_Rendered()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
await Expect(Btn("btn-submit")).ToBeVisibleAsync();
|
||||||
|
await Expect(Btn("btn-reset")).ToBeVisibleAsync();
|
||||||
|
await Expect(Btn("btn-disabled")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Input types
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TextInput_Has_Correct_Type()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-name")).ToHaveAttributeAsync("type", "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task EmailInput_Has_Correct_Type()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-email")).ToHaveAttributeAsync("type", "email");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PasswordInput_Has_Correct_Type()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-password")).ToHaveAttributeAsync("type", "password");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Has_Correct_Type()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-age")).ToHaveAttributeAsync("type", "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateInput_Has_Correct_Type()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-birthdate")).ToHaveAttributeAsync("type", "date");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TimeInput_Has_Correct_Type()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-time")).ToHaveAttributeAsync("type", "time");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateTimeInput_Has_Correct_Type()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-appointment")).ToHaveAttributeAsync("type", "datetime-local");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Placeholders
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TextInput_Shows_Placeholder()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-name")).ToHaveAttributeAsync("placeholder", "Jane Doe");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task EmailInput_Shows_Placeholder()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
await Expect(Input("input-email")).ToHaveAttributeAsync("placeholder", "jane@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Labels
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task FormFields_Have_Labels()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var labels = Page.Locator("label");
|
||||||
|
var count = await labels.CountAsync();
|
||||||
|
Assert.That(count, Is.EqualTo(8), "Expected 8 labels (one per form field)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Label_For_Attribute_Matches_Input_Id()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var label = Page.Locator("label[for='name']");
|
||||||
|
await Expect(label).ToHaveTextAsync("Full Name");
|
||||||
|
|
||||||
|
var input = Input("input-name");
|
||||||
|
await Expect(input).ToHaveAttributeAsync("id", "name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Value binding (native)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TextInput_Binds_Value()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-name");
|
||||||
|
await input.FillAsync("Alice");
|
||||||
|
await Expect(input).ToHaveValueAsync("Alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Binds_Value()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("30");
|
||||||
|
await Expect(input).ToHaveValueAsync("30");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateInput_Binds_Value()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Use the calendar popover to select June 15, 2000
|
||||||
|
await SelectDateAsync("trigger-birthdate", new DateOnly(2000, 6, 15));
|
||||||
|
|
||||||
|
// The hidden input should reflect the selected date
|
||||||
|
await Expect(Input("input-birthdate")).ToHaveValueAsync("2000-06-15");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TimeInput_Binds_Value()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Use the time picker popover to select 14:30 (2:30 PM)
|
||||||
|
await SelectTimeAsync("trigger-preferredtime", 14, 30);
|
||||||
|
|
||||||
|
// The hidden input should reflect the selected time
|
||||||
|
await Expect(Input("input-time")).ToHaveValueAsync("14:30");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateTimeInput_Binds_Value()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Pick the date part via the date trigger
|
||||||
|
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
|
||||||
|
|
||||||
|
// Pick the time part via the time trigger
|
||||||
|
await SelectTimeAsync("trigger-appointment-time", 10, 0);
|
||||||
|
|
||||||
|
// The hidden datetime-local input should have the combined value
|
||||||
|
// Note: DateTime hidden input is composed from separate date/time part hidden inputs
|
||||||
|
var datePartVal = await Page.Locator("#appointment-date-part").InputValueAsync();
|
||||||
|
var timePartVal = await Page.Locator("#appointment-time-part").InputValueAsync();
|
||||||
|
Assert.That(datePartVal, Is.EqualTo("2025-12-25"));
|
||||||
|
Assert.That(timePartVal, Is.EqualTo("10:00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Button variants
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Submit_Button_Has_Default_Variant_Classes()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var btn = Btn("btn-submit");
|
||||||
|
var cls = await btn.GetAttributeAsync("class");
|
||||||
|
Assert.That(cls, Does.Contain("bg-primary"), "Submit should use default variant");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Reset_Button_Has_Outline_Variant_Classes()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var btn = Btn("btn-reset");
|
||||||
|
var cls = await btn.GetAttributeAsync("class");
|
||||||
|
Assert.That(cls, Does.Contain("border"), "Reset should use outline variant");
|
||||||
|
Assert.That(cls, Does.Contain("bg-background"), "Reset should use outline variant");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Disabled_Button_Is_Actually_Disabled()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var btn = Btn("btn-disabled");
|
||||||
|
await Expect(btn).ToBeDisabledAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Disabled_Button_Has_Destructive_Variant()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var btn = Btn("btn-disabled");
|
||||||
|
var cls = await btn.GetAttributeAsync("class");
|
||||||
|
Assert.That(cls, Does.Contain("bg-destructive"), "Disabled button should have destructive variant");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Reset (native HTML reset)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Reset_Button_Clears_Form()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Fill some fields
|
||||||
|
await Input("input-name").FillAsync("Alice");
|
||||||
|
await Input("input-email").FillAsync("alice@test.com");
|
||||||
|
|
||||||
|
// Reset (native form reset)
|
||||||
|
await Btn("btn-reset").ClickAsync();
|
||||||
|
|
||||||
|
// Fields should be empty
|
||||||
|
await Expect(Input("input-name")).ToHaveValueAsync("");
|
||||||
|
await Expect(Input("input-email")).ToHaveValueAsync("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Input styling (base CSS classes present)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Inputs_Have_Base_Styling_Classes()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-name");
|
||||||
|
var cls = await input.GetAttributeAsync("class");
|
||||||
|
Assert.That(cls, Does.Contain("rounded-md"), "Input should have rounded-md class");
|
||||||
|
Assert.That(cls, Does.Contain("border"), "Input should have border class");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Navigation to forms page
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sidebar_Has_Forms_Link()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||||
|
|
||||||
|
var formsLink = Page.Locator("a[href='/forms']");
|
||||||
|
await Expect(formsLink).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Navigate_To_Forms_Via_Sidebar()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||||
|
|
||||||
|
await Page.Locator("a[href='/forms']").ClickAsync();
|
||||||
|
await Page.WaitForURLAsync($"{BaseUrl}/forms");
|
||||||
|
|
||||||
|
var heading = Page.Locator("[data-sidebar-inset] h1.text-3xl");
|
||||||
|
await Expect(heading).ToHaveTextAsync("Forms Demo");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assembly-level setup: boots the demo server once before any test runs.
|
||||||
|
/// </summary>
|
||||||
|
[SetUpFixture]
|
||||||
|
public class GlobalSetup
|
||||||
|
{
|
||||||
|
public static DemoServerFixture Server { get; private set; } = null!;
|
||||||
|
|
||||||
|
[OneTimeSetUp]
|
||||||
|
public async Task OneTimeSetUp()
|
||||||
|
{
|
||||||
|
Server = new DemoServerFixture();
|
||||||
|
await Server.StartAsync();
|
||||||
|
TestContext.Out.WriteLine($"Demo server started at {Server.BaseUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[OneTimeTearDown]
|
||||||
|
public void OneTimeTearDown()
|
||||||
|
{
|
||||||
|
Server?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that cover interactive behavior gaps to ensure safe JS migration.
|
||||||
|
/// Covers: NumberInput +/- buttons & min/max clamping, Popover open/close mechanics,
|
||||||
|
/// Calendar arrow navigation, and trigger text updates for Date/Time/DateTime inputs.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class InteractivityTests : PlaywrightTestBase
|
||||||
|
{
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task GoToFormsAsync()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync($"{BaseUrl}/forms", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
await Page.WaitForSelectorAsync("[data-testid='btn-submit']", new PageWaitForSelectorOptions { Timeout = 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILocator Input(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||||
|
private ILocator Trigger(string testId) => Page.Locator($"[data-testid='{testId}']");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the popover panel scoped to the popover containing a trigger.
|
||||||
|
/// </summary>
|
||||||
|
private ILocator PopoverPanelFor(string triggerId) =>
|
||||||
|
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-panel]");
|
||||||
|
|
||||||
|
private ILocator PopoverBackdropFor(string triggerId) =>
|
||||||
|
Trigger(triggerId).Locator("xpath=ancestor::*[@data-popover]").Locator("[data-popover-backdrop]");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigate the open calendar to a specific month/year using the month and year pickers.
|
||||||
|
/// </summary>
|
||||||
|
private async Task NavigateCalendarToDate(ILocator panel, DateOnly target)
|
||||||
|
{
|
||||||
|
var yearButton = panel.Locator("[data-calendar-year]");
|
||||||
|
await yearButton.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(150);
|
||||||
|
|
||||||
|
var yearGrid = panel.Locator(".grid.grid-cols-4");
|
||||||
|
var targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
|
||||||
|
|
||||||
|
var attempts = 0;
|
||||||
|
while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
|
||||||
|
{
|
||||||
|
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
|
||||||
|
var firstYear = int.Parse(firstYearText.Trim());
|
||||||
|
|
||||||
|
if (target.Year < firstYear)
|
||||||
|
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
|
||||||
|
else
|
||||||
|
await panel.Locator("button[aria-label='Next month']").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
yearGrid = panel.Locator(".grid.grid-cols-4");
|
||||||
|
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await targetYearBtn.First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(150);
|
||||||
|
|
||||||
|
var monthButton = panel.Locator("[data-calendar-month]");
|
||||||
|
await monthButton.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(150);
|
||||||
|
|
||||||
|
var monthGrid = panel.Locator(".grid.grid-cols-3");
|
||||||
|
var targetMonthText = new DateOnly(2000, target.Month, 1).ToString("MMM");
|
||||||
|
await monthGrid.Locator($"button:has-text('{targetMonthText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pick hour, minute, and AM/PM in an already-open time picker popover.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PickTimeInOpenPopover(ILocator panel, int hour, int minute)
|
||||||
|
{
|
||||||
|
var isPm = hour >= 12;
|
||||||
|
var hour12 = hour % 12;
|
||||||
|
if (hour12 == 0) hour12 = 12;
|
||||||
|
|
||||||
|
var hourText = hour12.ToString("D2");
|
||||||
|
var hourColumn = panel.Locator(".scrollbar-thin").First;
|
||||||
|
await hourColumn.Locator($"button:has-text('{hourText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
|
||||||
|
var minuteText = minute.ToString("D2");
|
||||||
|
var minuteColumn = panel.Locator(".scrollbar-thin").Nth(1);
|
||||||
|
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
|
||||||
|
var periodText = isPm ? "PM" : "AM";
|
||||||
|
await panel.Locator($"button:has-text('{periodText}')").First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// NumberInput: Increment / Decrement buttons
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Increment_Button_Increases_Value()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("25");
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
|
||||||
|
await incrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
await Expect(input).ToHaveValueAsync("26");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Decrement_Button_Decreases_Value()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("25");
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
|
||||||
|
await decrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
await Expect(input).ToHaveValueAsync("24");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Increment_Multiple_Times()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("10");
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
|
||||||
|
await incrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
await incrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(50);
|
||||||
|
await incrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
await Expect(input).ToHaveValueAsync("13");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Increment_From_Empty_Sets_Value_To_One()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("");
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
|
||||||
|
await incrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
await Expect(input).ToHaveValueAsync("1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Decrement_From_Empty_Sets_Value_To_Negative_One_Or_Min()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("");
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
|
||||||
|
await decrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
// Age has Min=0, so decrement from 0 (default) should clamp to 0
|
||||||
|
await Expect(input).ToHaveValueAsync("0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// NumberInput: Min / Max clamping
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Increment_Button_Disabled_At_Max()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("150"); // Max is 150
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
|
||||||
|
await Expect(incrementBtn).ToBeDisabledAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Decrement_Button_Disabled_At_Min()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("0"); // Min is 0
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
|
||||||
|
await Expect(decrementBtn).ToBeDisabledAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Increment_At_Max_Does_Not_Exceed()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("149");
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var incrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Increment']");
|
||||||
|
await incrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
await Expect(input).ToHaveValueAsync("150");
|
||||||
|
await Expect(incrementBtn).ToBeDisabledAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NumberInput_Decrement_At_Min_Does_Not_Go_Below()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var input = Input("input-age");
|
||||||
|
await input.FillAsync("1");
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
var decrementBtn = Page.Locator("[data-testid='input-age']").Locator("..").Locator("button[aria-label='Decrement']");
|
||||||
|
await decrementBtn.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(100);
|
||||||
|
|
||||||
|
await Expect(input).ToHaveValueAsync("0");
|
||||||
|
await Expect(decrementBtn).ToBeDisabledAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Popover: explicit open/close mechanics
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Popover_Opens_On_Trigger_Click()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Date input trigger opens a calendar popover
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// The popover panel scoped to this trigger should be visible
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
await Expect(panel).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Popover_Closes_On_Backdrop_Click()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open the time input popover
|
||||||
|
await Trigger("trigger-preferredtime").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-preferredtime");
|
||||||
|
await Expect(panel).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
// Click the backdrop overlay to close
|
||||||
|
var backdrop = PopoverBackdropFor("trigger-preferredtime");
|
||||||
|
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Popover should no longer be visible
|
||||||
|
await Expect(panel).ToBeHiddenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Popover_Stays_Open_On_Content_Click()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open the date input popover
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
await Expect(panel).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
// Click inside the popover content (e.g. the month header button) — should NOT close
|
||||||
|
var monthButton = panel.Locator("[data-calendar-month]");
|
||||||
|
await monthButton.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Popover should still be visible (month picker is now showing)
|
||||||
|
await Expect(panel).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Popover_Toggle_Opens_Then_Closes_Via_Backdrop()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var trigger = Trigger("trigger-birthdate");
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
|
||||||
|
// Open
|
||||||
|
await trigger.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
await Expect(panel).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
// Close via backdrop
|
||||||
|
var backdrop = PopoverBackdropFor("trigger-birthdate");
|
||||||
|
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
await Expect(panel).ToBeHiddenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Calendar: Previous / Next arrow buttons
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Calendar_Next_Arrow_Advances_Month()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open calendar
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
|
||||||
|
// Read the current displayed month
|
||||||
|
var monthLabel = panel.Locator("[data-calendar-month]");
|
||||||
|
var initialMonth = await monthLabel.InnerTextAsync();
|
||||||
|
|
||||||
|
// Click the next arrow
|
||||||
|
await panel.Locator("button[aria-label='Next month']").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
|
||||||
|
// Month should have changed
|
||||||
|
var newMonth = await monthLabel.InnerTextAsync();
|
||||||
|
Assert.That(newMonth, Is.Not.EqualTo(initialMonth), "Month label should change after clicking Next");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Calendar_Previous_Arrow_Goes_Back_Month()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open calendar
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
|
||||||
|
var monthLabel = panel.Locator("[data-calendar-month]");
|
||||||
|
var initialMonth = await monthLabel.InnerTextAsync();
|
||||||
|
|
||||||
|
// Click the previous arrow
|
||||||
|
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
|
||||||
|
var newMonth = await monthLabel.InnerTextAsync();
|
||||||
|
Assert.That(newMonth, Is.Not.EqualTo(initialMonth), "Month label should change after clicking Previous");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Calendar_Next_Arrow_Wraps_Year()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open calendar
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
|
||||||
|
// Navigate to Dec of current year
|
||||||
|
var target = new DateOnly(DateTime.Today.Year, 12, 1);
|
||||||
|
await NavigateCalendarToDate(panel, target);
|
||||||
|
|
||||||
|
var yearLabel = panel.Locator("[data-calendar-year]");
|
||||||
|
var initialYear = await yearLabel.InnerTextAsync();
|
||||||
|
|
||||||
|
// Click next — should go to Jan of next year
|
||||||
|
await panel.Locator("button[aria-label='Next month']").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
|
||||||
|
var monthLabel = panel.Locator("[data-calendar-month]");
|
||||||
|
var newMonth = await monthLabel.InnerTextAsync();
|
||||||
|
var newYear = await yearLabel.InnerTextAsync();
|
||||||
|
|
||||||
|
Assert.That(newMonth.Trim(), Is.EqualTo("Jan"), "Should wrap to January");
|
||||||
|
Assert.That(int.Parse(newYear.Trim()), Is.EqualTo(int.Parse(initialYear.Trim()) + 1), "Year should increment");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Calendar_Previous_Arrow_Wraps_Year()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open calendar
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
|
||||||
|
var target = new DateOnly(DateTime.Today.Year, 1, 1);
|
||||||
|
await NavigateCalendarToDate(panel, target);
|
||||||
|
|
||||||
|
var yearLabel = panel.Locator("[data-calendar-year]");
|
||||||
|
var initialYear = await yearLabel.InnerTextAsync();
|
||||||
|
|
||||||
|
// Click previous — should go to Dec of previous year
|
||||||
|
await panel.Locator("button[aria-label='Previous month']").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
|
||||||
|
var monthLabel = panel.Locator("[data-calendar-month]");
|
||||||
|
var newMonth = await monthLabel.InnerTextAsync();
|
||||||
|
var newYear = await yearLabel.InnerTextAsync();
|
||||||
|
|
||||||
|
Assert.That(newMonth.Trim(), Is.EqualTo("Dec"), "Should wrap to December");
|
||||||
|
Assert.That(int.Parse(newYear.Trim()), Is.EqualTo(int.Parse(initialYear.Trim()) - 1), "Year should decrement");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Calendar_Selecting_Day_Via_Arrow_Navigation()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open calendar
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
|
||||||
|
// Navigate forward one month using arrow
|
||||||
|
await panel.Locator("button[aria-label='Next month']").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
|
||||||
|
// Read the new month/year
|
||||||
|
var monthText = (await panel.Locator("[data-calendar-month]").InnerTextAsync()).Trim();
|
||||||
|
var yearText = (await panel.Locator("[data-calendar-year]").InnerTextAsync()).Trim();
|
||||||
|
var month = DateTime.ParseExact(monthText, "MMM", null).Month;
|
||||||
|
var year = int.Parse(yearText);
|
||||||
|
|
||||||
|
// Click day 15
|
||||||
|
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
|
||||||
|
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Verify the hidden input has the correct value
|
||||||
|
var expected = new DateOnly(year, month, 15).ToString("yyyy-MM-dd");
|
||||||
|
await Expect(Input("input-birthdate")).ToHaveValueAsync(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// DateInput: Trigger text updates after selection
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateInput_Trigger_Shows_Placeholder_Initially()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-birthdate").Locator("span");
|
||||||
|
var text = await triggerSpan.InnerTextAsync();
|
||||||
|
Assert.That(text.Trim(), Is.EqualTo("Select date"), "Should show placeholder before a date is selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateInput_Trigger_Shows_Formatted_Date_After_Selection()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open and select June 15, 2000
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
await NavigateCalendarToDate(panel, new DateOnly(2000, 6, 15));
|
||||||
|
|
||||||
|
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
|
||||||
|
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Trigger button text should now show the formatted date
|
||||||
|
var triggerSpan = Trigger("trigger-birthdate").Locator("span");
|
||||||
|
var text = (await triggerSpan.InnerTextAsync()).Trim();
|
||||||
|
Assert.That(text, Is.EqualTo("June 15, 2000"), "Trigger should display the formatted selected date");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateInput_Trigger_Text_Loses_Placeholder_Class_After_Selection()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-birthdate").Locator("span");
|
||||||
|
|
||||||
|
// Before selection — should have muted style
|
||||||
|
var classBefore = await triggerSpan.GetAttributeAsync("class");
|
||||||
|
Assert.That(classBefore, Does.Contain("text-muted-foreground"), "Placeholder text should have muted class");
|
||||||
|
|
||||||
|
// Select a date
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
await NavigateCalendarToDate(panel, new DateOnly(2000, 6, 15));
|
||||||
|
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
|
||||||
|
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// After selection — should NOT have muted class
|
||||||
|
var classAfter = await triggerSpan.GetAttributeAsync("class");
|
||||||
|
Assert.That(classAfter, Does.Not.Contain("text-muted-foreground"), "Selected date text should not have muted class");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// TimeInput: Trigger text updates after selection
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TimeInput_Trigger_Shows_Placeholder_Initially()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-preferredtime").Locator("span");
|
||||||
|
var text = await triggerSpan.InnerTextAsync();
|
||||||
|
Assert.That(text.Trim(), Is.EqualTo("Select time"), "Should show placeholder before a time is selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TimeInput_Trigger_Shows_Formatted_Time_After_Selection()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open time picker and select 2:30 PM
|
||||||
|
await Trigger("trigger-preferredtime").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-preferredtime");
|
||||||
|
await PickTimeInOpenPopover(panel, 14, 30);
|
||||||
|
|
||||||
|
// Close by clicking backdrop
|
||||||
|
var backdrop = PopoverBackdropFor("trigger-preferredtime");
|
||||||
|
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-preferredtime").Locator("span");
|
||||||
|
var text = (await triggerSpan.InnerTextAsync()).Trim();
|
||||||
|
Assert.That(text, Is.EqualTo("02:30 PM"), "Trigger should display the formatted selected time");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TimeInput_Trigger_Text_Loses_Placeholder_Class_After_Selection()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-preferredtime").Locator("span");
|
||||||
|
|
||||||
|
// Before selection
|
||||||
|
var classBefore = await triggerSpan.GetAttributeAsync("class");
|
||||||
|
Assert.That(classBefore, Does.Contain("text-muted-foreground"), "Placeholder should have muted class");
|
||||||
|
|
||||||
|
// Select a time
|
||||||
|
await Trigger("trigger-preferredtime").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
var panel = PopoverPanelFor("trigger-preferredtime");
|
||||||
|
await PickTimeInOpenPopover(panel, 14, 30);
|
||||||
|
var backdrop = PopoverBackdropFor("trigger-preferredtime");
|
||||||
|
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// After selection
|
||||||
|
var classAfter = await triggerSpan.GetAttributeAsync("class");
|
||||||
|
Assert.That(classAfter, Does.Not.Contain("text-muted-foreground"), "Selected time text should not have muted class");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// DateTimeInput: Trigger text updates after selection
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateTimeInput_Date_Trigger_Shows_Placeholder_Initially()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-appointment-date").Locator("span");
|
||||||
|
var text = await triggerSpan.InnerTextAsync();
|
||||||
|
Assert.That(text.Trim(), Is.EqualTo("Select date"), "Date trigger should show placeholder initially");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateTimeInput_Time_Trigger_Shows_Placeholder_Initially()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-appointment-time").Locator("span");
|
||||||
|
var text = await triggerSpan.InnerTextAsync();
|
||||||
|
Assert.That(text.Trim(), Is.EqualTo("Select time"), "Time trigger should show placeholder initially");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateTimeInput_Date_Trigger_Shows_Formatted_Date_After_Selection()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open and select Dec 25, 2025
|
||||||
|
await Trigger("trigger-appointment-date").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-appointment-date");
|
||||||
|
await NavigateCalendarToDate(panel, new DateOnly(2025, 12, 25));
|
||||||
|
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
|
||||||
|
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "25" }).First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-appointment-date").Locator("span");
|
||||||
|
var text = (await triggerSpan.InnerTextAsync()).Trim();
|
||||||
|
Assert.That(text, Is.EqualTo("Dec 25, 2025"), "Date trigger should display the formatted selected date");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateTimeInput_Time_Trigger_Shows_Formatted_Time_After_Selection()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Must select a date first so the component has a value
|
||||||
|
await Trigger("trigger-appointment-date").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
var datePanel = PopoverPanelFor("trigger-appointment-date");
|
||||||
|
await NavigateCalendarToDate(datePanel, new DateOnly(2025, 12, 25));
|
||||||
|
var dayGrid = datePanel.Locator(".grid.grid-cols-7").Last;
|
||||||
|
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "25" }).First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Now select time 10:00 AM
|
||||||
|
await Trigger("trigger-appointment-time").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
var timePanel = PopoverPanelFor("trigger-appointment-time");
|
||||||
|
await PickTimeInOpenPopover(timePanel, 10, 0);
|
||||||
|
var backdrop = PopoverBackdropFor("trigger-appointment-time");
|
||||||
|
await backdrop.ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var triggerSpan = Trigger("trigger-appointment-time").Locator("span");
|
||||||
|
var text = (await triggerSpan.InnerTextAsync()).Trim();
|
||||||
|
Assert.That(text, Is.EqualTo("10:00 AM"), "Time trigger should display the formatted selected time");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Calendar: day selection highlights correctly
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Calendar_Selected_Day_Has_Primary_Styling()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
// Open and select a date
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
|
||||||
|
var day15 = dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First;
|
||||||
|
await day15.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Re-open the calendar to verify the selected day is highlighted
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
var selectedDay = panel.Locator(".grid.grid-cols-7").Last
|
||||||
|
.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "15" }).First;
|
||||||
|
var cls = await selectedDay.GetAttributeAsync("class");
|
||||||
|
Assert.That(cls, Does.Contain("bg-primary"), "Selected day should have primary background styling");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// Popover: Date selection auto-closes popover
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DateInput_Popover_Closes_After_Day_Selection()
|
||||||
|
{
|
||||||
|
await GoToFormsAsync();
|
||||||
|
|
||||||
|
await Trigger("trigger-birthdate").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var panel = PopoverPanelFor("trigger-birthdate");
|
||||||
|
await Expect(panel).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
// Select a day
|
||||||
|
var dayGrid = panel.Locator(".grid.grid-cols-7").Last;
|
||||||
|
await dayGrid.Locator("button:not([disabled])").Filter(new LocatorFilterOptions { HasTextString = "10" }).First.ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(400);
|
||||||
|
|
||||||
|
// Popover should auto-close after date selection
|
||||||
|
await Expect(panel).ToBeHiddenAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for Playwright tests. Creates a fresh browser context per test.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PlaywrightTestBase
|
||||||
|
{
|
||||||
|
protected IBrowser Browser { get; private set; } = null!;
|
||||||
|
protected IBrowserContext Context { get; private set; } = null!;
|
||||||
|
protected IPage Page { get; private set; } = null!;
|
||||||
|
protected string BaseUrl => GlobalSetup.Server.BaseUrl;
|
||||||
|
|
||||||
|
protected static ILocatorAssertions Expect(ILocator locator) => Assertions.Expect(locator);
|
||||||
|
protected static IPageAssertions Expect(IPage page) => Assertions.Expect(page);
|
||||||
|
|
||||||
|
private static IPlaywright? _playwright;
|
||||||
|
private static IBrowser? _sharedBrowser;
|
||||||
|
|
||||||
|
[OneTimeSetUp]
|
||||||
|
public async Task PlaywrightOneTimeSetUp()
|
||||||
|
{
|
||||||
|
_playwright ??= await Playwright.CreateAsync();
|
||||||
|
_sharedBrowser ??= await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||||
|
{
|
||||||
|
Headless = true
|
||||||
|
});
|
||||||
|
Browser = _sharedBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public async Task PlaywrightSetUp()
|
||||||
|
{
|
||||||
|
Context = await Browser.NewContextAsync(new BrowserNewContextOptions
|
||||||
|
{
|
||||||
|
IgnoreHTTPSErrors = true
|
||||||
|
});
|
||||||
|
Page = await Context.NewPageAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public async Task PlaywrightTearDown()
|
||||||
|
{
|
||||||
|
await Context.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class SidebarTests : PlaywrightTestBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Helper: navigate to home and wait for sidebar JS to initialize.
|
||||||
|
/// </summary>
|
||||||
|
private async Task GoHomeAsync()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
|
||||||
|
// Wait for the sidebar JS to apply state (data-state attribute appears on wrapper)
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 10_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Initial render
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Page_Loads_Successfully()
|
||||||
|
{
|
||||||
|
var response = await Page.GotoAsync(BaseUrl);
|
||||||
|
Assert.That(response, Is.Not.Null);
|
||||||
|
Assert.That(response!.Status, Is.EqualTo(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sidebar_Wrapper_Has_DataState_After_Init()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
var state = await wrapper.GetAttributeAsync("data-state");
|
||||||
|
Assert.That(state, Is.Not.Null.And.Not.Empty, "Wrapper should have data-state after JS init");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sidebar_Starts_Expanded_On_Desktop()
|
||||||
|
{
|
||||||
|
// Ensure desktop viewport
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
var state = await wrapper.GetAttributeAsync("data-state");
|
||||||
|
Assert.That(state, Is.EqualTo("expanded"), "Sidebar should start expanded on desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sidebar_Element_Exists()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var sidebar = Page.Locator("[data-sidebar]");
|
||||||
|
await Expect(sidebar).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sidebar_Trigger_Exists_On_Header()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// The sidebar header itself is the trigger on desktop
|
||||||
|
var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
|
||||||
|
await Expect(trigger).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sidebar_Has_Navigation_Links()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var sidebar = Page.Locator("[data-sidebar]");
|
||||||
|
var links = sidebar.Locator("a[href]");
|
||||||
|
var count = await links.CountAsync();
|
||||||
|
Assert.That(count, Is.GreaterThanOrEqualTo(3), "Should have at least Home, Counter, Weather links");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sidebar_Has_Footer()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var footer = Page.Locator("[data-sidebar-footer]");
|
||||||
|
await Expect(footer).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Desktop toggle (collapse / expand)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Click_Trigger_Collapses_Sidebar()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Verify starts expanded
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||||
|
|
||||||
|
// Click the sidebar header (which is the trigger on desktop)
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
|
||||||
|
// Wait for state change
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Click_Trigger_Twice_Re_Expands_Sidebar()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var trigger = Page.Locator("[data-sidebar-header][data-sidebar-trigger]");
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
await trigger.ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
await trigger.ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||||
|
|
||||||
|
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
|
||||||
|
Assert.That(state, Is.EqualTo("expanded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Collapsed_Sidebar_Has_Reduced_Width()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var sidebar = Page.Locator("[data-sidebar]");
|
||||||
|
|
||||||
|
// Get expanded width
|
||||||
|
var expandedWidth = await sidebar.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||||
|
Assert.That(expandedWidth, Is.GreaterThan(100), "Expanded sidebar should be wider than 100px");
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
|
||||||
|
// Wait for CSS transition
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Get collapsed width
|
||||||
|
var collapsedWidth = await sidebar.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||||
|
Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Collapsed sidebar should be narrower");
|
||||||
|
Assert.That(collapsedWidth, Is.LessThanOrEqualTo(60), "Collapsed sidebar should be icon-only width (~3rem = 48px)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Collapsed_Hides_Menu_Labels()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Target only the menu item label spans (inside sidebar-content, not sidebar-header)
|
||||||
|
var labelSpans = Page.Locator("[data-sidebar-content] a span.truncate");
|
||||||
|
var countBefore = await labelSpans.CountAsync();
|
||||||
|
Assert.That(countBefore, Is.GreaterThan(0), "Should have label spans");
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
// Check that label text is hidden (CSS-driven via group-data-[state=collapsed])
|
||||||
|
for (int i = 0; i < countBefore; i++)
|
||||||
|
{
|
||||||
|
var display = await labelSpans.Nth(i).EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||||
|
Assert.That(display, Is.EqualTo("none"), $"Label span {i} should have display:none when sidebar is collapsed (got '{display}')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Collapsed_Hides_Group_Label()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var groupLabel = Page.Locator("[data-sidebar-group-label]");
|
||||||
|
await Expect(groupLabel).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
await Expect(groupLabel).Not.ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Spacer width tracks sidebar state
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Spacer_Width_Changes_On_Collapse()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var spacer = Page.Locator("[data-sidebar-spacer]");
|
||||||
|
var expandedWidth = await spacer.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||||
|
Assert.That(expandedWidth, Is.GreaterThan(100), "Spacer should be wide when expanded");
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var collapsedWidth = await spacer.EvaluateAsync<double>("el => el.getBoundingClientRect().width");
|
||||||
|
Assert.That(collapsedWidth, Is.LessThan(expandedWidth), "Spacer should shrink when collapsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Mobile behavior
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Mobile_Sidebar_Starts_Closed()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(375, 812);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
var state = await wrapper.GetAttributeAsync("data-state");
|
||||||
|
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should start collapsed on mobile");
|
||||||
|
|
||||||
|
var mobile = await wrapper.GetAttributeAsync("data-mobile");
|
||||||
|
Assert.That(mobile, Is.EqualTo("true"), "data-mobile should be 'true' on small viewport");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Mobile_Click_Trigger_Opens_Sidebar_And_Shows_Overlay()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(375, 812);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Click the mobile trigger button in the inset (visible only on mobile)
|
||||||
|
await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||||
|
|
||||||
|
// Overlay should be visible
|
||||||
|
var overlay = Page.Locator("[data-sidebar-overlay]");
|
||||||
|
var display = await overlay.EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||||
|
Assert.That(display, Is.Not.EqualTo("none"), "Overlay should be visible when mobile sidebar is open");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Mobile_Click_Overlay_Closes_Sidebar()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(375, 812);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Open via the inset trigger
|
||||||
|
await Page.Locator("[data-sidebar-inset] [data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||||
|
|
||||||
|
// Click overlay
|
||||||
|
await Page.Locator("[data-sidebar-overlay]").ClickAsync(new LocatorClickOptions { Force = true });
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
|
||||||
|
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
|
||||||
|
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar should close when overlay is clicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Mobile_Overlay_Hidden_When_Sidebar_Closed()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(375, 812);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var overlay = Page.Locator("[data-sidebar-overlay]");
|
||||||
|
var display = await overlay.EvaluateAsync<string>("el => el.style.display || getComputedStyle(el).display");
|
||||||
|
Assert.That(display, Is.EqualTo("none"), "Overlay should be hidden when mobile sidebar is closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Navigation links work
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Clicking_Counter_Link_Navigates_To_Counter()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
|
||||||
|
await counterLink.ClickAsync();
|
||||||
|
|
||||||
|
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||||
|
Assert.That(Page.Url, Does.Contain("/counter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Clicking_Weather_Link_Navigates_To_Weather()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var weatherLink = Page.Locator("[data-sidebar] a[href='/weather']");
|
||||||
|
await weatherLink.ClickAsync();
|
||||||
|
|
||||||
|
await Page.WaitForURLAsync($"{BaseUrl}/weather");
|
||||||
|
Assert.That(Page.Url, Does.Contain("/weather"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Clicking_Header_Toggles_Sidebar()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Starts expanded
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||||
|
|
||||||
|
// Click the sidebar header (logo area) to collapse
|
||||||
|
await Page.Locator("[data-sidebar-header]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||||
|
|
||||||
|
// Click again to expand
|
||||||
|
await Page.Locator("[data-sidebar-header]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='expanded']");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Navigation should NOT change sidebar state
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Navigation()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Verify starts expanded
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||||
|
|
||||||
|
// Collapse the sidebar
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||||
|
|
||||||
|
// Click a navigation link (Counter)
|
||||||
|
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
|
||||||
|
await counterLink.ClickAsync();
|
||||||
|
|
||||||
|
// Wait for navigation to complete
|
||||||
|
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||||
|
|
||||||
|
// Wait for the sidebar JS to re-apply state after enhanced navigation
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give any transitions/scripts time to settle
|
||||||
|
await Page.WaitForTimeoutAsync(500);
|
||||||
|
|
||||||
|
// Sidebar should STILL be collapsed
|
||||||
|
var stateAfterNav = await wrapper.GetAttributeAsync("data-state");
|
||||||
|
Assert.That(stateAfterNav, Is.EqualTo("collapsed"),
|
||||||
|
"Sidebar should remain collapsed after clicking a navigation link");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Multiple_Navigations()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Collapse the sidebar
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
|
||||||
|
// Navigate to Counter
|
||||||
|
await Page.Locator("[data-sidebar] a[href='/counter']").ClickAsync();
|
||||||
|
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||||
|
await Page.WaitForTimeoutAsync(500);
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||||
|
"Sidebar should remain collapsed after navigating to Counter");
|
||||||
|
|
||||||
|
// Navigate to Weather
|
||||||
|
await Page.Locator("[data-sidebar] a[href='/weather']").ClickAsync();
|
||||||
|
await Page.WaitForURLAsync($"{BaseUrl}/weather");
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||||
|
await Page.WaitForTimeoutAsync(500);
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||||
|
"Sidebar should remain collapsed after navigating to Weather");
|
||||||
|
|
||||||
|
// Navigate back Home
|
||||||
|
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
|
||||||
|
await Page.WaitForURLAsync(url => url == BaseUrl || url == BaseUrl + "/");
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||||
|
await Page.WaitForTimeoutAsync(500);
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||||
|
"Sidebar should remain collapsed after navigating back to Home");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Sidebar_Toggle_Works_After_Same_Page_Navigation()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
|
||||||
|
// Starts expanded
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||||
|
|
||||||
|
// Click the Home link while already on home (same-page navigation)
|
||||||
|
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
|
||||||
|
|
||||||
|
// Wait for any enhanced navigation / DOM mutation to settle
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 5_000
|
||||||
|
});
|
||||||
|
await Page.WaitForTimeoutAsync(500);
|
||||||
|
|
||||||
|
// Now click the sidebar trigger to collapse
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var stateAfterFirstToggle = await wrapper.GetAttributeAsync("data-state");
|
||||||
|
Assert.That(stateAfterFirstToggle, Is.EqualTo("collapsed"),
|
||||||
|
"Sidebar should be collapsed after one trigger click following same-page nav");
|
||||||
|
|
||||||
|
// Click the sidebar trigger again to expand
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
|
||||||
|
var stateAfterSecondToggle = await wrapper.GetAttributeAsync("data-state");
|
||||||
|
Assert.That(stateAfterSecondToggle, Is.EqualTo("expanded"),
|
||||||
|
"Sidebar should be expanded after second trigger click following same-page nav");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_Collapsed_Sidebar_Stays_Collapsed_After_Same_Page_Navigation()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
|
||||||
|
// Collapse the sidebar first
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||||
|
|
||||||
|
// Click the Home link while already on home (same-page navigation)
|
||||||
|
await Page.Locator("[data-sidebar] a[href='/']").ClickAsync();
|
||||||
|
|
||||||
|
// Wait for any enhanced navigation / DOM mutation to settle
|
||||||
|
await Page.WaitForTimeoutAsync(500);
|
||||||
|
|
||||||
|
// Sidebar should STILL be collapsed
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||||
|
"Sidebar should remain collapsed after same-page navigation");
|
||||||
|
|
||||||
|
// Toggle should still work correctly: collapse -> expand
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"),
|
||||||
|
"Trigger should expand sidebar after same-page nav while collapsed");
|
||||||
|
|
||||||
|
// And back to collapsed
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(300);
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"),
|
||||||
|
"Trigger should collapse sidebar again after same-page nav");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Cookie persistence
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Desktop_State_Persists_Via_Cookie()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
await Page.Locator("[data-sidebar-header][data-sidebar-trigger]").ClickAsync();
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state='collapsed']");
|
||||||
|
|
||||||
|
// Check cookie
|
||||||
|
var cookies = await Context.CookiesAsync();
|
||||||
|
var sidebarCookie = cookies.FirstOrDefault(c => c.Name == "sidebar:state");
|
||||||
|
Assert.That(sidebarCookie, Is.Not.Null, "sidebar:state cookie should exist");
|
||||||
|
Assert.That(sidebarCookie!.Value, Is.EqualTo("closed"), "Cookie should be 'closed' after collapse");
|
||||||
|
|
||||||
|
// Reload page — sidebar should remain collapsed
|
||||||
|
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]");
|
||||||
|
|
||||||
|
var state = await Page.Locator("[data-sidebar-wrapper]").GetAttributeAsync("data-state");
|
||||||
|
Assert.That(state, Is.EqualTo("collapsed"), "Sidebar state should persist after reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Viewport resize transitions
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Resize_From_Desktop_To_Mobile_Collapses_Sidebar()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||||
|
|
||||||
|
// Shrink to mobile
|
||||||
|
await Page.SetViewportSizeAsync(375, 812);
|
||||||
|
|
||||||
|
// Wait for resize handler to fire
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='true']", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Resize_From_Mobile_To_Desktop_Expands_Sidebar()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(375, 812);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var wrapper = Page.Locator("[data-sidebar-wrapper]");
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("collapsed"));
|
||||||
|
|
||||||
|
// Grow to desktop
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-mobile='false']", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-state"), Is.EqualTo("expanded"));
|
||||||
|
Assert.That(await wrapper.GetAttributeAsync("data-mobile"), Is.EqualTo("false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Sidebar inset (main content area)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SidebarInset_Exists_And_Contains_Page_Content()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var inset = Page.Locator("[data-sidebar-inset]");
|
||||||
|
await Expect(inset).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
// On mobile, the inset should contain the trigger button
|
||||||
|
await Page.SetViewportSizeAsync(375, 812);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var mobileTrigger = inset.Locator("[data-sidebar-trigger]");
|
||||||
|
await Expect(mobileTrigger).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class ThemeToggleTests : PlaywrightTestBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Navigate to home, wait for sidebar JS + darkmode JS to finish initializing.
|
||||||
|
/// </summary>
|
||||||
|
private async Task GoHomeAsync()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync(BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
|
||||||
|
// Wait for sidebar JS to apply state
|
||||||
|
await Page.WaitForSelectorAsync("[data-sidebar-wrapper][data-state]", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 10_000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the ThemeToggle button to appear
|
||||||
|
await Page.WaitForSelectorAsync("[data-theme-toggle]", new PageWaitForSelectorOptions
|
||||||
|
{
|
||||||
|
Timeout = 10_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Click the toggle and wait for the dark class to be added to <html>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ToggleToDarkAsync()
|
||||||
|
{
|
||||||
|
await Page.Locator("[data-theme-toggle]").ClickAsync();
|
||||||
|
await Page.WaitForFunctionAsync(
|
||||||
|
"() => document.documentElement.classList.contains('dark')",
|
||||||
|
null,
|
||||||
|
new PageWaitForFunctionOptions { Timeout = 5_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Click the toggle and wait for the dark class to be removed from <html>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ToggleToLightAsync()
|
||||||
|
{
|
||||||
|
await Page.Locator("[data-theme-toggle]").ClickAsync();
|
||||||
|
await Page.WaitForFunctionAsync(
|
||||||
|
"() => !document.documentElement.classList.contains('dark')",
|
||||||
|
null,
|
||||||
|
new PageWaitForFunctionOptions { Timeout = 5_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Initial render
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ThemeToggle_Button_Is_Visible()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var toggle = Page.Locator("[data-theme-toggle]");
|
||||||
|
await Expect(toggle).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ThemeToggle_Starts_In_Light_Mode_By_Default()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var hasDark = await Page.EvaluateAsync<bool>(
|
||||||
|
"() => document.documentElement.classList.contains('dark')");
|
||||||
|
Assert.That(hasDark, Is.False, "Page should start in light mode when no preference is stored");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ThemeToggle_Shows_Moon_Icon_In_Light_Mode()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
|
||||||
|
var moonDisplay = await moon.EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||||
|
Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible in light mode");
|
||||||
|
|
||||||
|
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
|
||||||
|
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
|
||||||
|
Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden in light mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Toggle to dark mode
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Click_Toggle_Adds_Dark_Class_To_Html()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
var hasDark = await Page.EvaluateAsync<bool>(
|
||||||
|
"() => document.documentElement.classList.contains('dark')");
|
||||||
|
Assert.That(hasDark, Is.True, "Clicking toggle should add 'dark' class to <html>");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Click_Toggle_Shows_Sun_Icon_In_Dark_Mode()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
|
||||||
|
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
|
||||||
|
Assert.That(sunDisplay, Is.Not.EqualTo("none"), "Sun icon should be visible in dark mode");
|
||||||
|
|
||||||
|
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
|
||||||
|
var moonDisplay = await moon.EvaluateAsync<string>("el => el.style.display");
|
||||||
|
Assert.That(moonDisplay, Is.EqualTo("none"), "Moon icon should be hidden in dark mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Click_Toggle_Stores_Dark_In_LocalStorage()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
|
||||||
|
Assert.That(stored, Is.EqualTo("dark"), "localStorage 'theme' should be 'dark' after toggle");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Toggle back to light mode
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Double_Click_Toggle_Returns_To_Light_Mode()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Toggle to dark
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
// Toggle back to light
|
||||||
|
await ToggleToLightAsync();
|
||||||
|
|
||||||
|
var hasDark = await Page.EvaluateAsync<bool>(
|
||||||
|
"() => document.documentElement.classList.contains('dark')");
|
||||||
|
Assert.That(hasDark, Is.False, "Double-clicking toggle should return to light mode");
|
||||||
|
|
||||||
|
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
|
||||||
|
Assert.That(stored, Is.EqualTo("light"), "localStorage should be 'light' after toggling back");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Double_Click_Toggle_Shows_Moon_Icon_Again()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Dark
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
// Light again
|
||||||
|
await ToggleToLightAsync();
|
||||||
|
|
||||||
|
var moon = Page.Locator("[data-theme-toggle] [data-theme-icon-moon]");
|
||||||
|
var moonDisplay = await moon.EvaluateAsync<string>("el => getComputedStyle(el).display");
|
||||||
|
Assert.That(moonDisplay, Is.Not.EqualTo("none"), "Moon icon should be visible again after toggling back");
|
||||||
|
|
||||||
|
var sun = Page.Locator("[data-theme-toggle] [data-theme-icon-sun]");
|
||||||
|
var sunDisplay = await sun.EvaluateAsync<string>("el => el.style.display");
|
||||||
|
Assert.That(sunDisplay, Is.EqualTo("none"), "Sun icon should be hidden again after toggling back");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Persistence across page reloads
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Dark_Mode_Persists_After_Reload()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
|
||||||
|
// The inline <script> in <head> should apply 'dark' before paint
|
||||||
|
var hasDark = await Page.EvaluateAsync<bool>(
|
||||||
|
"() => document.documentElement.classList.contains('dark')");
|
||||||
|
Assert.That(hasDark, Is.True, "Dark mode should persist after page reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Dark_Mode_Persists_After_Navigation()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
// Navigate to counter page via sidebar link
|
||||||
|
var counterLink = Page.Locator("[data-sidebar] a[href='/counter']");
|
||||||
|
await counterLink.ClickAsync();
|
||||||
|
await Page.WaitForURLAsync($"{BaseUrl}/counter");
|
||||||
|
|
||||||
|
// Wait for the page to fully settle after enhanced navigation
|
||||||
|
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
// Dark class should still be present on <html>
|
||||||
|
var hasDark = await Page.EvaluateAsync<bool>(
|
||||||
|
"() => document.documentElement.classList.contains('dark')");
|
||||||
|
Assert.That(hasDark, Is.True, "Dark mode should persist across navigation");
|
||||||
|
|
||||||
|
// localStorage should also still have 'dark'
|
||||||
|
var stored = await Page.EvaluateAsync<string>("() => localStorage.getItem('theme')");
|
||||||
|
Assert.That(stored, Is.EqualTo("dark"), "localStorage should still be 'dark' after navigation");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Light_Mode_Persists_After_Reload()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Toggle to dark then back to light
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
await ToggleToLightAsync();
|
||||||
|
|
||||||
|
// Reload
|
||||||
|
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||||
|
|
||||||
|
var hasDark = await Page.EvaluateAsync<bool>(
|
||||||
|
"() => document.documentElement.classList.contains('dark')");
|
||||||
|
Assert.That(hasDark, Is.False, "Light mode should persist after page reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// No FOUC (flash of unstyled content)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Dark_Mode_Applied_Before_First_Paint_No_FOUC()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
|
||||||
|
// Reload and check at DOMContentLoaded — the inline script should have already set .dark
|
||||||
|
await Page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.DOMContentLoaded });
|
||||||
|
|
||||||
|
var hasDarkImmediately = await Page.EvaluateAsync<bool>(
|
||||||
|
"() => document.documentElement.classList.contains('dark')");
|
||||||
|
Assert.That(hasDarkImmediately, Is.True,
|
||||||
|
"Dark class should be on <html> immediately on DOMContentLoaded (no FOUC)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Visual theming verification
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Dark_Mode_Changes_Background_Color()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
// Read the --background CSS custom property which drives bg-background
|
||||||
|
var lightBgVar = await Page.EvaluateAsync<string>(
|
||||||
|
"() => getComputedStyle(document.documentElement).getPropertyValue('--background').trim()");
|
||||||
|
|
||||||
|
// Toggle to dark
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
|
||||||
|
var darkBgVar = await Page.EvaluateAsync<string>(
|
||||||
|
"() => getComputedStyle(document.documentElement).getPropertyValue('--background').trim()");
|
||||||
|
|
||||||
|
Assert.That(darkBgVar, Is.Not.EqualTo(lightBgVar),
|
||||||
|
$"--background CSS variable should change in dark mode. Light={lightBgVar}, Dark={darkBgVar}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Dark_Mode_Changes_Sidebar_Background()
|
||||||
|
{
|
||||||
|
await Page.SetViewportSizeAsync(1280, 800);
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var sidebar = Page.Locator("[data-sidebar]");
|
||||||
|
var lightSidebarBg = await sidebar.EvaluateAsync<string>(
|
||||||
|
"el => getComputedStyle(el).backgroundColor");
|
||||||
|
|
||||||
|
// Toggle to dark
|
||||||
|
await ToggleToDarkAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(200);
|
||||||
|
|
||||||
|
var darkSidebarBg = await sidebar.EvaluateAsync<string>(
|
||||||
|
"el => getComputedStyle(el).backgroundColor");
|
||||||
|
Assert.That(darkSidebarBg, Is.Not.EqualTo(lightSidebarBg),
|
||||||
|
"Sidebar background should change in dark mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// Button styling (shadcn ghost button)
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ThemeToggle_Has_Correct_Dimensions()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var toggle = Page.Locator("[data-theme-toggle]");
|
||||||
|
var box = await toggle.BoundingBoxAsync();
|
||||||
|
|
||||||
|
Assert.That(box, Is.Not.Null, "Toggle button should have a bounding box");
|
||||||
|
// h-9 w-9 = 36px × 36px
|
||||||
|
Assert.That(box!.Width, Is.InRange(34, 38), "Toggle button width should be ~36px (h-9)");
|
||||||
|
Assert.That(box.Height, Is.InRange(34, 38), "Toggle button height should be ~36px (w-9)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ThemeToggle_Contains_Exactly_Two_Svg_Icons()
|
||||||
|
{
|
||||||
|
await GoHomeAsync();
|
||||||
|
|
||||||
|
var toggle = Page.Locator("[data-theme-toggle]");
|
||||||
|
var svgCount = await toggle.Locator("svg").CountAsync();
|
||||||
|
Assert.That(svgCount, Is.EqualTo(2),
|
||||||
|
"Toggle should contain exactly two SVG icons (moon and sun)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<Target Name="Tailwind" BeforeTargets="Build">
|
|
||||||
<Exec Command="npx @tailwindcss/cli -i ./input.css -o ./wwwroot/css/output.css" />
|
|
||||||
</Target>
|
|
||||||
</Project>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.5.2.0
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Enciphered.Blazor.UIComponents", "Enciphered.Blazor.UIComponents.csproj", "{DE462D5E-ACD4-81EA-1348-92F9F8ED70ED}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{DE462D5E-ACD4-81EA-1348-92F9F8ED70ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{DE462D5E-ACD4-81EA-1348-92F9F8ED70ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{DE462D5E-ACD4-81EA-1348-92F9F8ED70ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{DE462D5E-ACD4-81EA-1348-92F9F8ED70ED}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
|
||||||
SolutionGuid = {BD883197-1262-4DEF-87D0-4D8308793382}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="Enciphered.Blazor.UIComponents.Tests/Enciphered.Blazor.UIComponents.Tests.csproj" />
|
||||||
|
<Project Path="Enciphered.Blazor.UIComponents/Enciphered.Blazor.UIComponents.csproj" />
|
||||||
|
<Project Path="Enciphered.Blazor.UIComponents.Demo/Enciphered.Blazor.UIComponents.Demo.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="@ComputedClass" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The card's inner content (CardHeader, CardContent, CardFooter, etc.).</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes appended to the card wrapper.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden";
|
||||||
|
|
||||||
|
private string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div data-slot="card-action" class="@ComputedClass" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Action content rendered at the trailing edge of the header (e.g. a link or button).</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"self-start ml-auto";
|
||||||
|
|
||||||
|
private string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="@ComputedClass" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Main body content of the card.</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass = "p-6 pt-0";
|
||||||
|
|
||||||
|
private string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<p data-slot="card-description" class="@ComputedClass" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Description text or markup rendered below the title.</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"text-sm text-muted-foreground";
|
||||||
|
|
||||||
|
private string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="@ComputedClass" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Footer content — typically action buttons.</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"flex items-center p-6 pt-0";
|
||||||
|
|
||||||
|
private string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="@ComputedClass" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Header content — typically CardTitle, CardDescription, and CardAction.</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"flex flex-col gap-1.5 p-6 has-[data-slot=card-action]:grid has-[data-slot=card-action]:grid-cols-[1fr_auto] has-[data-slot=card-action]:items-center [&>[data-slot=card-description]]:col-span-full";
|
||||||
|
|
||||||
|
private string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="@ComputedWrapperClass">
|
||||||
|
<img src="@Src" alt="@Alt" class="@ComputedImageClass" @attributes="AdditionalAttributes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Image source URL.</summary>
|
||||||
|
[Parameter, EditorRequired] public string Src { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Image alt text for accessibility.</summary>
|
||||||
|
[Parameter] public string Alt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes for the image element.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes for the wrapper div.</summary>
|
||||||
|
[Parameter] public string? WrapperClass { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseImageClass = "w-full h-full object-cover";
|
||||||
|
private const string BaseWrapperClass = "overflow-hidden";
|
||||||
|
|
||||||
|
private string ComputedImageClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseImageClass : $"{BaseImageClass} {Class}";
|
||||||
|
|
||||||
|
private string ComputedWrapperClass =>
|
||||||
|
string.IsNullOrEmpty(WrapperClass) ? BaseWrapperClass : $"{BaseWrapperClass} {WrapperClass}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<h3 class="@ComputedClass" @attributes="AdditionalAttributes">
|
||||||
|
@ChildContent
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Title text or markup.</summary>
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional CSS classes.</summary>
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"text-lg font-semibold leading-none tracking-tight";
|
||||||
|
|
||||||
|
private string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageId>Enciphered.Blazor.UIComponents</PackageId>
|
||||||
|
<Version>0.0.1</Version>
|
||||||
|
<Authors>shaamilahmed</Authors>
|
||||||
|
<Company>nciphered</Company>
|
||||||
|
<Description>Pure static SSR Blazor component library styled with Tailwind CSS v4 and shadcn/ui. All interactivity powered by vanilla JS. htmx-powered form validation.</Description>
|
||||||
|
<PackageTags>blazor;ui;components;tailwind;htmx;ssr;static</PackageTags>
|
||||||
|
<RepositoryUrl>https://git.nciphered.com/shaamilahmed/Enciphered.Blazor.UIComponents</RepositoryUrl>
|
||||||
|
<PackageLicenseExpression></PackageLicenseExpression>
|
||||||
|
<PackageProjectUrl>https://git.nciphered.com/shaamilahmed/Enciphered.Blazor.UIComponents</PackageProjectUrl>
|
||||||
|
<PackageIcon>enci_white.png</PackageIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="TailwindBuild" BeforeTargets="Build">
|
||||||
|
<Exec Command="npx @tailwindcss/cli -i Styles/app.css -o wwwroot/css/app.css --minify" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<SupportedPlatform Include="browser" />
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="./Logo/enci_white.png" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<button type="@Type"
|
||||||
|
disabled="@Disabled"
|
||||||
|
class="@ComputedClass"
|
||||||
|
@attributes="AdditionalAttributes">
|
||||||
|
@if (Icon is not null)
|
||||||
|
{
|
||||||
|
<span class="shrink-0">@Icon</span>
|
||||||
|
}
|
||||||
|
@ChildContent
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public RenderFragment? Icon { get; set; }
|
||||||
|
[Parameter] public string Type { get; set; } = "button";
|
||||||
|
[Parameter] public bool Disabled { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string Variant { get; set; } = ButtonVariant.Default;
|
||||||
|
[Parameter] public string Size { get; set; } = ButtonSize.Default;
|
||||||
|
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium " +
|
||||||
|
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50 cursor-pointer";
|
||||||
|
|
||||||
|
private string ComputedClass
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var combined = $"{BaseClass} {Variant} {Size}";
|
||||||
|
return string.IsNullOrEmpty(Class) ? combined : $"{combined} {Class}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Enciphered.Blazor.UIComponents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Built-in visual style variants for <see cref="Button"/>.
|
||||||
|
/// Each constant is a Tailwind class string. Pass any of these to
|
||||||
|
/// <c>Button.Variant</c>, or supply your own CSS string for custom variants
|
||||||
|
/// without modifying the library.
|
||||||
|
/// </summary>
|
||||||
|
public static class ButtonVariant
|
||||||
|
{
|
||||||
|
public const string Default = "bg-primary text-primary-foreground shadow hover:bg-primary/90";
|
||||||
|
public const string Destructive = "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90";
|
||||||
|
public const string Outline = "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground";
|
||||||
|
public const string Secondary = "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80";
|
||||||
|
public const string Ghost = "hover:bg-accent hover:text-accent-foreground";
|
||||||
|
public const string Link = "text-primary underline-offset-4 hover:underline";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Built-in size presets for <see cref="Button"/>.
|
||||||
|
/// Each constant is a Tailwind class string. Pass any of these to
|
||||||
|
/// <c>Button.Size</c>, or supply your own CSS string.
|
||||||
|
/// </summary>
|
||||||
|
public static class ButtonSize
|
||||||
|
{
|
||||||
|
public const string Default = "h-9 px-4 py-2";
|
||||||
|
public const string Sm = "h-8 rounded-md px-3 text-xs";
|
||||||
|
public const string Lg = "h-10 rounded-md px-8";
|
||||||
|
public const string Icon = "h-9 w-9";
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
@* ── shadcn/ui-style calendar grid (JS-driven) ───────────────────────── *@
|
||||||
|
@{
|
||||||
|
var displayDate = SelectedDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var yearRangeStart = displayDate.Year - 10;
|
||||||
|
var yearRangeEnd = displayDate.Year + 10;
|
||||||
|
var selectedStr = SelectedDate.HasValue
|
||||||
|
? SelectedDate.Value.ToString("yyyy-MM-dd")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="p-4"
|
||||||
|
data-calendar
|
||||||
|
data-display-year="@displayDate.Year"
|
||||||
|
data-display-month="@displayDate.Month"
|
||||||
|
data-selected-date="@selectedStr"
|
||||||
|
data-view="days"
|
||||||
|
data-year-range-start="@yearRangeStart"
|
||||||
|
data-year-range-end="@yearRangeEnd"
|
||||||
|
data-linked-input="@LinkedInputId"
|
||||||
|
@attributes="AdditionalAttributes">
|
||||||
|
|
||||||
|
@* ── Month / Year navigation ── *@
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex items-center justify-center h-7 w-7 rounded-md text-sm
|
||||||
|
text-muted-foreground hover:bg-accent hover:text-accent-foreground
|
||||||
|
transition-colors cursor-pointer"
|
||||||
|
aria-label="Previous month"
|
||||||
|
data-calendar-prev>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m15 18-6-6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<button type="button"
|
||||||
|
data-calendar-month
|
||||||
|
class="px-2 py-0.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer">
|
||||||
|
@displayDate.ToString("MMM")
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
data-calendar-year
|
||||||
|
class="px-2 py-0.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer">
|
||||||
|
@displayDate.Year
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex items-center justify-center h-7 w-7 rounded-md text-sm
|
||||||
|
text-muted-foreground hover:bg-accent hover:text-accent-foreground
|
||||||
|
transition-colors cursor-pointer"
|
||||||
|
aria-label="Next month"
|
||||||
|
data-calendar-next>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m9 18 6-6-6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Dynamic content area (rendered by JS) ── *@
|
||||||
|
<div data-calendar-content>
|
||||||
|
@* Server-rendered initial day grid for SSR — JS will take over *@
|
||||||
|
@{
|
||||||
|
var dayHeaders = new[] { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" };
|
||||||
|
var firstOfMonth = new DateOnly(displayDate.Year, displayDate.Month, 1);
|
||||||
|
var startOffset = (int)firstOfMonth.DayOfWeek;
|
||||||
|
var start = firstOfMonth.AddDays(-startOffset);
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
}
|
||||||
|
<div class="grid grid-cols-7 gap-1 mb-1">
|
||||||
|
@foreach (var dow in dayHeaders)
|
||||||
|
{
|
||||||
|
<div class="h-9 w-9 flex items-center justify-center text-[0.8rem] text-muted-foreground font-medium">@dow</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-1">
|
||||||
|
@for (int i = 0; i < 42; i++)
|
||||||
|
{
|
||||||
|
var d = start.AddDays(i);
|
||||||
|
var isOutside = d.Month != displayDate.Month;
|
||||||
|
var isSelected = SelectedDate.HasValue && d == SelectedDate.Value;
|
||||||
|
var isToday = d == today;
|
||||||
|
var dateStr = d.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
var cls = "h-9 w-9 inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer";
|
||||||
|
if (isSelected) { cls += " bg-primary text-primary-foreground font-semibold"; }
|
||||||
|
else if (isOutside) { cls += " text-muted-foreground/40 cursor-default"; }
|
||||||
|
else if (isToday) { cls += " bg-accent text-accent-foreground font-medium"; }
|
||||||
|
else { cls += " hover:bg-accent hover:text-accent-foreground"; }
|
||||||
|
|
||||||
|
<button type="button" class="@cls" disabled="@isOutside" data-calendar-day="@dateStr">@d.Day</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The currently selected date.</summary>
|
||||||
|
[Parameter] public DateOnly? SelectedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The id of the linked hidden input to update when a day is selected.</summary>
|
||||||
|
[Parameter] public string? LinkedInputId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Any extra HTML attributes (data-testid, etc.).</summary>
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
@inherits InputBase<DateOnly?>
|
||||||
|
|
||||||
|
<input type="date"
|
||||||
|
id="@Id"
|
||||||
|
name="@Name"
|
||||||
|
value="@FormatValue()"
|
||||||
|
data-trigger-id="@($"trigger-{Id}")"
|
||||||
|
data-placeholder="@(Placeholder ?? "Select date")"
|
||||||
|
class="sr-only"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
disabled="@Disabled"
|
||||||
|
@attributes="MergedAttributes" />
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<Trigger>
|
||||||
|
<button type="button"
|
||||||
|
disabled="@Disabled"
|
||||||
|
data-testid="@($"trigger-{Id}")"
|
||||||
|
class="@TriggerClass">
|
||||||
|
<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">
|
||||||
|
<path d="M8 2v4" /><path d="M16 2v4" />
|
||||||
|
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||||
|
<path d="M3 10h18" />
|
||||||
|
</svg>
|
||||||
|
<span class="@(Value.HasValue ? "" : "text-muted-foreground")">
|
||||||
|
@(Value.HasValue ? Value.Value.ToString("MMMM d, yyyy") : (Placeholder ?? "Select date"))
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Trigger>
|
||||||
|
<Content>
|
||||||
|
<Calendar SelectedDate="@Value" LinkedInputId="@Id" />
|
||||||
|
</Content>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Min { get; set; }
|
||||||
|
[Parameter] public string? Max { get; set; }
|
||||||
|
|
||||||
|
private string? FormatValue() =>
|
||||||
|
Value?.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
private string TriggerClass
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
const string baseClass =
|
||||||
|
"flex h-9 w-full items-center rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm " +
|
||||||
|
"transition-colors cursor-pointer text-left " +
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(Class)
|
||||||
|
? $"{baseClass} border-input"
|
||||||
|
: $"{baseClass} border-input {Class}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
@inherits InputBase<DateTime?>
|
||||||
|
|
||||||
|
<input type="datetime-local"
|
||||||
|
id="@Id"
|
||||||
|
name="@Name"
|
||||||
|
value="@FormatValue()"
|
||||||
|
class="sr-only"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
disabled="@Disabled"
|
||||||
|
@attributes="MergedAttributes" />
|
||||||
|
|
||||||
|
<input type="hidden" id="@($"{Id}-date-part")"
|
||||||
|
value="@(SelectedDateOnly?.ToString("yyyy-MM-dd") ?? "")"
|
||||||
|
data-trigger-id="@($"trigger-{Id}-date")"
|
||||||
|
data-placeholder="@(Placeholder ?? "Select date")"
|
||||||
|
data-datetime-part="date"
|
||||||
|
data-datetime-input-id="@Id" />
|
||||||
|
<input type="hidden" id="@($"{Id}-time-part")"
|
||||||
|
value="@(SelectedTimeOnly.HasValue ? SelectedTimeOnly.Value.ToString("HH:mm") : "")"
|
||||||
|
data-trigger-id="@($"trigger-{Id}-time")"
|
||||||
|
data-placeholder="Select time"
|
||||||
|
data-datetime-part="time"
|
||||||
|
data-datetime-input-id="@Id" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Popover>
|
||||||
|
<Trigger>
|
||||||
|
<button type="button"
|
||||||
|
disabled="@Disabled"
|
||||||
|
data-testid="@($"trigger-{Id}-date")"
|
||||||
|
class="@TriggerClass">
|
||||||
|
<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">
|
||||||
|
<path d="M8 2v4" /><path d="M16 2v4" />
|
||||||
|
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||||
|
<path d="M3 10h18" />
|
||||||
|
</svg>
|
||||||
|
<span class="@(SelectedDateOnly.HasValue ? "" : "text-muted-foreground")">
|
||||||
|
@(SelectedDateOnly.HasValue ? SelectedDateOnly.Value.ToString("MMM d, yyyy") : (Placeholder ?? "Select date"))
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Trigger>
|
||||||
|
<Content>
|
||||||
|
<Calendar SelectedDate="@SelectedDateOnly" LinkedInputId="@($"{Id}-date-part")" />
|
||||||
|
</Content>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<Trigger>
|
||||||
|
<button type="button"
|
||||||
|
disabled="@Disabled"
|
||||||
|
data-testid="@($"trigger-{Id}-time")"
|
||||||
|
class="@TriggerClass">
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span class="@(SelectedTimeOnly.HasValue ? "" : "text-muted-foreground")">
|
||||||
|
@(SelectedTimeOnly.HasValue ? SelectedTimeOnly.Value.ToString("hh\\:mm tt") : "Select time")
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Trigger>
|
||||||
|
<Content>
|
||||||
|
<TimePicker SelectedTime="@SelectedTimeOnly" Use12Hour="true" LinkedInputId="@($"{Id}-time-part")" />
|
||||||
|
</Content>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Min { get; set; }
|
||||||
|
[Parameter] public string? Max { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string? Step { get; set; }
|
||||||
|
|
||||||
|
private DateOnly? SelectedDateOnly =>
|
||||||
|
Value.HasValue ? DateOnly.FromDateTime(Value.Value) : null;
|
||||||
|
|
||||||
|
private TimeOnly? SelectedTimeOnly =>
|
||||||
|
Value.HasValue ? TimeOnly.FromDateTime(Value.Value) : null;
|
||||||
|
|
||||||
|
private string? FormatValue() =>
|
||||||
|
Value?.ToString("yyyy-MM-ddTHH:mm");
|
||||||
|
|
||||||
|
private string TriggerClass
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
const string baseClass =
|
||||||
|
"flex h-9 w-full items-center rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm " +
|
||||||
|
"transition-colors cursor-pointer text-left " +
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(Class)
|
||||||
|
? $"{baseClass} border-input"
|
||||||
|
: $"{baseClass} border-input {Class}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="@ComputedClass" data-form-field="@For">
|
||||||
|
@if (!string.IsNullOrEmpty(Label))
|
||||||
|
{
|
||||||
|
<label for="@For"
|
||||||
|
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 @LabelClass">
|
||||||
|
@Label
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<CascadingValue Value="@For" Name="FieldName">
|
||||||
|
@ChildContent
|
||||||
|
</CascadingValue>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Description))
|
||||||
|
{
|
||||||
|
<p class="text-[0.8rem] text-muted-foreground">@Description</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Error))
|
||||||
|
{
|
||||||
|
<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 {
|
||||||
|
[Parameter] public string? Label { get; set; }
|
||||||
|
[Parameter] public string? For { get; set; }
|
||||||
|
[Parameter] public string? Description { get; set; }
|
||||||
|
[Parameter] public string? Error { get; set; }
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
[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 ?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents;
|
||||||
|
|
||||||
|
public abstract class InputBase<TValue> : ComponentBase
|
||||||
|
{
|
||||||
|
[Parameter] public TValue? Value { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string? Id { get; set; }
|
||||||
|
[Parameter] public string? Name { get; set; }
|
||||||
|
[Parameter] public bool Disabled { get; set; }
|
||||||
|
[Parameter] public bool ReadOnly { get; set; }
|
||||||
|
[Parameter] public string? Placeholder { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
[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 " +
|
||||||
|
"transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium " +
|
||||||
|
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
protected string ComputedClass =>
|
||||||
|
string.IsNullOrEmpty(Class) ? BaseInputClass : $"{BaseInputClass} {Class}";
|
||||||
|
|
||||||
|
protected Dictionary<string, object> MergedAttributes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
@inherits InputBase<double?>
|
||||||
|
|
||||||
|
<div class="relative flex items-center" data-number-input>
|
||||||
|
<input id="@Id"
|
||||||
|
name="@Name"
|
||||||
|
type="number"
|
||||||
|
value="@Value"
|
||||||
|
placeholder="@Placeholder"
|
||||||
|
disabled="@Disabled"
|
||||||
|
readonly="@ReadOnly"
|
||||||
|
step="@Step"
|
||||||
|
min="@Min"
|
||||||
|
max="@Max"
|
||||||
|
class="@ComputedClass [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none pr-8"
|
||||||
|
@attributes="MergedAttributes" />
|
||||||
|
|
||||||
|
@if (!Disabled && !ReadOnly)
|
||||||
|
{
|
||||||
|
<div class="absolute right-0 inset-y-0 flex flex-col border-l border-input">
|
||||||
|
<button type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Increment"
|
||||||
|
class="flex-1 flex items-center justify-center px-1.5 text-muted-foreground
|
||||||
|
hover:text-foreground hover:bg-accent transition-colors
|
||||||
|
rounded-tr-md cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled="@IsAtMax"
|
||||||
|
data-number-increment>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m18 15-6-6-6 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-input"></div>
|
||||||
|
<button type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Decrement"
|
||||||
|
class="flex-1 flex items-center justify-center px-1.5 text-muted-foreground
|
||||||
|
hover:text-foreground hover:bg-accent transition-colors
|
||||||
|
rounded-br-md cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled="@IsAtMin"
|
||||||
|
data-number-decrement>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m6 9 6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Step { get; set; }
|
||||||
|
[Parameter] public string? Min { get; set; }
|
||||||
|
[Parameter] public string? Max { get; set; }
|
||||||
|
|
||||||
|
private double? MaxValue => double.TryParse(Max, out var m) ? m : null;
|
||||||
|
private double? MinValue => double.TryParse(Min, out var m) ? m : null;
|
||||||
|
|
||||||
|
private bool IsAtMax => Value.HasValue && MaxValue.HasValue && Value.Value >= MaxValue.Value;
|
||||||
|
private bool IsAtMin => Value.HasValue && MinValue.HasValue && Value.Value <= MinValue.Value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
@* ── Generic popover: trigger + dropdown panel (JS-driven) ──────────── *@
|
||||||
|
<div class="relative inline-block w-full" data-popover data-popover-open="false" @attributes="AdditionalAttributes">
|
||||||
|
<div data-popover-trigger>
|
||||||
|
@Trigger
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Backdrop to close on outside click *@
|
||||||
|
<div class="fixed inset-0 z-40" data-popover-backdrop style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="absolute left-0 z-50 mt-1.5 rounded-lg border border-input bg-popover text-popover-foreground shadow-md
|
||||||
|
animate-in fade-in-0 zoom-in-95 origin-top-left"
|
||||||
|
data-popover-panel style="display:none">
|
||||||
|
@Content
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The trigger element (button) that opens the popover.</summary>
|
||||||
|
[Parameter] public RenderFragment? Trigger { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The popover content.</summary>
|
||||||
|
[Parameter] public RenderFragment? Content { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Any extra HTML attributes (data-testid, etc.).</summary>
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
@inherits InputBase<string>
|
||||||
|
|
||||||
|
<input id="@Id"
|
||||||
|
name="@Name"
|
||||||
|
type="@Type"
|
||||||
|
value="@Value"
|
||||||
|
placeholder="@Placeholder"
|
||||||
|
disabled="@Disabled"
|
||||||
|
readonly="@ReadOnly"
|
||||||
|
class="@ComputedClass"
|
||||||
|
@attributes="MergedAttributes" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Type { get; set; } = "text";
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
@inherits InputBase<TimeOnly?>
|
||||||
|
|
||||||
|
<input type="time"
|
||||||
|
id="@Id"
|
||||||
|
name="@Name"
|
||||||
|
value="@FormatValue()"
|
||||||
|
data-trigger-id="@($"trigger-{Id}")"
|
||||||
|
data-placeholder="@(Placeholder ?? "Select time")"
|
||||||
|
class="sr-only"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
disabled="@Disabled"
|
||||||
|
@attributes="MergedAttributes" />
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<Trigger>
|
||||||
|
<button type="button"
|
||||||
|
disabled="@Disabled"
|
||||||
|
data-testid="@($"trigger-{Id}")"
|
||||||
|
class="@TriggerClass">
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span class="@(Value.HasValue ? "" : "text-muted-foreground")">
|
||||||
|
@(Value.HasValue ? Value.Value.ToString("hh\\:mm tt") : (Placeholder ?? "Select time"))
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Trigger>
|
||||||
|
<Content>
|
||||||
|
<TimePicker SelectedTime="@Value" Use12Hour="true" MinuteStep="@ParsedMinuteStep" LinkedInputId="@Id" />
|
||||||
|
</Content>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Step { get; set; }
|
||||||
|
[Parameter] public bool Use12Hour { get; set; } = true;
|
||||||
|
|
||||||
|
private int ParsedMinuteStep => int.TryParse(Step, out var s) && s >= 60 ? s / 60 : 1;
|
||||||
|
|
||||||
|
private string? FormatValue() =>
|
||||||
|
Value?.ToString("HH:mm");
|
||||||
|
|
||||||
|
private string TriggerClass
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
const string baseClass =
|
||||||
|
"flex h-9 w-full items-center rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm " +
|
||||||
|
"transition-colors cursor-pointer text-left " +
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(Class)
|
||||||
|
? $"{baseClass} border-input"
|
||||||
|
: $"{baseClass} border-input {Class}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
@* ── shadcn/ui-style time picker with scrollable columns (JS-driven) ── *@
|
||||||
|
@{
|
||||||
|
var use12 = Use12Hour;
|
||||||
|
var minStep = MinuteStep < 1 ? 1 : MinuteStep;
|
||||||
|
var selHour = 0;
|
||||||
|
var selMinute = 0;
|
||||||
|
var isPm = false;
|
||||||
|
|
||||||
|
if (SelectedTime is { } t)
|
||||||
|
{
|
||||||
|
if (use12)
|
||||||
|
{
|
||||||
|
isPm = t.Hour >= 12;
|
||||||
|
selHour = t.Hour % 12;
|
||||||
|
selMinute = t.Minute;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
selHour = t.Hour;
|
||||||
|
selMinute = t.Minute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex items-stretch gap-1 p-4"
|
||||||
|
data-timepicker
|
||||||
|
data-selected-hour="@selHour"
|
||||||
|
data-selected-minute="@selMinute"
|
||||||
|
data-selected-pm="@(isPm ? "true" : "false")"
|
||||||
|
data-use-12-hour="@(use12 ? "true" : "false")"
|
||||||
|
data-linked-input="@LinkedInputId"
|
||||||
|
@attributes="AdditionalAttributes">
|
||||||
|
|
||||||
|
@* ── Hour column ── *@
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground mb-2">Hr</span>
|
||||||
|
<div class="h-52 w-16 overflow-y-auto flex flex-col gap-1 scrollbar-thin pr-1">
|
||||||
|
@for (int h = 0; h < (use12 ? 12 : 24); h++)
|
||||||
|
{
|
||||||
|
var hour = use12 ? (h == 0 ? 12 : h) : h;
|
||||||
|
var hourValue = h;
|
||||||
|
var isSelected = selHour == hourValue;
|
||||||
|
<button type="button"
|
||||||
|
class="@TimeItemClass(isSelected)"
|
||||||
|
data-tp-hour="@hourValue">
|
||||||
|
@hour.ToString("D2")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center px-1.5 text-muted-foreground font-medium text-lg">:</div>
|
||||||
|
|
||||||
|
@* ── Minute column ── *@
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground mb-2">Min</span>
|
||||||
|
<div class="h-52 w-16 overflow-y-auto flex flex-col gap-1 scrollbar-thin pr-1">
|
||||||
|
@for (int m = 0; m < 60; m += minStep)
|
||||||
|
{
|
||||||
|
var minute = m;
|
||||||
|
var isSel = selMinute == minute;
|
||||||
|
<button type="button"
|
||||||
|
class="@TimeItemClass(isSel)"
|
||||||
|
data-tp-minute="@minute">
|
||||||
|
@minute.ToString("D2")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (use12)
|
||||||
|
{
|
||||||
|
<div class="flex items-center px-1"></div>
|
||||||
|
|
||||||
|
@* ── AM/PM column ── *@
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-xs font-medium text-muted-foreground mb-2"> </span>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<button type="button"
|
||||||
|
class="@TimeItemClass(!isPm)"
|
||||||
|
data-tp-period="am">
|
||||||
|
AM
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="@TimeItemClass(isPm)"
|
||||||
|
data-tp-period="pm">
|
||||||
|
PM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The currently selected time (for initial render).</summary>
|
||||||
|
[Parameter] public TimeOnly? SelectedTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Use 12-hour format with AM/PM. Default is false (24-hour).</summary>
|
||||||
|
[Parameter] public bool Use12Hour { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Minute step interval. Default is 1.</summary>
|
||||||
|
[Parameter] public int MinuteStep { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>The id of the linked hidden input to sync time value to.</summary>
|
||||||
|
[Parameter] public string? LinkedInputId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Any extra HTML attributes.</summary>
|
||||||
|
[Parameter(CaptureUnmatchedValues = true)]
|
||||||
|
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||||
|
|
||||||
|
private static string TimeItemClass(bool isSelected)
|
||||||
|
{
|
||||||
|
const string baseClass = "h-9 w-full inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer";
|
||||||
|
return isSelected
|
||||||
|
? $"{baseClass} bg-primary text-primary-foreground font-semibold"
|
||||||
|
: $"{baseClass} hover:bg-accent hover:text-accent-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Validation;
|
||||||
|
|
||||||
|
public static class FormModelBinder
|
||||||
|
{
|
||||||
|
public static TModel Bind<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TModel>(IFormCollection form) where TModel : new()
|
||||||
|
{
|
||||||
|
var model = new TModel();
|
||||||
|
PropertyInfo[] props = typeof(TModel).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
foreach (var prop in props)
|
||||||
|
{
|
||||||
|
if (!prop.CanWrite) continue;
|
||||||
|
|
||||||
|
var key = form.Keys.FirstOrDefault(k =>
|
||||||
|
string.Equals(k, prop.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (key is null) continue;
|
||||||
|
|
||||||
|
var raw = form[key].ToString();
|
||||||
|
var value = ConvertValue(raw, prop.PropertyType);
|
||||||
|
if (value is not null)
|
||||||
|
prop.SetValue(model, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ConvertValue(string raw, Type target)
|
||||||
|
{
|
||||||
|
var underlying = Nullable.GetUnderlyingType(target);
|
||||||
|
var isNullable = underlying is not null;
|
||||||
|
var type = underlying ?? target;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
return isNullable ? null : (type == typeof(string) ? "" : null);
|
||||||
|
|
||||||
|
if (type == typeof(string)) return raw;
|
||||||
|
if (type == typeof(int)) return int.TryParse(raw, out var i) ? i : null;
|
||||||
|
if (type == typeof(long)) return long.TryParse(raw, out var l) ? l : null;
|
||||||
|
if (type == typeof(double)) return double.TryParse(raw, CultureInfo.InvariantCulture, out var d) ? d : null;
|
||||||
|
if (type == typeof(decimal)) return decimal.TryParse(raw, CultureInfo.InvariantCulture, out var m) ? m : null;
|
||||||
|
if (type == typeof(float)) return float.TryParse(raw, CultureInfo.InvariantCulture, out var f) ? f : null;
|
||||||
|
if (type == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "on" || raw == "1";
|
||||||
|
if (type == typeof(DateTime)) return DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) ? dt : null;
|
||||||
|
if (type == typeof(DateOnly)) return DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d2) ? d2 : null;
|
||||||
|
if (type == typeof(TimeOnly)) return TimeOnly.TryParse(raw, CultureInfo.InvariantCulture, out var t) ? t : null;
|
||||||
|
if (type == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null;
|
||||||
|
if (type.IsEnum) return Enum.TryParse(type, raw, ignoreCase: true, out var e) ? e : null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Validation;
|
||||||
|
|
||||||
|
public sealed class FormValidationRule
|
||||||
|
{
|
||||||
|
public required string Field { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public bool Required { get; init; }
|
||||||
|
public int? MinLength { get; init; }
|
||||||
|
public int? MaxLength { get; init; }
|
||||||
|
public string? Pattern { get; init; }
|
||||||
|
public double? Min { get; init; }
|
||||||
|
public double? Max { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public Func<string, string?>? Custom { get; init; }
|
||||||
|
|
||||||
|
public string? Validate(string? value)
|
||||||
|
{
|
||||||
|
var label = DisplayName ?? ToTitleCase(Field);
|
||||||
|
var v = value?.Trim() ?? "";
|
||||||
|
|
||||||
|
if (Required && string.IsNullOrWhiteSpace(v))
|
||||||
|
return $"{label} is required.";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(v))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (MinLength.HasValue && v.Length < MinLength.Value)
|
||||||
|
return $"{label} must be at least {MinLength.Value} characters.";
|
||||||
|
|
||||||
|
if (MaxLength.HasValue && v.Length > MaxLength.Value)
|
||||||
|
return $"{label} must be at most {MaxLength.Value} characters.";
|
||||||
|
|
||||||
|
if (Pattern is not null && !Regex.IsMatch(v, Pattern))
|
||||||
|
return Message ?? $"{label} is not in the correct format.";
|
||||||
|
|
||||||
|
if (Min.HasValue || Max.HasValue)
|
||||||
|
{
|
||||||
|
if (!double.TryParse(v, out var num))
|
||||||
|
return $"{label} must be a number.";
|
||||||
|
if (Min.HasValue && num < Min.Value)
|
||||||
|
return $"{label} must be at least {Min.Value}.";
|
||||||
|
if (Max.HasValue && num > Max.Value)
|
||||||
|
return $"{label} must be at most {Max.Value}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Custom is not null)
|
||||||
|
{
|
||||||
|
var customError = Custom(v);
|
||||||
|
if (customError is not null)
|
||||||
|
return customError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToTitleCase(string field)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(field)) return field;
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append(char.ToUpper(field[0]));
|
||||||
|
for (int i = 1; i < field.Length; i++)
|
||||||
|
{
|
||||||
|
if (char.IsUpper(field[i]))
|
||||||
|
sb.Append(' ');
|
||||||
|
sb.Append(field[i]);
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
namespace Enciphered.Blazor.UIComponents.Validation;
|
||||||
|
|
||||||
|
public abstract class FormValidator
|
||||||
|
{
|
||||||
|
private readonly List<FormValidationRule> _rules = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<FormValidationRule> Rules => _rules;
|
||||||
|
public IEnumerable<string> Fields => _rules.Select(r => r.Field);
|
||||||
|
|
||||||
|
protected void RuleFor(
|
||||||
|
string field,
|
||||||
|
string? displayName = null,
|
||||||
|
bool required = false,
|
||||||
|
int? minLength = null,
|
||||||
|
int? maxLength = null,
|
||||||
|
string? pattern = null,
|
||||||
|
double? min = null,
|
||||||
|
double? max = null,
|
||||||
|
string? message = null,
|
||||||
|
Func<string, string?>? custom = null)
|
||||||
|
{
|
||||||
|
_rules.Add(new FormValidationRule
|
||||||
|
{
|
||||||
|
Field = field,
|
||||||
|
DisplayName = displayName,
|
||||||
|
Required = required,
|
||||||
|
MinLength = minLength,
|
||||||
|
MaxLength = maxLength,
|
||||||
|
Pattern = pattern,
|
||||||
|
Min = min,
|
||||||
|
Max = max,
|
||||||
|
Message = message,
|
||||||
|
Custom = custom
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ValidateField(string field, string? value)
|
||||||
|
{
|
||||||
|
var rule = _rules.FirstOrDefault(r => r.Field == field);
|
||||||
|
return rule?.Validate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, string> ValidateAll(Func<string, string?> valueAccessor)
|
||||||
|
{
|
||||||
|
var errors = new Dictionary<string, string>();
|
||||||
|
foreach (var rule in _rules)
|
||||||
|
{
|
||||||
|
var value = valueAccessor(rule.Field);
|
||||||
|
var error = rule.Validate(value);
|
||||||
|
if (error is not null)
|
||||||
|
errors[rule.Field] = error;
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Validation;
|
||||||
|
|
||||||
|
public static class HtmxFormValidationExtensions
|
||||||
|
{
|
||||||
|
public static RouteGroupBuilder MapFormValidation<TValidator>(
|
||||||
|
this IEndpointRouteBuilder endpoints,
|
||||||
|
string basePath,
|
||||||
|
string successMessage = "✓ Form submitted successfully!",
|
||||||
|
Func<HttpContext, Task>? onSuccess = null)
|
||||||
|
where TValidator : FormValidator, new()
|
||||||
|
{
|
||||||
|
var validator = new TValidator();
|
||||||
|
return MapEndpoints(endpoints, basePath, validator, successMessage, onSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RouteGroupBuilder MapFormValidation<TValidator, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TModel>(
|
||||||
|
this IEndpointRouteBuilder endpoints,
|
||||||
|
string basePath,
|
||||||
|
Func<TModel, Task> onSuccess,
|
||||||
|
string successMessage = "✓ Form submitted successfully!")
|
||||||
|
where TValidator : FormValidator, new()
|
||||||
|
where TModel : new()
|
||||||
|
{
|
||||||
|
var validator = new TValidator();
|
||||||
|
return MapEndpoints(endpoints, basePath, validator, successMessage, async ctx =>
|
||||||
|
{
|
||||||
|
var model = FormModelBinder.Bind<TModel>(ctx.Request.Form);
|
||||||
|
await onSuccess(model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RouteGroupBuilder MapEndpoints(
|
||||||
|
IEndpointRouteBuilder endpoints,
|
||||||
|
string basePath,
|
||||||
|
FormValidator validator,
|
||||||
|
string successMessage,
|
||||||
|
Func<HttpContext, Task>? onSuccess)
|
||||||
|
{
|
||||||
|
var group = endpoints.MapGroup(basePath);
|
||||||
|
|
||||||
|
group.MapPost("/validate", (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var form = ctx.Request.Form;
|
||||||
|
var field = form["_field"].ToString();
|
||||||
|
var value = form.ContainsKey(field) ? form[field].ToString() : "";
|
||||||
|
|
||||||
|
var error = validator.ValidateField(field, value);
|
||||||
|
var html = HtmxFormValidationRenderer.FieldErrorFragment(field, error);
|
||||||
|
|
||||||
|
return Results.Content(html, "text/html");
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
group.MapPost("/submit", async (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var form = ctx.Request.Form;
|
||||||
|
var errors = validator.ValidateAll(f => form.ContainsKey(f) ? form[f].ToString() : "");
|
||||||
|
|
||||||
|
ctx.Response.ContentType = "text/html; charset=utf-8";
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
var html = HtmxFormValidationRenderer.ErrorResponse(validator.Fields, errors);
|
||||||
|
return Results.Content(html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSuccess is not null)
|
||||||
|
await onSuccess(ctx);
|
||||||
|
|
||||||
|
var successHtml = HtmxFormValidationRenderer.SuccessResponse(validator.Fields, successMessage);
|
||||||
|
return Results.Content(successHtml, "text/html");
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace Enciphered.Blazor.UIComponents.Validation;
|
||||||
|
|
||||||
|
public static class HtmxFormValidationRenderer
|
||||||
|
{
|
||||||
|
private const string ErrorClasses = "text-[0.8rem] font-medium text-destructive";
|
||||||
|
private const string HiddenClass = "hidden";
|
||||||
|
private const string SuccessClasses =
|
||||||
|
"rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400";
|
||||||
|
|
||||||
|
public static string FieldErrorFragment(string field, string? error)
|
||||||
|
{
|
||||||
|
var safeField = WebUtility.HtmlEncode(field);
|
||||||
|
if (string.IsNullOrEmpty(error))
|
||||||
|
return $"<p data-field-error=\"{safeField}\" class=\"{ErrorClasses} {HiddenClass}\"></p>";
|
||||||
|
var safeError = WebUtility.HtmlEncode(error);
|
||||||
|
return $"<p data-field-error=\"{safeField}\" class=\"{ErrorClasses}\">{safeError}</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string OobFieldErrorFragment(string field, string? error)
|
||||||
|
{
|
||||||
|
var safeField = WebUtility.HtmlEncode(field);
|
||||||
|
var oobAttr = $"hx-swap-oob=\"outerHTML:[data-field-error='{safeField}']\"";
|
||||||
|
if (string.IsNullOrEmpty(error))
|
||||||
|
return $"<p data-field-error=\"{safeField}\" {oobAttr} class=\"{ErrorClasses} {HiddenClass}\"></p>";
|
||||||
|
var safeError = WebUtility.HtmlEncode(error);
|
||||||
|
return $"<p data-field-error=\"{safeField}\" {oobAttr} class=\"{ErrorClasses}\">{safeError}</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ErrorResponse(
|
||||||
|
IEnumerable<string> allFields,
|
||||||
|
Dictionary<string, string> errors,
|
||||||
|
string resultDivId = "form-result")
|
||||||
|
{
|
||||||
|
var html = "";
|
||||||
|
foreach (var field in allFields)
|
||||||
|
{
|
||||||
|
errors.TryGetValue(field, out var err);
|
||||||
|
html += OobFieldErrorFragment(field, err) + "\n";
|
||||||
|
}
|
||||||
|
html += $"<div id=\"{resultDivId}\" class=\"{HiddenClass}\"></div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string SuccessResponse(
|
||||||
|
IEnumerable<string> allFields,
|
||||||
|
string successMessage,
|
||||||
|
string resultDivId = "form-result",
|
||||||
|
string? testId = "success-message")
|
||||||
|
{
|
||||||
|
var html = "";
|
||||||
|
foreach (var field in allFields)
|
||||||
|
html += OobFieldErrorFragment(field, null) + "\n";
|
||||||
|
var testIdAttr = testId is not null ? $" data-testid=\"{testId}\"" : "";
|
||||||
|
html += $"<div id=\"{resultDivId}\">";
|
||||||
|
html += $"<div{testIdAttr} class=\"{SuccessClasses}\">";
|
||||||
|
html += WebUtility.HtmlEncode(successMessage);
|
||||||
|
html += "</div></div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using System.Linq.Expressions
|
||||||
|
@using Enciphered.Blazor.UIComponents
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
@* Mobile overlay — hidden by default, JS shows it when sidebar opens on mobile *@
|
||||||
|
<div data-sidebar-overlay
|
||||||
|
class="fixed inset-0 z-50 bg-black/50"
|
||||||
|
style="display:none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Spacer — reserves horizontal space for the sidebar in the flex layout *@
|
||||||
|
<div data-sidebar-spacer
|
||||||
|
class="shrink-0 transition-[width] duration-200 ease-linear"
|
||||||
|
style="display:none; width:var(--sidebar-width);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* The actual sidebar panel *@
|
||||||
|
<aside data-sidebar
|
||||||
|
class="fixed inset-y-0 left-0 z-50 md:z-10 flex h-svh flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width,transform] duration-200 ease-linear @Class"
|
||||||
|
style="width:var(--sidebar-width);">
|
||||||
|
<div class="flex h-full w-full flex-col overflow-hidden">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div data-sidebar-content class="flex min-h-0 flex-1 flex-col gap-2 overflow-auto p-2 group-data-[state=collapsed]:group-data-[mobile=false]:items-center group-data-[state=collapsed]:group-data-[mobile=false]:px-0 @Class">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div data-sidebar-footer class="mt-auto flex flex-col gap-2 p-2 group-data-[state=collapsed]:group-data-[mobile=false]:items-center group-data-[state=collapsed]:group-data-[mobile=false]:px-0 @Class">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 @Class">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5 @Class">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div data-sidebar-group-label class="px-3 py-1.5 text-xs font-medium text-sidebar-foreground/70 truncate group-data-[state=collapsed]:group-data-[mobile=false]:hidden @Class">
|
||||||
|
@if (ChildContent is not null)
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@Label
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Label { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div data-sidebar-header data-sidebar-trigger
|
||||||
|
class="flex flex-col gap-2 p-2 cursor-pointer group-data-[state=collapsed]:group-data-[mobile=false]:items-center group-data-[state=collapsed]:group-data-[mobile=false]:px-0 @Class">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<main data-sidebar-inset class="relative flex flex-1 flex-col min-h-svh min-w-0 bg-background text-foreground @Class">
|
||||||
|
@ChildContent
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<a href="@Href"
|
||||||
|
title="@Tooltip"
|
||||||
|
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground group-data-[state=collapsed]:group-data-[mobile=false]:justify-center group-data-[state=collapsed]:group-data-[mobile=false]:size-8 group-data-[state=collapsed]:group-data-[mobile=false]:mx-auto group-data-[state=collapsed]:group-data-[mobile=false]:p-0 @ActiveCss @Class">
|
||||||
|
@if (Icon is not null)
|
||||||
|
{
|
||||||
|
<span class="shrink-0">@Icon</span>
|
||||||
|
}
|
||||||
|
<span class="truncate group-data-[state=collapsed]:group-data-[mobile=false]:hidden">@ChildContent</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public RenderFragment? Icon { get; set; }
|
||||||
|
[Parameter] public string? Href { get; set; }
|
||||||
|
[Parameter] public string? Tooltip { get; set; }
|
||||||
|
[Parameter] public bool IsActive { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
private string ActiveCss => IsActive
|
||||||
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground";
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div data-sidebar-wrapper data-default-open="@(DefaultOpen ? "true" : "false")" class="flex min-h-svh w-full group">
|
||||||
|
@ChildContent
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public bool DefaultOpen { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<div class="h-px bg-sidebar-border mx-2 my-2 @Class"></div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<button data-sidebar-trigger
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center rounded-md p-2 text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring @Class">
|
||||||
|
@if (ChildContent is not null)
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M9 3v18" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
<span class="sr-only">Toggle Sidebar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@using Enciphered.Blazor.UIComponents
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,153 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
/* ---------- shadcn/ui light tokens (neutral) ---------- */
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border-color: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar-background: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- shadcn/ui dark tokens (neutral) ---------- */
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border-color: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
--sidebar-background: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Map CSS vars → Tailwind theme ---------- */
|
||||||
|
@theme {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border-color);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-sidebar: var(--sidebar-background);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius: var(--radius);
|
||||||
|
--sidebar-width: 16rem;
|
||||||
|
--sidebar-width-icon: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Date / Time input native chrome overrides ---------- */
|
||||||
|
/* Hide the native calendar/clock picker indicator across browsers */
|
||||||
|
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||||
|
input[type="time"]::-webkit-calendar-picker-indicator,
|
||||||
|
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure the value text is left-aligned (WebKit centers it by default) */
|
||||||
|
input[type="date"]::-webkit-date-and-time-value,
|
||||||
|
input[type="time"]::-webkit-date-and-time-value,
|
||||||
|
input[type="datetime-local"]::-webkit-date-and-time-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use theme colors for date/time input text (inherits foreground) */
|
||||||
|
input[type="date"],
|
||||||
|
input[type="time"],
|
||||||
|
input[type="datetime-local"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input[type="date"],
|
||||||
|
.dark input[type="time"],
|
||||||
|
.dark input[type="datetime-local"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Custom scrollbar for picker columns ---------- */
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--muted) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--muted);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--muted-foreground);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
@typeparam TItem
|
||||||
|
|
||||||
|
<div class="w-full overflow-auto rounded-md border border-zinc-800">
|
||||||
|
<table class="w-full caption-bottom text-sm">
|
||||||
|
<thead class="bg-zinc-900/50">
|
||||||
|
<tr class="border-b border-zinc-800 transition-colors">
|
||||||
|
@Columns
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="[&_tr:last-child]:border-0">
|
||||||
|
@foreach (TItem item in Items)
|
||||||
|
{
|
||||||
|
<tr class="border-b border-zinc-800 transition-colors hover:bg-zinc-900/50 text-center">
|
||||||
|
@RowTemplate(item)
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required IReadOnlyList<TItem> Items { get; set; }
|
||||||
|
[Parameter] public required RenderFragment Columns { get; set; }
|
||||||
|
[Parameter] public required RenderFragment<TItem> RowTemplate { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@namespace Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
data-theme-toggle
|
||||||
|
class="@ComputedClass"
|
||||||
|
aria-label="Toggle dark mode">
|
||||||
|
<!-- Moon icon (shown in light mode) -->
|
||||||
|
<svg data-theme-icon-moon xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Sun icon (shown in dark mode) -->
|
||||||
|
<svg data-theme-icon-sun 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"
|
||||||
|
style="display:none;">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="M12 20v2" />
|
||||||
|
<path d="m4.93 4.93 1.41 1.41" />
|
||||||
|
<path d="m17.66 17.66 1.41 1.41" />
|
||||||
|
<path d="M2 12h2" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="m6.34 17.66-1.41 1.41" />
|
||||||
|
<path d="m19.07 4.93-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? Class { get; set; }
|
||||||
|
|
||||||
|
private const string BaseClass =
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors " +
|
||||||
|
"hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50 h-9 w-9 cursor-pointer";
|
||||||
|
|
||||||
|
private string ComputedClass => string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using Enciphered.Blazor.UIComponents
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* Dark-mode Module — toggles light/dark theme, persists to localStorage,
|
||||||
|
* and survives Blazor enhanced-navigation DOM patching.
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
const STORAGE_KEY = 'theme';
|
||||||
|
|
||||||
|
// ── Module state ─────────────────────────────────────────────────────────────
|
||||||
|
let initialized = false;
|
||||||
|
let classObserver = null;
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Read the persisted theme (defaults to 'light'). */
|
||||||
|
function readTheme() {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure <html> class matches the persisted theme. */
|
||||||
|
function applyTheme() {
|
||||||
|
const isDark = readTheme() === 'dark';
|
||||||
|
document.documentElement.classList.toggle('dark', isDark);
|
||||||
|
syncIcons(isDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show/hide moon and sun SVGs to match the current theme. */
|
||||||
|
function syncIcons(isDark) {
|
||||||
|
document.querySelectorAll('[data-theme-icon-moon]').forEach(el => {
|
||||||
|
el.style.display = isDark ? 'none' : '';
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-theme-icon-sun]').forEach(el => {
|
||||||
|
el.style.display = isDark ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event handlers (named for clean removal) ─────────────────────────────────
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
if (!e.target.closest('[data-theme-toggle]')) return;
|
||||||
|
const next = readTheme() === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After Blazor enhanced-nav replaces body content, icons in the new DOM
|
||||||
|
* need their display style set. This fires once per navigation — far
|
||||||
|
* cheaper than the previous body MutationObserver (childList + subtree).
|
||||||
|
*/
|
||||||
|
function handleEnhancedLoad() {
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
if (initialized) {
|
||||||
|
applyTheme();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
applyTheme();
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
|
||||||
|
// Blazor enhanced-nav may patch <html> and strip the 'dark' class.
|
||||||
|
classObserver = new MutationObserver(() => {
|
||||||
|
const shouldBeDark = readTheme() === 'dark';
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
if (shouldBeDark !== isDark) {
|
||||||
|
document.documentElement.classList.toggle('dark', shouldBeDark);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
classObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-sync icons after enhanced-nav replaces body content
|
||||||
|
document.addEventListener('blazor:enhanced-load', handleEnhancedLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispose() {
|
||||||
|
document.removeEventListener('click', handleClick);
|
||||||
|
document.removeEventListener('blazor:enhanced-load', handleEnhancedLoad);
|
||||||
|
classObserver?.disconnect();
|
||||||
|
classObserver = null;
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
let initialized = false;
|
||||||
|
let bodyObserver = null;
|
||||||
|
|
||||||
|
function openPopover(wrapper) {
|
||||||
|
const panel = wrapper.querySelector('[data-popover-panel]');
|
||||||
|
const backdrop = wrapper.querySelector('[data-popover-backdrop]');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.style.display = '';
|
||||||
|
if (backdrop) backdrop.style.display = '';
|
||||||
|
wrapper.setAttribute('data-popover-open', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopover(wrapper) {
|
||||||
|
const panel = wrapper.querySelector('[data-popover-panel]');
|
||||||
|
const backdrop = wrapper.querySelector('[data-popover-backdrop]');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.style.display = 'none';
|
||||||
|
if (backdrop) backdrop.style.display = 'none';
|
||||||
|
wrapper.setAttribute('data-popover-open', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePopover(wrapper) {
|
||||||
|
const isOpen = wrapper.getAttribute('data-popover-open') === 'true';
|
||||||
|
if (isOpen) closePopover(wrapper);
|
||||||
|
else openPopover(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||||
|
const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
function getCalendarState(cal) {
|
||||||
|
return {
|
||||||
|
displayYear: parseInt(cal.getAttribute('data-display-year')),
|
||||||
|
displayMonth: parseInt(cal.getAttribute('data-display-month')),
|
||||||
|
selectedDate: cal.getAttribute('data-selected-date') || '',
|
||||||
|
view: cal.getAttribute('data-view') || 'days', // 'days' | 'months' | 'years'
|
||||||
|
yearRangeStart: parseInt(cal.getAttribute('data-year-range-start') || '0'),
|
||||||
|
yearRangeEnd: parseInt(cal.getAttribute('data-year-range-end') || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCalendarState(cal, state) {
|
||||||
|
cal.setAttribute('data-display-year', state.displayYear);
|
||||||
|
cal.setAttribute('data-display-month', state.displayMonth);
|
||||||
|
if (state.selectedDate !== undefined) cal.setAttribute('data-selected-date', state.selectedDate);
|
||||||
|
cal.setAttribute('data-view', state.view);
|
||||||
|
cal.setAttribute('data-year-range-start', state.yearRangeStart);
|
||||||
|
cal.setAttribute('data-year-range-end', state.yearRangeEnd);
|
||||||
|
renderCalendar(cal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarDays(year, month) {
|
||||||
|
const days = [];
|
||||||
|
const firstOfMonth = new Date(year, month - 1, 1);
|
||||||
|
const startOffset = firstOfMonth.getDay(); // Sunday = 0
|
||||||
|
const start = new Date(firstOfMonth);
|
||||||
|
start.setDate(start.getDate() - startOffset);
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateISO(d) {
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateISO(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const [y, m, d] = str.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar(cal) {
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
const contentEl = cal.querySelector('[data-calendar-content]');
|
||||||
|
if (!contentEl) return;
|
||||||
|
|
||||||
|
const monthLabel = cal.querySelector('[data-calendar-month]');
|
||||||
|
const yearLabel = cal.querySelector('[data-calendar-year]');
|
||||||
|
if (monthLabel) monthLabel.textContent = MONTH_NAMES_SHORT[state.displayMonth - 1];
|
||||||
|
if (yearLabel) yearLabel.textContent = state.displayYear;
|
||||||
|
|
||||||
|
const selectedParsed = parseDateISO(state.selectedDate);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
if (state.view === 'months') {
|
||||||
|
contentEl.innerHTML = renderMonthPicker(state);
|
||||||
|
} else if (state.view === 'years') {
|
||||||
|
contentEl.innerHTML = renderYearPicker(state);
|
||||||
|
} else {
|
||||||
|
contentEl.innerHTML = renderDayGrid(state, selectedParsed, today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDayGrid(state, selectedParsed, today) {
|
||||||
|
const baseBtn = 'h-9 w-9 inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer';
|
||||||
|
|
||||||
|
let html = '<div class="grid grid-cols-7 gap-1 mb-1">';
|
||||||
|
for (const dow of DAY_HEADERS) {
|
||||||
|
html += `<div class="h-9 w-9 flex items-center justify-center text-[0.8rem] text-muted-foreground font-medium">${dow}</div>`;
|
||||||
|
}
|
||||||
|
html += '</div><div class="grid grid-cols-7 gap-1">';
|
||||||
|
|
||||||
|
const days = getCalendarDays(state.displayYear, state.displayMonth);
|
||||||
|
for (const d of days) {
|
||||||
|
const isOutside = d.getMonth() !== state.displayMonth - 1;
|
||||||
|
const isSelected = selectedParsed && isSameDay(d, selectedParsed);
|
||||||
|
const isToday = isSameDay(d, today);
|
||||||
|
const dateStr = formatDateISO(d);
|
||||||
|
|
||||||
|
let cls = baseBtn;
|
||||||
|
if (isSelected) cls += ' bg-primary text-primary-foreground font-semibold';
|
||||||
|
else if (isOutside) cls += ' text-muted-foreground/40 cursor-default';
|
||||||
|
else if (isToday) cls += ' bg-accent text-accent-foreground font-medium';
|
||||||
|
else cls += ' hover:bg-accent hover:text-accent-foreground';
|
||||||
|
|
||||||
|
html += `<button type="button" class="${cls}" ${isOutside ? 'disabled' : ''} data-calendar-day="${dateStr}">${d.getDate()}</button>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonthPicker(state) {
|
||||||
|
let html = '<div class="grid grid-cols-3 gap-2 py-2">';
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
const isCurrent = state.displayMonth === m + 1;
|
||||||
|
const cls = isCurrent
|
||||||
|
? 'h-9 rounded-md text-sm transition-colors cursor-pointer bg-primary text-primary-foreground'
|
||||||
|
: 'h-9 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground';
|
||||||
|
html += `<button type="button" class="${cls}" data-calendar-select-month="${m + 1}">${MONTH_NAMES_SHORT[m]}</button>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderYearPicker(state) {
|
||||||
|
let html = '<div class="grid grid-cols-4 gap-2 py-2 max-h-52 overflow-y-auto scrollbar-thin pr-1">';
|
||||||
|
for (let y = state.yearRangeStart; y <= state.yearRangeEnd; y++) {
|
||||||
|
const isCurrent = state.displayYear === y;
|
||||||
|
const cls = isCurrent
|
||||||
|
? 'h-9 rounded-md text-sm transition-colors cursor-pointer bg-primary text-primary-foreground'
|
||||||
|
: 'h-9 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground';
|
||||||
|
html += `<button type="button" class="${cls}" data-calendar-select-year="${y}">${y}</button>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarPrev(cal) {
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
if (state.view === 'years') {
|
||||||
|
state.yearRangeStart -= 20;
|
||||||
|
state.yearRangeEnd -= 20;
|
||||||
|
} else if (state.view === 'months') {
|
||||||
|
state.displayYear--;
|
||||||
|
state.yearRangeStart = state.displayYear - 10;
|
||||||
|
state.yearRangeEnd = state.displayYear + 10;
|
||||||
|
} else {
|
||||||
|
state.displayMonth--;
|
||||||
|
if (state.displayMonth < 1) { state.displayMonth = 12; state.displayYear--; }
|
||||||
|
state.yearRangeStart = state.displayYear - 10;
|
||||||
|
state.yearRangeEnd = state.displayYear + 10;
|
||||||
|
}
|
||||||
|
setCalendarState(cal, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarNext(cal) {
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
if (state.view === 'years') {
|
||||||
|
state.yearRangeStart += 20;
|
||||||
|
state.yearRangeEnd += 20;
|
||||||
|
} else if (state.view === 'months') {
|
||||||
|
state.displayYear++;
|
||||||
|
state.yearRangeStart = state.displayYear - 10;
|
||||||
|
state.yearRangeEnd = state.displayYear + 10;
|
||||||
|
} else {
|
||||||
|
state.displayMonth++;
|
||||||
|
if (state.displayMonth > 12) { state.displayMonth = 1; state.displayYear++; }
|
||||||
|
state.yearRangeStart = state.displayYear - 10;
|
||||||
|
state.yearRangeEnd = state.displayYear + 10;
|
||||||
|
}
|
||||||
|
setCalendarState(cal, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimePickerState(tp) {
|
||||||
|
return {
|
||||||
|
hour: parseInt(tp.getAttribute('data-selected-hour') || '0'),
|
||||||
|
minute: parseInt(tp.getAttribute('data-selected-minute') || '0'),
|
||||||
|
isPm: tp.getAttribute('data-selected-pm') === 'true',
|
||||||
|
use12Hour: tp.getAttribute('data-use-12-hour') === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimePickerState(tp, state) {
|
||||||
|
tp.setAttribute('data-selected-hour', state.hour);
|
||||||
|
tp.setAttribute('data-selected-minute', state.minute);
|
||||||
|
tp.setAttribute('data-selected-pm', state.isPm);
|
||||||
|
renderTimePicker(tp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimePicker(tp) {
|
||||||
|
const state = getTimePickerState(tp);
|
||||||
|
|
||||||
|
tp.querySelectorAll('[data-tp-hour]').forEach(btn => {
|
||||||
|
const val = parseInt(btn.getAttribute('data-tp-hour'));
|
||||||
|
updateTimeItemSelected(btn, val === state.hour);
|
||||||
|
});
|
||||||
|
tp.querySelectorAll('[data-tp-minute]').forEach(btn => {
|
||||||
|
const val = parseInt(btn.getAttribute('data-tp-minute'));
|
||||||
|
updateTimeItemSelected(btn, val === state.minute);
|
||||||
|
});
|
||||||
|
tp.querySelectorAll('[data-tp-period]').forEach(btn => {
|
||||||
|
const isPm = btn.getAttribute('data-tp-period') === 'pm';
|
||||||
|
updateTimeItemSelected(btn, isPm === state.isPm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimeItemSelected(btn, isSelected) {
|
||||||
|
const base = 'h-9 w-full inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer';
|
||||||
|
if (isSelected) {
|
||||||
|
btn.className = base + ' bg-primary text-primary-foreground font-semibold';
|
||||||
|
} else {
|
||||||
|
btn.className = base + ' hover:bg-accent hover:text-accent-foreground';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimePickerValue(tp) {
|
||||||
|
const state = getTimePickerState(tp);
|
||||||
|
let hour = state.hour;
|
||||||
|
if (state.use12Hour) {
|
||||||
|
hour = (state.hour % 12) + (state.isPm ? 12 : 0);
|
||||||
|
}
|
||||||
|
return { hour, minute: state.minute };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime24(hour, minute) {
|
||||||
|
return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime12(hour, minute) {
|
||||||
|
const isPm = hour >= 12;
|
||||||
|
let h12 = hour % 12;
|
||||||
|
if (h12 === 0) h12 = 12;
|
||||||
|
return String(h12).padStart(2, '0') + ':' + String(minute).padStart(2, '0') + ' ' + (isPm ? 'PM' : 'AM');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNumberIncrement(wrapper, direction) {
|
||||||
|
const input = wrapper.querySelector('input[type="number"]');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const step = parseFloat(input.getAttribute('step')) || 1;
|
||||||
|
const min = input.hasAttribute('min') ? parseFloat(input.getAttribute('min')) : null;
|
||||||
|
const max = input.hasAttribute('max') ? parseFloat(input.getAttribute('max')) : null;
|
||||||
|
|
||||||
|
let current = parseFloat(input.value) || 0;
|
||||||
|
current += step * direction;
|
||||||
|
|
||||||
|
if (min !== null && current < min) current = min;
|
||||||
|
if (max !== null && current > max) current = max;
|
||||||
|
|
||||||
|
input.value = current;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
updateStepperDisabledState(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStepperDisabledState(wrapper) {
|
||||||
|
const input = wrapper.querySelector('input[type="number"]');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const min = input.hasAttribute('min') ? parseFloat(input.getAttribute('min')) : null;
|
||||||
|
const max = input.hasAttribute('max') ? parseFloat(input.getAttribute('max')) : null;
|
||||||
|
const val = parseFloat(input.value);
|
||||||
|
|
||||||
|
const incBtn = wrapper.querySelector('[data-number-increment]');
|
||||||
|
const decBtn = wrapper.querySelector('[data-number-decrement]');
|
||||||
|
|
||||||
|
if (incBtn) {
|
||||||
|
incBtn.disabled = (max !== null && !isNaN(val) && val >= max);
|
||||||
|
}
|
||||||
|
if (decBtn) {
|
||||||
|
decBtn.disabled = (min !== null && !isNaN(val) && val <= min);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDateTriggerText(hiddenInput) {
|
||||||
|
const triggerId = hiddenInput.getAttribute('data-trigger-id');
|
||||||
|
if (!triggerId) return;
|
||||||
|
const trigger = document.querySelector(`[data-testid="${triggerId}"]`);
|
||||||
|
if (!trigger) return;
|
||||||
|
const span = trigger.querySelector('span');
|
||||||
|
if (!span) return;
|
||||||
|
|
||||||
|
const val = hiddenInput.value;
|
||||||
|
if (val) {
|
||||||
|
const [y, m, d] = val.split('-').map(Number);
|
||||||
|
const date = new Date(y, m - 1, d);
|
||||||
|
const isDateTimePart = hiddenInput.hasAttribute('data-datetime-part');
|
||||||
|
const options = isDateTimePart
|
||||||
|
? { month: 'short', day: 'numeric', year: 'numeric' }
|
||||||
|
: { month: 'long', day: 'numeric', year: 'numeric' };
|
||||||
|
span.textContent = date.toLocaleDateString('en-US', options);
|
||||||
|
span.classList.remove('text-muted-foreground');
|
||||||
|
} else {
|
||||||
|
span.textContent = hiddenInput.getAttribute('data-placeholder') || 'Select date';
|
||||||
|
span.classList.add('text-muted-foreground');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTimeTriggerText(hiddenInput) {
|
||||||
|
const triggerId = hiddenInput.getAttribute('data-trigger-id');
|
||||||
|
if (!triggerId) return;
|
||||||
|
const trigger = document.querySelector(`[data-testid="${triggerId}"]`);
|
||||||
|
if (!trigger) return;
|
||||||
|
const span = trigger.querySelector('span');
|
||||||
|
if (!span) return;
|
||||||
|
|
||||||
|
const val = hiddenInput.value;
|
||||||
|
if (val) {
|
||||||
|
const [h, m] = val.split(':').map(Number);
|
||||||
|
span.textContent = formatTime12(h, m);
|
||||||
|
span.classList.remove('text-muted-foreground');
|
||||||
|
} else {
|
||||||
|
span.textContent = hiddenInput.getAttribute('data-placeholder') || 'Select time';
|
||||||
|
span.classList.add('text-muted-foreground');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
const target = e.target;
|
||||||
|
|
||||||
|
const popoverTrigger = target.closest('[data-popover-trigger]');
|
||||||
|
if (popoverTrigger) {
|
||||||
|
const wrapper = popoverTrigger.closest('[data-popover]');
|
||||||
|
if (wrapper) {
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePopover(wrapper);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backdrop = target.closest('[data-popover-backdrop]');
|
||||||
|
if (backdrop) {
|
||||||
|
const wrapper = backdrop.closest('[data-popover]');
|
||||||
|
if (wrapper) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closePopover(wrapper);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevBtn = target.closest('[data-calendar-prev]');
|
||||||
|
if (prevBtn) {
|
||||||
|
const cal = prevBtn.closest('[data-calendar]');
|
||||||
|
if (cal) { calendarPrev(cal); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextBtn = target.closest('[data-calendar-next]');
|
||||||
|
if (nextBtn) {
|
||||||
|
const cal = nextBtn.closest('[data-calendar]');
|
||||||
|
if (cal) { calendarNext(cal); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthHeader = target.closest('[data-calendar-month]');
|
||||||
|
if (monthHeader) {
|
||||||
|
const cal = monthHeader.closest('[data-calendar]');
|
||||||
|
if (cal) {
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
state.view = state.view === 'months' ? 'days' : 'months';
|
||||||
|
setCalendarState(cal, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearHeader = target.closest('[data-calendar-year]');
|
||||||
|
if (yearHeader) {
|
||||||
|
const cal = yearHeader.closest('[data-calendar]');
|
||||||
|
if (cal) {
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
state.view = state.view === 'years' ? 'days' : 'years';
|
||||||
|
setCalendarState(cal, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthBtn = target.closest('[data-calendar-select-month]');
|
||||||
|
if (monthBtn) {
|
||||||
|
const cal = monthBtn.closest('[data-calendar]');
|
||||||
|
if (cal) {
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
state.displayMonth = parseInt(monthBtn.getAttribute('data-calendar-select-month'));
|
||||||
|
state.view = 'days';
|
||||||
|
setCalendarState(cal, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearBtn = target.closest('[data-calendar-select-year]');
|
||||||
|
if (yearBtn) {
|
||||||
|
const cal = yearBtn.closest('[data-calendar]');
|
||||||
|
if (cal) {
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
state.displayYear = parseInt(yearBtn.getAttribute('data-calendar-select-year'));
|
||||||
|
state.view = 'days';
|
||||||
|
state.yearRangeStart = state.displayYear - 10;
|
||||||
|
state.yearRangeEnd = state.displayYear + 10;
|
||||||
|
setCalendarState(cal, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayBtn = target.closest('[data-calendar-day]');
|
||||||
|
if (dayBtn && !dayBtn.disabled) {
|
||||||
|
const cal = dayBtn.closest('[data-calendar]');
|
||||||
|
if (cal) {
|
||||||
|
const dateStr = dayBtn.getAttribute('data-calendar-day');
|
||||||
|
const [y, m, d] = dateStr.split('-').map(Number);
|
||||||
|
const state = getCalendarState(cal);
|
||||||
|
state.selectedDate = dateStr;
|
||||||
|
state.displayYear = y;
|
||||||
|
state.displayMonth = m;
|
||||||
|
setCalendarState(cal, state);
|
||||||
|
|
||||||
|
const inputId = cal.getAttribute('data-linked-input');
|
||||||
|
if (inputId) {
|
||||||
|
const hiddenInput = document.getElementById(inputId);
|
||||||
|
if (hiddenInput) {
|
||||||
|
hiddenInput.value = dateStr;
|
||||||
|
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
syncDateTriggerText(hiddenInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const popover = cal.closest('[data-popover]');
|
||||||
|
if (popover) closePopover(popover);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourBtn = target.closest('[data-tp-hour]');
|
||||||
|
if (hourBtn) {
|
||||||
|
const tp = hourBtn.closest('[data-timepicker]');
|
||||||
|
if (tp) {
|
||||||
|
const state = getTimePickerState(tp);
|
||||||
|
state.hour = parseInt(hourBtn.getAttribute('data-tp-hour'));
|
||||||
|
setTimePickerState(tp, state);
|
||||||
|
syncTimeToHiddenInput(tp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minuteBtn = target.closest('[data-tp-minute]');
|
||||||
|
if (minuteBtn) {
|
||||||
|
const tp = minuteBtn.closest('[data-timepicker]');
|
||||||
|
if (tp) {
|
||||||
|
const state = getTimePickerState(tp);
|
||||||
|
state.minute = parseInt(minuteBtn.getAttribute('data-tp-minute'));
|
||||||
|
setTimePickerState(tp, state);
|
||||||
|
syncTimeToHiddenInput(tp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodBtn = target.closest('[data-tp-period]');
|
||||||
|
if (periodBtn) {
|
||||||
|
const tp = periodBtn.closest('[data-timepicker]');
|
||||||
|
if (tp) {
|
||||||
|
const state = getTimePickerState(tp);
|
||||||
|
state.isPm = periodBtn.getAttribute('data-tp-period') === 'pm';
|
||||||
|
setTimePickerState(tp, state);
|
||||||
|
syncTimeToHiddenInput(tp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const incBtn = target.closest('[data-number-increment]');
|
||||||
|
if (incBtn) {
|
||||||
|
const wrapper = incBtn.closest('[data-number-input]');
|
||||||
|
if (wrapper) { handleNumberIncrement(wrapper, 1); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const decBtn = target.closest('[data-number-decrement]');
|
||||||
|
if (decBtn) {
|
||||||
|
const wrapper = decBtn.closest('[data-number-input]');
|
||||||
|
if (wrapper) { handleNumberIncrement(wrapper, -1); return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTimeToHiddenInput(tp) {
|
||||||
|
const inputId = tp.getAttribute('data-linked-input');
|
||||||
|
if (!inputId) return;
|
||||||
|
const hiddenInput = document.getElementById(inputId);
|
||||||
|
if (!hiddenInput) return;
|
||||||
|
|
||||||
|
const { hour, minute } = getTimePickerValue(tp);
|
||||||
|
hiddenInput.value = formatTime24(hour, minute);
|
||||||
|
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
syncTimeTriggerText(hiddenInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e) {
|
||||||
|
const target = e.target;
|
||||||
|
if (target.matches('[data-number-input] input[type="number"]')) {
|
||||||
|
const wrapper = target.closest('[data-number-input]');
|
||||||
|
if (wrapper) updateStepperDisabledState(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncValidationStyling() {
|
||||||
|
document.querySelectorAll('[data-field-error]').forEach(errEl => {
|
||||||
|
const fieldName = errEl.getAttribute('data-field-error');
|
||||||
|
if (!fieldName) return;
|
||||||
|
const field = document.querySelector(`[data-form-field="${fieldName}"]`);
|
||||||
|
if (!field) return;
|
||||||
|
const input = field.querySelector('input, select, textarea');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const hasError = !errEl.classList.contains('hidden') && errEl.textContent.trim().length > 0;
|
||||||
|
if (hasError) {
|
||||||
|
input.classList.add('border-destructive');
|
||||||
|
input.classList.remove('border-input');
|
||||||
|
} else {
|
||||||
|
input.classList.remove('border-destructive');
|
||||||
|
input.classList.add('border-input');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFormReset(e) {
|
||||||
|
if (e.target.tagName !== 'FORM') return;
|
||||||
|
const form = e.target;
|
||||||
|
setTimeout(() => {
|
||||||
|
form.querySelectorAll('[data-field-error]').forEach(errEl => {
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
errEl.textContent = '';
|
||||||
|
});
|
||||||
|
form.querySelectorAll('.border-destructive').forEach(input => {
|
||||||
|
input.classList.remove('border-destructive');
|
||||||
|
input.classList.add('border-input');
|
||||||
|
});
|
||||||
|
const result = form.querySelector('#form-result');
|
||||||
|
if (result) { result.className = 'hidden'; result.innerHTML = ''; }
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initComponents() {
|
||||||
|
document.querySelectorAll('[data-calendar]').forEach(cal => {
|
||||||
|
renderCalendar(cal);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-timepicker]').forEach(tp => {
|
||||||
|
renderTimePicker(tp);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-number-input]').forEach(wrapper => {
|
||||||
|
updateStepperDisabledState(wrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-popover]').forEach(wrapper => {
|
||||||
|
if (wrapper.getAttribute('data-popover-open') !== 'true') {
|
||||||
|
closePopover(wrapper);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
if (initialized) {
|
||||||
|
initComponents();
|
||||||
|
if (typeof htmx !== 'undefined') htmx.process(document.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
initComponents();
|
||||||
|
if (typeof htmx !== 'undefined') htmx.process(document.body);
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
document.addEventListener('input', handleInput);
|
||||||
|
document.addEventListener('reset', handleFormReset, true);
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterSwap', () => {
|
||||||
|
syncValidationStyling();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.Blazor.addEventListener('enhancedload', () => {
|
||||||
|
initComponents();
|
||||||
|
if (typeof htmx !== 'undefined') htmx.process(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
bodyObserver = new MutationObserver(() => {
|
||||||
|
const uninitCals = document.querySelectorAll('[data-calendar]:not([data-initialized])');
|
||||||
|
uninitCals.forEach(cal => {
|
||||||
|
cal.setAttribute('data-initialized', 'true');
|
||||||
|
renderCalendar(cal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bodyObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispose() {
|
||||||
|
document.removeEventListener('click', handleClick);
|
||||||
|
document.removeEventListener('input', handleInput);
|
||||||
|
bodyObserver?.disconnect();
|
||||||
|
bodyObserver = null;
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* Sidebar Module — manages expand/collapse, mobile/desktop responsiveness,
|
||||||
|
* and cookie-based persistence. Survives Blazor enhanced-navigation DOM
|
||||||
|
* patching via MutationObservers.
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
const COOKIE_NAME = 'sidebar:state';
|
||||||
|
const COOKIE_MAX_AGE = 31_536_000; // 1 year in seconds
|
||||||
|
const WIDTH_FULL = 'var(--sidebar-width)';
|
||||||
|
const WIDTH_ICON = 'var(--sidebar-width-icon)';
|
||||||
|
|
||||||
|
// ── Module state ─────────────────────────────────────────────────────────────
|
||||||
|
let isOpen = true;
|
||||||
|
let isMobile = false;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
// Cached DOM references — refreshed via queryElements()
|
||||||
|
let el = { wrapper: null, sidebar: null, spacer: null, inset: null, overlay: null };
|
||||||
|
|
||||||
|
// Cleanup handles
|
||||||
|
let attrObserver = null;
|
||||||
|
let bodyObserver = null;
|
||||||
|
let applyScheduled = false;
|
||||||
|
|
||||||
|
// ── DOM queries (single place to refresh cache) ──────────────────────────────
|
||||||
|
function queryElements() {
|
||||||
|
el.wrapper = document.querySelector('[data-sidebar-wrapper]');
|
||||||
|
el.sidebar = document.querySelector('[data-sidebar]');
|
||||||
|
el.spacer = document.querySelector('[data-sidebar-spacer]');
|
||||||
|
el.inset = document.querySelector('[data-sidebar-inset]');
|
||||||
|
el.overlay = document.querySelector('[data-sidebar-overlay]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Apply the current isOpen / isMobile state to the DOM. */
|
||||||
|
function applyState() {
|
||||||
|
const { wrapper, sidebar, spacer, inset, overlay } = el;
|
||||||
|
if (!sidebar || !wrapper) return;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Mobile: sidebar slides in/out; spacer hidden
|
||||||
|
if (spacer) { spacer.style.display = 'none'; spacer.style.width = '0px'; }
|
||||||
|
sidebar.style.width = WIDTH_FULL;
|
||||||
|
sidebar.style.transform = isOpen ? 'translateX(0)' : 'translateX(-100%)';
|
||||||
|
if (overlay) overlay.style.display = isOpen ? 'block' : 'none';
|
||||||
|
if (inset) inset.style.marginLeft = '0px';
|
||||||
|
} else {
|
||||||
|
// Desktop: always visible, collapses to icon-only width
|
||||||
|
sidebar.style.transform = 'translateX(0)';
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
|
||||||
|
const width = isOpen ? WIDTH_FULL : WIDTH_ICON;
|
||||||
|
sidebar.style.width = width;
|
||||||
|
if (spacer) { spacer.style.display = 'block'; spacer.style.width = width; }
|
||||||
|
if (inset) inset.style.marginLeft = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.setAttribute('data-state', isOpen ? 'expanded' : 'collapsed');
|
||||||
|
wrapper.setAttribute('data-mobile', isMobile ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the sidebar state as a cookie.
|
||||||
|
* Called only on explicit user actions (toggle / overlay-close / mobile-link),
|
||||||
|
* not on every applyState() call.
|
||||||
|
*/
|
||||||
|
function persistState() {
|
||||||
|
document.cookie = `${COOKIE_NAME}=${isOpen ? 'open' : 'closed'};path=/;max-age=${COOKIE_MAX_AGE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State logic ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
applyState();
|
||||||
|
persistState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
const wasMobile = isMobile;
|
||||||
|
isMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
|
||||||
|
if (wasMobile !== isMobile) {
|
||||||
|
isOpen = !isMobile; // expand on desktop, collapse on mobile
|
||||||
|
}
|
||||||
|
applyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read persisted state from cookie or fallback to data-default-open attr. */
|
||||||
|
function readPersistedState() {
|
||||||
|
const cookie = document.cookie.split('; ').find(c => c.startsWith(COOKIE_NAME + '='));
|
||||||
|
if (cookie) {
|
||||||
|
isOpen = cookie.split('=')[1] === 'open';
|
||||||
|
} else {
|
||||||
|
const defaultOpen = el.wrapper?.getAttribute('data-default-open');
|
||||||
|
isOpen = defaultOpen !== 'false';
|
||||||
|
}
|
||||||
|
isMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
if (isMobile) isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event handlers (named for clean removal) ─────────────────────────────────
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
if (e.target.closest('[data-sidebar-trigger]')) { toggle(); return; }
|
||||||
|
if (e.target.closest('[data-sidebar-overlay]')) { isOpen = false; applyState(); persistState(); return; }
|
||||||
|
|
||||||
|
// Mobile: close sidebar when a link inside it is clicked
|
||||||
|
if (isMobile) {
|
||||||
|
const link = e.target.closest('a[href]');
|
||||||
|
if (link && el.sidebar?.contains(link)) { isOpen = false; applyState(); persistState(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MutationObservers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Coalesce rapid mutations into a single rAF-batched applyState(). */
|
||||||
|
function scheduleApply() {
|
||||||
|
if (applyScheduled) return;
|
||||||
|
applyScheduled = true;
|
||||||
|
requestAnimationFrame(() => { applyScheduled = false; applyState(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch the wrapper element for attribute removal.
|
||||||
|
* Blazor enhanced-nav keeps the element but strips dynamically-added attrs.
|
||||||
|
*/
|
||||||
|
function observeWrapperAttrs(wrapper) {
|
||||||
|
// Disconnect previous observer when re-binding to a new wrapper
|
||||||
|
attrObserver?.disconnect();
|
||||||
|
|
||||||
|
attrObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
if (m.attributeName === 'data-state' && !wrapper.hasAttribute('data-state')) {
|
||||||
|
scheduleApply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attrObserver.observe(wrapper, { attributes: true, attributeFilter: ['data-state'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch <body> for child-list changes.
|
||||||
|
* When enhanced-nav replaces page content, re-cache elements and re-observe.
|
||||||
|
*/
|
||||||
|
function startBodyObserver() {
|
||||||
|
bodyObserver = new MutationObserver(() => {
|
||||||
|
const wrapper = document.querySelector('[data-sidebar-wrapper]');
|
||||||
|
if (wrapper && !wrapper.hasAttribute('data-state')) {
|
||||||
|
queryElements();
|
||||||
|
observeWrapperAttrs(wrapper);
|
||||||
|
scheduleApply();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bodyObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
if (initialized) {
|
||||||
|
// Already running — just refresh DOM cache and re-apply
|
||||||
|
queryElements();
|
||||||
|
applyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
queryElements();
|
||||||
|
readPersistedState();
|
||||||
|
applyState();
|
||||||
|
persistState();
|
||||||
|
|
||||||
|
// Listeners
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
|
||||||
|
// Observers
|
||||||
|
if (el.wrapper) observeWrapperAttrs(el.wrapper);
|
||||||
|
startBodyObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispose() {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
document.removeEventListener('click', handleClick);
|
||||||
|
attrObserver?.disconnect();
|
||||||
|
bodyObserver?.disconnect();
|
||||||
|
attrObserver = null;
|
||||||
|
bodyObserver = null;
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Enciphered.Blazor.UIComponents.Components;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Add services to the container.
|
|
||||||
builder.Services.AddRazorComponents()
|
|
||||||
.AddInteractiveServerComponents();
|
|
||||||
|
|
||||||
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.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseAntiforgery();
|
|
||||||
|
|
||||||
app.MapStaticAssets();
|
|
||||||
app.MapRazorComponents<App>()
|
|
||||||
.AddInteractiveServerRenderMode();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
@@ -1 +1,51 @@
|
|||||||
# Blazor UI Components
|
# Enciphered.Blazor.UIComponents
|
||||||
|
|
||||||
|
A **pure static SSR** Blazor component library styled with **Tailwind CSS v4** and **shadcn/ui** design tokens. All interactivity is powered by vanilla JavaScript — no SignalR, no WebAssembly, no `InteractiveServer` render mode required. Form validation and submission are handled entirely through **htmx**.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Zero Blazor interactivity** — components work with `AddRazorComponents()` alone
|
||||||
|
- **Tailwind CSS v4** with oklch color tokens (light + dark mode)
|
||||||
|
- **htmx form validation** — per-field blur validation and full form submission with a fluent `FormValidator` API
|
||||||
|
- **Strongly-typed model binding** — bind submitted form data to POCOs automatically
|
||||||
|
- **Sidebar layout** — collapsible, responsive, cookie-persisted
|
||||||
|
- **Card components** — composable header/content/footer/image/action slots
|
||||||
|
- **Dark mode** — toggle with localStorage persistence, FOUC-free
|
||||||
|
- **Date/Time pickers** — calendar and time picker popovers with hidden native inputs
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet add package Enciphered.Blazor.UIComponents
|
||||||
|
```
|
||||||
|
|
||||||
|
> See the full [Getting Started guide](docs/getting-started.md) for setup instructions.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|---|---|
|
||||||
|
| [Getting Started](docs/getting-started.md) | Installation, prerequisites, and first-app setup |
|
||||||
|
| [Sidebar](docs/components/sidebar.md) | Collapsible sidebar layout system |
|
||||||
|
| [Card](docs/components/card.md) | Composable card components |
|
||||||
|
| [Button](docs/components/button.md) | Button with variants and sizes |
|
||||||
|
| [Form Inputs](docs/components/form-inputs.md) | TextInput, NumberInput, DateInput, TimeInput, DateTimeInput |
|
||||||
|
| [Form Validation](docs/forms/validation.md) | htmx-powered validation with FormValidator |
|
||||||
|
| [Form Submission](docs/forms/submission.md) | Handling form submit with model binding |
|
||||||
|
| [Theme Toggle](docs/components/theme-toggle.md) | Dark/light mode toggle |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All interactive behavior (popovers, sidebar collapse, calendar navigation, number input steppers, form resets) is implemented via three vanilla JS modules that use `data-*` attribute selectors and event delegation:
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `darkmode.js` | Theme toggle, localStorage persistence, SVG icon sync |
|
||||||
|
| `sidebar.js` | Expand/collapse, mobile responsiveness, cookie persistence |
|
||||||
|
| `forms.js` | Popover, calendar, time picker, number stepper, form reset |
|
||||||
|
|
||||||
|
These modules survive Blazor enhanced navigation via `MutationObserver` patterns.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ISC
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Button
|
||||||
|
|
||||||
|
A styled button component with variant and size presets built from Tailwind utility classes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Button>Default Button</Button>
|
||||||
|
<Button Type="submit">Submit</Button>
|
||||||
|
<Button Disabled="true">Can't Click</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variants
|
||||||
|
|
||||||
|
Use the `Variant` parameter with constants from `ButtonVariant`:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Button Variant="@ButtonVariant.Default">Default</Button>
|
||||||
|
<Button Variant="@ButtonVariant.Secondary">Secondary</Button>
|
||||||
|
<Button Variant="@ButtonVariant.Destructive">Destructive</Button>
|
||||||
|
<Button Variant="@ButtonVariant.Outline">Outline</Button>
|
||||||
|
<Button Variant="@ButtonVariant.Ghost">Ghost</Button>
|
||||||
|
<Button Variant="@ButtonVariant.Link">Link</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Constant | Tailwind Classes |
|
||||||
|
|---|---|
|
||||||
|
| `ButtonVariant.Default` | `bg-primary text-primary-foreground shadow hover:bg-primary/90` |
|
||||||
|
| `ButtonVariant.Destructive` | `bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90` |
|
||||||
|
| `ButtonVariant.Outline` | `border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground` |
|
||||||
|
| `ButtonVariant.Secondary` | `bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80` |
|
||||||
|
| `ButtonVariant.Ghost` | `hover:bg-accent hover:text-accent-foreground` |
|
||||||
|
| `ButtonVariant.Link` | `text-primary underline-offset-4 hover:underline` |
|
||||||
|
|
||||||
|
You can also pass any custom Tailwind class string directly:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Button Variant="bg-blue-600 text-white hover:bg-blue-700">Custom</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Custom Variants
|
||||||
|
|
||||||
|
Since `Variant` and `Size` are plain strings (not enums), you can create your own variant constants without modifying the library. Define a static class in your app with any Tailwind utility combinations you need:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static class AppButtonVariant
|
||||||
|
{
|
||||||
|
public const string Success =
|
||||||
|
"bg-green-600 text-white shadow-sm hover:bg-green-700";
|
||||||
|
|
||||||
|
public const string Warning =
|
||||||
|
"bg-amber-500 text-white shadow-sm hover:bg-amber-600";
|
||||||
|
|
||||||
|
public const string Info =
|
||||||
|
"bg-sky-500 text-white shadow-sm hover:bg-sky-600";
|
||||||
|
|
||||||
|
public const string OutlineDestructive =
|
||||||
|
"border border-destructive text-destructive bg-transparent shadow-sm hover:bg-destructive/10";
|
||||||
|
|
||||||
|
public const string Gradient =
|
||||||
|
"bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-sm hover:from-purple-600 hover:to-pink-600";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use them exactly like the built-in variants:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Button Variant="@AppButtonVariant.Success">Save Changes</Button>
|
||||||
|
<Button Variant="@AppButtonVariant.Warning">Proceed with Caution</Button>
|
||||||
|
<Button Variant="@AppButtonVariant.Gradient">Upgrade Plan</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can do the same for custom sizes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static class AppButtonSize
|
||||||
|
{
|
||||||
|
public const string Xs = "h-7 rounded-md px-2 text-xs";
|
||||||
|
public const string Xl = "h-12 rounded-lg px-10 text-base";
|
||||||
|
public const string Wide = "h-9 px-12 py-2";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Button Size="@AppButtonSize.Xl" Variant="@AppButtonVariant.Gradient">
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach works because the `Button` component simply concatenates the variant and size strings into the element's `class` attribute — there is no closed set of allowed values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sizes
|
||||||
|
|
||||||
|
Use the `Size` parameter with constants from `ButtonSize`:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Button Size="@ButtonSize.Sm">Small</Button>
|
||||||
|
<Button Size="@ButtonSize.Default">Default</Button>
|
||||||
|
<Button Size="@ButtonSize.Lg">Large</Button>
|
||||||
|
<Button Size="@ButtonSize.Icon">🔔</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Constant | Tailwind Classes |
|
||||||
|
|---|---|
|
||||||
|
| `ButtonSize.Default` | `h-9 px-4 py-2` |
|
||||||
|
| `ButtonSize.Sm` | `h-8 rounded-md px-3 text-xs` |
|
||||||
|
| `ButtonSize.Lg` | `h-10 rounded-md px-8` |
|
||||||
|
| `ButtonSize.Icon` | `h-9 w-9` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button with Icon
|
||||||
|
|
||||||
|
Use the `Icon` render fragment to prepend an icon:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Button>
|
||||||
|
<Icon>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>Continue</ChildContent>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ChildContent` | `RenderFragment?` | — | Button label content |
|
||||||
|
| `Icon` | `RenderFragment?` | — | Icon slot rendered before the label |
|
||||||
|
| `Type` | `string` | `"button"` | HTML button type (`button`, `submit`, `reset`) |
|
||||||
|
| `Disabled` | `bool` | `false` | Disables the button |
|
||||||
|
| `Variant` | `string` | `ButtonVariant.Default` | Visual style classes |
|
||||||
|
| `Size` | `string` | `ButtonSize.Default` | Size classes |
|
||||||
|
| `Class` | `string?` | — | Additional CSS classes appended |
|
||||||
|
|
||||||
|
All unmatched HTML attributes (`data-testid`, `aria-*`, etc.) are passed through via `@attributes`.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Card
|
||||||
|
|
||||||
|
A composable card component suite for displaying grouped content in a bordered container with optional header, footer, image, and action slots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|---|---|
|
||||||
|
| `Card` | Root container with border, shadow, and rounded corners |
|
||||||
|
| `CardHeader` | Top section — contains title, description, and optional action |
|
||||||
|
| `CardTitle` | `<h3>` heading styled for cards |
|
||||||
|
| `CardDescription` | Muted paragraph below the title |
|
||||||
|
| `CardAction` | Trailing action element (button/link) aligned to the header's right edge |
|
||||||
|
| `CardContent` | Main body area |
|
||||||
|
| `CardFooter` | Bottom area for buttons or metadata |
|
||||||
|
| `CardImage` | Full-width image with optional wrapper styling |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Card
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notifications</CardTitle>
|
||||||
|
<CardDescription>You have 3 unread messages.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>Here is the main content of the card.</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button>View All</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card with Action
|
||||||
|
|
||||||
|
The `CardAction` renders at the trailing edge of the header using CSS grid:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Team Members</CardTitle>
|
||||||
|
<CardDescription>Manage your team.</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button Variant="@ButtonVariant.Outline" Size="@ButtonSize.Sm">
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<!-- member list -->
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card with Image
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<Card Class="max-w-sm">
|
||||||
|
<CardImage Src="/images/hero.jpg" Alt="Hero image" WrapperClass="h-48" />
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Beautiful Scenery</CardTitle>
|
||||||
|
<CardDescription>A mountain landscape at sunset.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>The image fills the card width and crops via object-cover.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### Card
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ChildContent` | `RenderFragment?` | — | Card inner content |
|
||||||
|
| `Class` | `string?` | — | Additional CSS classes |
|
||||||
|
|
||||||
|
Base classes: `rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden`
|
||||||
|
|
||||||
|
### CardImage
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Src` | `string` | **required** | Image source URL |
|
||||||
|
| `Alt` | `string` | `""` | Alt text for accessibility |
|
||||||
|
| `Class` | `string?` | — | Additional classes on the `<img>` |
|
||||||
|
| `WrapperClass` | `string?` | — | Additional classes on the wrapper `<div>` |
|
||||||
|
|
||||||
|
### CardTitle, CardDescription, CardContent, CardFooter, CardAction
|
||||||
|
|
||||||
|
All accept `ChildContent` and `Class` parameters. All support unmatched HTML attributes via `@attributes`.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Form Inputs
|
||||||
|
|
||||||
|
All input components extend a shared `InputBase<T>` class that provides consistent styling, htmx validation integration, and parameter unification. When placed inside an `HtmxForm` with a `FormField`, inputs automatically attach `hx-post`, `hx-trigger="blur"`, and `hx-target` attributes for real-time per-field validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TextInput
|
||||||
|
|
||||||
|
A standard text input for strings. Supports all HTML input types (`text`, `email`, `password`, `search`, etc.).
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<FormField Label="Full Name" For="name">
|
||||||
|
<TextInput Id="name" Name="name" Placeholder="Jane Doe" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField Label="Email" For="email">
|
||||||
|
<TextInput Id="email" Name="email" Type="email" Placeholder="jane@example.com" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField Label="Password" For="password">
|
||||||
|
<TextInput Id="password" Name="password" Type="password" Placeholder="••••••••" />
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Type` | `string` | `"text"` | HTML input type |
|
||||||
|
| `Id` | `string?` | — | Input element ID |
|
||||||
|
| `Name` | `string?` | — | Form field name (submitted to the server) |
|
||||||
|
| `Value` | `string?` | — | Current value |
|
||||||
|
| `Placeholder` | `string?` | — | Placeholder text |
|
||||||
|
| `Disabled` | `bool` | `false` | Disables the input |
|
||||||
|
| `ReadOnly` | `bool` | `false` | Makes the input read-only |
|
||||||
|
| `Class` | `string?` | — | Additional CSS classes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NumberInput
|
||||||
|
|
||||||
|
A numeric input with built-in increment/decrement stepper buttons. Hides the browser's native spinner.
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<FormField Label="Age" For="age">
|
||||||
|
<NumberInput Id="age" Name="age" Placeholder="25" Min="0" Max="150" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField Label="Quantity" For="quantity">
|
||||||
|
<NumberInput Id="quantity" Name="quantity" Value="1" Min="1" Max="99" Step="1" />
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Min` | `string?` | — | Minimum allowed value |
|
||||||
|
| `Max` | `string?` | — | Maximum allowed value |
|
||||||
|
| `Step` | `string?` | — | Step increment |
|
||||||
|
| `Value` | `double?` | — | Current numeric value |
|
||||||
|
|
||||||
|
Inherits all parameters from `InputBase<double?>` (`Id`, `Name`, `Placeholder`, `Disabled`, `ReadOnly`, `Class`).
|
||||||
|
|
||||||
|
The stepper buttons are disabled when the value reaches `Min` or `Max`. They are hidden when `Disabled` or `ReadOnly` is true.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DateInput
|
||||||
|
|
||||||
|
A date picker that combines a hidden `<input type="date">` with a popover calendar. Users select dates through the calendar UI — the native date picker chrome is hidden.
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<FormField Label="Birth Date" For="birthdate">
|
||||||
|
<DateInput Id="birthdate" Name="birthdate" Placeholder="Select your birth date" />
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. A hidden `<input type="date">` holds the actual form value in `yyyy-MM-dd` format
|
||||||
|
2. A styled button trigger shows the selected date (or placeholder)
|
||||||
|
3. Clicking the trigger opens a `Popover` containing a `Calendar` component
|
||||||
|
4. Selecting a day updates the hidden input and closes the popover
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Value` | `DateOnly?` | — | Currently selected date |
|
||||||
|
| `Min` | `string?` | — | Minimum date |
|
||||||
|
| `Max` | `string?` | — | Maximum date |
|
||||||
|
| `Placeholder` | `string?` | `"Select date"` | Trigger button placeholder |
|
||||||
|
|
||||||
|
Inherits all parameters from `InputBase<DateOnly?>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TimeInput
|
||||||
|
|
||||||
|
A time picker that combines a hidden `<input type="time">` with a popover time picker. Features scrollable hour/minute columns and AM/PM toggle.
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<FormField Label="Preferred Time" For="preferredtime">
|
||||||
|
<TimeInput Id="preferredtime" Name="preferredtime" />
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Value` | `TimeOnly?` | — | Currently selected time |
|
||||||
|
| `Step` | `string?` | — | Minute step interval |
|
||||||
|
| `Use12Hour` | `bool` | `true` | 12-hour format with AM/PM |
|
||||||
|
| `Placeholder` | `string?` | `"Select time"` | Trigger button placeholder |
|
||||||
|
|
||||||
|
Inherits all parameters from `InputBase<TimeOnly?>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DateTimeInput
|
||||||
|
|
||||||
|
Combines a date picker and time picker side by side for selecting both date and time. Internally manages a hidden `<input type="datetime-local">` plus two helper hidden inputs for the date and time parts.
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<FormField Label="Appointment" For="appointment">
|
||||||
|
<DateTimeInput Id="appointment" Name="appointment" />
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. A hidden `<input type="datetime-local">` holds the combined value in `yyyy-MM-ddTHH:mm` format
|
||||||
|
2. Two helper hidden inputs hold the date-part and time-part separately
|
||||||
|
3. Two popover triggers (calendar + time picker) are displayed side by side
|
||||||
|
4. The `forms.js` module automatically combines the date-part and time-part values into the main hidden input
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Value` | `DateTime?` | — | Currently selected date and time |
|
||||||
|
| `Min` | `string?` | — | Minimum datetime |
|
||||||
|
| `Max` | `string?` | — | Maximum datetime |
|
||||||
|
| `Step` | `string?` | — | Minute step interval |
|
||||||
|
| `Placeholder` | `string?` | `"Select date"` | Date trigger placeholder |
|
||||||
|
|
||||||
|
Inherits all parameters from `InputBase<DateTime?>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FormField
|
||||||
|
|
||||||
|
Wraps an input with a label, description, and error placeholder. The error element is used by htmx to display server-side validation messages.
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<FormField Label="Email" For="email" Description="We'll never share your email.">
|
||||||
|
<TextInput Id="email" Name="email" Type="email" />
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Label` | `string?` | — | Label text rendered as `<label>` |
|
||||||
|
| `For` | `string?` | — | Links the label to the input, and identifies the field for validation errors |
|
||||||
|
| `Description` | `string?` | — | Helper text below the input |
|
||||||
|
| `Error` | `string?` | — | Pre-set error message (for server-rendered errors) |
|
||||||
|
| `Class` | `string?` | — | Additional CSS classes on the wrapper |
|
||||||
|
| `LabelClass` | `string?` | — | Additional CSS classes on the label |
|
||||||
|
|
||||||
|
### How validation errors are displayed
|
||||||
|
|
||||||
|
`FormField` renders a `<p data-field-error="fieldname">` element. When htmx receives a validation response, it swaps this element with the server's HTML fragment (which includes or hides the error message). This is the core mechanism for per-field validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## InputBase
|
||||||
|
|
||||||
|
All input components inherit from `InputBase<TValue>`, which provides:
|
||||||
|
|
||||||
|
- **Common parameters**: `Id`, `Name`, `Value`, `Placeholder`, `Disabled`, `ReadOnly`, `Class`
|
||||||
|
- **Computed CSS**: A consistent input style using Tailwind classes
|
||||||
|
- **htmx auto-wiring**: When `ValidationEndpoint` and `FieldName` cascading values are present (provided by `HtmxForm` and `FormField`), the input automatically gets:
|
||||||
|
- `hx-post` pointing to the validation endpoint
|
||||||
|
- `hx-trigger="blur"` for on-blur validation
|
||||||
|
- `hx-target="next [data-field-error]"` to update the error element
|
||||||
|
- `hx-swap="outerHTML"` for full element replacement
|
||||||
|
- `hx-include="this"` to send only this input's value
|
||||||
|
- `hx-vals='{"_field": "fieldname"}'` to identify which field is being validated
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Sidebar
|
||||||
|
|
||||||
|
A collapsible, responsive sidebar layout system. Persists expand/collapse state via cookies and adapts between desktop (collapsible rail) and mobile (overlay drawer) modes automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|---|---|
|
||||||
|
| `SidebarProvider` | Root wrapper — provides collapse state context |
|
||||||
|
| `Sidebar` | The sidebar panel itself |
|
||||||
|
| `SidebarHeader` | Top area (logo, app name) — also acts as a collapse trigger |
|
||||||
|
| `SidebarContent` | Scrollable middle area for navigation groups |
|
||||||
|
| `SidebarFooter` | Bottom area (copyright, user info) |
|
||||||
|
| `SidebarGroup` | Groups related menu items |
|
||||||
|
| `SidebarGroupLabel` | Section heading within a group |
|
||||||
|
| `SidebarGroupContent` | Container for menu items within a group |
|
||||||
|
| `SidebarMenuItem` | Navigation link with icon support |
|
||||||
|
| `SidebarInset` | Main content area adjacent to the sidebar |
|
||||||
|
| `SidebarSeparator` | Horizontal divider line |
|
||||||
|
| `SidebarTrigger` | Standalone toggle button (for mobile header bars) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Layout
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<SidebarProvider DefaultOpen="true">
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<div class="flex items-center gap-2 px-1 py-1.5">
|
||||||
|
<img src="logo.svg" alt="Logo" class="size-8" />
|
||||||
|
<span class="font-semibold text-sm">My App</span>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel Label="Navigation" />
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenuItem Href="/" Tooltip="Home" IsActive="true">
|
||||||
|
<Icon>
|
||||||
|
<!-- SVG icon -->
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>Home</ChildContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem Href="/about" Tooltip="About">
|
||||||
|
<Icon>
|
||||||
|
<!-- SVG icon -->
|
||||||
|
</Icon>
|
||||||
|
<ChildContent>About</ChildContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarSeparator />
|
||||||
|
<div class="px-3 py-2 text-xs text-sidebar-foreground/50">
|
||||||
|
© 2026 My Company
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<SidebarInset>
|
||||||
|
<header class="flex h-14 items-center gap-2 border-b border-border px-4">
|
||||||
|
<SidebarTrigger Class="md:hidden" />
|
||||||
|
<h1 class="text-sm font-medium">My App</h1>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 p-4 md:p-6">
|
||||||
|
@Body
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### SidebarProvider
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `DefaultOpen` | `bool` | `true` | Initial sidebar state on first visit |
|
||||||
|
| `ChildContent` | `RenderFragment` | — | Must contain `Sidebar` + `SidebarInset` |
|
||||||
|
|
||||||
|
### SidebarMenuItem
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Href` | `string?` | — | Navigation URL |
|
||||||
|
| `Tooltip` | `string?` | — | Tooltip text (shown on hover when collapsed) |
|
||||||
|
| `IsActive` | `bool` | `false` | Highlights the item with accent styling |
|
||||||
|
| `Icon` | `RenderFragment?` | — | Icon slot (typically an SVG) |
|
||||||
|
| `Class` | `string?` | — | Additional CSS classes |
|
||||||
|
|
||||||
|
### SidebarGroupLabel
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Label` | `string?` | — | Label text (alternative to ChildContent) |
|
||||||
|
| `ChildContent` | `RenderFragment?` | — | Custom label markup |
|
||||||
|
| `Class` | `string?` | — | Additional CSS classes |
|
||||||
|
|
||||||
|
### All sidebar components
|
||||||
|
|
||||||
|
Every sidebar component accepts:
|
||||||
|
- `ChildContent` — slot for nested content
|
||||||
|
- `Class` — additional CSS classes to append
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
### Desktop (≥ 768px)
|
||||||
|
- Clicking the **SidebarHeader** or **SidebarTrigger** toggles between expanded and collapsed (icon rail) states
|
||||||
|
- Collapsed state shrinks to `var(--sidebar-width-icon)` (3rem) showing only icons
|
||||||
|
- State persists via a `sidebar:state` cookie (1 year)
|
||||||
|
|
||||||
|
### Mobile (< 768px)
|
||||||
|
- Sidebar renders as an off-screen drawer
|
||||||
|
- Opens with a semi-transparent backdrop overlay
|
||||||
|
- Clicking the overlay or trigger closes it
|
||||||
|
- Navigation link clicks auto-close the sidebar
|
||||||
|
|
||||||
|
### CSS Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `--sidebar-width` | `16rem` | Expanded sidebar width |
|
||||||
|
| `--sidebar-width-icon` | `3rem` | Collapsed (icon rail) width |
|
||||||
|
|
||||||
|
These can be overridden in your `@theme` block.
|
||||||