diff --git a/ZB.MOM.WW.Theme/CLAUDE.md b/ZB.MOM.WW.Theme/CLAUDE.md index d42f7d9..bd815af 100644 --- a/ZB.MOM.WW.Theme/CLAUDE.md +++ b/ZB.MOM.WW.Theme/CLAUDE.md @@ -2,6 +2,38 @@ Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`. +## Published 0.5.0 (2026-11-04) + +Published the tested ignitionoee backlog package to the shared Gitea feed on +2026-11-04. Independent download matched the reviewed artifact SHA256 +`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`. +The ignitionoee router deployed this package to CR on 2026-11-04. No other +consumer was deployed in this task. Do not repack or replace published 0.5.0. +Retains 0.4.2's `.rise` fill-mode fix. Adds the remaining shared application CSS +from ignitionoee (including `td.row-actions`), composable `TechButton.Class` and +`ButtonSize`, plus `ITreeRow`/stateless `TreeTable` in `ZB.MOM.WW.Theme`. +Tree styles live once in `layout.css`, with arbitrary-depth indentation and +keyboard-focus styling. Caller-owned filtering/collapse behavior stays intact. + +Test harness migrated to bUnit 2.7.2 with AngleSharp 1.5.0 to address the existing +NU1902 advisory without disabling audit. Runtime package dependencies are unchanged. +Local verification: Release build passed with zero warnings/errors; Release tests +passed 60 tests, none skipped. `build/pack.sh` produced the local 0.5.0 nupkg. +All 19 remaining ignitionoee app.css rule blocks are present verbatim in the +shared stylesheet; the Impeccable detector reported no findings on changed UI files. +Browser QA also exposed a pre-existing mobile shell bug: closed navigation hid +the page through Chromium's disclosure wrapper. The wrapper override now applies +at all widths, with an explicit mobile rule hiding only the rail and author +display rules for older browsers. Two regression tests cover CSS placement and +closed-shell component structure; computed mobile/desktop behavior was checked in +the disposable ignitionoee local browser fixture. +Browser QA confirmed the visibility fix and isolated an additional mobile +min-content sizing issue: bounding the direct page to 100% width/max-width with +border-box reduced the page/document from 1055px to the 390px viewport and kept +wide-table scrolling inside its wrapper. That measured constraint is included. +See README's 0.5.0 migration section; deployed consumer validation remains separate +from local source/package/browser verification. + ## Index history (migrated from the scadaproj root CLAUDE.md) > Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.** diff --git a/ZB.MOM.WW.Theme/Directory.Build.props b/ZB.MOM.WW.Theme/Directory.Build.props index 017182a..15f1d8a 100644 --- a/ZB.MOM.WW.Theme/Directory.Build.props +++ b/ZB.MOM.WW.Theme/Directory.Build.props @@ -4,7 +4,7 @@ enable enable latest - 0.4.2 + 0.5.0 true diff --git a/ZB.MOM.WW.Theme/Directory.Packages.props b/ZB.MOM.WW.Theme/Directory.Packages.props index 2dda526..2abd96f 100644 --- a/ZB.MOM.WW.Theme/Directory.Packages.props +++ b/ZB.MOM.WW.Theme/Directory.Packages.props @@ -3,7 +3,9 @@ true - + + + diff --git a/ZB.MOM.WW.Theme/README.md b/ZB.MOM.WW.Theme/README.md index 68dd72c..6e66f5d 100644 --- a/ZB.MOM.WW.Theme/README.md +++ b/ZB.MOM.WW.Theme/README.md @@ -65,7 +65,8 @@ server-side** before redirecting (open-redirect risk): | `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `
` group; no JS, works in static SSR. | | `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. | | `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `` via `ChildContent`; validate `ReturnUrl` server-side. | -| `TechButton` (`ButtonVariant`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. `ButtonVariant` enum is in `ZB.MOM.WW.Theme`. | +| `TechButton` (`ButtonVariant`, `ButtonSize`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Size` (`Default`/`Small`/`Large`), `Class`, `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. Caller classes are composed with required variant/size classes. | +| `TreeTable` (`ITreeRow`) | `Rows`*, `Head`*, `Row`*, `Label`*, `Collapsed`, `OnToggle` | Stateless preorder tree table. The caller filters visible rows and owns expansion state. | | `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. | | `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. | @@ -80,7 +81,7 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin | Path | Contents | |---|---| | `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers | -| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing | +| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing, shared page/group headers, badges, row actions, info notices, payload blocks and tree-table styles | **Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its 0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes @@ -128,6 +129,52 @@ so a token-block edit re-themes the whole kit. ```bash # from ZB.MOM.WW.Theme/ dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings -dotnet test # 38 bUnit tests -./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg +dotnet test # 60 tests verified for 0.5.0 +./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.5.0.nupkg ``` + +## 0.5.0 migration (published 2026-11-04) + +The tested package was published to the shared Gitea feed on 2026-11-04. +An independent feed download exactly matched the reviewed artifact (SHA256 +`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`). +The ignitionoee router deployed this package to CR on 2026-11-04; other consumer +rollouts remain separate. It retains the 0.4.2 `.rise` animation +fill-mode fix. The published package is immutable; this source documentation +update does not repack or replace it. + +The shell now keeps page content visible when mobile navigation is collapsed. +The outer disclosure's `::details-content` wrapper is dissolved at every width; +an explicit mobile closed-state selector hides only the rail. Author display +rules retain the page for older browsers without `::details-content` support. +The menu remains a native keyboard-accessible disclosure without custom JavaScript. +Mobile pages are constrained to the shell width so wide tables scroll inside their +table wrapper instead of widening the whole document. + +The remaining ignitionoee `app.css` rules now ship in `layout.css`: page and section +headers/meta, group headings/meta, count badges, `.notice.info`, `.row-actions`, +**`td.row-actions`**, and `.check-data`. Consumers may delete matching local copies +after adopting this package. The element-qualified row-action selector must remain: +the bare class loses to `.data-table td` regardless of stylesheet load order. + +Use `Size="ButtonSize.Small"` or `Size="ButtonSize.Large"` on `TechButton`. +`Class="my-action"` and an attribute dictionary's `class` value are composed with +the required button classes. A lowercase `class` supplied in Razor binds to `Class` +as normal Blazor parameter binding. Supplying both spellings as separate component +parameters is unnecessary; use `Class` plus `AdditionalAttributes` when merging two +sources. Explicit `Busy` and `Type` continue to control the underlying attributes. + +Implement `ZB.MOM.WW.Theme.ITreeRow` on a consumer's row model (`Key`, `Depth`, +`HasChildren`). Feed `TreeTable` visible rows in preorder, with each subtree +contiguous after its parent. `Head` supplies all header cells; `Row` supplies cells +after the component's name cell; `Label` supplies the name. `OnToggle` reports the +stable row key, and the caller supplies a new `Collapsed` state and filters `Rows`. +The component never filters or mutates caller state. Indentation supports arbitrary +depth through `--tt-depth`; styles ship once, with no per-instance stylesheet. +Leaf controls preserve label alignment and omit subtree ARIA attributes. Interactive +expand/collapse requires an interactive Blazor host and Bootstrap Icons for chevrons. + +The test harness uses bUnit 2.7.2 and directly pins AngleSharp 1.5.0. This avoids +the vulnerable parser minimum ([GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg)) +and the binary incompatibility between bUnit 1.x and the patched parser. These +dependencies belong to the test project; the Theme runtime package gains none. diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonSize.cs b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonSize.cs new file mode 100644 index 0000000..7a9a33d --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonSize.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.Theme; + +/// Bootstrap size modifier for TechButton. +public enum ButtonSize +{ + /// The theme's standard button size, without a Bootstrap size modifier. + Default, + /// Compact button, using btn-sm. + Small, + /// Large button, using btn-lg. + Large, +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor index 1cba5f6..a2cf568 100644 --- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor @@ -1,6 +1,6 @@ @namespace ZB.MOM.WW.Theme @* Components/TechButton.razor *@ - @@ -12,6 +12,12 @@ /// [Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary; + /// Bootstrap size modifier. Defaults to the theme's standard button size. + [Parameter] public ButtonSize Size { get; set; } + + /// Additional CSS classes, composed with the variant, size and unmatched class attribute. + [Parameter] public string? Class { get; set; } + /// HTML type attribute (button, submit, reset). Defaults to button. [Parameter] public string Type { get; set; } = "button"; @@ -30,6 +36,14 @@ /// [Parameter(CaptureUnmatchedValues = true)] public IDictionary? AdditionalAttributes { get; set; } + private string ButtonClass => string.Join(" ", new[] + { + "btn", VariantClass, + Size switch { ButtonSize.Small => "btn-sm", ButtonSize.Large => "btn-lg", _ => null }, + Class, + AdditionalAttributes?.FirstOrDefault(pair => string.Equals(pair.Key, "class", StringComparison.OrdinalIgnoreCase)).Value?.ToString() + }.Where(value => !string.IsNullOrWhiteSpace(value))); + private string VariantClass => Variant switch { ButtonVariant.Secondary => "btn-outline-secondary", diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TreeTable.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TreeTable.razor new file mode 100644 index 0000000..1d7e1dd --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TreeTable.razor @@ -0,0 +1,88 @@ +@namespace ZB.MOM.WW.Theme +@typeparam TRow where TRow : ITreeRow + +@* Stateless preorder tree table. The caller filters Rows and owns collapse state. + Shared styles ship once in layout.css, including arbitrary-depth indentation. *@ + +
+ + + @Head + + + @foreach (TRow row in Rows) + { + + + @Row(row) + + } + +
+ + @Label(row) +
+
+ +@code { + /// + /// The rows to render, in order — already filtered through + /// the caller's visibility policy. + /// + /// + /// The filtering is the caller's so that this component holds no state at all: the page owns + /// , and what is on screen is a pure function of it. + /// + [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = []; + + /// + /// The header cells — every <th>, including the one over the name column. The + /// component supplies the <thead> and its <tr>. + /// + [Parameter, EditorRequired] public RenderFragment Head { get; set; } = builder => { }; + + /// + /// One row's cells after the name cell: the component renders the name column, the + /// caller renders the rest. + /// + [Parameter, EditorRequired] public RenderFragment Row { get; set; } = _ => builder => { }; + + /// The text of a row's name cell, beside its twist. + [Parameter, EditorRequired] public Func Label { get; set; } = _ => string.Empty; + + /// + /// The keys of the collapsed rows — read only to draw each twist the right way round. + /// + /// + /// It is the same set the caller filtered with, so a row can be collapsed + /// and on screen (that is what a shut branch looks like) but never expanded and hidden. + /// + [Parameter] public IReadOnlySet Collapsed { get; set; } = new HashSet(StringComparer.Ordinal); + + /// + /// Raised with a row's when its twist is clicked. The caller + /// toggles that key in its own collapse set; this component changes nothing itself. + /// + [Parameter] public EventCallback OnToggle { get; set; } + + private static string DepthStyle(TRow row) + => "--tt-depth: " + Math.Max(0, row.Depth).ToString(System.Globalization.CultureInfo.InvariantCulture); + + private bool IsCollapsed(TRow row) => Collapsed.Contains(row.Key); + + private string TwistClass(TRow row) => row.HasChildren ? "tt-twist" : "tt-twist tt-leaf"; + + private string ChevronClass(TRow row) => IsCollapsed(row) ? "bi bi-chevron-right" : "bi bi-chevron-down"; + + // Null omits the attribute: a leaf's twist is decoration, and announcing it as a collapsed + // control would promise a subtree that is not there. + private string? AriaExpanded(TRow row) + => !row.HasChildren ? null : IsCollapsed(row) ? "false" : "true"; + + private string? TwistLabel(TRow row) + => !row.HasChildren ? null : (IsCollapsed(row) ? "Expand " : "Collapse ") + Label(row); +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ITreeRow.cs b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ITreeRow.cs new file mode 100644 index 0000000..21124f3 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ITreeRow.cs @@ -0,0 +1,14 @@ +namespace ZB.MOM.WW.Theme; + +/// A row in a preorder flat tree, with each subtree contiguous after its parent. +public interface ITreeRow +{ + /// Stable identity, unique in the table and used for keyed rendering and collapse state. + string Key { get; } + + /// Nesting depth, starting at zero for a root. + int Depth { get; } + + /// Whether this row owns a subtree and offers an expand/collapse toggle. + bool HasChildren { get; } +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj index 9376b32..4419416 100644 --- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj @@ -11,10 +11,12 @@ $(NoWarn);CS1591 true ZB.MOM.WW.Theme + README.md Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family. + diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css index 5c2621a..41bebd7 100644 --- a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css @@ -1,5 +1,5 @@ /* ZB.MOM.WW.Theme — side-rail + login layout - Tokens live in theme.css; this sheet carries only layout + the side rail. */ + Tokens live in theme.css; this sheet carries application layout and shared controls. */ /* ── Bootstrap button sizing ───────────────────────────────────────────────── Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against @@ -45,6 +45,12 @@ --bs-btn-padding-y: .25rem; --bs-btn-padding-x: .6rem; } +.btn-group-lg > .btn, +.btn-lg { + --bs-btn-font-size: 1rem; + --bs-btn-padding-y: .5rem; + --bs-btn-padding-x: 1rem; +} /* ── App shell: side rail + page ─────────────────────────────────────────── */ /* The shell is a native
disclosure so the narrow-viewport hamburger @@ -59,6 +65,21 @@ min-height: calc(100vh - 3.3rem); } +/* A closed shell must hide navigation only, never the page. Modern Chromium + wraps all non-summary children in ::details-content and hides that box while + closed. Dissolve it at EVERY width so both children retain the shell's flex + layout. The mobile closed-rail rule below owns navigation visibility. */ +.app-shell::details-content { + display: contents; + content-visibility: visible; +} + +/* Older browsers use a UA display rule on closed disclosure children instead + of ::details-content. Author display rules keep the page present there too; + an unsupported pseudo-element rule above is simply ignored. */ +.app-shell > main.page { display: block; } +.app-shell > #theme-rail { display: block; } + .app-shell .page { flex: 1; min-width: 0; @@ -72,9 +93,8 @@ } .app-shell > summary.rail-toggle::-webkit-details-marker { display: none; } -/* Below lg the native
shows/hides #theme-rail via [open]; nothing - extra is needed there. On lg+ we force the rail visible (see media query) - and keep the toggle hidden via Bootstrap's d-lg-none. */ +/* The native
owns its open state; the mobile rule explicitly hides + only #theme-rail when closed. Bootstrap's d-lg-none hides the toggle on lg+. */ /* ── Side rail ───────────────────────────────────────────────────────────── */ .side-rail { @@ -92,20 +112,6 @@ and force it shown regardless of the
open state (the hamburger toggle is hidden at this width). */ @media (min-width: 992px) { - /* Chromium >=121 wraps a
's content in a generated ::details-content - box that carries content-visibility:hidden while the
is closed. - Because our app-shell ships closed by default (SSR, no JS) and the toggle - is d-lg-none here, that wrapper would (a) hide the rail+page entirely on - lg+ and (b) sit between .app-shell and its rail/page children, collapsing - the flex-lg-row layout into a vertical stack. Dissolving the wrapper with - display:contents removes its box (so content-visibility no longer hides the - content AND rail/page become direct flex children of .app-shell again). - Browsers that don't support ::details-content treat this as an invalid - selector and drop the rule, falling back to the legacy force-show below. */ - .app-shell::details-content { - display: contents; - } - #theme-rail { display: block; position: sticky; @@ -116,10 +122,16 @@ } } -/* When the side rail is collapsed under is closed) - the native disclosure hides #theme-rail; when open, restore full width on - mobile so the rail spans the viewport. */ +/* The disclosure toggles navigation while the page remains usable underneath. */ @media (max-width: 991.98px) { + .app-shell:not([open]) > #theme-rail { display: none; } + /* Native disclosure layout can otherwise size the page to a table's + min-content width. Bound the page so .table-wrap scrolls internally. */ + .app-shell > .page { + width: 100%; + max-width: 100%; + box-sizing: border-box; + } .side-rail { width: 100%; min-width: 100%; @@ -323,3 +335,79 @@ .login-body { padding: 1.4rem 1.1rem 1.25rem; } .login-error { margin-bottom: 0.85rem; } .field-error { font-size: 0.78rem; margin-top: 0.2rem; } + +/* Shared application presentation, upstreamed from ignitionoee in 0.5.0. */ +/* ── Page header ───────────────────────────────────────────────────────────── + Title left, `.spacer` eating the slack, actions/meta pushed hard right. */ +.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; } +.page-head h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; margin: 0; text-wrap: balance; } +.page-head .spacer { flex: 1; } +.page-meta { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-soft); } + +/* ── Application sections ──────────────────────────────────────────────────── + A rule-underlined h2 band; same spacer trick as the page head. */ +.app-section { margin-bottom: 1.5rem; } +.app-head { + display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; + padding-bottom: 0.4rem; border-bottom: 1px solid var(--rule-strong); margin-bottom: 0.75rem; +} +.app-head h2 { font-size: 1.02rem; font-weight: 600; margin: 0; } +.app-head .spacer { flex: 1; } +.app-meta { font-family: var(--mono); font-size: 0.76rem; color: var(--ink-faint); } + +/* ── Group header (per-site issue groups) ────────────────────────────────── */ +.group-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.5rem; } +.group-name { font-size: 0.74rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-soft); } +.group-meta { font-family: var(--mono); font-size: 0.74rem; color: var(--ink-faint); } + +/* ── Per-group count badge ─────────────────────────────────────────────────── + Red by default (it counts problems); `.warn` for the softer amber case. */ +.count-badge { + display: inline-block; min-width: 1.4rem; text-align: center; + font-family: var(--mono); font-size: 0.72rem; font-weight: 600; + padding: 0.05rem 0.35rem; border-radius: 999px; + color: var(--bad); background: var(--bad-bg); border: 1px solid var(--bad-border); +} +.count-badge.warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); } + +/* ── Inline notice, info variant ───────────────────────────────────────────── + Theme 0.3.1 ships `.notice` (amber) but no variants; `.info` re-tints it to + the on-palette blue wash. `--info-bg`/`--info-border` come from the theme. */ +.notice.info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); } + +/* ── Table row-action cell ───────────────────────────────────────────────── + The bare class is what the mockup ships and what a `` + still needs. It is NOT enough on its own: every use in this app is a table + cell, and Theme 0.3.1's `.data-table th, .data-table td { text-align: left }` + is class+element (0-1-1) against this rule's bare class (0-1-0) — specificity + beats load order, so the alignment silently lost everywhere. Observed in a + browser on 2026-08-05 (every row-action cell left-aligned; `nowrap` applied, + `text-align` did not); the cell-level restatement below is the fix. + See docs/recipes/router-ui-render-validation.md, Task 1 Defect 2. */ +.row-actions { text-align: right; white-space: nowrap; } +td.row-actions { text-align: right; } + +/* ── Preformatted check/payload block ────────────────────────────────────── */ +.check-data { + margin: 0.35rem 0.9rem 0.6rem; padding: 0.4rem 0.6rem; background: var(--zebra-bg); + border: 1px solid var(--rule); border-radius: 4px; + font-family: var(--mono); font-size: 0.72rem; color: var(--ink-soft); line-height: 1.6; + overflow-x: auto; white-space: nowrap; +} +.check-data b { color: var(--accent-deep); font-weight: 500; } + +/* ── Tree table ────────────────────────────────────────────────────────────── + Name-cell depth is a number emitted by TreeTable; it adds to the table's + standard 0.8rem cell padding and works at every depth. */ +.data-table td.tt-name { + font-weight: 600; + padding-left: calc(0.8rem + var(--tt-depth, 0) * 1.2rem); +} +.tt-twist { + background: none; border: none; padding: 0; margin-right: 0.45rem; + color: var(--ink-faint); font-size: 0.75rem; line-height: 1; cursor: pointer; +} +.tt-twist:hover { color: var(--ink); } +.tt-twist:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* A hidden leaf control preserves the width of its siblings' toggles. */ +.tt-leaf { visibility: hidden; } diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs index 40b6774..48e0035 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs @@ -1,11 +1,11 @@ namespace ZB.MOM.WW.Theme.Tests; -public class BrandBarTests : TestContext +public class BrandBarTests : BunitContext { [Fact] public void Renders_product_with_default_mark() { - var cut = RenderComponent(p => p.Add(x => x.Product, "OtOpcUa")); + var cut = Render(p => p.Add(x => x.Product, "OtOpcUa")); Assert.Contains("OtOpcUa", cut.Markup); Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo } @@ -13,7 +13,7 @@ public class BrandBarTests : TestContext [Fact] public void Custom_logo_replaces_default_mark() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Product, "ScadaBridge") .Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "")))); Assert.NotNull(cut.Find(".brand .logo")); diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs index 97ca130..23074e8 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs @@ -1,6 +1,6 @@ namespace ZB.MOM.WW.Theme.Tests; -public class CommonControlsTests : TestContext +public class CommonControlsTests : BunitContext { [Theory] [InlineData(ButtonVariant.Primary, "btn-primary")] @@ -9,7 +9,7 @@ public class CommonControlsTests : TestContext [InlineData(ButtonVariant.Ghost, "btn-link")] public void TechButton_maps_variant(ButtonVariant v, string cls) { - var cut = RenderComponent(p => p.Add(x => x.Variant, v).AddChildContent("Go")); + var cut = Render(p => p.Add(x => x.Variant, v).AddChildContent("Go")); var btn = cut.Find("button"); Assert.Contains("btn", btn.ClassList); Assert.Contains(cls, btn.ClassList); @@ -18,7 +18,7 @@ public class CommonControlsTests : TestContext [Fact] public void TechButton_busy_disables_and_passes_through_attributes() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Busy, true) .AddUnmatched("id", "save") .AddChildContent("Save")); @@ -30,14 +30,14 @@ public class CommonControlsTests : TestContext [Fact] public void TechButton_not_busy_is_not_disabled() { - var cut = RenderComponent(p => p.Add(x => x.Busy, false).AddChildContent("Go")); + var cut = Render(p => p.Add(x => x.Busy, false).AddChildContent("Go")); Assert.False(cut.Find("button").HasAttribute("disabled")); } [Fact] public void TechCard_renders_title_and_body() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Drivers") .AddChildContent("
x
")); Assert.Contains("Drivers", cut.Find(".panel-head").TextContent); @@ -47,7 +47,7 @@ public class CommonControlsTests : TestContext [Fact] public void TechField_renders_label_hint_error() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Label, "Name") .Add(x => x.Hint, "required") .Add(x => x.Error, "missing") diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs index cc69192..0ba6b43 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs @@ -1,11 +1,11 @@ namespace ZB.MOM.WW.Theme.Tests; -public class LoginCardTests : TestContext +public class LoginCardTests : BunitContext { [Fact] public void Posts_to_action_with_username_password_fields() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Product, "OtOpcUa") .Add(x => x.Action, "/auth/login")); var form = cut.Find("form"); @@ -19,7 +19,7 @@ public class LoginCardTests : TestContext [Fact] public void ReturnUrl_renders_hidden_input() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Product, "OtOpcUa") .Add(x => x.ReturnUrl, "/clusters")); var hidden = cut.Find("input[name=returnUrl]"); @@ -29,7 +29,7 @@ public class LoginCardTests : TestContext [Fact] public void Error_renders_notice() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Product, "OtOpcUa") .Add(x => x.Error, "Bad credentials")); Assert.Contains("Bad credentials", cut.Find(".notice").TextContent); @@ -38,7 +38,7 @@ public class LoginCardTests : TestContext [Fact] public void No_returnUrl_no_hidden_input() { - var cut = RenderComponent(p => p.Add(x => x.Product, "OtOpcUa")); + var cut = Render(p => p.Add(x => x.Product, "OtOpcUa")); Assert.Empty(cut.FindAll("input[name=returnUrl]")); } @@ -47,7 +47,7 @@ public class LoginCardTests : TestContext [Fact] public void Product_is_wrapped_in_login_product_span() { - var cut = RenderComponent(p => p.Add(x => x.Product, "OtOpcUa")); + var cut = Render(p => p.Add(x => x.Product, "OtOpcUa")); var product = cut.Find(".login-title .login-product"); Assert.Equal("OtOpcUa", product.TextContent); Assert.Contains("sign in", cut.Find(".login-title").TextContent); @@ -57,7 +57,7 @@ public class LoginCardTests : TestContext [Fact] public void Heading_overrides_default_heading_when_set() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Product, "OtOpcUa") .Add(x => x.Heading, "Welcome back")); var title = cut.Find(".login-title"); diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs index e3d6e9b..af3c4c8 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs @@ -1,11 +1,11 @@ namespace ZB.MOM.WW.Theme.Tests; -public class NavRailTests : TestContext +public class NavRailTests : BunitContext { [Fact] public void NavRailItem_renders_rail_link_with_href_and_text() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Href, "/clusters") .Add(x => x.Text, "Clusters")); var a = cut.Find("a.rail-link"); @@ -17,7 +17,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Href, "/clusters") .Add(x => x.Text, "Clusters") .Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "")))); @@ -29,7 +29,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailItem_omits_rail_ico_span_when_no_icon() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Href, "/clusters") .Add(x => x.Text, "Clusters")); Assert.Empty(cut.FindAll(".rail-ico")); @@ -38,7 +38,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_renders_title_and_children_open_by_default() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Navigation") .AddChildContent("X")); var details = cut.Find("details.rail-section"); @@ -52,7 +52,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_summary_aria_expanded_true_when_open() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Navigation") .AddChildContent("X")); Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); @@ -61,7 +61,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_collapsed_when_not_expanded() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .AddChildContent("X")); Assert.False(cut.Find("details.rail-section").HasAttribute("open")); @@ -71,7 +71,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_summary_aria_expanded_false_when_collapsed() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .AddChildContent("X")); Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); @@ -80,7 +80,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_emits_data_nav_key_slug_from_title_by_default() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Site Calls") .AddChildContent("X")); Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key")); @@ -89,7 +89,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_emits_explicit_key_when_supplied() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Navigation").Add(x => x.Key, "nav") .AddChildContent("X")); Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key")); @@ -98,7 +98,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, " ") .AddChildContent("X")); Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key")); @@ -107,7 +107,7 @@ public class NavRailTests : TestContext [Fact] public void NavRailSection_slug_preserves_unicode_letters() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Title, "Café") .AddChildContent("X")); Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key")); diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs index 86e8d43..975f5a3 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs @@ -39,6 +39,36 @@ public class StaticAssetsTests public void NavStateScript_ships() => Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js"))); + [Fact] + public void Shared_layout_ships_row_action_specificity_and_variable_size_modifiers() + { + var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css")); + Assert.Matches(@"td\.row-actions\s*\{\s*text-align:\s*right", css); + foreach (var selector in new[] { ".page-head", ".page-meta", ".app-section", ".app-head", + ".app-meta", ".group-head", ".group-name", ".group-meta", ".count-badge", + ".count-badge.warn", ".notice.info", ".row-actions", ".check-data" }) + Assert.Contains(selector, css); + Assert.Contains("var(--tt-depth, 0)", css); + Assert.Matches(@"\.btn-lg\s*\{\s*--bs-btn-font-size:\s*1rem", css); + Assert.Matches(@"\.tt-leaf\s*\{\s*visibility:\s*hidden", css); + } + + [Fact] + public void Closed_mobile_shell_hides_only_the_rail_and_keeps_main_content_visible() + { + var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css")); + css = Regex.Replace(css, @"/\*.*?\*/", "", RegexOptions.Singleline); + var baseRules = css[..css.IndexOf("@media", StringComparison.Ordinal)]; + Assert.Matches(@"\.app-shell::details-content\s*\{[^}]*display:\s*contents", baseRules); + Assert.Matches(@"\.app-shell\s*>\s*main\.page\s*\{[^}]*display:\s*block", baseRules); + var mobile = css[css.IndexOf("@media (max-width: 991.98px)", StringComparison.Ordinal)..]; + Assert.Matches(@"\.app-shell:not\(\[open\]\)\s*>\s*#theme-rail\s*\{\s*display:\s*none", mobile); + var page = Regex.Match(mobile, @"\.app-shell\s*>\s*\.page\s*\{([^}]*)\}").Groups[1].Value; + Assert.Matches(@"\bwidth:\s*100%", page); + Assert.Matches(@"\bmax-width:\s*100%", page); + Assert.Matches(@"\bbox-sizing:\s*border-box", page); + } + // Theme-002: .chip-idle pairs the idle background with the matching --idle // foreground token (per DESIGN-TOKENS.md), not --ink-soft. [Fact] diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs index de42144..0ee2f8b 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs @@ -1,6 +1,6 @@ namespace ZB.MOM.WW.Theme.Tests; -public class StatusPillTests : TestContext +public class StatusPillTests : BunitContext { [Theory] [InlineData(StatusState.Ok, "chip-ok")] @@ -10,7 +10,7 @@ public class StatusPillTests : TestContext [InlineData(StatusState.Info, "chip-info")] public void Maps_state_to_chip_class(StatusState state, string expected) { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.State, state) .AddChildContent("Connected")); var span = cut.Find("span"); diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/TechButtonExtensionsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/TechButtonExtensionsTests.cs new file mode 100644 index 0000000..da423ce --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/TechButtonExtensionsTests.cs @@ -0,0 +1,66 @@ +namespace ZB.MOM.WW.Theme.Tests; + +public sealed class TechButtonExtensionsTests : BunitContext +{ + [Theory] + [InlineData(ButtonSize.Default, null)] + [InlineData(ButtonSize.Small, "btn-sm")] + [InlineData(ButtonSize.Large, "btn-lg")] + public void Size_preserves_variant_and_composes_both_class_inputs(ButtonSize size, string? expected) + { + var cut = Render(p => p + .Add(x => x.Variant, ButtonVariant.Secondary) + .Add(x => x.Size, size) + .Add(x => x.Class, "explicit-action") + .Add(x => x.AdditionalAttributes, new Dictionary + { + ["class"] = "caller-action", ["aria-label"] = "Save changes" + }) + .AddChildContent("Save")); + + var button = cut.Find("button"); + foreach (var cls in new[] { "btn", "btn-outline-secondary", "explicit-action", "caller-action" }) + Assert.Contains(cls, button.ClassList); + Assert.Equal("Save changes", button.GetAttribute("aria-label")); + if (expected is not null) Assert.Contains(expected, button.ClassList); + else + { + Assert.DoesNotContain("btn-sm", button.ClassList); + Assert.DoesNotContain("btn-lg", button.ClassList); + } + } + + [Fact] + public void Class_composition_keeps_busy_type_and_click_behavior() + { + var clicks = 0; + var attributes = new Dictionary + { + ["class"] = "caller", ["disabled"] = false, + ["onclick"] = EventCallback.Factory.Create(this, () => clicks++) + }; + var cut = Render(p => p + .Add(x => x.Class, "extra") + .Add(x => x.Type, "submit") + .Add(x => x.Busy, true) + .Add(x => x.AdditionalAttributes, attributes) + .AddChildContent("Save")); + + Assert.True(cut.Find("button").HasAttribute("disabled")); + Assert.Equal("submit", cut.Find("button").GetAttribute("type")); + Assert.NotNull(cut.Find(".spinner-border")); + Assert.Equal("caller", attributes["class"]); + cut.Render(p => p.Add(x => x.Busy, false)); + cut.Find("button").Click(); + Assert.Equal(1, clicks); + Assert.DoesNotContain(cut.FindAll("span"), x => x.ClassList.Contains("spinner-border")); + } + + [Fact] + public void Lowercase_class_attribute_preserves_required_button_classes() + { + var cut = Render(p => p.AddUnmatched("class", "caller").AddChildContent("Go")); + foreach (var cls in new[] { "btn", "btn-primary", "caller" }) + Assert.Contains(cls, cut.Find("button").ClassList); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs index 1cbed92..6615953 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs @@ -1,11 +1,11 @@ namespace ZB.MOM.WW.Theme.Tests; -public class ThemeHeadTests : TestContext +public class ThemeHeadTests : BunitContext { [Fact] public void Emits_theme_and_layout_links_to_content_path() { - var cut = RenderComponent(); + var cut = Render(); var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList(); Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs); Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs); diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeScriptsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeScriptsTests.cs index 4478cb3..c8e32d6 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeScriptsTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeScriptsTests.cs @@ -1,11 +1,11 @@ namespace ZB.MOM.WW.Theme.Tests; -public class ThemeScriptsTests : TestContext +public class ThemeScriptsTests : BunitContext { [Fact] public void ThemeScripts_emits_nav_state_script_tag() { - var cut = RenderComponent(); + var cut = Render(); var script = cut.Find("script"); Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src")); Assert.True(script.HasAttribute("defer")); diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs index 67f282f..ac81402 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs @@ -1,11 +1,11 @@ namespace ZB.MOM.WW.Theme.Tests; -public class ThemeShellTests : TestContext +public class ThemeShellTests : BunitContext { [Fact] public void Renders_product_nav_and_body() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Product, "OtOpcUa") .Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "N"))) .AddChildContent("
BODY
")); @@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext [Fact] public void Accent_sets_css_variable_on_shell_root() { - var cut = RenderComponent(p => p + var cut = Render(p => p .Add(x => x.Product, "ScadaBridge") .Add(x => x.Accent, "#2f855a")); var shell = cut.Find(".app-shell"); @@ -28,14 +28,14 @@ public class ThemeShellTests : TestContext [Fact] public void No_accent_emits_no_style() { - var cut = RenderComponent(p => p.Add(x => x.Product, "MXAccess Gateway")); + var cut = Render(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(p => p + var cut = Render(p => p .Add(x => x.Product, "OtOpcUa") .Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "S")))); Assert.NotNull(cut.Find(".rail-foot .sess")); @@ -47,7 +47,7 @@ public class ThemeShellTests : TestContext [Fact] public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse() { - var cut = RenderComponent(p => p.Add(x => x.Product, "OtOpcUa")); + var cut = Render(p => p.Add(x => x.Product, "OtOpcUa")); // Root is a
whose direct is the hamburger toggle. var shell = cut.Find("details.app-shell"); @@ -62,4 +62,18 @@ public class ThemeShellTests : TestContext Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml); Assert.DoesNotContain("data-bs-target", shell.OuterHtml); } + + [Fact] + public void Closed_shell_keeps_page_body_separate_from_the_toggled_rail() + { + var cut = Render(p => p.Add(x => x.Product, "OEE") + .Add(x => x.Nav, builder => builder.AddMarkupContent(0, "OEE")) + .AddChildContent("

OEE report

")); + + Assert.False(cut.Find("details.app-shell").HasAttribute("open")); + Assert.NotNull(cut.Find("details.app-shell > #theme-rail > nav")); + Assert.Equal("OEE report", cut.Find("details.app-shell > main.page > h1").TextContent); + Assert.NotNull(cut.Find("details.app-shell > main.page > input")); + Assert.Empty(cut.FindAll("#theme-rail main")); + } } diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/TreeTableTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/TreeTableTests.cs new file mode 100644 index 0000000..753bed0 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/TreeTableTests.cs @@ -0,0 +1,70 @@ +namespace ZB.MOM.WW.Theme.Tests; + +public sealed class TreeTableTests : BunitContext +{ + private sealed record TestRow(string Key, int Depth, bool HasChildren, string Name) : ITreeRow; + + private IRenderedComponent> RenderTreeTable(IReadOnlyList rows, + IReadOnlySet? collapsed = null, Action? toggle = null) + => Render>(p => p + .Add(x => x.Rows, rows) + .Add(x => x.Collapsed, collapsed ?? new HashSet()) + .Add(x => x.Label, row => row.Name) + .Add(x => x.Head, builder => builder.AddMarkupContent(0, "ScopeValue")) + .Add(x => x.Row, row => builder => + { + builder.OpenElement(0, "td"); + builder.AddContent(1, "Value for " + row.Name); + builder.CloseElement(); + }) + .Add(x => x.OnToggle, key => toggle?.Invoke(key))); + + [Fact] + public void Caller_fragments_and_arbitrary_depth_are_rendered_without_inline_stylesheets() + { + var cut = RenderTreeTable([new("root", 0, true, "Site"), new("deep", 7, false, "Machine <7>")]); + Assert.Equal(2, cut.FindAll("thead th").Count); + Assert.Equal(2, cut.FindAll("tbody tr").Count); + Assert.Equal("Value for Machine <7>", cut.FindAll("tbody td")[3].TextContent); + Assert.Contains("Machine <7>", cut.Find(".tt-d7 .tt-name").TextContent); + Assert.Contains("--tt-depth: 7", cut.Find(".tt-d7 .tt-name").GetAttribute("style")); + Assert.Empty(cut.FindAll("style")); + } + + [Fact] + public void Leaf_keeps_alignment_without_promising_a_subtree() + { + var cut = RenderTreeTable([new("leaf", 1, false, "Cell")]); + var twist = cut.Find("button"); + Assert.Contains("tt-leaf", twist.ClassList); + Assert.True(twist.HasAttribute("disabled")); + Assert.False(twist.HasAttribute("aria-expanded")); + Assert.False(twist.HasAttribute("aria-label")); + Assert.Equal("true", cut.Find("i").GetAttribute("aria-hidden")); + } + + [Fact] + public void Toggle_reports_stable_key_and_caller_controls_expansion() + { + string? toggled = null; + var cut = RenderTreeTable([new("site-key", 0, true, "Site")], + new HashSet { "site-key" }, key => toggled = key); + Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded")); + Assert.Equal("Expand Site", cut.Find("button").GetAttribute("aria-label")); + cut.Find("button").Click(); + Assert.Equal("site-key", toggled); + Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded")); + cut.Render(p => p.Add(x => x.Collapsed, new HashSet())); + Assert.Equal("true", cut.Find("button").GetAttribute("aria-expanded")); + Assert.Equal("Collapse Site", cut.Find("button").GetAttribute("aria-label")); + Assert.Contains("bi-chevron-down", cut.Find("i").ClassList); + } + + [Fact] + public void Empty_rows_keep_the_header_and_empty_body() + { + var cut = RenderTreeTable([]); + Assert.Equal(2, cut.FindAll("thead th").Count); + Assert.Empty(cut.FindAll("tbody tr")); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj index 0c719d7..b446a22 100644 --- a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj @@ -4,6 +4,7 @@ +