feat(theme): opt-in ThemeShell ScrollNav rail scroll pane (ScadaBridge#61)

Upstreams ScadaBridge's local .rail-scroll pane. ScrollNav (default false)
wraps Nav in .rail-scroll and adds side-rail-scroll; layout.css (lg+ only)
fills the rail and scrolls just the pane so .rail-foot stays pinned.
Default markup/CSS unchanged for other consumers. Stays at 0.5.2, unpublished.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:52:57 -05:00
parent 6123d03f92
commit 3df31d0c7a
6 changed files with 86 additions and 3 deletions
+4
View File
@@ -6,6 +6,10 @@ Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-ra
## 0.5.2 (2026-11-09, UNPUBLISHED)
Also adds the opt-in `ThemeShell ScrollNav` parameter (ScadaBridge#61, upstreamed from ScadaBridge's local `.rail-scroll`):
default false = markup/CSS unchanged for every consumer; true = `Nav` wrapped in `.rail-scroll`, `nav` gets
`side-rail-scroll`, and layout.css (lg+ only) fills the rail and scrolls just the pane. Not yet adopted by ScadaBridge.
Version bumped in `Directory.Build.props`, not packed or pushed. Fixes Theme-008 (scadaproj#48): `nav-state.js` tracks
wired sections in a JS `WeakSet` instead of the `data-zbnav-initialized` DOM attribute, which Blazor's enhanced-navigation
DOM sync strips (toggle listeners stacked and the transient active-link reveal was persisted). Regression test
+1 -1
View File
@@ -59,7 +59,7 @@ server-side** before redirecting (open-redirect risk):
| Component | Parameters (key) | Notes |
|---|---|---|
| `ThemeHead` | — | Emits `<link>` tags for `theme.css` and `layout.css`. Place in `<head>` after Bootstrap. |
| `ThemeShell` | `Product`*, `Accent`, `Logo`, `Nav`, `RailFooter`, `ChildContent` | Side-rail chassis. Not a layout — delegated to from `MainLayout`. `Accent` overrides `--accent` token. |
| `ThemeShell` | `Product`*, `Accent`, `Logo`, `Nav`, `RailFooter`, `ChildContent`, `ScrollNav` | Side-rail chassis. Not a layout — delegated to from `MainLayout`. `Accent` overrides `--accent` token. `ScrollNav` (opt-in, default false; 0.5.2) wraps `Nav` in a `.rail-scroll` pane and fills the rail height on lg+ so a long nav scrolls while `RailFooter` stays pinned. |
| `BrandBar` | `Product`*, `Logo` | Brand glyph + product name; rendered inside `ThemeShell`'s rail header. |
| `NavRailItem` | `Href`*, `Text`*, `Icon`, `Match` | Wraps `<NavLink class="rail-link">`. |
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. |
@@ -10,9 +10,16 @@
&#9776;
</summary>
<div class="theme-rail" id="theme-rail">
<nav class="side-rail">
<nav class="@(ScrollNav ? "side-rail side-rail-scroll" : "side-rail")">
<BrandBar Product="@Product" Logo="@Logo" />
@Nav
@if (ScrollNav)
{
<div class="rail-scroll">@Nav</div>
}
else
{
@Nav
}
@if (RailFooter is not null)
{
<div class="rail-foot">@RailFooter</div>
@@ -47,6 +54,13 @@
/// </summary>
[Parameter] public RenderFragment? Nav { get; set; }
/// <summary>
/// Opt-in (default <c>false</c>, markup unchanged). When <c>true</c>, the <see cref="Nav"/> content is wrapped
/// in a <c>.rail-scroll</c> pane and the rail fills the viewport height on lg+, so a long nav scrolls under
/// the brand bar while the <see cref="RailFooter"/> stays pinned instead of being pushed off-screen.
/// </summary>
[Parameter] public bool ScrollNav { get; set; }
/// <summary>
/// Optional content pinned to the bottom of the rail (e.g. session info / sign-out).
/// </summary>
@@ -122,6 +122,26 @@
}
}
/* Opt-in rail nav scroll pane (ThemeShell ScrollNav="true"; ScadaBridge#61).
The sticky #theme-rail is 100vh on lg+ but .side-rail never filled it, so a
long nav grew past the viewport and carried .rail-foot off-screen. With the
opt-in class the rail fills the rail and only .rail-scroll scrolls; the brand
bar and .rail-foot stay put. Below lg the rail is inline and scrolls with
the page, so this is scoped to lg+. Without .side-rail-scroll nothing here
matches, so other consumers render unchanged. */
@media (min-width: 992px) {
#theme-rail > .side-rail.side-rail-scroll { height: 100%; box-sizing: border-box; }
.side-rail-scroll > .rail-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
}
/* The disclosure toggles navigation while the page remains usable underneath. */
@media (max-width: 991.98px) {
.app-shell:not([open]) > #theme-rail { display: none; }
@@ -69,6 +69,23 @@ public class StaticAssetsTests
Assert.Matches(@"\bbox-sizing:\s*border-box", page);
}
[Fact]
public void Rail_scroll_pane_css_is_opt_in_and_scoped_to_lg_up()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
css = Regex.Replace(css, @"/\*.*?\*/", "", RegexOptions.Singleline);
// Nothing outside the lg+ media query may style the scroll pane or fill the rail.
var start = css.IndexOf("#theme-rail > .side-rail.side-rail-scroll", StringComparison.Ordinal);
Assert.True(start > 0);
var mq = css.LastIndexOf("@media (min-width: 992px)", start, StringComparison.Ordinal);
Assert.True(mq >= 0);
var block = css[mq..(css.IndexOf(".side-rail-scroll > .rail-scroll", start, StringComparison.Ordinal) + 300)];
Assert.Matches(@"#theme-rail\s*>\s*\.side-rail\.side-rail-scroll\s*\{[^}]*height:\s*100%", block);
Assert.Matches(@"\.side-rail-scroll\s*>\s*\.rail-scroll\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto", block);
// No unscoped (non-opt-in) rule targets #theme-rail > .side-rail height.
Assert.DoesNotMatch(@"#theme-rail\s*>\s*\.side-rail\s*\{", css);
}
// Theme-002: .chip-idle pairs the idle background with the matching --idle
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
[Fact]
@@ -15,6 +15,34 @@ public class ThemeShellTests : BunitContext
Assert.NotNull(cut.Find("main.page .pagebody"));
}
[Fact]
public void ScrollNav_defaults_off_and_markup_is_unchanged()
{
var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>"))));
Assert.Empty(cut.FindAll(".rail-scroll"));
Assert.Equal("side-rail", cut.Find("nav").ClassName);
Assert.NotNull(cut.Find("nav.side-rail > .rail-link"));
}
[Fact]
public void ScrollNav_wraps_nav_in_scroll_pane_between_brand_and_footer()
{
var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "ScadaBridge")
.Add(x => x.ScrollNav, true)
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.Contains("side-rail-scroll", cut.Find("nav").ClassList);
Assert.NotNull(cut.Find("nav.side-rail-scroll > .rail-scroll > .rail-link"));
// Footer is a sibling of the pane, not inside it, so it stays pinned.
Assert.NotNull(cut.Find("nav.side-rail-scroll > .rail-foot .sess"));
Assert.Empty(cut.FindAll(".rail-scroll .rail-foot"));
var kids = cut.Find("nav").Children.Select(c => c.ClassName).ToList();
Assert.True(kids.IndexOf("rail-scroll") < kids.IndexOf("rail-foot"));
}
[Fact]
public void Accent_sets_css_variable_on_shell_root()
{