feat(theme): ThemeShell canonical side-rail

Add ThemeShell.razor (regular component, not LayoutComponentBase) with
Product, Accent, Logo, Nav, RailFooter, and ChildContent parameters.
Accent uses nullable AccentStyle so the style attribute is entirely
absent when null. Composes BrandBar inside .side-rail, wraps page in
<main class="page">. Add ThemeShellTests.cs (4 tests: product/nav/body,
accent sets css var, no-accent emits no style, RailFooter). All 18 tests
green, 0 build warnings.
This commit is contained in:
Joseph Doherty
2026-06-01 04:53:52 -04:00
parent 75e58085d1
commit b09de9b777
2 changed files with 75 additions and 0 deletions
@@ -0,0 +1,32 @@
@* Components/ThemeShell.razor — the one canonical side-rail chassis.
Not a LayoutComponentBase: the app's thin MainLayout delegates to this. *@
@namespace ZB.MOM.WW.Theme
<div class="app-shell d-flex flex-column flex-lg-row" style="@AccentStyle">
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button" data-bs-toggle="collapse" data-bs-target="#theme-rail"
aria-controls="theme-rail" aria-expanded="false" aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="theme-rail">
<nav class="side-rail">
<BrandBar Product="@Product" Logo="@Logo" />
@Nav
@if (RailFooter is not null)
{
<div class="rail-foot">@RailFooter</div>
}
</nav>
</div>
<main class="page">@ChildContent</main>
</div>
@code {
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
[Parameter] public string? Accent { get; set; }
[Parameter] public RenderFragment? Logo { get; set; }
[Parameter] public RenderFragment? Nav { get; set; }
[Parameter] public RenderFragment? RailFooter { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
private string? AccentStyle => Accent is null ? null : $"--accent: {Accent}";
}
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeShellTests : TestContext
{
[Fact]
public void Renders_product_nav_and_body()
{
var cut = RenderComponent<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
.AddChildContent("<div class='pagebody'>BODY</div>"));
Assert.NotNull(cut.Find(".side-rail .brand"));
Assert.Contains("OtOpcUa", cut.Markup);
Assert.NotNull(cut.Find(".side-rail .rail-link"));
Assert.NotNull(cut.Find("main.page .pagebody"));
}
[Fact]
public void Accent_sets_css_variable_on_shell_root()
{
var cut = RenderComponent<ThemeShell>(p => p
.Add(x => x.Product, "ScadaBridge")
.Add(x => x.Accent, "#2f855a"));
var shell = cut.Find(".app-shell");
Assert.Contains("--accent: #2f855a", shell.GetAttribute("style"));
}
[Fact]
public void No_accent_emits_no_style()
{
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
Assert.False(cut.Find(".app-shell").HasAttribute("style"));
}
[Fact]
public void RailFooter_renders_when_supplied()
{
var cut = RenderComponent<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess"));
}
}