feat(theme): upstream router controls and responsive shell fixes
This commit is contained in:
@@ -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<TRow>` 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.**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.4.2</Version>
|
||||
<Version>0.5.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="bunit" Version="1.40.0" />
|
||||
<PackageVersion Include="bunit" Version="2.7.2" />
|
||||
<!-- Test DOM parser: direct pin avoids bUnit's vulnerable 1.2.0 minimum. -->
|
||||
<PackageVersion Include="AngleSharp" Version="1.5.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
@@ -65,7 +65,8 @@ server-side** before redirecting (open-redirect risk):
|
||||
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` 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 `<AntiforgeryToken/>` 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<TRow>` (`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<TRow>` 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.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Theme;
|
||||
|
||||
/// <summary>Bootstrap size modifier for <c>TechButton</c>.</summary>
|
||||
public enum ButtonSize
|
||||
{
|
||||
/// <summary>The theme's standard button size, without a Bootstrap size modifier.</summary>
|
||||
Default,
|
||||
/// <summary>Compact button, using <c>btn-sm</c>.</summary>
|
||||
Small,
|
||||
/// <summary>Large button, using <c>btn-lg</c>.</summary>
|
||||
Large,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@namespace ZB.MOM.WW.Theme
|
||||
@* Components/TechButton.razor *@
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="btn @VariantClass" disabled="@Busy">
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="@ButtonClass" disabled="@Busy">
|
||||
@if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
|
||||
@ChildContent
|
||||
</button>
|
||||
@@ -12,6 +12,12 @@
|
||||
/// </summary>
|
||||
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
|
||||
|
||||
/// <summary>Bootstrap size modifier. Defaults to the theme's standard button size.</summary>
|
||||
[Parameter] public ButtonSize Size { get; set; }
|
||||
|
||||
/// <summary>Additional CSS classes, composed with the variant, size and unmatched class attribute.</summary>
|
||||
[Parameter] public string? Class { get; set; }
|
||||
|
||||
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
|
||||
[Parameter] public string Type { get; set; } = "button";
|
||||
|
||||
@@ -30,6 +36,14 @@
|
||||
/// </summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? 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",
|
||||
|
||||
@@ -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. *@
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>@Head</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (TRow row in Rows)
|
||||
{
|
||||
<tr @key="row.Key" class="tt-d@(row.Depth)">
|
||||
<td class="tt-name" style="@DepthStyle(row)">
|
||||
<button type="button" class="@TwistClass(row)" disabled="@(!row.HasChildren)"
|
||||
aria-expanded="@AriaExpanded(row)" aria-label="@TwistLabel(row)"
|
||||
@onclick="() => OnToggle.InvokeAsync(row.Key)">
|
||||
<i class="@ChevronClass(row)" aria-hidden="true"></i>
|
||||
</button>
|
||||
@Label(row)
|
||||
</td>
|
||||
@Row(row)
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The rows to render, in order — already filtered through
|
||||
/// the caller's visibility policy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The filtering is the caller's so that this component holds no state at all: the page owns
|
||||
/// <see cref="Collapsed"/>, and what is on screen is a pure function of it.
|
||||
/// </remarks>
|
||||
[Parameter, EditorRequired] public IReadOnlyList<TRow> Rows { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The header cells — every <c><th></c>, including the one over the name column. The
|
||||
/// component supplies the <c><thead></c> and its <c><tr></c>.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public RenderFragment Head { get; set; } = builder => { };
|
||||
|
||||
/// <summary>
|
||||
/// One row's cells <b>after</b> the name cell: the component renders the name column, the
|
||||
/// caller renders the rest.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public RenderFragment<TRow> Row { get; set; } = _ => builder => { };
|
||||
|
||||
/// <summary>The text of a row's name cell, beside its twist.</summary>
|
||||
[Parameter, EditorRequired] public Func<TRow, string> Label { get; set; } = _ => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The keys of the collapsed rows — read only to draw each twist the right way round.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It is the same set the caller filtered <see cref="Rows"/> with, so a row can be collapsed
|
||||
/// and on screen (that is what a shut branch looks like) but never expanded and hidden.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlySet<string> Collapsed { get; set; } = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Raised with a row's <see cref="ITreeRow.Key"/> when its twist is clicked. The caller
|
||||
/// toggles that key in its own collapse set; this component changes nothing itself.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<string> 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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.Theme;
|
||||
|
||||
/// <summary>A row in a preorder flat tree, with each subtree contiguous after its parent.</summary>
|
||||
public interface ITreeRow
|
||||
{
|
||||
/// <summary>Stable identity, unique in the table and used for keyed rendering and collapse state.</summary>
|
||||
string Key { get; }
|
||||
|
||||
/// <summary>Nesting depth, starting at zero for a root.</summary>
|
||||
int Depth { get; }
|
||||
|
||||
/// <summary>Whether this row owns a subtree and offers an expand/collapse toggle.</summary>
|
||||
bool HasChildren { get; }
|
||||
}
|
||||
@@ -11,10 +11,12 @@
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Theme</PackageId>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
|
||||
|
||||
@@ -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 <details> 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 <details> 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 <details> 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 <details> open state (the hamburger
|
||||
toggle is hidden at this width). */
|
||||
@media (min-width: 992px) {
|
||||
/* Chromium >=121 wraps a <details>'s content in a generated ::details-content
|
||||
box that carries content-visibility:hidden while the <details> 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 <lg viewports (the <details> 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 `<span class="row-actions">`
|
||||
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; }
|
||||
|
||||
@@ -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<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<BrandBar>(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<BrandBar>(p => p
|
||||
var cut = Render<BrandBar>(p => p
|
||||
.Add(x => x.Product, "ScadaBridge")
|
||||
.Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
|
||||
Assert.NotNull(cut.Find(".brand .logo"));
|
||||
|
||||
@@ -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<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
|
||||
var cut = Render<TechButton>(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<TechButton>(p => p
|
||||
var cut = Render<TechButton>(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<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
|
||||
var cut = Render<TechButton>(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<TechCard>(p => p
|
||||
var cut = Render<TechCard>(p => p
|
||||
.Add(x => x.Title, "Drivers")
|
||||
.AddChildContent("<div class='b'>x</div>"));
|
||||
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<TechField>(p => p
|
||||
var cut = Render<TechField>(p => p
|
||||
.Add(x => x.Label, "Name")
|
||||
.Add(x => x.Hint, "required")
|
||||
.Add(x => x.Error, "missing")
|
||||
|
||||
@@ -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<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(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<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(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<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(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<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<LoginCard>(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<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<LoginCard>(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<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.Heading, "Welcome back"));
|
||||
var title = cut.Find(".login-title");
|
||||
|
||||
@@ -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<NavRailItem>(p => p
|
||||
var cut = Render<NavRailItem>(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<NavRailItem>(p => p
|
||||
var cut = Render<NavRailItem>(p => p
|
||||
.Add(x => x.Href, "/clusters")
|
||||
.Add(x => x.Text, "Clusters")
|
||||
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
|
||||
@@ -29,7 +29,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailItem_omits_rail_ico_span_when_no_icon()
|
||||
{
|
||||
var cut = RenderComponent<NavRailItem>(p => p
|
||||
var cut = Render<NavRailItem>(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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Navigation")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Navigation")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Site Calls")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, " ")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
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<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Café")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<StatusPill>(p => p
|
||||
var cut = Render<StatusPill>(p => p
|
||||
.Add(x => x.State, state)
|
||||
.AddChildContent("Connected"));
|
||||
var span = cut.Find("span");
|
||||
|
||||
@@ -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<TechButton>(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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["class"] = "caller", ["disabled"] = false,
|
||||
["onclick"] = EventCallback.Factory.Create(this, () => clicks++)
|
||||
};
|
||||
var cut = Render<TechButton>(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<TechButton>(p => p.AddUnmatched("class", "caller").AddChildContent("Go"));
|
||||
foreach (var cls in new[] { "btn", "btn-primary", "caller" })
|
||||
Assert.Contains(cls, cut.Find("button").ClassList);
|
||||
}
|
||||
}
|
||||
@@ -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<ThemeHead>();
|
||||
var cut = Render<ThemeHead>();
|
||||
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);
|
||||
|
||||
@@ -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<ThemeScripts>();
|
||||
var cut = Render<ThemeScripts>();
|
||||
var script = cut.Find("script");
|
||||
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
|
||||
Assert.True(script.HasAttribute("defer"));
|
||||
|
||||
@@ -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<ThemeShell>(p => p
|
||||
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>")))
|
||||
.AddChildContent("<div class='pagebody'>BODY</div>"));
|
||||
@@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext
|
||||
[Fact]
|
||||
public void Accent_sets_css_variable_on_shell_root()
|
||||
{
|
||||
var cut = RenderComponent<ThemeShell>(p => p
|
||||
var cut = Render<ThemeShell>(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<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
|
||||
var cut = Render<ThemeShell>(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<ThemeShell>(p => p
|
||||
var cut = Render<ThemeShell>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
|
||||
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<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
|
||||
// Root is a <details> whose direct <summary> 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<ThemeShell>(p => p.Add(x => x.Product, "OEE")
|
||||
.Add(x => x.Nav, builder => builder.AddMarkupContent(0, "<a href='/oee'>OEE</a>"))
|
||||
.AddChildContent("<h1>OEE report</h1><input aria-label='Filter' />"));
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TreeTable<TestRow>> RenderTreeTable(IReadOnlyList<TestRow> rows,
|
||||
IReadOnlySet<string>? collapsed = null, Action<string>? toggle = null)
|
||||
=> Render<TreeTable<TestRow>>(p => p
|
||||
.Add(x => x.Rows, rows)
|
||||
.Add(x => x.Collapsed, collapsed ?? new HashSet<string>())
|
||||
.Add(x => x.Label, row => row.Name)
|
||||
.Add(x => x.Head, builder => builder.AddMarkupContent(0, "<th>Scope</th><th>Value</th>"))
|
||||
.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<string> { "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<string>()));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
|
||||
Reference in New Issue
Block a user