# 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` shared component. ## Location `src/ScadaLink.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` | Yes | Root-level items | | `ChildrenSelector` | `Func>` | Yes | Returns children for a given node | | `HasChildrenSelector` | `Func` | Yes | Whether the node can be expanded (branch vs. leaf) | | `KeySelector` | `Func` | Yes | Unique key per node (for state tracking) | ### R2 — Render Fragments | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `NodeContent` | `RenderFragment` | 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?` | No | Predicate — nodes matching this start expanded (first load only, before any persisted state exists) | ### 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). | 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` | 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>>` 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 ... @code { private int? _selectedSiteId; private List 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 only (no third-party frameworks). - No hardcoded colors — uses standard Bootstrap text/background utilities. - Toggle icons: Unicode characters (`+` / `−`) in a `` 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`). ### 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 @code { private TreeView _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 `
    ` has `role="tree"`. - Each node `
  • ` has `role="treeitem"`. - Branch nodes have `aria-expanded="true"` or `aria-expanded="false"`. - Child `
      ` 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?` | 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: `