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:
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
☰
|
||||
</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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user