diff --git a/.gitignore b/.gitignore index 5a26525..113026f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +[Ll]og/ +[Ll]ogs/ + +## NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ + +## OS files +Thumbs.db +ehthumbs.db +Desktop.ini +.DS_Store diff --git a/Components/App.razor b/Components/App.razor deleted file mode 100644 index cbca62b..0000000 --- a/Components/App.razor +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor deleted file mode 100644 index bb2a579..0000000 --- a/Components/Layout/MainLayout.razor +++ /dev/null @@ -1,77 +0,0 @@ -@inherits LayoutComponentBase - -
- - @* Desktop sidebar — collapsible *@ - - - @* Mobile overlay *@ - @if (mobileOpen) - { -
- @* Backdrop *@ -
- - @* Drawer *@ - -
- } - - @* Main content *@ -
- @* Top bar *@ -
- @* Mobile menu button *@ - - - @* Desktop collapse toggle *@ - - -
- Enciphered UI Components -
- - About - -
- - @* Page content *@ -
- @Body -
-
-
- -@code { - private bool sidebarCollapsed; - private bool mobileOpen; - - private void ToggleSidebar() => sidebarCollapsed = !sidebarCollapsed; - private void OpenMobile() => mobileOpen = true; - private void CloseMobile() => mobileOpen = false; -} diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor deleted file mode 100644 index 3a252f2..0000000 --- a/Components/Layout/NavMenu.razor +++ /dev/null @@ -1,73 +0,0 @@ -@* Sidebar header *@ -
- -
- -@* Navigation *@ -
- @if (!Collapsed) - { -

- Navigation -

- } - - - - - - - @if (!Collapsed) - { - Home - } - - - - - - - @if (!Collapsed) - { - Weather - } - -
- -@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(); - } -} - diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor deleted file mode 100644 index 9001e0b..0000000 --- a/Components/Pages/Home.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -Home - -

Hello, world!

- -Welcome to your new app. diff --git a/Components/Pages/NotFound.razor b/Components/Pages/NotFound.razor deleted file mode 100644 index 917ada1..0000000 --- a/Components/Pages/NotFound.razor +++ /dev/null @@ -1,5 +0,0 @@ -@page "/not-found" -@layout MainLayout - -

Not Found

-

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/Components/Routes.razor b/Components/Routes.razor deleted file mode 100644 index de342b1..0000000 --- a/Components/Routes.razor +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/App.razor b/Enciphered.Blazor.UIComponents.Demo/Components/App.razor new file mode 100644 index 0000000..3e1e747 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Components/App.razor @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Layout/MainLayout.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..0e92629 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Components/Layout/MainLayout.razor @@ -0,0 +1,87 @@ +@inherits LayoutComponentBase +@using Enciphered.Blazor.UIComponents + + + + +
+
+ + Enciphered +
+ Enciphered UI +
+
+ + + + + + + + + + + + + Home + + + + + + + + + + Counter + + + + + + + + Weather + + + + + + + + + Forms + + + + + + +
+ +
+ © 2026 Enciphered +
+
+
+
+ + +
+ +

Demo App

+
+ +
+
+ +
+ @Body +
+
+
diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Counter.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Counter.razor new file mode 100644 index 0000000..1a4f8e7 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Counter.razor @@ -0,0 +1,19 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/Components/Pages/Error.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Error.razor similarity index 100% rename from Components/Pages/Error.razor rename to Enciphered.Blazor.UIComponents.Demo/Components/Pages/Error.razor diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Pages/FormsDemo.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/FormsDemo.razor new file mode 100644 index 0000000..9aaa1bd --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/FormsDemo.razor @@ -0,0 +1,126 @@ +@page "/forms" +@rendermode InteractiveServer +@using System.ComponentModel.DataAnnotations + +Forms + +
+
+

Forms Demo

+

All input components with DataAnnotations validation.

+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ + @if (_submitted) + { +
+

✓ Form submitted successfully

+

Name: @_submittedName

+
+ } +
+ +@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; } + } +} diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Home.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Home.razor new file mode 100644 index 0000000..5bfc55f --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Home.razor @@ -0,0 +1,8 @@ +@page "/" + +Home + +
+

Welcome

+

This is the Enciphered Blazor UI Components demo app.

+
diff --git a/Components/Pages/Weather.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Weather.razor similarity index 96% rename from Components/Pages/Weather.razor rename to Enciphered.Blazor.UIComponents.Demo/Components/Pages/Weather.razor index f437e5e..381bbd2 100644 --- a/Components/Pages/Weather.razor +++ b/Enciphered.Blazor.UIComponents.Demo/Components/Pages/Weather.razor @@ -18,7 +18,7 @@ else Date Temp. (C) - Temp. (F) + Temp. (F) Summary diff --git a/Enciphered.Blazor.UIComponents.Demo/Components/Routes.razor b/Enciphered.Blazor.UIComponents.Demo/Components/Routes.razor new file mode 100644 index 0000000..1b80547 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Components/Routes.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/Components/_Imports.razor b/Enciphered.Blazor.UIComponents.Demo/Components/_Imports.razor similarity index 78% rename from Components/_Imports.razor rename to Enciphered.Blazor.UIComponents.Demo/Components/_Imports.razor index 7ed3f68..230977f 100644 --- a/Components/_Imports.razor +++ b/Enciphered.Blazor.UIComponents.Demo/Components/_Imports.razor @@ -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 diff --git a/Enciphered.Blazor.UIComponents.Demo/Enciphered.Blazor.UIComponents.Demo.csproj b/Enciphered.Blazor.UIComponents.Demo/Enciphered.Blazor.UIComponents.Demo.csproj new file mode 100644 index 0000000..4336ecc --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Enciphered.Blazor.UIComponents.Demo.csproj @@ -0,0 +1,17 @@ + + + + + + + + net9.0 + enable + enable + + + + + + + diff --git a/Program.cs b/Enciphered.Blazor.UIComponents.Demo/Program.cs similarity index 75% rename from Program.cs rename to Enciphered.Blazor.UIComponents.Demo/Program.cs index 5d166e6..b65e9c0 100644 --- a/Program.cs +++ b/Enciphered.Blazor.UIComponents.Demo/Program.cs @@ -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() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(Enciphered.Blazor.UIComponents.SidebarProvider).Assembly); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Enciphered.Blazor.UIComponents.Demo/Properties/launchSettings.json similarity index 81% rename from Properties/launchSettings.json rename to Enciphered.Blazor.UIComponents.Demo/Properties/launchSettings.json index 14690c5..63305cf 100644 --- a/Properties/launchSettings.json +++ b/Enciphered.Blazor.UIComponents.Demo/Properties/launchSettings.json @@ -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" } diff --git a/Enciphered.Blazor.UIComponents.Demo/Styles/app.css b/Enciphered.Blazor.UIComponents.Demo/Styles/app.css new file mode 100644 index 0000000..d3c2799 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/Styles/app.css @@ -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); +} diff --git a/appsettings.Development.json b/Enciphered.Blazor.UIComponents.Demo/appsettings.Development.json similarity index 100% rename from appsettings.Development.json rename to Enciphered.Blazor.UIComponents.Demo/appsettings.Development.json diff --git a/appsettings.json b/Enciphered.Blazor.UIComponents.Demo/appsettings.json similarity index 100% rename from appsettings.json rename to Enciphered.Blazor.UIComponents.Demo/appsettings.json diff --git a/Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css b/Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css new file mode 100644 index 0000000..4467541 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Demo/wwwroot/css/app.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-lg:32rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-md:.375rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius:var(--radius);--color-background:var(--background);--color-foreground:var(--foreground);--color-card:var(--card);--color-card-foreground:var(--card-foreground);--color-muted-foreground:var(--muted-foreground);--color-border:var(--border-color);--color-input:var(--input);--color-sidebar-foreground:var(--sidebar-foreground)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.end{inset-inline-end:var(--spacing)}.mt-1{margin-top:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.hidden{display:none}.table{display:table}.aspect-square{aspect-ratio:1}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.h-14{height:calc(var(--spacing) * 14)}.min-h-svh{min-height:100svh}.max-w-lg{max-width:var(--container-lg)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-border{border-color:var(--color-border)}.border-input{border-color:var(--color-input)}.bg-background{background-color:var(--color-background)}.bg-card{background-color:var(--color-card)}.p-4{padding:calc(var(--spacing) * 4)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.pt-2{padding-top:calc(var(--spacing) * 2)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-card-foreground{color:var(--color-card-foreground)}.text-foreground{color:var(--color-foreground)}.text-muted-foreground{color:var(--color-muted-foreground)}.text-sidebar-foreground\/50{color:var(--color-sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/50{color:color-mix(in oklab, var(--color-sidebar-foreground) 50%, transparent)}}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.group-data-\[state\=collapsed\]\:group-data-\[mobile\=false\]\:hidden:is(:where(.group)[data-state=collapsed] *):is(:where(.group)[data-mobile=false] *){display:none}@media (min-width:48rem){.md\:hidden{display:none}.md\:p-6{padding:calc(var(--spacing) * 6)}}.dark\:block:where(.dark,.dark *){display:block}.dark\:hidden:where(.dark,.dark *){display:none}}:root{--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(20.5% 0 0);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(98.5% 0 0);--border-color:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--radius:.625rem;--sidebar-background:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(14.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(14.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(98.5% 0 0);--primary-foreground:oklch(20.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(57.7% .245 27.325);--destructive-foreground:oklch(98.5% 0 0);--border-color:oklch(26.9% 0 0);--input:oklch(26.9% 0 0);--ring:oklch(43.9% 0 0);--sidebar-background:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(98.5% 0 0);--sidebar-primary-foreground:oklch(20.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(26.9% 0 0);--sidebar-ring:oklch(55.6% 0 0)}input[type=date]::-webkit-calendar-picker-indicator{-webkit-appearance:none;display:none}input[type=time]::-webkit-calendar-picker-indicator{-webkit-appearance:none;display:none}input[type=datetime-local]::-webkit-calendar-picker-indicator{-webkit-appearance:none;display:none}input[type=date]::-webkit-date-and-time-value{text-align:left}input[type=time]::-webkit-date-and-time-value{text-align:left}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}.scrollbar-thin{scrollbar-width:thin;scrollbar-color:var(--muted) transparent}.scrollbar-thin::-webkit-scrollbar{width:6px}.scrollbar-thin::-webkit-scrollbar-track{background:0 0;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)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false} \ No newline at end of file diff --git a/wwwroot/enci.svg b/Enciphered.Blazor.UIComponents.Demo/wwwroot/enci.svg similarity index 100% rename from wwwroot/enci.svg rename to Enciphered.Blazor.UIComponents.Demo/wwwroot/enci.svg diff --git a/wwwroot/enci_white.svg b/Enciphered.Blazor.UIComponents.Demo/wwwroot/enci_white.svg similarity index 100% rename from wwwroot/enci_white.svg rename to Enciphered.Blazor.UIComponents.Demo/wwwroot/enci_white.svg diff --git a/wwwroot/favicon.png b/Enciphered.Blazor.UIComponents.Demo/wwwroot/favicon.png similarity index 100% rename from wwwroot/favicon.png rename to Enciphered.Blazor.UIComponents.Demo/wwwroot/favicon.png diff --git a/Enciphered.Blazor.UIComponents.Tests/.runsettings b/Enciphered.Blazor.UIComponents.Tests/.runsettings new file mode 100644 index 0000000..1bb6d43 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/.runsettings @@ -0,0 +1,9 @@ + + + + chromium + + true + + + diff --git a/Enciphered.Blazor.UIComponents.Tests/DemoServerFixture.cs b/Enciphered.Blazor.UIComponents.Tests/DemoServerFixture.cs new file mode 100644 index 0000000..99b6f99 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/DemoServerFixture.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Enciphered.Blazor.UIComponents.Tests; + +/// +/// Launches the demo Blazor app as a separate process on a random free port. +/// Shared across all tests in the assembly via [SetUpFixture]. +/// +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; + } +} diff --git a/Enciphered.Blazor.UIComponents.Tests/Enciphered.Blazor.UIComponents.Tests.csproj b/Enciphered.Blazor.UIComponents.Tests/Enciphered.Blazor.UIComponents.Tests.csproj new file mode 100644 index 0000000..a2e8204 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/Enciphered.Blazor.UIComponents.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + diff --git a/Enciphered.Blazor.UIComponents.Tests/FormsTests.cs b/Enciphered.Blazor.UIComponents.Tests/FormsTests.cs new file mode 100644 index 0000000..adf1226 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/FormsTests.cs @@ -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}']"); + + /// + /// Select a date via the calendar popover. + /// Opens the trigger, uses month/year pickers to navigate, then clicks the day. + /// + 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); + } + + /// + /// Navigate the open calendar to a specific month/year using the month and year pickers. + /// + 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); + } + + /// + /// Select a time via the time picker popover. + /// Opens the trigger, clicks the hour, minute, and AM/PM. + /// + 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); + } + + /// + /// Pick hour, minute, and AM/PM in an already-open time picker. + /// Scopes all locators to the visible popover content to avoid backdrop interception. + /// + 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"); + } +} diff --git a/Enciphered.Blazor.UIComponents.Tests/GlobalSetup.cs b/Enciphered.Blazor.UIComponents.Tests/GlobalSetup.cs new file mode 100644 index 0000000..19f9927 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/GlobalSetup.cs @@ -0,0 +1,24 @@ +namespace Enciphered.Blazor.UIComponents.Tests; + +/// +/// Assembly-level setup: boots the demo server once before any test runs. +/// +[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(); + } +} diff --git a/Enciphered.Blazor.UIComponents.Tests/PlaywrightTestBase.cs b/Enciphered.Blazor.UIComponents.Tests/PlaywrightTestBase.cs new file mode 100644 index 0000000..02e21c7 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/PlaywrightTestBase.cs @@ -0,0 +1,47 @@ +using Microsoft.Playwright; + +namespace Enciphered.Blazor.UIComponents.Tests; + +/// +/// Base class for Playwright tests. Creates a fresh browser context per test. +/// +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(); + } +} diff --git a/Enciphered.Blazor.UIComponents.Tests/SidebarTests.cs b/Enciphered.Blazor.UIComponents.Tests/SidebarTests.cs new file mode 100644 index 0000000..87d613f --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/SidebarTests.cs @@ -0,0 +1,590 @@ +using Microsoft.Playwright; + +namespace Enciphered.Blazor.UIComponents.Tests; + +[TestFixture] +public class SidebarTests : PlaywrightTestBase +{ + /// + /// Helper: navigate to home and wait for sidebar JS to initialize. + /// + 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("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("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("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("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("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("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("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(); + } +} diff --git a/Enciphered.Blazor.UIComponents.Tests/ThemeToggleTests.cs b/Enciphered.Blazor.UIComponents.Tests/ThemeToggleTests.cs new file mode 100644 index 0000000..3bde662 --- /dev/null +++ b/Enciphered.Blazor.UIComponents.Tests/ThemeToggleTests.cs @@ -0,0 +1,324 @@ +using Microsoft.Playwright; + +namespace Enciphered.Blazor.UIComponents.Tests; + +[TestFixture] +public class ThemeToggleTests : PlaywrightTestBase +{ + /// + /// Navigate to home, wait for sidebar JS + darkmode JS to finish initializing. + /// + 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 + }); + } + + /// + /// Click the toggle and wait for the dark class to be added to <html>. + /// + 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 }); + } + + /// + /// Click the toggle and wait for the dark class to be removed from <html>. + /// + 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( + "() => 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("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("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( + "() => document.documentElement.classList.contains('dark')"); + Assert.That(hasDark, Is.True, "Clicking toggle should add 'dark' class to "); + } + + [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("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("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("() => 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( + "() => document.documentElement.classList.contains('dark')"); + Assert.That(hasDark, Is.False, "Double-clicking toggle should return to light mode"); + + var stored = await Page.EvaluateAsync("() => 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("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("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