# TreeView Component

## Purpose

A reusable, generic Blazor Server component that renders hierarchical data as an expandable/collapsible tree. The component is data-agnostic — it accepts any tree-shaped data via type parameters and render fragments, following the same pattern as the existing `DataTable<TItem>` shared component.

## Location

`src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor`

## Primary Use Case: Instance Hierarchy

The motivating use case is displaying instances organized by site and area:

```
- Site A
  + Area 1
    - Sub Area 1
        Instance 1
        Instance 2
  + Area 2
+ Site B
+ Site C
```

**Hierarchy**: Site → Area → Sub Area (recursive) → Instance (leaf)

Nodes at each level may be expandable (branches) or plain items (leaves). Leaf nodes have no expand/collapse toggle.

## Requirements

### R1 — Generic Type Parameter

The component accepts a single type parameter `TItem` representing any node in the tree. The consumer provides:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `Items` | `IReadOnlyList<TItem>` | Yes | Root-level items |
| `ChildrenSelector` | `Func<TItem, IReadOnlyList<TItem>>` | Yes | Returns children for a given node |
| `HasChildrenSelector` | `Func<TItem, bool>` | Yes | Whether the node can be expanded (branch vs. leaf) |
| `KeySelector` | `Func<TItem, object>` | Yes | Unique key per node (for state tracking) |

### R2 — Render Fragments

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `NodeContent` | `RenderFragment<TItem>` | Yes | Renders the label/content for each node |
| `EmptyContent` | `RenderFragment?` | No | Shown when `Items` is empty |

The `NodeContent` fragment receives the `TItem` and is responsible for rendering the node's display (text, icons, badges, action buttons, etc.). The tree component only renders the structural chrome (indentation, expand/collapse toggle, vertical guide lines).

### R3 — Expand/Collapse Behavior

- Each branch node displays a toggle indicator: `+` when collapsed, `−` when expanded.
- Clicking the **toggle icon** expands/collapses the node. Clicking the **content area** does **not** toggle expansion (it is reserved for selection — see R5).
- Leaf nodes (where `HasChildrenSelector` returns `false`) display no toggle — they are indented inline with sibling branch nodes.
- Expand/collapse state is tracked internally by the component using `KeySelector` for identity.
- All nodes start collapsed by default unless `InitiallyExpanded` is set.
- **Session persistence**: When the user navigates away and returns, previously expanded nodes are restored (see R11).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `InitiallyExpanded` | `Func<TItem, bool>?` | No | Predicate — nodes matching this start expanded (first load only, before any persisted state exists) |

### R4 — Indentation and Visual Structure

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 |
|-----------|------|----------|-------------|
| `IndentPx` | `int` | No | Pixels per indent level. Default: 24 |
| `ShowGuideLines` | `bool` | No | Show vertical connector lines. Default: true |

### R5 — Selection

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `Selectable` | `bool` | No | Enable click-to-select. Default: false |
| `SelectedKey` | `object?` | No | Currently selected node key (two-way binding) |
| `SelectedKeyChanged` | `EventCallback<object?>` | No | Fires when selection changes |
| `SelectedCssClass` | `string` | No | CSS class for selected node. Default: `"bg-primary bg-opacity-10"` |

When `Selectable` is true, clicking a node row selects it (highlighted). Clicking the expand/collapse toggle does **not** change selection — only clicking the content area does.

### R6 — Lazy Loading (Deferred)

Future enhancement. For now, all children are provided synchronously via `ChildrenSelector`. A future version may support `Func<TItem, Task<IReadOnlyList<TItem>>>` for on-demand loading with a spinner placeholder.

### R7 — Keyboard Navigation (Deferred)

Future enhancement. Arrow keys for navigation, Enter/Space for expand/collapse, Home/End for first/last.

### R8 — External Filtering

The tree component itself does **not** implement filter UI. Filtering is driven externally by the consuming page — for example, a site dropdown that filters the tree to show only the selected site's hierarchy.

**How it works:**
- The consumer filters `Items` (and/or adjusts `ChildrenSelector` results) and passes the filtered list to the component.
- When `Items` changes (Blazor re-render), the component re-renders the tree with the new data.
- **Expansion state is preserved across filter changes.** Nodes that were expanded before filtering remain expanded if they reappear after the filter changes. The component tracks expanded keys independently of the current `Items` — keys are never purged when items disappear, so re-adding a previously expanded node restores its expanded state.
- Selection is cleared if the selected node is no longer present after filtering.

**Example — site filter on the instances page:**
```razor
<select class="form-select form-select-sm" @bind="_selectedSiteId">
    <option value="">All Sites</option>
    @foreach (var site in _sites)
    {
        <option value="@site.Id">@site.Name</option>
    }
</select>

<TreeView TItem="TreeNode" Items="GetFilteredRoots()" ...>
    ...
</TreeView>

@code {
    private int? _selectedSiteId;

    private List<TreeNode> GetFilteredRoots()
    {
        if (_selectedSiteId == null) return _allRoots;
        return _allRoots.Where(r => r.SiteId == _selectedSiteId).ToList();
    }
}
```

This keeps filter logic in the page (domain-specific) while the component handles rendering whatever it receives.

### R9 — Styling

- 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** (V1–V7). This requirement is non-normative summary; the Guide is authoritative.

### R10 — No Internal Scrolling

The tree renders inline in the page flow. The consuming page is responsible for placing it in a scrollable container if needed (e.g., `overflow-auto` with `max-height`).

### R11 — Session-Persistent Expansion State

When a user expands nodes, navigates away (e.g., clicks an instance link to the configure page), and returns to the page, the tree must restore the same expansion state.

**Mechanism:**
- The component requires a `StorageKey` parameter — a unique string identifying this tree instance (e.g., `"instances-tree"`, `"data-connections-tree"`).
- Expanded node keys are stored in browser `sessionStorage` under the key `treeview:{StorageKey}`.
- On mount (`OnAfterRenderAsync` first render), the component reads `sessionStorage` and expands any nodes whose keys are present. This takes precedence over `InitiallyExpanded`.
- On every expand/collapse toggle, the component writes the updated set of expanded keys to `sessionStorage`.
- `sessionStorage` is scoped to the browser tab — each tab has independent state. State is cleared when the tab is closed.

**Implementation note:** Blazor Server requires `IJSRuntime` to access `sessionStorage`. The component injects `IJSRuntime` and uses a small JS interop helper (inline or in a shared `.js` file) for `getItem`/`setItem`.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `StorageKey` | `string?` | No | Key for sessionStorage persistence. If null, expansion state is not persisted (in-memory only). |

### R12 — Expand All / Collapse All

The component exposes methods that the consumer can call via `@ref`:

```csharp
/// Expands all branch nodes in the tree (recursive).
public void ExpandAll();

/// Collapses all branch nodes in the tree.
public void CollapseAll();
```

**Usage:**
```razor
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.ExpandAll()">Expand All</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.CollapseAll()">Collapse All</button>

<TreeView @ref="_tree" TItem="TreeNode" ... />

@code {
    private TreeView<TreeNode> _tree = default!;
}
```

Both methods update sessionStorage if `StorageKey` is set. `ExpandAll` requires walking the full tree via `ChildrenSelector` to collect all branch node keys.

### R13 — Programmatic Expand-to-Node

The component exposes a method to reveal a specific node by expanding all of its ancestors:

```csharp
/// Expands all ancestor nodes so that the node with the given key becomes visible.
/// Optionally selects the node and scrolls it into view.
public void RevealNode(object key, bool select = false);
```

This requires the component to build a parent lookup (key → parent key) from the tree data. When called:

1. Walk from the target node's key up to the root, collecting ancestor keys.
2. Expand all ancestors.
3. If `select` is true, set the node as selected and fire `SelectedKeyChanged`.
4. After rendering, scroll the node element into view via JS interop (`element.scrollIntoView({ block: 'nearest' })`).

**Use case:** Search box on the instances page — user types "Motor-1", results list shows matching instances. Clicking a result calls `_tree.RevealNode(instanceKey, select: true)` to expand the Site → Area path and highlight the instance.

### R14 — Accessibility (ARIA)

The component renders semantic ARIA attributes for screen reader support:

- The root `<ul>` has `role="tree"`.
- Each node `<li>` has `role="treeitem"`.
- Branch nodes have `aria-expanded="true"` or `aria-expanded="false"`.
- Child `<ul>` containers have `role="group"`.
- When `Selectable` is true, the selected node has `aria-selected="true"`.
- Each node row has a unique `id` derived from `KeySelector` for anchor targeting.

This is baseline accessibility — no keyboard navigation yet (deferred in R7), but screen readers can understand the tree structure.

### R15 — Context Menu

The component supports an optional right-click context menu on nodes, defined by the consumer via a render fragment.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `ContextMenu` | `RenderFragment<TItem>?` | No | Menu content rendered when a node is right-clicked. Receives the right-clicked `TItem`. |

**Behavior:**
- Right-clicking a node renders the `ContextMenu` fragment for that node. The component checks whether the fragment produces any content — **if the fragment renders nothing (empty markup), no menu is shown and the browser default context menu is used.** This is how per-node-type menus work: the consumer uses `@if` blocks in the fragment, and nodes that don't match any condition simply produce no output.
- When content is produced, the browser's default context menu is suppressed (`@oncontextmenu:preventDefault`) and a floating menu is shown at the cursor.
- The menu is rendered as a Bootstrap dropdown: `<div class="dropdown-menu show">` containing `<button class="dropdown-item">` elements.
- Clicking a menu item or clicking anywhere outside the menu dismisses it.
- Pressing Escape dismisses the menu.
- Only one context menu is visible at a time — right-clicking another node replaces the current menu.
- If the `ContextMenu` parameter itself is null (not provided), right-click always uses the browser default for all nodes.

**The consumer controls which items appear and what they do:**
```razor
<TreeView TItem="TreeNode" Items="_roots" ... >
    <NodeContent Context="node">
        <span>@node.Label</span>
    </NodeContent>
    <ContextMenu Context="node">
        @if (node.Kind == NodeKind.Instance)
        {
            <button class="dropdown-item" @onclick="() => DeployInstance(node)">
                Deploy
            </button>
            @if (node.State == InstanceState.Enabled)
            {
                <button class="dropdown-item" @onclick="() => DisableInstance(node)">
                    Disable
                </button>
            }
            else if (node.State == InstanceState.Disabled)
            {
                <button class="dropdown-item" @onclick="() => EnableInstance(node)">
                    Enable
                </button>
            }
            <button class="dropdown-item" @onclick="() => NavigateToConfigure(node)">
                Configure
            </button>
            <button class="dropdown-item" @onclick="() => ShowDiff(node)">
                Diff
            </button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger" @onclick="() => DeleteInstance(node)">
                Delete
            </button>
        }
        else if (node.Kind == NodeKind.Site)
        {
            <button class="dropdown-item" @onclick="() => DeployAllInSite(node)">
                Deploy All
            </button>
        }
    </ContextMenu>
</TreeView>
```

This keeps the tree clean — no inline action buttons cluttering leaf nodes. Different node types can show different menu items (instances get full CRUD actions, sites might get bulk operations, areas might have no menu at all).

**Positioning:**
- The menu is absolutely positioned relative to the viewport using the mouse event's `clientX`/`clientY`.
- If the menu would overflow the viewport bottom or right edge, it flips direction (opens upward or leftward).
- The component handles positioning internally — no JS interop needed (CSS `position: fixed` with `top`/`left` from the mouse event).

### R16 — Multi-Selection (Deferred)

Future enhancement. Single selection (R5) covers current needs. A future version may add:

- `MultiSelect` bool parameter
- `SelectedKeys` / `SelectedKeysChanged` for set-based selection
- Shift+click for range select, Ctrl+click for toggle
- Use case: bulk operations (select multiple instances → deploy/disable all)

## 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 V5–V6; `/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/ZB.MOM.WW.ScadaBridge.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 (permanently deferred; M9/T23 delivers menu-based folder sibling reorder instead).

Three node kinds; concrete recipes following V1–V6.

| 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) | — | none |

**Template tree search (M9/T22):** A search box above the tree is wired to `TemplateFolderTree.Filter`. Typing a term dims non-matching nodes (case-insensitive substring against folder/template name) while preserving tree shape and expansion state.

**Folder sibling reorder (M9/T23):** The folder context menu exposes **Move up** / **Move down** items that invoke `ReorderTemplateFolderCommand` to swap `SortOrder` values for adjacent siblings. Root-level folders also have a context menu (previously only nested folders did). Drag-drop reorganization is **not implemented** — it is permanently deferred; the menu is the intended interaction model.

**`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:
        <span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
        <span class="tv-label" title="@node.Label">@node.Label</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

// Data
[Parameter] public IReadOnlyList<TItem> Items { get; set; }
[Parameter] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; }
[Parameter] public Func<TItem, bool> HasChildrenSelector { get; set; }
[Parameter] public Func<TItem, object> KeySelector { get; set; }

// Rendering
[Parameter] public RenderFragment<TItem> NodeContent { get; set; }
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }

// Layout
[Parameter] public int IndentPx { get; set; } = 24;
[Parameter] public bool ShowGuideLines { get; set; } = true;

// Expand/Collapse
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
[Parameter] public string? StorageKey { get; set; }  // sessionStorage persistence key

// Selection
[Parameter] public bool Selectable { get; set; }
[Parameter] public object? SelectedKey { get; set; }
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";

// Public methods (called via @ref)
public void ExpandAll();
public void CollapseAll();
public void RevealNode(object key, bool select = false);
```

## Usage Example: Instance Hierarchy

```razor
@* Build a unified tree model from sites, areas, and instances *@

<TreeView TItem="TreeNode" Items="_roots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => n.Key"
          Selectable="true"
          SelectedKey="_selectedKey"
          SelectedKeyChanged="key => { _selectedKey = key; StateHasChanged(); }">
    <NodeContent Context="node">
        @switch (node.Kind)
        {
            case NodeKind.Site:
                <span class="fw-semibold">@node.Label</span>
                break;
            case NodeKind.Area:
                <span class="text-secondary">@node.Label</span>
                break;
            case NodeKind.Instance:
                <span>@node.Label</span>
                <span class="badge bg-success ms-2">Enabled</span>
                break;
        }
    </NodeContent>
    <EmptyContent>
        <span class="text-muted fst-italic">No items to display.</span>
    </EmptyContent>
</TreeView>

@code {
    private object? _selectedKey;
    private List<TreeNode> _roots = new();

    record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children);
    enum NodeKind { Site, Area, Instance }
}
```

## Usage Example: Data Connections by Site

A simpler two-level tree — Site → Data Connections (leaves):

```
- Site A
    Data Connection 1
    Data Connection 2
+ Site B
+ Site C
```

```razor
<TreeView TItem="TreeNode" Items="_roots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => n.Key">
    <NodeContent Context="node">
        @if (node.Kind == NodeKind.Site)
        {
            <span class="fw-semibold">@node.Label</span>
        }
        else
        {
            <span>@node.Label</span>
            <span class="badge bg-info ms-2">@node.Protocol</span>
        }
    </NodeContent>
</TreeView>

@code {
    private List<TreeNode> _roots = new();

    record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children, string? Protocol = null);
    enum NodeKind { Site, DataConnection }

    // Build: group data connections by SiteId, wrap each site as a branch
    // with its connections as leaf children
}
```

This demonstrates the component working with a flat two-level grouping — no recursive hierarchy needed. The consumer simply groups data connections by site and builds one level of children per site node.

## Tree Model Construction Pattern

The consuming page is responsible for building the tree model. The component only knows about `TItem`.

**Instance hierarchy** (deep, recursive):
1. Load sites, areas (with `ParentAreaId` hierarchy), and instances.
2. Build `Area` subtree per site using recursive `ParentAreaId` traversal.
3. Attach instances as leaf children of their assigned area (or directly under the site if `AreaId` is null).
4. Wrap each entity in a uniform `TreeNode`.

**Data connections by site** (flat, two-level):
1. Load sites and data connections.
2. Group connections by `SiteId`.
3. Each site becomes a branch node with its connections as leaf children.

## Other Potential Uses

The component is generic enough for:

- **Template inheritance tree**: Template → child templates (via `ParentTemplateId`)
- **Area management**: Site → Area hierarchy (replace current flat indentation in Areas.razor)
- **Data connections**: Site → connections (flat grouping, as shown above)
- **Navigation sidebar**: Hierarchical menu structure
- **File/folder browser**: Any nested structure

## Testing

Unit tests use the existing bUnit + xUnit + NSubstitute setup in `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/`. Tests live in a dedicated file: `TreeViewTests.cs`.

All tests use a simple test model:

```csharp
record TestNode(string Key, string Label, List<TestNode> Children);
```

### Test Categories

**Rendering:**
- Renders root-level items with correct labels
- Renders `EmptyContent` when `Items` is empty
- Does not render `EmptyContent` when items exist
- Leaf nodes have no expand/collapse toggle
- Branch nodes show `+` toggle when collapsed

**Expand/Collapse:**
- Clicking toggle expands node and shows children
- Clicking expanded toggle collapses node and hides children
- Children of collapsed nodes are not in the DOM
- Deep nesting: expand parent, then expand child — grandchildren visible
- `InitiallyExpanded` predicate expands matching nodes on first render

**Indentation:**
- Root nodes have zero indentation
- Child nodes are indented by `IndentPx` pixels per depth level
- Custom `IndentPx` value is applied correctly

**Selection:**
- When `Selectable` is false (default), clicking a node does not fire `SelectedKeyChanged`
- When `Selectable` is true, clicking node content fires `SelectedKeyChanged` with correct key
- Clicking expand toggle does **not** change selection
- Selected node has `SelectedCssClass` applied
- Custom `SelectedCssClass` is used when provided

**External Filtering:**
- Re-rendering with a filtered `Items` list removes hidden root nodes
- Expansion state is preserved after filter changes — expanding Site A, filtering to Site A only, then removing filter still shows Site A expanded
- Selection is cleared when the selected node disappears from filtered results

**Session Persistence (R11):**
- When `StorageKey` is null, no JS interop calls are made
- When `StorageKey` is set, expanding a node writes to sessionStorage via JS interop
- On mount with a `StorageKey`, reads sessionStorage and restores expanded nodes
- Persisted state takes precedence over `InitiallyExpanded`

*Note: sessionStorage tests mock `IJSRuntime` (already available via bUnit's `JSInterop`).*

**Expand All / Collapse All (R12):**
- `ExpandAll()` expands all branch nodes — all descendants visible
- `CollapseAll()` collapses all branch nodes — only roots visible
- `ExpandAll()` updates sessionStorage when `StorageKey` is set
- `CollapseAll()` clears sessionStorage expanded set when `StorageKey` is set

**RevealNode (R13):**
- `RevealNode(key)` expands all ancestors of the target node
- Target node's content is present in the DOM after reveal
- `RevealNode(key, select: true)` selects the node and fires `SelectedKeyChanged`
- `RevealNode` with unknown key is a no-op (does not throw)
- Deeply nested node (3+ levels) — all intermediate ancestors expanded

**Accessibility (R14):**
- Root `<ul>` has `role="tree"`
- Node `<li>` elements have `role="treeitem"`
- Expanded branch has `aria-expanded="true"`
- Collapsed branch has `aria-expanded="false"`
- Child container `<ul>` has `role="group"`
- Selected node has `aria-selected="true"` when `Selectable` is true

**Context Menu (R15):**
- Right-clicking a node shows the context menu with consumer-defined content
- Context menu is positioned at cursor coordinates
- When `ContextMenu` parameter is null, right-click does not render a menu
- When `ContextMenu` fragment renders empty content for a node type, no menu appears and browser default is used
- Right-clicking a node type with menu items shows the menu; right-clicking a node type without menu items does not
- Clicking a menu item dismisses the menu
- Clicking outside the menu dismisses it
- Right-clicking a different node replaces the current menu

### Test File Location

`tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs`

## Dependencies

- Bootstrap 5 (already included in CentralUI)
- No additional packages
- bUnit 2.0.33-preview (already in test project)

## Page Integration Notes

### 1. Topology Page (`/deployment/topology` — Topology.razor)

The Topology page is the single home for Site → Area → Instance hierarchy management. It replaces the former `/deployment/instances` page (the legacy URL is retained as a secondary `@page` directive on `Topology.razor` so existing bookmarks resolve) and the former `/admin/areas*` admin pages.

**Scope:**
- Structural management of areas (create, rename inline, move, delete) and instance placement (move to area).
- Instance lifecycle: Deploy/Redeploy, Enable/Disable, Configure, Diff, Delete via per-node context menu.
- Search-only filter row (single text input) — dims non-matching rows, preserves tree shape, no collapse.

**TreeView wiring:**
- `Items` = list of Site root nodes built from `_sites`, `_allAreas`, and `_allInstances`.
- `KeySelector` returns prefixed keys (`s:{id}`, `a:{id}`, `i:{id}`).
- `StorageKey` = `"topology-tree"` for expansion state.
- A separate `topology-tree-selected` sessionStorage key persists the selected node across navigation.
- `Selectable` = true; selection does not navigate (instance configure goes through the context menu).
- Empty containers always rendered (so they can be drop/move targets).

**Glyphs (V1–V7 visual guide):**
- Site: `bi-building`
- Area: `bi-diagram-3`
- Instance: `bi-box` + state badge + Stale/Current badge when deployed.

**Context menus:**
- **Site:** Add Area, Create Instance here.
- **Area:** Add Sub-area, Create Instance here, Move to Area…, Rename… (also F2 / double-click inline), Delete.
- **Instance:** Deploy/Redeploy, Enable/Disable (state-dependent), Configure, Diff, Move to Area…, Delete. Instance rename is intentionally absent (see "Instance rename" below).

**Inline rename:** Area rows only. F2 or double-click swaps the label for an input bound to a local buffer. Enter commits via `AreaService.UpdateAreaAsync`; Escape cancels; server validation errors stay surfaced inline.

**Search behavior:** Single text input above the tree. While text is present, any row whose label does not match (case-insensitive substring) and whose subtree contains no match is rendered at `opacity: 0.4`. The tree shape stays intact.

**Top-of-page buttons:** `+ Area` (opens `CreateAreaDialog` with site picker), `+ Instance` (navigates to `/deployment/instances/create` with no preselection), `Refresh`, `Expand`, `Collapse`.

**Files added:**
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor`

**Files removed:**
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Instances.razor`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Areas.razor` (and AreaAdd / AreaEdit / AreaDelete)

**Backend addition:** `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` adds area re-parenting (cycle prevention, same-site, name collision at new parent). Pairs with the existing `InstanceService.AssignToAreaAsync`.

**Instance rename:** Out of scope for this page. `InstanceService` does not currently support renaming an instance (`UniqueName` is also the site-side `InstanceActor` identity and appears in deployment records). A separate design pass is required if rename is wanted.

---

### 2. Data Connections Page (`/admin/data-connections` — DataConnections.razor)

**Current state:** Flat table listing all data connections across all sites. Columns: ID, Name, Protocol, Site, Primary Config, Backup Config, Actions (Edit, Delete). No filters. ~119 lines.

**Change to:**
- Replace the `<table>` with a `<TreeView>` showing Site → Data Connection hierarchy (two levels, no recursion).
- **No filter bar needed initially** — the tree naturally groups by site. If the number of sites grows, a site filter dropdown can be added later using the external filtering pattern.
- **Move Edit and Delete into the `ContextMenu` fragment**, shown only for data connection nodes:
  - Edit → navigates to `/admin/data-connections/{id}/edit`
  - Delete → shows confirm dialog, then deletes
  - Site nodes get no context menu.
- **Node content per type:**
  - Site nodes: `<span class="fw-semibold">SiteName</span>` + child count badge (e.g., `<span class="badge bg-secondary ms-1">3</span>`)
  - Data Connection nodes: `<span>Name</span>` + protocol badge (e.g., `<span class="badge bg-info ms-2">OPC UA</span>`)
- **Tree model:** Group data connections by `SiteId`. Each site becomes a branch, its connections become leaves. Sites with no connections still appear as empty branches (expandable but no children).
- **StorageKey:** `"data-connections-tree"`

**Files to modify:**
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/DataConnections.razor` — replace table with TreeView, add tree model building, move actions to context menu.

**Removed code:**
- `<table>` / `<thead>` / `<tbody>` structure
- Inline Edit/Delete buttons

---

## Interactions

- **DataTable**: The tree replaces flat tables on the Topology and Data Connections pages. Other pages that don't need hierarchy continue using DataTable.
- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`. Back-nav returns to `/deployment/topology`.
