From addbb6ffebd0d194e46be0f10c22af026f5cab78 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Mar 2026 05:36:32 -0400 Subject: [PATCH] fix(ui): move treeview-storage.js to Host wwwroot where static files are served --- docs/plans/2026-03-23-treeview-component.md | 1128 +++++++++++++++++ ...026-03-23-treeview-component.md.tasks.json | 16 + docs/requirements/Component-TreeView.md | 626 +++++++++ .../wwwroot/js/treeview-storage.js | 0 4 files changed, 1770 insertions(+) create mode 100644 docs/plans/2026-03-23-treeview-component.md create mode 100644 docs/plans/2026-03-23-treeview-component.md.tasks.json create mode 100644 docs/requirements/Component-TreeView.md rename src/{ScadaLink.CentralUI => ScadaLink.Host}/wwwroot/js/treeview-storage.js (100%) diff --git a/docs/plans/2026-03-23-treeview-component.md b/docs/plans/2026-03-23-treeview-component.md new file mode 100644 index 0000000..bb30792 --- /dev/null +++ b/docs/plans/2026-03-23-treeview-component.md @@ -0,0 +1,1128 @@ +# TreeView Component Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Build a reusable generic `TreeView` Blazor component and integrate it into the Instances, Data Connections, and Areas pages. + +**Architecture:** Generic component in `Components/Shared/` with `@typeparam TItem`, render fragments for content/context menu, internal expand/collapse state with sessionStorage persistence via JS interop, and ARIA accessibility. Three pages refactored to use it. + +**Tech Stack:** Blazor Server, Bootstrap 5, bUnit 2.0.33-preview, xUnit, IJSRuntime for sessionStorage. + +--- + +### Task 1: Create TreeView.razor — Core Rendering (R1, R2, R3, R4, R14) + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` + +**Step 1: Write the failing tests** + +Create test file with core rendering tests. + +Create: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs` + +```csharp +using Bunit; +using ScadaLink.CentralUI.Components.Shared; + +namespace ScadaLink.CentralUI.Tests; + +public class TreeViewTests : BunitContext +{ + // Test model + record TestNode(string Key, string Label, List Children); + + private static List SimpleRoots() => new() + { + new("a", "Alpha", new() + { + new("a1", "Alpha-1", new()), + new("a2", "Alpha-2", new() + { + new("a2x", "Alpha-2-X", new()) + }) + }), + new("b", "Beta", new()), + }; + + private IRenderedComponent> RenderTree( + List? items = null, + Action>>? configure = null) + { + return Render>(parameters => + { + parameters + .Add(p => p.Items, items ?? SimpleRoots()) + .Add(p => p.ChildrenSelector, n => n.Children) + .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0) + .Add(p => p.KeySelector, n => n.Key) + .Add(p => p.NodeContent, node => $"{node.Label}"); + configure?.Invoke(parameters); + }); + } + + [Fact] + public void Renders_RootLevelItems() + { + var cut = RenderTree(); + var rootItems = cut.FindAll("[role='tree'] > li[role='treeitem']"); + Assert.Equal(2, rootItems.Count); + Assert.Contains("Alpha", rootItems[0].TextContent); + Assert.Contains("Beta", rootItems[1].TextContent); + } + + [Fact] + public void Renders_EmptyContent_WhenNoItems() + { + var cut = RenderTree(items: new(), configure: p => + p.Add(x => x.EmptyContent, "Nothing here")); + Assert.Contains("Nothing here", cut.Markup); + } + + [Fact] + public void LeafNodes_HaveNoToggle() + { + var cut = RenderTree(); + // Beta is a leaf (no children) + var betaItem = cut.FindAll("li[role='treeitem']") + .First(li => li.TextContent.Contains("Beta")); + Assert.Empty(betaItem.QuerySelectorAll(".tv-toggle")); + } + + [Fact] + public void BranchNodes_ShowCollapsedToggle() + { + var cut = RenderTree(); + var alphaItem = cut.FindAll("li[role='treeitem']") + .First(li => li.TextContent.Contains("Alpha")); + var toggle = alphaItem.QuerySelector(".tv-toggle"); + Assert.NotNull(toggle); + Assert.Equal("false", alphaItem.GetAttribute("aria-expanded")); + } + + [Fact] + public void CollapsedBranch_ChildrenNotInDom() + { + var cut = RenderTree(); + // Children of collapsed Alpha should not be in the DOM + Assert.DoesNotContain("Alpha-1", cut.Markup); + } + + [Fact] + public void ClickToggle_ExpandsNode_ShowsChildren() + { + var cut = RenderTree(); + var toggle = cut.Find(".tv-toggle"); + toggle.Click(); + Assert.Contains("Alpha-1", cut.Markup); + Assert.Contains("Alpha-2", cut.Markup); + } + + [Fact] + public void ClickToggle_CollapseExpandedNode() + { + var cut = RenderTree(); + var toggle = cut.Find(".tv-toggle"); + toggle.Click(); // expand + Assert.Contains("Alpha-1", cut.Markup); + toggle.Click(); // collapse + Assert.DoesNotContain("Alpha-1", cut.Markup); + } + + [Fact] + public void DeepNesting_ExpandParentThenChild() + { + var cut = RenderTree(); + // Expand Alpha + cut.Find(".tv-toggle").Click(); + // Now find Alpha-2's toggle and expand it + var a2Item = cut.FindAll("li[role='treeitem']") + .First(li => li.TextContent.Contains("Alpha-2")); + a2Item.QuerySelector(".tv-toggle")!.Click(); + Assert.Contains("Alpha-2-X", cut.Markup); + } + + [Fact] + public void InitiallyExpanded_ExpandsMatchingNodes() + { + var cut = RenderTree(configure: p => + p.Add(x => x.InitiallyExpanded, n => n.Key == "a")); + // Alpha should be expanded showing children + Assert.Contains("Alpha-1", cut.Markup); + Assert.Contains("Alpha-2", cut.Markup); + } + + [Fact] + public void RootUl_HasTreeRole() + { + var cut = RenderTree(); + Assert.NotNull(cut.Find("ul[role='tree']")); + } + + [Fact] + public void NodeLi_HasTreeitemRole() + { + var cut = RenderTree(); + var items = cut.FindAll("li[role='treeitem']"); + Assert.True(items.Count >= 2); + } + + [Fact] + public void ExpandedBranch_HasAriaExpandedTrue() + { + var cut = RenderTree(configure: p => + p.Add(x => x.InitiallyExpanded, n => n.Key == "a")); + var alphaItem = cut.FindAll("li[role='treeitem']") + .First(li => li.TextContent.Contains("Alpha")); + Assert.Equal("true", alphaItem.GetAttribute("aria-expanded")); + } + + [Fact] + public void ChildGroup_HasGroupRole() + { + var cut = RenderTree(configure: p => + p.Add(x => x.InitiallyExpanded, n => n.Key == "a")); + Assert.NotNull(cut.Find("ul[role='group']")); + } + + [Fact] + public void Indentation_ChildrenIndentedByIndentPx() + { + var cut = RenderTree(configure: p => + { + p.Add(x => x.InitiallyExpanded, n => n.Key == "a"); + p.Add(x => x.IndentPx, 30); + }); + // Alpha-1 is depth 1 → padding-left should be 30px + var a1Row = cut.FindAll(".tv-row") + .First(r => r.TextContent.Contains("Alpha-1")); + Assert.Contains("padding-left: 30px", a1Row.GetAttribute("style") ?? ""); + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal` +Expected: Compilation error — `TreeView` component doesn't exist yet. + +**Step 3: Implement TreeView.razor** + +Create: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` + +The component should: +- Accept `@typeparam TItem` with all parameters from the API summary in the requirements doc. +- Use a `HashSet _expandedKeys` for expand/collapse state. +- Render recursively: a private `RenderNode(TItem item, int depth)` method outputting `
  • ` with toggle span (`.tv-toggle`), content area (`.tv-content`), and recursive `
      ` for children. +- Each row `
      ` has `style="padding-left: {depth * IndentPx}px"`. +- Branch toggle: `` showing `+` or `−`. +- Leaf nodes: no toggle, but same left padding alignment (the content area starts at the same x as branch content — add a spacer element the same width as the toggle). +- `aria-expanded` on branch `
    • `, `role="group"` on child `
        `. +- When collapsed, do NOT render child `
          ` at all (not just `display:none`). +- `EmptyContent` shown when `Items` is null or empty. +- Apply `InitiallyExpanded` in `OnParametersSet` on first render only: walk all items recursively, add matching keys to `_expandedKeys`. + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal` +Expected: All tests PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs +git commit -m "feat(ui): add TreeView component with core rendering, expand/collapse, ARIA (R1-R4, R14)" +``` + +--- + +### Task 2: Add Selection Support (R5) + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` +- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs` + +**Step 1: Write the failing tests** + +Add to `TreeViewTests.cs`: + +```csharp +[Fact] +public void Selection_Disabled_ClickDoesNotFireCallback() +{ + object? selected = null; + var cut = RenderTree(configure: p => + { + p.Add(x => x.Selectable, false); + p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key); + }); + cut.Find(".tv-content").Click(); + Assert.Null(selected); +} + +[Fact] +public void Selection_Enabled_ClickContentFiresCallback() +{ + object? selected = null; + var cut = RenderTree(configure: p => + { + p.Add(x => x.Selectable, true); + p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key); + }); + // Click Alpha's content area + var alphaContent = cut.FindAll(".tv-content") + .First(c => c.TextContent.Contains("Alpha")); + alphaContent.Click(); + Assert.Equal("a", selected); +} + +[Fact] +public void Selection_ClickToggle_DoesNotChangeSelection() +{ + object? selected = null; + var cut = RenderTree(configure: p => + { + p.Add(x => x.Selectable, true); + p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key); + }); + cut.Find(".tv-toggle").Click(); + Assert.Null(selected); +} + +[Fact] +public void Selection_SelectedNode_HasCssClass() +{ + var cut = RenderTree(configure: p => + { + p.Add(x => x.Selectable, true); + p.Add(x => x.SelectedKey, (object)"a"); + }); + var alphaRow = cut.FindAll(".tv-row") + .First(r => r.TextContent.Contains("Alpha")); + Assert.Contains("bg-primary", alphaRow.ClassList); +} + +[Fact] +public void Selection_CustomCssClass_Applied() +{ + var cut = RenderTree(configure: p => + { + p.Add(x => x.Selectable, true); + p.Add(x => x.SelectedKey, (object)"a"); + p.Add(x => x.SelectedCssClass, "my-highlight"); + }); + var alphaRow = cut.FindAll(".tv-row") + .First(r => r.TextContent.Contains("Alpha")); + Assert.Contains("my-highlight", alphaRow.ClassList); +} + +[Fact] +public void Selection_AriaSelected_SetOnSelectedNode() +{ + var cut = RenderTree(configure: p => + { + p.Add(x => x.Selectable, true); + p.Add(x => x.SelectedKey, (object)"a"); + }); + var alphaItem = cut.FindAll("li[role='treeitem']") + .First(li => li.TextContent.Contains("Alpha")); + Assert.Equal("true", alphaItem.GetAttribute("aria-selected")); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.Selection" -v minimal` +Expected: FAIL + +**Step 3: Implement selection in TreeView.razor** + +Add to the component: +- When `Selectable` is true and the `.tv-content` area is clicked, invoke `SelectedKeyChanged` with the node's key. +- When rendering, if a node's key equals `SelectedKey`, add `SelectedCssClass` to the `.tv-row` div. +- Add `aria-selected="true"` on the selected `
        • `. +- The `.tv-toggle` click handler must NOT propagate to selection (use `@onclick:stopPropagation` or separate event handling). + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal` +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs +git commit -m "feat(ui): add selection support to TreeView (R5)" +``` + +--- + +### Task 3: Add Session Storage Persistence (R11) + +**Files:** +- Create: `src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js` +- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` +- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs` + +**Step 1: Write the failing tests** + +Add to `TreeViewTests.cs`: + +```csharp +[Fact] +public void SessionStorage_Null_NoJsInteropCalls() +{ + var cut = RenderTree(configure: p => + p.Add(x => x.StorageKey, (string?)null)); + // Expand a node + cut.Find(".tv-toggle").Click(); + // No JS interop should have been invoked + Assert.Empty(JSInterop.Invocations); +} + +[Fact] +public void SessionStorage_Set_ExpandWritesToStorage() +{ + JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); + var cut = RenderTree(configure: p => + p.Add(x => x.StorageKey, "test-tree")); + // Expand Alpha + cut.Find(".tv-toggle").Click(); + var saveInvocations = JSInterop.Invocations + .Where(i => i.Identifier == "treeviewStorage.save"); + Assert.NotEmpty(saveInvocations); +} + +[Fact] +public void SessionStorage_RestoresExpandedOnMount() +{ + // Pre-populate storage with Alpha expanded + JSInterop.Setup("treeviewStorage.load", _ => true) + .SetResult("[\"a\"]"); + JSInterop.SetupVoid("treeviewStorage.save", _ => true); + var cut = RenderTree(configure: p => + p.Add(x => x.StorageKey, "test-tree")); + // Alpha should be expanded from storage + Assert.Contains("Alpha-1", cut.Markup); +} + +[Fact] +public void SessionStorage_TakesPrecedenceOverInitiallyExpanded() +{ + // Storage says nothing expanded, but InitiallyExpanded says expand Alpha + JSInterop.Setup("treeviewStorage.load", _ => true) + .SetResult("[]"); + JSInterop.SetupVoid("treeviewStorage.save", _ => true); + var cut = RenderTree(configure: p => + { + p.Add(x => x.StorageKey, "test-tree"); + p.Add(x => x.InitiallyExpanded, n => n.Key == "a"); + }); + // Storage (empty) takes precedence — Alpha should NOT be expanded + Assert.DoesNotContain("Alpha-1", cut.Markup); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.SessionStorage" -v minimal` +Expected: FAIL + +**Step 3: Create JS interop file** + +Create: `src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js` + +```javascript +window.treeviewStorage = { + save: function (storageKey, keysJson) { + sessionStorage.setItem("treeview:" + storageKey, keysJson); + }, + load: function (storageKey) { + return sessionStorage.getItem("treeview:" + storageKey); + } +}; +``` + +Add a ` +``` + +**Step 4: Implement persistence in TreeView.razor** + +- Inject `[Inject] private IJSRuntime JSRuntime { get; set; } = default!;` +- In `OnAfterRenderAsync(bool firstRender)`: if `firstRender && StorageKey != null`, call `JSRuntime.InvokeAsync("treeviewStorage.load", StorageKey)`. If result is not null, deserialize JSON array of keys into `_expandedKeys` and call `StateHasChanged()`. Set a `_storageLoaded = true` flag. +- Only apply `InitiallyExpanded` when `StorageKey` is null OR storage returned null (no prior state). +- On every `ToggleExpand`, if `StorageKey != null`, serialize `_expandedKeys` to JSON and call `JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json)`. + +**Step 5: Run tests to verify they pass** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal` +Expected: All PASS. + +**Step 6: Commit** + +```bash +git add src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs +git commit -m "feat(ui): add sessionStorage persistence for TreeView expansion state (R11)" +``` + +--- + +### Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13) + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` +- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs` + +**Step 1: Write the failing tests** + +Add to `TreeViewTests.cs`: + +```csharp +[Fact] +public void ExpandAll_ExpandsAllBranches() +{ + var cut = RenderTree(); + cut.Instance.ExpandAll(); + cut.Render(); + // All nested content should be visible + Assert.Contains("Alpha-1", cut.Markup); + Assert.Contains("Alpha-2", cut.Markup); + Assert.Contains("Alpha-2-X", cut.Markup); +} + +[Fact] +public void CollapseAll_CollapsesAllBranches() +{ + var cut = RenderTree(configure: p => + p.Add(x => x.InitiallyExpanded, _ => true)); + Assert.Contains("Alpha-1", cut.Markup); + cut.Instance.CollapseAll(); + cut.Render(); + Assert.DoesNotContain("Alpha-1", cut.Markup); +} + +[Fact] +public void RevealNode_ExpandsAncestors() +{ + var cut = RenderTree(); + // Alpha-2-X is at depth 2 — both Alpha and Alpha-2 need expanding + cut.Instance.RevealNode("a2x"); + cut.Render(); + Assert.Contains("Alpha-2-X", cut.Markup); + // Intermediate nodes should also be visible + Assert.Contains("Alpha-2", cut.Markup); +} + +[Fact] +public void RevealNode_WithSelect_SelectsNode() +{ + object? selected = null; + var cut = RenderTree(configure: p => + { + p.Add(x => x.Selectable, true); + p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key); + }); + cut.Instance.RevealNode("a2x", select: true); + cut.Render(); + Assert.Equal("a2x", selected); +} + +[Fact] +public void RevealNode_UnknownKey_NoOp() +{ + var cut = RenderTree(); + // Should not throw + cut.Instance.RevealNode("nonexistent"); + cut.Render(); + // Alpha still collapsed + Assert.DoesNotContain("Alpha-1", cut.Markup); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ExpandAll|CollapseAll|RevealNode" -v minimal` +Expected: FAIL (methods don't exist) + +**Step 3: Implement the public methods** + +In `TreeView.razor`: + +- `ExpandAll()`: Walk the entire tree recursively starting from `Items`. For each node where `HasChildrenSelector` returns true, add its key to `_expandedKeys`. Call `PersistExpandedState()` and `StateHasChanged()`. +- `CollapseAll()`: Clear `_expandedKeys`. Call `PersistExpandedState()` and `StateHasChanged()`. +- `RevealNode(object key, bool select = false)`: Build a parent lookup dictionary by walking the full tree (key → parent key). Starting from the target key, walk up through parents, adding each ancestor key to `_expandedKeys`. If `select` is true, set `SelectedKey` and invoke `SelectedKeyChanged`. Call `PersistExpandedState()` and `StateHasChanged()`. If key not found in the tree, return silently. +- Extract `PersistExpandedState()` private method that serializes and writes to sessionStorage if `StorageKey` is set. + +**Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal` +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs +git commit -m "feat(ui): add ExpandAll, CollapseAll, RevealNode to TreeView (R12, R13)" +``` + +--- + +### Task 5: Add Context Menu (R15) + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` +- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs` + +**Step 1: Write the failing tests** + +Add to `TreeViewTests.cs`: + +```csharp +[Fact] +public void ContextMenu_Null_NoMenuRendered() +{ + var cut = RenderTree(); + // Right-click Alpha — no context menu should appear + var alphaRow = cut.FindAll(".tv-row") + .First(r => r.TextContent.Contains("Alpha")); + alphaRow.ContextMenu(); + Assert.Throws(() => cut.Find(".dropdown-menu")); +} + +[Fact] +public void ContextMenu_RightClickShowsMenu() +{ + var cut = RenderTree(configure: p => + p.Add(x => x.ContextMenu, (TestNode node) => + $"")); + var alphaRow = cut.FindAll(".tv-row") + .First(r => r.TextContent.Contains("Alpha")); + alphaRow.ContextMenu(); + var menu = cut.Find(".dropdown-menu"); + Assert.Contains("Action for Alpha", menu.TextContent); +} + +[Fact] +public void ContextMenu_EmptyFragment_NoMenu() +{ + // ContextMenu renders nothing for leaves + var cut = RenderTree(configure: p => + p.Add(x => x.ContextMenu, (TestNode node) => + node.Children.Count > 0 ? $"" : "")); + // Right-click Beta (leaf) — fragment renders empty + var betaRow = cut.FindAll(".tv-row") + .First(r => r.TextContent.Contains("Beta")); + betaRow.ContextMenu(); + Assert.Throws(() => cut.Find(".dropdown-menu")); +} + +[Fact] +public void ContextMenu_RightClickDifferentNode_ReplacesMenu() +{ + var cut = RenderTree(configure: p => + { + p.Add(x => x.InitiallyExpanded, n => n.Key == "a"); + p.Add(x => x.ContextMenu, (TestNode node) => + $""); + }); + // Right-click Alpha + cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha")).ContextMenu(); + Assert.Contains("Alpha", cut.Find(".dropdown-menu").TextContent); + // Right-click Alpha-1 + cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha-1")).ContextMenu(); + var menus = cut.FindAll(".dropdown-menu"); + Assert.Single(menus); + Assert.Contains("Alpha-1", menus[0].TextContent); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ContextMenu" -v minimal` +Expected: FAIL + +**Step 3: Implement context menu** + +In `TreeView.razor`: + +- Add `[Parameter] public RenderFragment? ContextMenu { get; set; }` +- Add state: `private TItem? _contextMenuItem`, `private double _contextMenuX`, `private double _contextMenuY`, `private bool _showContextMenu` +- On the `.tv-row` div, add `@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="_showContextMenu"`. +- `OnContextMenu(MouseEventArgs e, TItem item)`: set `_contextMenuItem = item`, `_contextMenuX = e.ClientX`, `_contextMenuY = e.ClientY`, `_showContextMenu = true`. +- Render the menu: if `_showContextMenu && _contextMenuItem != null && ContextMenu != null`, render a `