feat(ui/templates): adopt TreeView design guide; split editor to /design/templates/{id}

Templates page is now a tree-only browser; editing happens on a dedicated
TemplateEdit page. Drag-drop is replaced by context-menu Move-to-Folder.
TreeView gains Bootstrap Icons (chevron + per-kind glyphs), ancestor guide
lines, defined hover/selected/focus tokens, and Escape-dismisses-menu per
the new Visual Design Guide (V1-V7) in Component-TreeView.md.
This commit is contained in:
Joseph Doherty
2026-05-11 20:52:34 -04:00
parent f3b33e7e1d
commit 8e388a89c5
14 changed files with 3515 additions and 1127 deletions

View File

@@ -64,10 +64,9 @@ The `NodeContent` fragment receives the `TItem` and is responsible for rendering
### R4 — Indentation and Visual Structure
- Each depth level is indented by a fixed amount (default 24px, configurable via `IndentPx` parameter).
- Vertical guide lines connect parent to children at each depth level (thin left-border or CSS pseudo-element).
- The toggle icon is inline with the node content, left-aligned at the current depth.
- Leaf nodes align with sibling branch labels (the content starts at the same horizontal position, with empty space where the toggle would be).
The component renders the structural chrome: indent gutters per depth, the toggle slot, and ancestor guide lines. Leaf nodes render an empty toggle placeholder so labels align across siblings.
The exact tokens (indent unit, toggle glyph, guide-line treatment) are specified in **V2** of the Visual Design Guide.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -132,12 +131,11 @@ This keeps filter logic in the page (domain-specific) while the component handle
### R9 — Styling
- Uses Bootstrap 5 utility classes only (no third-party frameworks).
- No hardcoded colors — uses standard Bootstrap text/background utilities.
- Toggle icons: Unicode characters (`+` / ``) in a `<span>` with `cursor: pointer`, or a small SVG chevron. No icon library dependency.
- Compact row height for dense data (matching `table-sm` density).
- Hover effect on rows: subtle background highlight (`bg-light` or similar).
- CSS scoped to the component via Blazor CSS isolation (`TreeView.razor.css`).
- Uses Bootstrap 5 utility classes and CSS variables. No third-party Blazor component frameworks.
- Adds one icon-library dependency: **Bootstrap Icons** (static files at `wwwroot/lib/bootstrap-icons/`). Distribution rules in **V4** of the Visual Design Guide.
- Hardcoded colors are forbidden; use Bootstrap utility classes (`bg-primary bg-opacity-10`, `text-muted`) or CSS variables (`var(--bs-tertiary-bg)`, `var(--bs-border-color)`).
- Component-local CSS lives in `TreeView.razor.css` (Blazor CSS isolation).
- All visual tokens (row density, indent, state visuals, glyphs, labels, badges) are specified in the **Visual Design Guide** (V1V7). This requirement is non-normative summary; the Guide is authoritative.
### R10 — No Internal Scrolling
@@ -296,7 +294,183 @@ Future enhancement. Single selection (R5) covers current needs. A future version
- Shift+click for range select, Ctrl+click for toggle
- Use case: bulk operations (select multiple instances → deploy/disable all)
## Component API Summary
## Visual Design Guide
This section is the canonical visual specification for the TreeView. It is normative: any change to the chrome (row layout, indentation, glyphs, state visuals, badge styling) must update this section. Consumers' `NodeContent` fragments follow the label and badge recipes in V5V6; `/design/templates` is the worked example in V7.
R4 and R9 above describe *that* the component renders structural chrome and uses Bootstrap utilities. This section says *exactly how*.
### V1 — Density & Row Anatomy
Each `<li role="treeitem">` renders one row. The row is a flexbox so trailing meta can right-align cleanly and the entire row width is a hover/selected/drop-target surface.
**Row container** (replaces today's `.tv-row` styling):
```html
<div class="tv-row d-flex align-items-center"
style="gap:.25rem; padding:.25rem .5rem; padding-left: calc(.5rem + var(--tv-indent, 0px));">
<span class="tv-toggle">…chevron or placeholder…</span>
<span class="tv-glyph">…Bootstrap Icon or placeholder…</span>
<span class="tv-label">…primary + secondary…</span>
<span class="tv-meta ms-auto">…badges…</span>
</div>
```
| Token | Value | Notes |
|---|---|---|
| Row vertical padding | `py-1` (0.25rem top/bottom) | Yields ~32px row height at base font-size + line-height 1.5. |
| Row horizontal padding | `px-2` (0.5rem left/right) | Selected/hover background spans full row including this padding. |
| Inter-slot gap | `gap: .25rem` | Between toggle, glyph, label. The meta slot is offset by `margin-left: auto`. |
| Font size | inherits (1rem base) | Compact pages may opt into `small` per-page, not at the component level. |
| Line height | inherits (1.5) | Aligns the chevron, glyph, and label baselines correctly. |
| Toggle slot width | 20px (`width: 1.25rem`) | Always present, even on leaves (which render an empty placeholder). |
| Glyph slot width | 20px (`width: 1.25rem`) | Always present; consumer may render an empty span to preserve alignment. |
| Label slot | `flex: 1 1 auto; min-width: 0;` | `min-width: 0` is required for ellipsis truncation to work in a flex child. |
| Meta slot | `margin-left: auto;` | Pushes badges to the right edge of the row. |
**Hit semantics**:
- The full row (`tv-row`) is the surface for hover, selected, focus-visible, and drop-target backgrounds.
- Click-to-select fires only on the **label slot** (preserves R5: toggle clicks do not select).
- The toggle slot's invisible tap target is enlarged by negative margins inside the 20px slot so it remains a comfortable 24×24px target.
### V2 — Depth, Indent & Guide Lines
| Token | Value |
|---|---|
| Indent per depth | 24px (`IndentPx` default, unchanged) |
| Toggle glyph (collapsed) | `<i class="bi bi-chevron-right">` |
| Toggle glyph (expanded) | `<i class="bi bi-chevron-down">` (or `bi-chevron-right` rotated 90° via CSS) |
| Guide line color | `var(--bs-border-color)` |
| Guide line width | 1px |
| Guide line style | solid, vertical-only (no horizontal stubs) |
| Guide line position | one line per ancestor depth, drawn down the indent column (left edge of each 24px indent slot) |
| Guide lines enabled | `ShowGuideLines` parameter (default true) |
| Leaf alignment | identical depth gutter as siblings; the toggle slot renders an empty placeholder so glyphs and labels align across leaves and branches |
Implementation note: guide lines are drawn by repeating a `linear-gradient` background or by stacking `border-left` on indent spacers — both are pure CSS, no extra DOM. The current `tv-guides` class is the hook.
### V3 — State Visuals
States compose: focus rings layer on top of hover/selected; drop-target overrides hover and selected. All states paint the full row width (V1).
| State | Visual | Implementation |
|---|---|---|
| Default | none | — |
| Hover | full-row tint | `background: var(--bs-tertiary-bg);` on `:hover` of `.tv-row` |
| Focus-visible | inset 2px primary ring | `box-shadow: inset 0 0 0 2px var(--bs-primary);` on `:focus-visible` |
| Selected | full-row primary tint | `class="bg-primary bg-opacity-10"` (existing `SelectedCssClass` default, unchanged) |
| Selected + hover | selected tint persists; hover does not deepen | hover background applies only when not selected (`:hover:not(.bg-primary)`) |
| Selected + focus | tint + ring both visible | focus ring layers via box-shadow |
| Drop-target (valid) | `bg-info bg-opacity-25` | overrides hover/selected backgrounds; opt-in per consumer |
| Drop-target (invalid) | cursor `not-allowed`, no tint change | absence of valid-tint is the cue |
| Dragging source | `opacity: 0.5` | applied to the row currently being dragged |
| Dimmed (non-droppable while a drag is in progress) | `opacity: 0.5` | applied to nodes the consumer marks as unsuitable drop targets |
Drag-drop is **not** part of the TreeView component's intrinsic behavior — it is opt-in per consuming page. The drag-related state visuals (drop-target, dragging, dimmed) are documented here so consumers that *do* implement DnD share the same visual language. The `/design/templates` page (V7) explicitly does **not** use drag-drop; reorganization happens via the right-click context menu.
### V4 — Glyph & Icon System
**Distribution**: Bootstrap Icons ships as static files under `src/ScadaLink.CentralUI/wwwroot/lib/bootstrap-icons/` (`bootstrap-icons.css` + `fonts/*.woff2`). Referenced once from `MainLayout.razor`:
```html
<link rel="stylesheet" href="~/lib/bootstrap-icons/bootstrap-icons.css" />
```
No CDN dependency — works on air-gapped industrial deployments. Version pinned in the file path or filename.
**Rules**:
- Glyphs are inline `<i class="bi bi-…"></i>` elements inside the 20px glyph slot.
- Branches render an **open/closed pair**: a `closed` glyph when collapsed, an `open` glyph when expanded (consumer chooses both via `NodeContent`). The chevron toggle reinforces the same state.
- Leaves render a single static glyph or no glyph (empty span preserves alignment).
- **Color**: glyphs inherit `color` from their row. Default is body text; consumers may apply `text-muted` for de-emphasis. Kind is communicated by *shape*, not by color, to keep the palette available for status badges.
- **Size**: glyphs render at `1em` (inherits row font-size). No fixed pixel size.
### V5 — Label Recipe & Typography
The label slot contains, in order: **[primary] [secondary modifiers]**. Trailing meta lives in the separate `.tv-meta` slot (V1).
| Element | Style |
|---|---|
| Primary label (branches) | `class="fw-semibold"` |
| Primary label (leaves) | normal weight |
| Secondary modifiers | `class="text-muted small ms-1"` |
| Overflow handling | `.tv-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }` |
| Tooltip | `title` attribute on the primary label span, set to the full name on every row (cheap, helps when the row is narrower than the name) |
**Rule of thumb**: font-weight tracks *has children*, not *kind*. A folder with no children renders regular weight; a leaf-template promoted to a branch by adding compositions becomes semibold automatically.
### V6 — Badge Taxonomy
Three semantic badge roles. The meta slot holds **at most two** badges per row. All badges live in `.tv-meta`, right-aligned (V1).
| Role | Purpose | Markup | Examples |
|---|---|---|---|
| Count | numeric child aggregation | `<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@N</span>` | folder child count; area instance count |
| Status | semantic state | `<span class="badge bg-{success\|warning\|danger\|info}">@Label</span>` | Enabled / Disabled / Stale / Error |
| Kind | category / type tag | same filled semantic style, used sparingly | Protocol (OPC UA), Source (Inherited) |
**Rules**:
- Counts represent **direct children only**. Never transitive descendants.
- A count of 0 **renders nothing** — no badge at all.
- Status uses Bootstrap semantic colors; do not introduce custom palettes.
- The component does not enforce the 2-badge cap; it is a documented convention. PR review should catch violations.
### V7 — Worked Example: `/design/templates`
**Page model**: the templates page is a **tree browser only**. Selecting a template in the tree navigates to a dedicated edit page (`/design/templates/{id}`); creating a template navigates to `/design/templates/create`. No split-pane editor. Reorganization (move folder, move template) happens exclusively through the **right-click context menu** with modal dialog pickers — there is no drag-and-drop on this page.
Three node kinds; concrete recipes following V1V6.
| Kind | Glyph (collapsed) | Glyph (expanded) | Primary | Secondary | Badges |
|---|---|---|---|---|---|
| Folder | `bi-folder` | `bi-folder2-open` | folder name (semibold when has children, regular otherwise) | — | count of direct children (subtle pill), only if ≥ 1 |
| Template | `bi-file-earmark-text` | same (templates with compositions still use the same glyph — chevron carries state) | `$Name` (semibold when has compositions, regular otherwise) | — | none |
| Composition | `bi-arrow-return-right` | n/a (leaf, no expanded state) | composition instance name (regular weight) | `<span class="text-muted small ms-1">→ $TargetName</span>` | none |
**`NodeContent` fragment** for the templates page (replaces the current `RenderNodeLabel` in `Templates.razor`):
```razor
@switch (node.Kind)
{
case TmplNodeKind.Folder:
var folderOpen = _tree.IsExpanded(node.Key);
<span class="tv-glyph"><i class="bi @(folderOpen ? "bi-folder2-open" : "bi-folder")"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label">@node.Label</span>
@if (node.Children.Count > 0)
{
<span class="tv-meta ms-auto">
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
</span>
}
break;
case TmplNodeKind.Template:
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label">@node.Label</span>
break;
case TmplNodeKind.Composition:
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
?? $"#{node.Composition!.ComposedTemplateId}";
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
<span class="tv-label" title="@node.Label">
@node.Label
<span class="text-muted small ms-1">→ @composedName</span>
</span>
break;
}
```
**Locked subtractions from the previous design**:
- Template node "inherits $Parent" muted text — **removed**. Inheritance is shown in the right pane only.
- Template node "X attr, Y alm, Z scr" compound badge — **removed**.
- Template node "N comp" accent badge — **removed**.
These subtractions are deliberate: templates are leaves-from-the-tree's-perspective (their inner attributes/alarms/scripts are not tree-navigable), so the tree row should carry only what's needed to identify and pick the template. All counts and inheritance information live in the right detail pane.
```csharp
@typeparam TItem