From fef4d4cf832f0565848a90ae01a47f02be590c72 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 20:09:00 -0400 Subject: [PATCH] feat(centralui): dark-mode toggle + localStorage persistence + SSR pre-hydration (T34b) --- .../Components/Layout/MainLayout.razor | 4 + .../Components/Shared/DarkModeToggle.razor | 54 ++++++++++++ .../wwwroot/js/theme.js | 32 +++++++ .../Components/App.razor | 14 +++ .../Shared/DarkModeToggleTests.cs | 85 +++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor index 675eef3d..a65c2607 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor @@ -8,6 +8,10 @@ + @* T34b: dark-mode toggle sits in the rail footer alongside the session + block. It is auth-agnostic (pure client-side theme) so it renders even + on the login page. *@ + @* CentralUI-024: claim type resolved via JwtTokenService. *@ diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor new file mode 100644 index 00000000..42b2b3b5 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor @@ -0,0 +1,54 @@ +@inject IJSRuntime JS + +@* T34b: dark-mode toggle. Pure JS-interop + localStorage — no DI service and no + server state. The actual theme switch happens in the browser via window.sbTheme + (wwwroot/js/theme.js); this button just drives toggle() and reflects the result. + The page-load default (no-flash) is owned by the inline pre-hydration script in + App.razor, not here. *@ + + +@code { + /// Mirrors the browser's current theme so the glyph + aria-pressed stay in sync. + private bool _isDark; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + // Reflect the persisted theme once the circuit is interactive. Prerender + // has no JS runtime, so guard the call; a disconnect/blip just leaves the + // button on its default (light) glyph until the next render. + try + { + var mode = await JS.InvokeAsync("sbTheme.get"); + var isDark = mode == "dark"; + if (isDark != _isDark) + { + _isDark = isDark; + StateHasChanged(); + } + } + catch (JSException) { } + catch (JSDisconnectedException) { } + catch (InvalidOperationException) { } + } + + private async Task ToggleAsync() + { + try + { + var mode = await JS.InvokeAsync("sbTheme.toggle"); + _isDark = mode == "dark"; + } + catch (JSException) { } + catch (JSDisconnectedException) { } + catch (InvalidOperationException) { } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js new file mode 100644 index 00000000..e40fa19e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js @@ -0,0 +1,32 @@ +// Dark-mode theme helper (T34b). Plain browser global in the treeview-storage.js +// style (no ES-module export) so the App.razor pre-hydration inline +// script and this file agree on one localStorage key and one apply rule. +// +// * get() — reads the persisted choice ('dark' only when explicitly stored; +// anything else is 'light'). +// * apply() — sets data-bs-theme on ; the site.css [data-bs-theme="dark"] +// token-override block (T34a) does the rest, side-rail included. +// * set() — persists + applies, returns the mode. +// * toggle() — flips and persists, returns the new mode (the DarkModeToggle +// component's click path). +// +// Every localStorage touch is wrapped in try/catch so private-browsing modes +// (which throw on access) degrade to light rather than break the page. +window.sbTheme = { + KEY: 'sb-theme', + get() { + try { return localStorage.getItem(this.KEY) === 'dark' ? 'dark' : 'light'; } + catch { return 'light'; } + }, + apply(mode) { + document.documentElement.setAttribute('data-bs-theme', mode); + }, + set(mode) { + try { localStorage.setItem(this.KEY, mode); } catch { } + this.apply(mode); + return mode; + }, + toggle() { + return this.set(this.get() === 'dark' ? 'light' : 'dark'); + } +}; diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor b/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor index eb515924..54d7204c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor @@ -10,6 +10,19 @@ + @@ -78,6 +91,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs new file mode 100644 index 00000000..b45ce31c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DarkModeToggleTests.cs @@ -0,0 +1,85 @@ +using Bunit; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; + +/// +/// Tests for T34b's . The component is pure +/// JS-interop over window.sbTheme (theme.js): sbTheme.get reflects +/// the persisted theme on first render and sbTheme.toggle flips it on +/// click. The toggle carries the required a11y attributes +/// (aria-label="Toggle dark mode" + aria-pressed). +/// +public class DarkModeToggleTests : BunitContext +{ + public DarkModeToggleTests() + { + // Loose mode: unconfigured interop returns default. We configure the two + // calls the component actually makes (get on first render, toggle on click). + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public void Renders_Button_WithToggleDarkModeAriaLabel() + { + JSInterop.Setup("sbTheme.get").SetResult("light"); + + var cut = Render(); + + var button = cut.Find("button[aria-label='Toggle dark mode']"); + Assert.NotNull(button); + } + + [Fact] + public void OnFirstRender_PersistedLight_AriaPressedFalse() + { + // sbTheme.get returns light → button reflects light (aria-pressed=false, + // moon glyph offering "switch to dark"). + JSInterop.Setup("sbTheme.get").SetResult("light"); + + var cut = Render(); + + var button = cut.Find("button.sb-theme-toggle"); + Assert.Equal("false", button.GetAttribute("aria-pressed")); + Assert.NotNull(cut.Find("i.bi-moon-stars")); + } + + [Fact] + public void OnFirstRender_PersistedDark_AriaPressedTrue() + { + // sbTheme.get returns dark → button reflects dark (aria-pressed=true, + // sun glyph offering "switch to light"). + JSInterop.Setup("sbTheme.get").SetResult("dark"); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + var button = cut.Find("button.sb-theme-toggle"); + Assert.Equal("true", button.GetAttribute("aria-pressed")); + Assert.NotNull(cut.Find("i.bi-sun")); + }); + } + + [Fact] + public void Click_InvokesToggle_AndReflectsNewState() + { + // Start persisted-light; toggling returns "dark". The click handler must + // invoke sbTheme.toggle and flip aria-pressed/glyph to the dark state. + JSInterop.Setup("sbTheme.get").SetResult("light"); + var toggle = JSInterop.Setup("sbTheme.toggle").SetResult("dark"); + + var cut = Render(); + + cut.Find("button.sb-theme-toggle").Click(); + + cut.WaitForAssertion(() => + { + // The handler invoked sbTheme.toggle. + Assert.Single(toggle.Invocations); + var button = cut.Find("button.sb-theme-toggle"); + Assert.Equal("true", button.GetAttribute("aria-pressed")); + Assert.NotNull(cut.Find("i.bi-sun")); + }); + } +}