# Tree View

`TreeView<TItem>` is a generic, reusable Blazor Server component that renders any tree-shaped data as an expandable/collapsible hierarchy with ARIA roles, optional guide lines, single or checkbox selection, and session-persistent expansion state.

## Overview

The component lives at `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor` alongside its scoped stylesheet `TreeView.razor.css`. It is data-agnostic: the caller supplies the data source, a children accessor, a key function, and a `RenderFragment<TItem>` for node content — the component owns only the structural chrome (indentation, toggle, guide lines, ARIA attributes, selection highlight, context menu positioning).

Active uses within the Central UI:

- **Data Connections page** (`DataConnections.razor`) — two-level Site → Connection tree with a kebab action menu per node and search-based dimming.
- **Topology page** (`Topology.razor`) — three-level Site → Area → Instance tree with inline rename, context menus, and `StorageKey = "topology-tree"` expansion persistence.
- **`TemplateFolderTree`** (`TemplateFolderTree.razor`) — a domain-specific wrapper that projects `TemplateFolder` / `Template` entities into `TemplateTreeNode` items and delegates to `TreeView<TemplateTreeNode>`. Consumed by the Templates browser page (Single mode, click-to-navigate) and the Transport Export wizard (Checkbox mode, bulk template selection).

## Key Concepts

### Generic node typing

`@typeparam TItem` means the component imposes no node base class. The consumer brings its own model — a record, a class, an interface — and wires four `[EditorRequired]` delegates that teach the component how to navigate it. The `TemplateTreeNode` class in `Components/Shared/TemplateTreeNode.cs` is the shared adapter type for template/folder hierarchies; the Data Connections and Topology pages use local `record` types.

### Expansion state

Expanded-node keys are stored in `_expandedKeys` (`HashSet<string>`). Keys are stringified via `KeyStr(object) => key.ToString()!` for consistency with `sessionStorage`, which stores them as a JSON array when `StorageKey` is set. On first render the component reads `sessionStorage` via `treeviewStorage.load`; on every toggle it writes back via `treeviewStorage.save`. `InitiallyExpanded` applies only when no persisted state exists (or `StorageKey` is null). The two sources are always unioned — a `RevealNode` call before the async storage read completes is not clobbered.

### Selection modes

`TreeViewSelectionMode` (defined in `TreeViewSelectionMode.cs`) controls how nodes are selected:

```csharp
public enum TreeViewSelectionMode
{
    Single,    // Default. Clicking node content fires SelectedKeyChanged with the node key.
    Checkbox,  // Renders a tri-state checkbox per node. Folder check state is aggregated
               // from descendant leaves; only leaf keys enter SelectedKeys.
}
```

In `Single` mode the component uses `SelectedKey` / `SelectedKeyChanged` (two-way binding on a single `object?`). In `Checkbox` mode it uses `SelectedKeys` / `SelectedKeysChanged` (`HashSet<object>`). Checking a folder selects or deselects all of its descendant leaf keys. The `indeterminate` checkbox property is set via JS interop (`treeviewStorage.setIndeterminate`) after every render because Blazor does not bind `input.indeterminate` natively.

### Context menu

When `ContextMenu` is non-null, right-clicking any row suppresses the browser default and positions a Bootstrap `dropdown-menu show` div at the cursor coordinates using `position: fixed`. An invisible overlay behind the menu dismisses it on click-outside; Escape also dismisses it. The menu receives the `TItem` of the right-clicked node, so the consumer's fragment can branch on node type.

### Keyboard navigation & accessibility (WAI-ARIA tree pattern)

Delivered 2026-08-01 (M10 residual R7). The component implements the full [WAI-ARIA tree pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).

**Roving tabindex.** Exactly one `li[role="treeitem"]` carries `tabindex="0"` at a time; every other node is `tabindex="-1"`, so the whole tree is a single Tab stop. The target is resolved once per render by `ResolveTabbableKey()`: the node the user last landed on (`_focusedKey`) while it is still visible → else the currently `SelectedKey` node → else the first visible node. A keyboard move sets `_focusNeedsApply`, and `OnAfterRenderAsync` pulls browser focus onto the new node via a per-node `ElementReference`, guarded by the same `JSException` / `JSDisconnectedException` / `InvalidOperationException` triple the context-menu focus uses (under bUnit there is no real focus, so it is a safe no-op).

| Key | Behaviour |
| --- | --- |
| `ArrowDown` / `ArrowUp` | Move to the next / previous **visible** node (a collapsed branch's children are skipped). No-op at the ends. |
| `ArrowRight` | Collapsed branch → expand (focus stays); expanded branch → move to first child; leaf → no-op. |
| `ArrowLeft` | Expanded branch → collapse (focus stays); otherwise → move to parent (via `BuildParentLookup()`). No-op at a root leaf. |
| `Home` / `End` | Move to the first / last visible node. |
| `Enter` / `Space` | Activate the node — routed to the *same* path a mouse click takes (`OnContentClick` in `Single` mode, `OnCheckboxToggle` in `Checkbox` mode), so selection semantics never diverge between input modes. |

The visible order is produced by `BuildVisibleNodes()`, which mirrors `RenderNode`'s visibility rules exactly — **the two must stay in step**, or arrow navigation will skip or invent rows.

**ARIA attributes.** `ul[role=tree]` root, `ul[role=group]` for children, `li[role=treeitem]` nodes carrying `aria-level` (1-based), `aria-posinset`, `aria-setsize`, `aria-expanded` (branches only), and `aria-selected` (selectable / checkbox trees only — absent on a non-selectable tree).

**Event scoping.** `@onkeydown:stopPropagation` sits on the `<li>` (so a nested node's keypress is not also handled by its ancestors) and on the chevron, the checkbox, and the `.tv-content` slot — so a consumer's own buttons and inputs inside `NodeContent` keep their key handling. Browser scroll-on-Space/Arrow is suppressed by a small **native** inline `onkeydown` on the root `<ul>` that calls `preventDefault()` only for the navigation keys and only when the event target is the treeitem itself; Blazor's `preventDefault` directive is all-or-nothing per element, so putting it on the `<li>` would both trap Tab inside the tree and cancel Enter/Space activation of consumer buttons. (The app sets no Content-Security-Policy, so the inline handler executes.)

Covered by `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs` (31 bUnit tests).

## Architecture

The component is a single `@typeparam` `.razor` file with a private `void RenderNode(TItem item, int depth, int posInSet, int setSize)` local function that recurses the tree at render time — no intermediate view model is built inside the component. Every `<li>` carries `@key="key"` so Blazor can diff the list efficiently.

`IJSRuntime` is injected for two purposes: reading/writing `sessionStorage` for expansion persistence, and setting `input.indeterminate` for tri-state checkboxes. Both call sites guard `JSDisconnectedException` so a disconnected circuit never throws out of the lifecycle methods.

The public surface the caller can invoke via `@ref`:

```csharp
public bool IsExpanded(object key);        // Whether the given key is currently expanded.
public void ExpandAll();                   // Expand every branch node; persists if StorageKey set.
public void CollapseAll();                 // Collapse every node; clears persisted state.
public async Task RevealNode(object key, bool select = false);
    // Expands all ancestors of the given key. Optionally selects the node.
    // No-op if the key does not exist in the current tree.
```

## Usage

The Data Connections page binds a two-level Site → Connection tree with `StorageKey`, single selection, and a context menu for Edit and Delete actions on connection nodes:

```razor
<TreeView @ref="_tree" TItem="DcTreeNode" Items="_treeRoots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => (object)n.Key"
          StorageKey="data-connections-tree"
          Selectable="true"
          SelectedKey="_selectedKey"
          SelectedKeyChanged="OnTreeNodeSelected">
    <NodeContent Context="node">
        @if (node.Kind == DcNodeKind.Site)
        {
            <span class="tv-label fw-semibold">@node.Label</span>
            <span class="badge bg-secondary ms-1">@node.Children.Count</span>
        }
        else
        {
            <span class="tv-label">@node.Label</span>
            <span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
        }
    </NodeContent>
    <ContextMenu Context="node">
        @if (node.Kind == DcNodeKind.Site)
        {
            <button class="dropdown-item"
                    @onclick="() => AddConnectionForSite(node.SiteId!.Value)">
                Add Connection here
            </button>
        }
        else
        {
            <button class="dropdown-item"
                    @onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
                Edit
            </button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger"
                    @onclick="() => DeleteConnection(node.Connection!)">
                Delete
            </button>
        }
    </ContextMenu>
    <EmptyContent>
        <span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
    </EmptyContent>
</TreeView>

@code {
    record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children,
        int? SiteId = null, DataConnection? Connection = null);
    enum DcNodeKind { Site, DataConnection }

    private TreeView<DcTreeNode>? _tree;
    private object? _selectedKey;

    private void OnTreeNodeSelected(object? key) => _selectedKey = key;
}
```

The `TemplateFolderTree` wrapper demonstrates `Checkbox` mode, where `SelectedKeys` / `SelectedKeysChanged` drive bulk template selection in the Transport Export wizard:

```razor
<TreeView @ref="_tree" TItem="TemplateTreeNode"
          Items="_visibleRoots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => (object)n.Key"
          Selectable="@(SelectionMode == TreeViewSelectionMode.Single)"
          SelectionMode="SelectionMode"
          SelectedKeys="SelectedKeys"
          SelectedKeysChanged="SelectedKeysChanged"
          InitiallyExpanded="@(_initiallyExpanded)"
          StorageKey="@StorageKey">
    <NodeContent Context="node">
        <span class="tv-glyph"><i class="bi @(NodeGlyph(node))"></i></span>
        <span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
              title="@node.Name">@node.Name</span>
    </NodeContent>
</TreeView>
```

## Configuration

All `[Parameter]` properties on `TreeView<TItem>`. Parameters marked **required** carry `[EditorRequired]` and must be supplied; omitting them produces a build warning.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `Items` | `IReadOnlyList<TItem>` | — | **Required.** Root-level nodes to render. |
| `ChildrenSelector` | `Func<TItem, IReadOnlyList<TItem>>` | — | **Required.** Returns the ordered children of a node. |
| `HasChildrenSelector` | `Func<TItem, bool>` | — | **Required.** Returns `true` for branch nodes. Determines whether the expand toggle is rendered. |
| `KeySelector` | `Func<TItem, object>` | — | **Required.** Unique stable key per node. Used for expansion tracking, selection, and `@key` diffing. |
| `NodeContent` | `RenderFragment<TItem>` | — | **Required.** Render fragment for node label content. Receives the `TItem`; responsible for all domain-specific markup (glyphs, labels, badges). |
| `EmptyContent` | `RenderFragment?` | `null` | Shown when `Items` is empty or null. |
| `ContextMenu` | `RenderFragment<TItem>?` | `null` | Right-click menu content. Receives the right-clicked node. If null, right-click is not intercepted and the browser default is preserved. If non-null, `@oncontextmenu:preventDefault` is always active — the browser default is suppressed for every node regardless of whether the fragment renders any items for that node type. |
| `IndentPx` | `int` | `24` | Pixels of left padding added per depth level via inline `style`. |
| `ShowGuideLines` | `bool` | `true` | Adds `tv-guides` CSS class to the root `<ul>`, enabling the depth guide lines drawn by a `linear-gradient` pseudo-element in `TreeView.razor.css`. |
| `InitiallyExpanded` | `Func<TItem, bool>?` | `null` | Predicate used to expand matching nodes on first load. When `StorageKey` is null it is applied immediately (synchronously in `OnParametersSet`). When `StorageKey` is set, the predicate is applied only after the async storage read completes and returns empty — persisted state takes precedence and `InitiallyExpanded` is a fallback for first-ever loads. |
| `StorageKey` | `string?` | `null` | Browser `sessionStorage` key for expansion persistence (`treeview:{StorageKey}`). When null, expansion is in-memory only. |
| `Selectable` | `bool` | `false` | Enables click-to-select on node content. Clicking the expand toggle never changes selection. |
| `SelectedKey` | `object?` | `null` | Currently selected node key for `Single` mode. Supports two-way binding (`@bind-SelectedKey`). |
| `SelectedKeyChanged` | `EventCallback<object?>` | — | Fires when selection changes in `Single` mode. Also fires on `RevealNode(..., select: true)`. Fires with `null` when the selected key disappears from the tree. |
| `SelectedCssClass` | `string` | `"bg-primary bg-opacity-10"` | CSS class(es) applied to the selected node's row div in addition to `tv-selected`. |
| `SelectionMode` | `TreeViewSelectionMode` | `Single` | Switches between single-key selection and tri-state checkbox selection. |
| `SelectedKeys` | `HashSet<object>?` | `null` | Set of currently selected leaf keys for `Checkbox` mode. |
| `SelectedKeysChanged` | `EventCallback<HashSet<object>>` | — | Fires with the updated set when any checkbox is toggled in `Checkbox` mode. Always fires with a fresh `HashSet` reference. |

### CSS utility classes in `NodeContent`

The scoped stylesheet defines layout slots that `NodeContent` fragments should use for consistent alignment:

| Class | Purpose |
|---|---|
| `tv-glyph` | 20 px flex slot for a Bootstrap Icon (`<i class="bi bi-…">`). |
| `tv-label` | `flex: 1 1 auto; min-width: 0` — primary text with ellipsis overflow. |
| `tv-meta` | `margin-left: auto` — right-aligned badges or trailing controls. |
| `tv-kebab` | Opt-in hidden-by-default "more actions" slot; revealed on row hover. |

## Dependencies & Interactions

- **Bootstrap 5** — all state visuals use Bootstrap utility classes and CSS variables (`--bs-tertiary-bg`, `--bs-border-color`, `--bs-primary-rgb`). No third-party Blazor component frameworks.
- **Bootstrap Icons** — static files served from `wwwroot/lib/bootstrap-icons/`; referenced once in `MainLayout.razor`. `NodeContent` fragments use `<i class="bi bi-…">` inside the `tv-glyph` slot.
- **`IJSRuntime`** — injected for `treeviewStorage.load` / `treeviewStorage.save` (expansion persistence) and `treeviewStorage.setIndeterminate` (checkbox tri-state). The JS helpers live in the CentralUI's shared JS bundle.
- **`TemplateFolderTree`** (`Components/Shared/TemplateFolderTree.razor`) — a domain-specific wrapper around `TreeView<TemplateTreeNode>` that handles folder/template tree construction, text filtering, and `ExtraTemplateChildren` injection. Consumers that need the template hierarchy use `TemplateFolderTree`; they do not wire `TreeView<TemplateTreeNode>` directly.
- **`TemplateTreeNode` / `TemplateTreeNodeKind`** (`Components/Shared/TemplateTreeNode.cs`) — the shared node model used by `TemplateFolderTree` and its callers. Folder keys are prefixed `f:`, template keys `t:`, composition keys `c:`.
- **Data Connections page** (`Components/Pages/Design/DataConnections.razor`) — binds `TreeView<DcTreeNode>` directly with a local two-level record type.
- **Topology page** (`Components/Pages/Deployment/Topology.razor`) — binds `TreeView` for the Site → Area → Instance hierarchy; calls `ExpandAll` and `CollapseAll` via `@ref`.
- **Central UI component** — see [./CentralUI.md](./CentralUI.md) for the broader Blazor Server application context.

## Related Documentation

- [Tree View design specification](../requirements/Component-TreeView.md)
- [Central UI](./CentralUI.md)
- [Template Engine](./TemplateEngine.md)
