Basics Done
This commit is contained in:
+41
-1
@@ -1,4 +1,44 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
|
||||
## Node
|
||||
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/
|
||||
[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,37 @@
|
||||
<!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"]" />
|
||||
<link rel="stylesheet" href="@Assets["Enciphered.Blazor.UIComponents.Demo.styles.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 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';
|
||||
initDarkMode();
|
||||
initSidebar();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
@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>
|
||||
</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,19 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
<h1>Counter</h1>
|
||||
|
||||
<p role="status">Current count: @currentCount</p>
|
||||
|
||||
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
|
||||
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
currentCount++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@page "/forms"
|
||||
@rendermode InteractiveServer
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
|
||||
<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 with DataAnnotations validation.</p>
|
||||
</div>
|
||||
|
||||
<EditForm EditContext="_editContext" OnSubmit="HandleSubmit" FormName="demo-form">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormField Label="Full Name" For="name" Error="@GetError(nameof(Model.Name))">
|
||||
<TextInput Id="name" @bind-Value="Model.Name" Placeholder="Jane Doe" data-testid="input-name" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Email" For="email" Error="@GetError(nameof(Model.Email))">
|
||||
<TextInput Id="email" Type="email" @bind-Value="Model.Email" Placeholder="jane@example.com" data-testid="input-email" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Password" For="password" Error="@GetError(nameof(Model.Password))">
|
||||
<TextInput Id="password" Type="password" @bind-Value="Model.Password" Placeholder="••••••••" data-testid="input-password" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Age" For="age" Error="@GetError(nameof(Model.Age))">
|
||||
<NumberInput Id="age" @bind-Value="Model.Age" Placeholder="25" Min="0" Max="150" data-testid="input-age" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Birth Date" For="birthdate" Error="@GetError(nameof(Model.BirthDate))">
|
||||
<DateInput Id="birthdate" @bind-Value="Model.BirthDate" data-testid="input-birthdate" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Preferred Time" For="preferredtime" Error="@GetError(nameof(Model.PreferredTime))">
|
||||
<TimeInput Id="preferredtime" @bind-Value="Model.PreferredTime" data-testid="input-time" />
|
||||
</FormField>
|
||||
|
||||
<FormField Label="Appointment" For="appointment" Error="@GetError(nameof(Model.Appointment))">
|
||||
<DateTimeInput Id="appointment" @bind-Value="Model.Appointment" data-testid="input-appointment" />
|
||||
</FormField>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button Type="submit" data-testid="btn-submit">Submit</Button>
|
||||
<Button Variant="@ButtonVariant.Outline" OnClick="HandleReset" data-testid="btn-reset">Reset</Button>
|
||||
<Button Variant="@ButtonVariant.Destructive" Disabled="true" data-testid="btn-disabled">Disabled</Button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (_submitted)
|
||||
{
|
||||
<div data-testid="success-message"
|
||||
class="rounded-md border border-input bg-card p-4 text-sm text-card-foreground">
|
||||
<p class="font-medium">✓ Form submitted successfully</p>
|
||||
<p class="text-muted-foreground mt-1">Name: @_submittedName</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private FormModel Model { get; set; } = new();
|
||||
private EditContext _editContext = null!;
|
||||
private bool _submitted;
|
||||
private string _submittedName = "";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_editContext = new EditContext(Model);
|
||||
}
|
||||
|
||||
private string? GetError(string fieldName)
|
||||
{
|
||||
var field = _editContext.Field(fieldName);
|
||||
var messages = _editContext.GetValidationMessages(field);
|
||||
return messages.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void HandleSubmit()
|
||||
{
|
||||
_submitted = false;
|
||||
|
||||
if (!_editContext.Validate())
|
||||
return;
|
||||
|
||||
_submittedName = Model.Name!;
|
||||
_submitted = true;
|
||||
}
|
||||
|
||||
private void HandleReset()
|
||||
{
|
||||
Model = new();
|
||||
_submitted = false;
|
||||
_editContext = new EditContext(Model);
|
||||
}
|
||||
|
||||
public class FormModel
|
||||
{
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2–100 characters.")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Email is required.")]
|
||||
[EmailAddress(ErrorMessage = "Invalid email address.")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Password is required.")]
|
||||
[StringLength(64, MinimumLength = 8, ErrorMessage = "Password must be 8–64 characters.")]
|
||||
public string? Password { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Age is required.")]
|
||||
[Range(1, 150, ErrorMessage = "Age must be between 1 and 150.")]
|
||||
public double? Age { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Birth date is required.")]
|
||||
public DateOnly? BirthDate { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Preferred time is required.")]
|
||||
public TimeOnly? PreferredTime { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Appointment is required.")]
|
||||
public DateTime? Appointment { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -18,7 +18,7 @@ else
|
||||
<tr>
|
||||
<th>Date</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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -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>
|
||||
+2
-2
@@ -6,6 +6,6 @@
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using Enciphered.Blazor.UIComponents.Demo
|
||||
@using Enciphered.Blazor.UIComponents.Demo.Components
|
||||
@using Enciphered.Blazor.UIComponents
|
||||
@using Enciphered.Blazor.UIComponents.Components
|
||||
@using Enciphered.Blazor.UIComponents.Components.Layout
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Enciphered.Blazor.UIComponents\Enciphered.Blazor.UIComponents.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="TailwindBuild" BeforeTargets="Build">
|
||||
<Exec Command="npx @tailwindcss/cli -i Styles/app.css -o wwwroot/css/app.css --minify" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
using Enciphered.Blazor.UIComponents.Components;
|
||||
using Enciphered.Blazor.UIComponents.Demo.Components;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -15,13 +15,15 @@ if (!app.Environment.IsDevelopment())
|
||||
// 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();
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly);
|
||||
|
||||
app.Run();
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5182",
|
||||
"applicationUrl": "http://localhost:5146",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7009;http://localhost:5182",
|
||||
"applicationUrl": "https://localhost:7065;http://localhost:5146",
|
||||
"environmentVariables": {
|
||||
"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);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
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,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,605 @@
|
||||
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 Blazor interactive mode to be ready
|
||||
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>
|
||||
/// 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(200);
|
||||
|
||||
await NavigateCalendarToDate(target);
|
||||
|
||||
// Click the target day (only enabled buttons in the calendar day grid)
|
||||
// The day grid is the last grid-cols-7 div; find the button with matching day text
|
||||
var dayGrid = Page.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(200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigate the open calendar to a specific month/year using the month and year pickers.
|
||||
/// </summary>
|
||||
private async Task NavigateCalendarToDate(DateOnly target)
|
||||
{
|
||||
// Click year header to open year picker, then select the year
|
||||
var yearButton = Page.Locator("[data-calendar-year]");
|
||||
await yearButton.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
|
||||
// The year picker is a scrollable grid; find and click the target year
|
||||
var yearGrid = Page.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 (±20 per click)
|
||||
var attempts = 0;
|
||||
while (await targetYearBtn.CountAsync() == 0 && attempts < 10)
|
||||
{
|
||||
// Read the first year button text to determine which direction to go
|
||||
var firstYearText = await yearGrid.Locator("button").First.InnerTextAsync();
|
||||
var firstYear = int.Parse(firstYearText.Trim());
|
||||
|
||||
if (target.Year < firstYear)
|
||||
await Page.Locator("button[aria-label='Previous month']").ClickAsync();
|
||||
else
|
||||
await Page.Locator("button[aria-label='Next month']").ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(50);
|
||||
yearGrid = Page.Locator(".grid.grid-cols-4");
|
||||
targetYearBtn = yearGrid.Locator($"button:has-text('{target.Year}')");
|
||||
attempts++;
|
||||
}
|
||||
|
||||
await targetYearBtn.First.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
|
||||
// Now click month header to open month picker, then select the month
|
||||
var monthButton = Page.Locator("[data-calendar-month]");
|
||||
await monthButton.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
|
||||
var monthGrid = Page.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(100);
|
||||
}
|
||||
|
||||
/// <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(200);
|
||||
|
||||
await PickTimeInOpenPopover(hour, minute);
|
||||
|
||||
// Close popover by clicking the backdrop overlay
|
||||
await Page.Locator(".fixed.inset-0.z-40").ClickAsync(new LocatorClickOptions { Force = true });
|
||||
await Page.WaitForTimeoutAsync(100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick hour, minute, and AM/PM in an already-open time picker.
|
||||
/// Scopes all locators to the visible popover content to avoid backdrop interception.
|
||||
/// </summary>
|
||||
private async Task PickTimeInOpenPopover(int hour, int minute)
|
||||
{
|
||||
// The popover content sits in a z-50 absolutely positioned container
|
||||
var popoverContent = Page.Locator(".absolute.z-50");
|
||||
|
||||
// Convert to 12-hour format
|
||||
var isPm = hour >= 12;
|
||||
var hour12 = hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
|
||||
// Click the hour in the first scrollable column (within the popover)
|
||||
var hourText = hour12.ToString("D2");
|
||||
var hourColumn = popoverContent.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 (within the popover)
|
||||
var minuteText = minute.ToString("D2");
|
||||
var minuteColumn = popoverContent.Locator(".scrollbar-thin").Nth(1);
|
||||
await minuteColumn.Locator($"button:has-text('{minuteText}')").First.ClickAsync();
|
||||
await Page.WaitForTimeoutAsync(50);
|
||||
|
||||
// Click AM/PM (within the popover)
|
||||
var periodText = isPm ? "PM" : "AM";
|
||||
await popoverContent.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(7), "Expected 7 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");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Two-way binding
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[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 input should have the combined datetime value
|
||||
var value = await Input("input-appointment").InputValueAsync();
|
||||
Assert.That(value, Does.StartWith("2025-12-25T10:00"));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Validation — empty submit shows errors
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Submit_Shows_Validation_Errors()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
|
||||
// Wait for at least one error message to appear
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var errors = Page.Locator("p.text-destructive");
|
||||
var count = await errors.CountAsync();
|
||||
Assert.That(count, Is.GreaterThanOrEqualTo(7), "Expected at least 7 validation errors (one per required field)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Validation_Error_Shows_Name_Required()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var nameError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Name is required" });
|
||||
await Expect(nameError).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Validation_Error_Shows_Email_Required()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var emailError = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Email is required" });
|
||||
await Expect(emailError).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Validation — specific error messages
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Short_Name_Shows_Length_Error()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-name");
|
||||
await input.FillAsync("A");
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "2–100 characters" });
|
||||
await Expect(error).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invalid_Email_Shows_Error()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-email");
|
||||
await input.FillAsync("not-an-email");
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "Invalid email" });
|
||||
await Expect(error).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Short_Password_Shows_Error()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var input = Input("input-password");
|
||||
await input.FillAsync("123");
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
await Page.WaitForSelectorAsync("p.text-destructive", new PageWaitForSelectorOptions { Timeout = 5_000 });
|
||||
|
||||
var error = Page.Locator("p.text-destructive", new PageLocatorOptions { HasTextString = "8–64 characters" });
|
||||
await Expect(error).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Valid submission
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Test]
|
||||
public async Task Valid_Form_Shows_Success_Message()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
await Input("input-name").FillAsync("Jane Doe");
|
||||
await Input("input-email").FillAsync("jane@example.com");
|
||||
await Input("input-password").FillAsync("securepassword123");
|
||||
await Input("input-age").FillAsync("30");
|
||||
|
||||
// Use popover pickers for date/time fields
|
||||
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
|
||||
await SelectTimeAsync("trigger-preferredtime", 9, 30);
|
||||
|
||||
// DateTime: pick date and time via separate triggers
|
||||
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
|
||||
await SelectTimeAsync("trigger-appointment-time", 10, 0);
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
|
||||
var success = Page.Locator("[data-testid='success-message']");
|
||||
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
|
||||
await Expect(success).ToContainTextAsync("Form submitted successfully");
|
||||
await Expect(success).ToContainTextAsync("Jane Doe");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task No_Success_Message_Before_Submit()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
var success = Page.Locator("[data-testid='success-message']");
|
||||
await Expect(success).ToBeHiddenAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// 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
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[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
|
||||
await Btn("btn-reset").ClickAsync();
|
||||
|
||||
// Fields should be empty
|
||||
await Expect(Input("input-name")).ToHaveValueAsync("");
|
||||
await Expect(Input("input-email")).ToHaveValueAsync("");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_Button_Clears_Success_Message()
|
||||
{
|
||||
await GoToFormsAsync();
|
||||
|
||||
// Submit valid form
|
||||
await Input("input-name").FillAsync("Jane Doe");
|
||||
await Input("input-email").FillAsync("jane@example.com");
|
||||
await Input("input-password").FillAsync("securepassword123");
|
||||
await Input("input-age").FillAsync("30");
|
||||
|
||||
await SelectDateAsync("trigger-birthdate", new DateOnly(1995, 3, 15));
|
||||
await SelectTimeAsync("trigger-preferredtime", 9, 30);
|
||||
|
||||
// DateTime picker — date and time via separate triggers
|
||||
await SelectDateAsync("trigger-appointment-date", new DateOnly(2025, 12, 25));
|
||||
await SelectTimeAsync("trigger-appointment-time", 10, 0);
|
||||
|
||||
await Btn("btn-submit").ClickAsync();
|
||||
|
||||
var success = Page.Locator("[data-testid='success-message']");
|
||||
await Expect(success).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
|
||||
|
||||
// Reset
|
||||
await Btn("btn-reset").ClickAsync();
|
||||
|
||||
await Expect(success).ToBeHiddenAsync();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Input styling (base CSS classes present)
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
[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,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)");
|
||||
}
|
||||
}
|
||||
@@ -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,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.14" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
|
||||
<button type="@Type"
|
||||
disabled="@Disabled"
|
||||
class="@ComputedClass"
|
||||
@onclick="OnClick"
|
||||
@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; }
|
||||
|
||||
/// <summary>
|
||||
/// Visual variant — accepts any <see cref="ButtonVariant"/> constant
|
||||
/// or a custom Tailwind class string.
|
||||
/// </summary>
|
||||
[Parameter] public string Variant { get; set; } = ButtonVariant.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Size preset — accepts any <see cref="ButtonSize"/> constant
|
||||
/// or a custom Tailwind class string.
|
||||
/// </summary>
|
||||
[Parameter] public string Size { get; set; } = ButtonSize.Default;
|
||||
|
||||
[Parameter] public EventCallback<Microsoft.AspNetCore.Components.Web.MouseEventArgs> OnClick { get; set; }
|
||||
[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,258 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
|
||||
@* ── shadcn/ui-style calendar grid ────────────────────────────────────── *@
|
||||
<div class="p-4" @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"
|
||||
@onclick="Previous">
|
||||
<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"
|
||||
@onclick="ToggleMonthPicker">
|
||||
@_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"
|
||||
@onclick="ToggleYearPicker">
|
||||
@_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"
|
||||
@onclick="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>
|
||||
|
||||
@if (_showMonthPicker)
|
||||
{
|
||||
@* ── Month picker grid ── *@
|
||||
<div class="grid grid-cols-3 gap-2 py-2">
|
||||
@for (int m = 1; m <= 12; m++)
|
||||
{
|
||||
var month = m;
|
||||
var isCurrentMonth = _displayDate.Month == month;
|
||||
<button type="button"
|
||||
class="@($"h-9 rounded-md text-sm transition-colors cursor-pointer {(isCurrentMonth ? "bg-primary text-primary-foreground" : "hover:bg-accent hover:text-accent-foreground")}")"
|
||||
@onclick="() => SelectMonth(month)">
|
||||
@(new DateOnly(2000, month, 1).ToString("MMM"))
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (_showYearPicker)
|
||||
{
|
||||
@* ── Year picker grid ── *@
|
||||
<div class="grid grid-cols-4 gap-2 py-2 max-h-52 overflow-y-auto scrollbar-thin pr-1">
|
||||
@for (int y = _yearRangeStart; y <= _yearRangeEnd; y++)
|
||||
{
|
||||
var year = y;
|
||||
var isCurrentYear = _displayDate.Year == year;
|
||||
<button type="button"
|
||||
class="@($"h-9 rounded-md text-sm transition-colors cursor-pointer {(isCurrentYear ? "bg-primary text-primary-foreground" : "hover:bg-accent hover:text-accent-foreground")}")"
|
||||
@onclick="() => SelectYear(year)">
|
||||
@year
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Day-of-week headers ── *@
|
||||
<div class="grid grid-cols-7 gap-1 mb-1">
|
||||
@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>
|
||||
|
||||
@* ── Day grid ── *@
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
@foreach (var day in GetCalendarDays())
|
||||
{
|
||||
var d = day;
|
||||
var isSelected = SelectedDate.HasValue && d == SelectedDate.Value;
|
||||
var isToday = d == DateOnly.FromDateTime(DateTime.Today);
|
||||
var isOutside = d.Month != _displayDate.Month;
|
||||
|
||||
<button type="button"
|
||||
class="@DayCellClass(isSelected, isToday, isOutside)"
|
||||
disabled="@isOutside"
|
||||
@onclick="() => SelectDay(d)">
|
||||
@d.Day
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The currently selected date (two-way bindable).</summary>
|
||||
[Parameter] public DateOnly? SelectedDate { get; set; }
|
||||
[Parameter] public EventCallback<DateOnly?> SelectedDateChanged { get; set; }
|
||||
|
||||
/// <summary>Any extra HTML attributes (data-testid, etc.).</summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
private DateOnly _displayDate;
|
||||
private bool _showMonthPicker;
|
||||
private bool _showYearPicker;
|
||||
private int _yearRangeStart;
|
||||
private int _yearRangeEnd;
|
||||
|
||||
private static readonly string[] _dayHeaders = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_displayDate = SelectedDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
UpdateYearRange();
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// If the selected date changes externally, navigate to that month
|
||||
if (SelectedDate.HasValue && (SelectedDate.Value.Year != _displayDate.Year || SelectedDate.Value.Month != _displayDate.Month))
|
||||
{
|
||||
_displayDate = new DateOnly(SelectedDate.Value.Year, SelectedDate.Value.Month, 1);
|
||||
UpdateYearRange();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────
|
||||
|
||||
private void Previous()
|
||||
{
|
||||
if (_showYearPicker)
|
||||
{
|
||||
// Shift year range back by 20
|
||||
_yearRangeStart -= 20;
|
||||
_yearRangeEnd -= 20;
|
||||
}
|
||||
else if (_showMonthPicker)
|
||||
{
|
||||
_displayDate = new DateOnly(_displayDate.Year - 1, _displayDate.Month, 1);
|
||||
UpdateYearRange();
|
||||
}
|
||||
else
|
||||
{
|
||||
_displayDate = _displayDate.AddMonths(-1);
|
||||
UpdateYearRange();
|
||||
}
|
||||
}
|
||||
|
||||
private void Next()
|
||||
{
|
||||
if (_showYearPicker)
|
||||
{
|
||||
// Shift year range forward by 20
|
||||
_yearRangeStart += 20;
|
||||
_yearRangeEnd += 20;
|
||||
}
|
||||
else if (_showMonthPicker)
|
||||
{
|
||||
_displayDate = new DateOnly(_displayDate.Year + 1, _displayDate.Month, 1);
|
||||
UpdateYearRange();
|
||||
}
|
||||
else
|
||||
{
|
||||
_displayDate = _displayDate.AddMonths(1);
|
||||
UpdateYearRange();
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleMonthPicker()
|
||||
{
|
||||
_showMonthPicker = !_showMonthPicker;
|
||||
_showYearPicker = false;
|
||||
}
|
||||
|
||||
private void ToggleYearPicker()
|
||||
{
|
||||
_showYearPicker = !_showYearPicker;
|
||||
_showMonthPicker = false;
|
||||
}
|
||||
|
||||
private void SelectMonth(int month)
|
||||
{
|
||||
_displayDate = new DateOnly(_displayDate.Year, month, 1);
|
||||
_showMonthPicker = false;
|
||||
}
|
||||
|
||||
private void SelectYear(int year)
|
||||
{
|
||||
_displayDate = new DateOnly(year, _displayDate.Month, 1);
|
||||
_showYearPicker = false;
|
||||
UpdateYearRange();
|
||||
}
|
||||
|
||||
private void UpdateYearRange()
|
||||
{
|
||||
_yearRangeStart = _displayDate.Year - 10;
|
||||
_yearRangeEnd = _displayDate.Year + 10;
|
||||
}
|
||||
|
||||
// ── Day selection ────────────────────────────────────────────────────
|
||||
|
||||
private async Task SelectDay(DateOnly date)
|
||||
{
|
||||
SelectedDate = date;
|
||||
_displayDate = new DateOnly(date.Year, date.Month, 1);
|
||||
await SelectedDateChanged.InvokeAsync(date);
|
||||
}
|
||||
|
||||
// ── Calendar generation ──────────────────────────────────────────────
|
||||
|
||||
private List<DateOnly> GetCalendarDays()
|
||||
{
|
||||
var days = new List<DateOnly>();
|
||||
var firstOfMonth = new DateOnly(_displayDate.Year, _displayDate.Month, 1);
|
||||
var startOffset = (int)firstOfMonth.DayOfWeek; // Sunday = 0
|
||||
|
||||
// Previous month's trailing days
|
||||
var start = firstOfMonth.AddDays(-startOffset);
|
||||
// Always show 6 rows (42 cells) for consistent height
|
||||
for (int i = 0; i < 42; i++)
|
||||
days.Add(start.AddDays(i));
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static string DayCellClass(bool isSelected, bool isToday, bool isOutside)
|
||||
{
|
||||
const string baseClass = "h-9 w-9 inline-flex items-center justify-center rounded-md text-sm transition-colors cursor-pointer";
|
||||
|
||||
if (isSelected)
|
||||
return $"{baseClass} bg-primary text-primary-foreground font-semibold";
|
||||
if (isOutside)
|
||||
return $"{baseClass} text-muted-foreground/40 cursor-default";
|
||||
if (isToday)
|
||||
return $"{baseClass} bg-accent text-accent-foreground font-medium";
|
||||
|
||||
return $"{baseClass} hover:bg-accent hover:text-accent-foreground";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
@inherits InputBase<DateOnly?>
|
||||
|
||||
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
|
||||
<input type="date"
|
||||
id="@Id"
|
||||
name="@Name"
|
||||
value="@FormatValue()"
|
||||
class="sr-only"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
disabled="@Disabled"
|
||||
@attributes="AdditionalAttributes" />
|
||||
|
||||
<Popover @ref="_popover">
|
||||
<Trigger>
|
||||
<button type="button"
|
||||
disabled="@Disabled"
|
||||
data-testid="@($"trigger-{Id}")"
|
||||
class="@TriggerClass"
|
||||
@onclick="() => _popover?.Toggle()">
|
||||
@* Lucide calendar icon *@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
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" SelectedDateChanged="OnCalendarDateChanged" />
|
||||
</Content>
|
||||
</Popover>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Min { get; set; }
|
||||
[Parameter] public string? Max { get; set; }
|
||||
|
||||
private Popover? _popover;
|
||||
|
||||
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";
|
||||
|
||||
var validation = GetTriggerValidationClass();
|
||||
return string.IsNullOrEmpty(Class)
|
||||
? $"{baseClass} {validation}"
|
||||
: $"{baseClass} {validation} {Class}";
|
||||
}
|
||||
}
|
||||
|
||||
[CascadingParameter] private EditContext? _cascadedEditContext { get; set; }
|
||||
|
||||
private string GetTriggerValidationClass()
|
||||
{
|
||||
if (_cascadedEditContext is null || FieldId is not { } fi) return "border-input";
|
||||
return _cascadedEditContext.GetValidationMessages(fi).Any()
|
||||
? "border-destructive focus-visible:ring-destructive"
|
||||
: "border-input";
|
||||
}
|
||||
|
||||
private async Task OnCalendarDateChanged(DateOnly? date)
|
||||
{
|
||||
await SetValueAsync(date);
|
||||
_popover?.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
@inherits InputBase<DateTime?>
|
||||
|
||||
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
|
||||
<input type="datetime-local"
|
||||
id="@Id"
|
||||
name="@Name"
|
||||
value="@FormatValue()"
|
||||
class="sr-only"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
disabled="@Disabled"
|
||||
@attributes="AdditionalAttributes" />
|
||||
|
||||
@* ── Two side-by-side triggers: date field + time field ── *@
|
||||
<div class="flex gap-2">
|
||||
@* ── Date portion ── *@
|
||||
<Popover @ref="_datePopover">
|
||||
<Trigger>
|
||||
<button type="button"
|
||||
disabled="@Disabled"
|
||||
data-testid="@($"trigger-{Id}-date")"
|
||||
class="@TriggerClass"
|
||||
@onclick="() => _datePopover?.Toggle()">
|
||||
@* Lucide calendar icon *@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
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" SelectedDateChanged="OnDatePartChanged" />
|
||||
</Content>
|
||||
</Popover>
|
||||
|
||||
@* ── Time portion ── *@
|
||||
<Popover @ref="_timePopover">
|
||||
<Trigger>
|
||||
<button type="button"
|
||||
disabled="@Disabled"
|
||||
data-testid="@($"trigger-{Id}-time")"
|
||||
class="@TriggerClass"
|
||||
@onclick="() => _timePopover?.Toggle()">
|
||||
@* Lucide clock icon *@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
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" SelectedTimeChanged="OnTimePartChanged" Use12Hour="true" />
|
||||
</Content>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Min { get; set; }
|
||||
[Parameter] public string? Max { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Step in seconds. Use "1" for second precision, "60" (default) for minutes only.
|
||||
/// </summary>
|
||||
[Parameter] public string? Step { get; set; }
|
||||
|
||||
private Popover? _datePopover;
|
||||
private Popover? _timePopover;
|
||||
|
||||
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";
|
||||
|
||||
var validation = GetTriggerValidationClass();
|
||||
return string.IsNullOrEmpty(Class)
|
||||
? $"{baseClass} {validation}"
|
||||
: $"{baseClass} {validation} {Class}";
|
||||
}
|
||||
}
|
||||
|
||||
[CascadingParameter] private EditContext? _cascadedEditContext { get; set; }
|
||||
|
||||
private string GetTriggerValidationClass()
|
||||
{
|
||||
if (_cascadedEditContext is null || FieldId is not { } fi) return "border-input";
|
||||
return _cascadedEditContext.GetValidationMessages(fi).Any()
|
||||
? "border-destructive focus-visible:ring-destructive"
|
||||
: "border-input";
|
||||
}
|
||||
|
||||
private async Task OnDatePartChanged(DateOnly? date)
|
||||
{
|
||||
if (date is null) return;
|
||||
var timePart = SelectedTimeOnly ?? new TimeOnly(0, 0);
|
||||
await SetValueAsync(date.Value.ToDateTime(timePart));
|
||||
_datePopover?.Close();
|
||||
}
|
||||
|
||||
private async Task OnTimePartChanged(TimeOnly? time)
|
||||
{
|
||||
if (time is null) return;
|
||||
var datePart = SelectedDateOnly ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
await SetValueAsync(datePart.ToDateTime(time.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
|
||||
<div class="@ComputedClass">
|
||||
@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>
|
||||
}
|
||||
|
||||
@ChildContent
|
||||
|
||||
@if (!string.IsNullOrEmpty(Description))
|
||||
{
|
||||
<p class="text-[0.8rem] text-muted-foreground">@Description</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Error))
|
||||
{
|
||||
<p class="text-[0.8rem] font-medium text-destructive">@Error</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Label text displayed above the input.</summary>
|
||||
[Parameter] public string? Label { get; set; }
|
||||
|
||||
/// <summary>The id of the associated input (for the label's "for" attribute).</summary>
|
||||
[Parameter] public string? For { get; set; }
|
||||
|
||||
/// <summary>Help text displayed below the input.</summary>
|
||||
[Parameter] public string? Description { get; set; }
|
||||
|
||||
/// <summary>Validation error message displayed below the input.</summary>
|
||||
[Parameter] public string? Error { get; set; }
|
||||
|
||||
/// <summary>The input component(s) to render inside the field.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>Additional CSS classes for the wrapper div.</summary>
|
||||
[Parameter] public string? Class { get; set; }
|
||||
|
||||
/// <summary>Additional CSS classes for the label element.</summary>
|
||||
[Parameter] public string? LabelClass { get; set; }
|
||||
|
||||
private const string BaseClass = "space-y-2";
|
||||
|
||||
private string ComputedClass => string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace Enciphered.Blazor.UIComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base for all form input components. Provides two-way binding
|
||||
/// (<see cref="Value"/>/<see cref="ValueChanged"/>/<see cref="ValueExpression"/>),
|
||||
/// optional <see cref="EditContext"/> integration for validation CSS,
|
||||
/// and attribute splatting via <see cref="AdditionalAttributes"/>.
|
||||
/// </summary>
|
||||
public abstract class InputBase<TValue> : ComponentBase
|
||||
{
|
||||
// ── Two-way binding triad ────────────────────────────────────────────────
|
||||
|
||||
[Parameter] public TValue? Value { get; set; }
|
||||
[Parameter] public EventCallback<TValue> ValueChanged { get; set; }
|
||||
[Parameter] public Expression<Func<TValue>>? ValueExpression { get; set; }
|
||||
|
||||
// ── Common parameters ────────────────────────────────────────────────────
|
||||
|
||||
[Parameter] public string? Id { get; set; }
|
||||
[Parameter] public string? 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; }
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary HTML attributes forwarded to the root element via <c>@attributes</c>.
|
||||
/// Allows consumers to pass <c>required</c>, <c>aria-*</c>, <c>data-*</c>,
|
||||
/// <c>maxlength</c>, etc. without the component needing to declare them.
|
||||
/// </summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
// ── EditContext integration (optional) ────────────────────────────────────
|
||||
|
||||
[CascadingParameter] private EditContext? EditContext { get; set; }
|
||||
|
||||
private FieldIdentifier? _fieldIdentifier;
|
||||
|
||||
protected FieldIdentifier? FieldId => _fieldIdentifier;
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ValueExpression is not null)
|
||||
_fieldIdentifier = FieldIdentifier.Create(ValueExpression);
|
||||
}
|
||||
|
||||
// ── Value helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Called by derived components when the user changes the value.
|
||||
/// Fires <see cref="ValueChanged"/> and notifies the <see cref="EditContext"/>.
|
||||
/// </summary>
|
||||
protected async Task SetValueAsync(TValue? value)
|
||||
{
|
||||
Value = value;
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
|
||||
if (EditContext is not null && _fieldIdentifier is FieldIdentifier fi)
|
||||
EditContext.NotifyFieldChanged(fi);
|
||||
}
|
||||
|
||||
// ── CSS helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private const string BaseInputClass =
|
||||
"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";
|
||||
|
||||
/// <summary>
|
||||
/// Computes the full CSS class string: base + validation state + consumer override.
|
||||
/// </summary>
|
||||
protected string ComputedClass
|
||||
{
|
||||
get
|
||||
{
|
||||
var validation = GetValidationClass();
|
||||
return string.IsNullOrEmpty(Class)
|
||||
? string.IsNullOrEmpty(validation) ? BaseInputClass : $"{BaseInputClass} {validation}"
|
||||
: string.IsNullOrEmpty(validation) ? $"{BaseInputClass} {Class}" : $"{BaseInputClass} {validation} {Class}";
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetValidationClass()
|
||||
{
|
||||
if (EditContext is null || _fieldIdentifier is not FieldIdentifier fi)
|
||||
return null;
|
||||
|
||||
var isValid = !EditContext.GetValidationMessages(fi).Any();
|
||||
return isValid ? "border-input" : "border-destructive focus-visible:ring-destructive";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
@inherits InputBase<double?>
|
||||
|
||||
<div class="relative flex items-center">
|
||||
<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"
|
||||
@oninput="OnInput"
|
||||
@attributes="AdditionalAttributes" />
|
||||
|
||||
@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"
|
||||
@onclick="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"
|
||||
@onclick="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 StepValue => double.TryParse(Step, out var s) ? s : 1;
|
||||
private double? MinValue => double.TryParse(Min, out var m) ? m : null;
|
||||
private double? MaxValue => double.TryParse(Max, out var m) ? m : null;
|
||||
|
||||
private bool IsAtMax => Value.HasValue && MaxValue.HasValue && Value.Value >= MaxValue.Value;
|
||||
private bool IsAtMin => Value.HasValue && MinValue.HasValue && Value.Value <= MinValue.Value;
|
||||
|
||||
private async Task OnInput(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString();
|
||||
if (double.TryParse(raw, out var parsed))
|
||||
await SetValueAsync(Clamp(parsed));
|
||||
else if (string.IsNullOrWhiteSpace(raw))
|
||||
await SetValueAsync(null);
|
||||
}
|
||||
|
||||
private async Task Increment()
|
||||
{
|
||||
var current = Value ?? 0;
|
||||
await SetValueAsync(Clamp(current + StepValue));
|
||||
}
|
||||
|
||||
private async Task Decrement()
|
||||
{
|
||||
var current = Value ?? 0;
|
||||
await SetValueAsync(Clamp(current - StepValue));
|
||||
}
|
||||
|
||||
private double Clamp(double value)
|
||||
{
|
||||
if (MinValue.HasValue && value < MinValue.Value) return MinValue.Value;
|
||||
if (MaxValue.HasValue && value > MaxValue.Value) return MaxValue.Value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
|
||||
@* ── Generic popover: trigger + dropdown panel ───────────────────────── *@
|
||||
<div class="relative inline-block w-full" @attributes="AdditionalAttributes">
|
||||
@Trigger
|
||||
|
||||
@if (_open)
|
||||
{
|
||||
@* Backdrop to close on outside click *@
|
||||
<div class="fixed inset-0 z-40" @onclick="Close" @onclick:stopPropagation></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"
|
||||
@onclick:stopPropagation>
|
||||
@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.</summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
private bool _open;
|
||||
|
||||
/// <summary>Toggle the popover open/closed. Call from the trigger button.</summary>
|
||||
public void Toggle()
|
||||
{
|
||||
_open = !_open;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Close the popover.</summary>
|
||||
public void Close()
|
||||
{
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Open the popover.</summary>
|
||||
public void Open()
|
||||
{
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Whether the popover is currently open.</summary>
|
||||
public bool IsOpen => _open;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
@inherits InputBase<string>
|
||||
|
||||
<input id="@Id"
|
||||
name="@Name"
|
||||
type="@Type"
|
||||
value="@Value"
|
||||
placeholder="@Placeholder"
|
||||
disabled="@Disabled"
|
||||
readonly="@ReadOnly"
|
||||
class="@ComputedClass"
|
||||
@oninput="OnInput"
|
||||
@attributes="AdditionalAttributes" />
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// HTML input type. Defaults to "text".
|
||||
/// Supports: text, email, password, url, tel, search.
|
||||
/// </summary>
|
||||
[Parameter] public string Type { get; set; } = "text";
|
||||
|
||||
private async Task OnInput(ChangeEventArgs e)
|
||||
{
|
||||
await SetValueAsync(e.Value?.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
@inherits InputBase<TimeOnly?>
|
||||
|
||||
@* Hidden native input preserves form semantics, name, id, and data-testid for tests *@
|
||||
<input type="time"
|
||||
id="@Id"
|
||||
name="@Name"
|
||||
value="@FormatValue()"
|
||||
class="sr-only"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
disabled="@Disabled"
|
||||
@attributes="AdditionalAttributes" />
|
||||
|
||||
<Popover @ref="_popover">
|
||||
<Trigger>
|
||||
<button type="button"
|
||||
disabled="@Disabled"
|
||||
data-testid="@($"trigger-{Id}")"
|
||||
class="@TriggerClass"
|
||||
@onclick="() => _popover?.Toggle()">
|
||||
@* Lucide clock icon *@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
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" SelectedTimeChanged="OnTimeChanged" Use12Hour="true" MinuteStep="@ParsedMinuteStep" />
|
||||
</Content>
|
||||
</Popover>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Step in seconds. Use "1" for second precision, "60" (default) for minutes only.
|
||||
/// </summary>
|
||||
[Parameter] public string? Step { get; set; }
|
||||
|
||||
/// <summary>Use 12-hour format with AM/PM. Default is true.</summary>
|
||||
[Parameter] public bool Use12Hour { get; set; } = true;
|
||||
|
||||
private Popover? _popover;
|
||||
|
||||
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";
|
||||
|
||||
var validation = GetTriggerValidationClass();
|
||||
return string.IsNullOrEmpty(Class)
|
||||
? $"{baseClass} {validation}"
|
||||
: $"{baseClass} {validation} {Class}";
|
||||
}
|
||||
}
|
||||
|
||||
[CascadingParameter] private EditContext? _cascadedEditContext { get; set; }
|
||||
|
||||
private string GetTriggerValidationClass()
|
||||
{
|
||||
if (_cascadedEditContext is null || FieldId is not { } fi) return "border-input";
|
||||
return _cascadedEditContext.GetValidationMessages(fi).Any()
|
||||
? "border-destructive focus-visible:ring-destructive"
|
||||
: "border-input";
|
||||
}
|
||||
|
||||
private async Task OnTimeChanged(TimeOnly? time)
|
||||
{
|
||||
await SetValueAsync(time);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
@namespace Enciphered.Blazor.UIComponents
|
||||
|
||||
@* ── shadcn/ui-style time picker with scrollable columns ─────────────── *@
|
||||
<div class="flex items-stretch gap-1 p-4" @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 < (_use12Hour ? 12 : 24); h++)
|
||||
{
|
||||
var hour = _use12Hour ? (h == 0 ? 12 : h) : h;
|
||||
var hourValue = h;
|
||||
var isSelected = _selectedHour == hourValue;
|
||||
<button type="button"
|
||||
class="@TimeItemClass(isSelected)"
|
||||
@onclick="() => SelectHour(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 += _minuteStep)
|
||||
{
|
||||
var minute = m;
|
||||
var isSelected = _selectedMinute == minute;
|
||||
<button type="button"
|
||||
class="@TimeItemClass(isSelected)"
|
||||
@onclick="() => SelectMinute(minute)">
|
||||
@minute.ToString("D2")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_use12Hour)
|
||||
{
|
||||
<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)"
|
||||
@onclick='() => SelectPeriod(false)'>
|
||||
AM
|
||||
</button>
|
||||
<button type="button"
|
||||
class="@TimeItemClass(_isPm)"
|
||||
@onclick='() => SelectPeriod(true)'>
|
||||
PM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The currently selected time (two-way bindable).</summary>
|
||||
[Parameter] public TimeOnly? SelectedTime { get; set; }
|
||||
[Parameter] public EventCallback<TimeOnly?> SelectedTimeChanged { get; set; }
|
||||
|
||||
/// <summary>Use 12-hour format with AM/PM. Default is false (24-hour).</summary>
|
||||
[Parameter] public bool Use12Hour { get; set; }
|
||||
|
||||
/// <summary>Minute step interval. Default is 1.</summary>
|
||||
[Parameter] public int MinuteStep { get; set; } = 1;
|
||||
|
||||
/// <summary>Any extra HTML attributes.</summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public Dictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
private int _selectedHour;
|
||||
private int _selectedMinute;
|
||||
private bool _isPm;
|
||||
private bool _use12Hour;
|
||||
private int _minuteStep = 1;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_use12Hour = Use12Hour;
|
||||
_minuteStep = MinuteStep < 1 ? 1 : MinuteStep;
|
||||
ApplyFromValue();
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_use12Hour = Use12Hour;
|
||||
_minuteStep = MinuteStep < 1 ? 1 : MinuteStep;
|
||||
ApplyFromValue();
|
||||
}
|
||||
|
||||
private void ApplyFromValue()
|
||||
{
|
||||
if (SelectedTime is { } t)
|
||||
{
|
||||
if (_use12Hour)
|
||||
{
|
||||
_isPm = t.Hour >= 12;
|
||||
_selectedHour = t.Hour % 12;
|
||||
_selectedMinute = t.Minute;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedHour = t.Hour;
|
||||
_selectedMinute = t.Minute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SelectHour(int hour)
|
||||
{
|
||||
_selectedHour = hour;
|
||||
await EmitValue();
|
||||
}
|
||||
|
||||
private async Task SelectMinute(int minute)
|
||||
{
|
||||
_selectedMinute = minute;
|
||||
await EmitValue();
|
||||
}
|
||||
|
||||
private async Task SelectPeriod(bool isPm)
|
||||
{
|
||||
_isPm = isPm;
|
||||
await EmitValue();
|
||||
}
|
||||
|
||||
private async Task EmitValue()
|
||||
{
|
||||
var hour = _use12Hour ? (_selectedHour % 12) + (_isPm ? 12 : 0) : _selectedHour;
|
||||
var time = new TimeOnly(hour, _selectedMinute);
|
||||
SelectedTime = time;
|
||||
await SelectedTimeChanged.InvokeAsync(time);
|
||||
}
|
||||
|
||||
private static string TimeItemClass(bool isSelected)
|
||||
{
|
||||
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,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
|
||||
@@ -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,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
|
||||
File diff suppressed because one or more lines are too long
@@ -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,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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@source "../**/*.{razor,cs,html}";
|
||||
|
||||
@theme {
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
--color-card: oklch(0.17 0 0);
|
||||
--color-card-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.21 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-border: oklch(0.3 0 0);
|
||||
--color-primary: oklch(0.985 0 0);
|
||||
--color-primary-foreground: oklch(0.205 0 0);
|
||||
--color-secondary: oklch(0.269 0 0);
|
||||
--color-secondary-foreground: oklch(0.985 0 0);
|
||||
--color-accent: oklch(0.269 0 0);
|
||||
--color-accent-foreground: oklch(0.985 0 0);
|
||||
--color-destructive: oklch(0.396 0.141 25.723);
|
||||
--color-ring: oklch(0.556 0 0);
|
||||
--color-sidebar: oklch(0.15 0 0);
|
||||
--color-sidebar-foreground: oklch(0.985 0 0);
|
||||
--color-sidebar-accent: oklch(0.269 0 0);
|
||||
--color-sidebar-border: oklch(0.3 0 0);
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
h1 {
|
||||
@apply text-3xl font-bold tracking-tight;
|
||||
}
|
||||
h2 {
|
||||
@apply text-2xl font-semibold tracking-tight;
|
||||
}
|
||||
h3 {
|
||||
@apply text-xl font-semibold tracking-tight;
|
||||
}
|
||||
p {
|
||||
@apply leading-7;
|
||||
}
|
||||
table {
|
||||
@apply w-full caption-bottom text-sm;
|
||||
}
|
||||
thead {
|
||||
@apply border-b border-border;
|
||||
}
|
||||
th {
|
||||
@apply h-10 px-2 text-left align-middle font-medium text-muted-foreground;
|
||||
}
|
||||
td {
|
||||
@apply p-2 align-middle;
|
||||
}
|
||||
tr {
|
||||
@apply border-b border-border transition-colors hover:bg-muted/50;
|
||||
}
|
||||
}
|
||||
Generated
+995
@@ -0,0 +1,995 @@
|
||||
{
|
||||
"name": "enciphered.blazor.uicomponents",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "enciphered.blazor.uicomponents",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.2.2",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||
"@parcel/watcher-win32-x64": "2.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-ia32": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/cli": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz",
|
||||
"integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
"@tailwindcss/node": "4.2.2",
|
||||
"@tailwindcss/oxide": "4.2.2",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"mri": "^1.2.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"tailwindcss": "dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.19.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
|
||||
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
|
||||
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
|
||||
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
|
||||
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
|
||||
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
|
||||
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
|
||||
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.8.1",
|
||||
"@emnapi/runtime": "^1.8.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
|
||||
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -4,18 +4,22 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"build:lib": "npx @tailwindcss/cli -i ./Enciphered.Blazor.UIComponents/Styles/app.css -o ./Enciphered.Blazor.UIComponents/wwwroot/css/app.css --minify",
|
||||
"build:demo": "npx @tailwindcss/cli -i ./Enciphered.Blazor.UIComponents.Demo/Styles/app.css -o ./Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css --minify",
|
||||
"build": "npm run build:lib && npm run build:demo",
|
||||
"watch:lib": "npx @tailwindcss/cli -i ./Enciphered.Blazor.UIComponents/Styles/app.css -o ./Enciphered.Blazor.UIComponents/wwwroot/css/app.css --watch",
|
||||
"watch:demo": "npx @tailwindcss/cli -i ./Enciphered.Blazor.UIComponents.Demo/Styles/app.css -o ./Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.nciphered.com/shaamilahmed/Enciphered.Blazor.UIComponents.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.9",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.2.2"
|
||||
"@tailwindcss/cli": "^4.2.2",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user