fix(theme): resolve nav/login kit issues + bump 0.2.1 -> 0.3.0

Addresses ZB.MOM.WW.Theme/themeissues.md:
- #1 NavRailSection <summary> renders aria-expanded (SSR from Expanded),
  kept in sync by nav-state.js on restore + toggle.
- #2 nav-state.js auto-expands the section holding a.rail-link.active
  (transient via data-zbnav-transient — does not overwrite saved state).
- #3 nav-state.js re-applies on Blazor 'enhancedload' (idempotent via
  per-element init guard).
- #5 LoginCard wraps product in span.login-product + optional Heading
  override param.
- #4 documented as an accepted client-only-persistence tradeoff (no code change).

+4 bUnit tests (48 total, all green).
This commit is contained in:
Joseph Doherty
2026-06-05 04:42:24 -04:00
parent 5f97c9d1ed
commit 0e41e7c2e4
7 changed files with 376 additions and 16 deletions
@@ -8,7 +8,17 @@
<div class="login-wrap rise">
<section class="panel">
<div class="login-body">
<h1 class="login-title">@Product &mdash; sign in</h1>
@* The product token is wrapped in its own span so consumers can restyle
it and tests can assert the product in isolation (kit issue #5). Set
Heading to replace the whole heading copy (e.g. for localization). *@
@if (!string.IsNullOrWhiteSpace(Heading))
{
<h1 class="login-title">@Heading</h1>
}
else
{
<h1 class="login-title"><span class="login-product">@Product</span> &mdash; sign in</h1>
}
<form method="post" action="@Action" data-enhance="false">
@if (!string.IsNullOrEmpty(ReturnUrl))
{
@@ -36,9 +46,21 @@
</div>
@code {
/// <summary>Product name shown in the card heading. Required.</summary>
/// <summary>
/// Product name shown in the card heading (rendered inside a
/// <c>&lt;span class="login-product"&gt;</c>, followed by the &quot;&#8212; sign in&quot;
/// suffix). Required. Ignored when <see cref="Heading"/> is set.
/// </summary>
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
/// <summary>
/// Optional full heading override. When set (non-whitespace), it replaces the
/// default <c>&lt;Product&gt; &#8212; sign in</c> heading entirely — use it to
/// localize or fully customize the heading copy. When unset, the default heading
/// (with <see cref="Product"/> in a <c>.login-product</c> span) is rendered.
/// </summary>
[Parameter] public string? Heading { get; set; }
/// <summary>
/// Form <c>action</c> URL the sign-in POST targets. Defaults to <c>/auth/login</c>.
/// </summary>
@@ -2,7 +2,10 @@
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
@namespace ZB.MOM.WW.Theme
<details class="rail-section" open="@Expanded" data-nav-key="@ResolvedKey">
<summary class="rail-eyebrow-toggle">@Title</summary>
@* aria-expanded mirrors the native <details open> state so tests and assistive
tech have a stable, queryable attribute (kit issue #1). It is rendered from
Expanded at SSR time and kept in sync by nav-state.js on restore and toggle. *@
<summary class="rail-eyebrow-toggle" aria-expanded="@(Expanded ? "true" : "false")">@Title</summary>
<div class="rail-section-body">@ChildContent</div>
</details>
@@ -5,22 +5,64 @@
(function () {
var PREFIX = "zbnav:";
var INIT_ATTR = "data-zbnav-initialized";
function apply() {
document.querySelectorAll("details.rail-section[data-nav-key]").forEach(function (el) {
if (el.hasAttribute(INIT_ATTR)) return; // already wired — avoid duplicate listeners
el.setAttribute(INIT_ATTR, "");
var key = PREFIX + el.getAttribute("data-nav-key");
var saved = null;
try { saved = window.localStorage.getItem(key); } catch (e) { return; }
if (saved === "1") el.open = true;
else if (saved === "0") el.open = false;
el.addEventListener("toggle", function () {
try { window.localStorage.setItem(key, el.open ? "1" : "0"); } catch (e) { /* ignore */ }
});
var TRANSIENT_ATTR = "data-zbnav-transient";
// Mirror a section's native <details open> onto its <summary aria-expanded>
// so tests and assistive tech have a stable, queryable attribute (issue #1).
function syncAria(el) {
var summary = el.querySelector("summary.rail-eyebrow-toggle");
if (summary) summary.setAttribute("aria-expanded", el.open ? "true" : "false");
}
function wire(el) {
el.setAttribute(INIT_ATTR, "");
var key = PREFIX + el.getAttribute("data-nav-key");
var saved = null;
try { saved = window.localStorage.getItem(key); } catch (e) { saved = null; }
if (saved === "1") el.open = true;
else if (saved === "0") el.open = false;
el.addEventListener("toggle", function () {
syncAria(el);
// An active-link reveal (issue #2) is a transient open that must NOT
// overwrite the user's saved preference. The reveal flags the element
// before flipping open; consume the flag here and skip persistence.
if (el.getAttribute(TRANSIENT_ATTR) !== null) {
el.removeAttribute(TRANSIENT_ATTR);
return;
}
try { window.localStorage.setItem(key, el.open ? "1" : "0"); } catch (e) { /* ignore */ }
});
}
function apply() {
document.querySelectorAll("details.rail-section[data-nav-key]").forEach(function (el) {
if (!el.hasAttribute(INIT_ATTR)) wire(el); // wire once — avoid duplicate listeners
syncAria(el); // re-sync aria on every pass
});
// Reveal the section that holds the active link even if the user (or the
// app) left it collapsed, so the nav always shows where the user is
// (issue #2). Transient: flagged so the toggle handler does not persist it.
document.querySelectorAll("details.rail-section a.rail-link.active").forEach(function (link) {
var sec = link.closest("details.rail-section");
if (sec && !sec.open) {
sec.setAttribute(TRANSIENT_ATTR, "");
sec.open = true;
syncAria(sec);
}
});
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", apply);
else
apply();
// Re-run after Blazor static-SSR enhanced navigation (or any re-render that
// replaces the rail nodes) so freshly inserted sections are wired, restored,
// and active-revealed (issue #3). The per-element INIT_ATTR guard keeps this
// idempotent for nodes that survived the navigation.
if (window.Blazor && typeof window.Blazor.addEventListener === "function") {
window.Blazor.addEventListener("enhancedload", apply);
}
})();