diff --git a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor new file mode 100644 index 0000000..fcc9981 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor @@ -0,0 +1,106 @@ +@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@ +@typeparam TItem + +@if (_items is null || _items.Count == 0) +{ + if (EmptyContent != null) + { + @EmptyContent + } +} +else +{ + +} + +@{ void RenderNode(TItem item, int depth) + { + var key = KeySelector(item); + var children = ChildrenSelector(item); + var isBranch = HasChildrenSelector(item); + var isExpanded = _expandedKeys.Contains(key); + +
  • +
    + @if (isBranch) + { + @(isExpanded ? "\u2212" : "+") + } + else + { + + } + + @NodeContent(item) + +
    + @if (isBranch && isExpanded && children is { Count: > 0 }) + { + + } +
  • + } +} + +@code { + private IReadOnlyList? _items; + private HashSet _expandedKeys = new(); + private bool _initialExpansionApplied; + + [Parameter, EditorRequired] public IReadOnlyList Items { get; set; } = []; + [Parameter, EditorRequired] public Func> ChildrenSelector { get; set; } = default!; + [Parameter, EditorRequired] public Func HasChildrenSelector { get; set; } = default!; + [Parameter, EditorRequired] public Func KeySelector { get; set; } = default!; + [Parameter, EditorRequired] public RenderFragment NodeContent { get; set; } = default!; + [Parameter] public RenderFragment? EmptyContent { get; set; } + [Parameter] public int IndentPx { get; set; } = 24; + [Parameter] public bool ShowGuideLines { get; set; } = true; + [Parameter] public Func? InitiallyExpanded { get; set; } + + protected override void OnParametersSet() + { + _items = Items; + + if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 }) + { + _initialExpansionApplied = true; + ApplyInitialExpansion(_items); + } + } + + private void ApplyInitialExpansion(IReadOnlyList items) + { + foreach (var item in items) + { + if (InitiallyExpanded!(item)) + { + _expandedKeys.Add(KeySelector(item)); + } + + var children = ChildrenSelector(item); + if (children is { Count: > 0 }) + { + ApplyInitialExpansion(children); + } + } + } + + private void ToggleExpand(object key) + { + if (!_expandedKeys.Remove(key)) + { + _expandedKeys.Add(key); + } + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs new file mode 100644 index 0000000..a17cf2c --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs @@ -0,0 +1,241 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using ScadaLink.CentralUI.Components.Shared; + +namespace ScadaLink.CentralUI.Tests; + +/// +/// bUnit tests for the TreeView component covering core rendering, +/// expand/collapse behavior, ARIA attributes, and indentation. +/// +public class TreeViewTests : BunitContext +{ + private 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> RenderTreeView( + List? items = null, + RenderFragment? emptyContent = null, + int indentPx = 24, + Func? initiallyExpanded = 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 => builder => + { + builder.AddMarkupContent(0, $"{node.Label}"); + }) + .Add(p => p.IndentPx, indentPx) + .Add(p => p.EmptyContent, emptyContent) + .Add(p => p.InitiallyExpanded, initiallyExpanded)); + } + + [Fact] + public void RendersRootLevelItems_WithCorrectLabels() + { + var cut = RenderTreeView(); + + var labels = cut.FindAll(".node-label"); + // Only root-level items visible (children collapsed) + Assert.Equal(2, labels.Count); + Assert.Equal("Alpha", labels[0].TextContent); + Assert.Equal("Beta", labels[1].TextContent); + } + + [Fact] + public void RendersEmptyContent_WhenItemsEmpty() + { + var cut = RenderTreeView( + items: new List(), + emptyContent: builder => + { + builder.AddMarkupContent(0, "

    Nothing here

    "); + }); + + var msg = cut.Find(".empty-msg"); + Assert.Equal("Nothing here", msg.TextContent); + Assert.Throws(() => cut.Find("ul[role='tree']")); + } + + [Fact] + public void LeafNodes_HaveNoToggle() + { + var cut = RenderTreeView(); + + // Beta is a leaf (index 1 in the li list) + var treeItems = cut.FindAll("li[role='treeitem']"); + var betaLi = treeItems[1]; // Beta is second root + Assert.Throws(() => betaLi.QuerySelector(".tv-toggle") + ?? throw new Bunit.ElementNotFoundException(".tv-toggle")); + // Should have spacer instead + Assert.NotNull(betaLi.QuerySelector(".tv-spacer")); + } + + [Fact] + public void BranchNodes_ShowCollapsedToggle() + { + var cut = RenderTreeView(); + + var alphaLi = cut.FindAll("li[role='treeitem']")[0]; + Assert.Equal("false", alphaLi.GetAttribute("aria-expanded")); + var toggle = alphaLi.QuerySelector(".tv-toggle"); + Assert.NotNull(toggle); + Assert.Equal("+", toggle!.TextContent); + } + + [Fact] + public void CollapsedBranch_ChildrenNotInDom() + { + var cut = RenderTreeView(); + + // Alpha is collapsed by default, children should not be in DOM + var groups = cut.FindAll("ul[role='group']"); + Assert.Empty(groups); + } + + [Fact] + public void ClickToggle_ExpandsNode_ShowsChildren() + { + var cut = RenderTreeView(); + + // Click Alpha's toggle + var toggle = cut.Find(".tv-toggle"); + toggle.Click(); + + // Alpha should now be expanded + var alphaLi = cut.FindAll("li[role='treeitem']")[0]; + Assert.Equal("true", alphaLi.GetAttribute("aria-expanded")); + + // Children should appear + var labels = cut.FindAll(".node-label"); + Assert.Contains(labels, l => l.TextContent == "Alpha-1"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2"); + } + + [Fact] + public void ClickExpandedToggle_Collapses_HidesChildren() + { + var cut = RenderTreeView(); + + // Expand Alpha + var toggle = cut.Find(".tv-toggle"); + toggle.Click(); + + // Verify children visible + Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); + + // Collapse Alpha - find the toggle again (DOM changed) + var toggleAgain = cut.Find(".tv-toggle"); + toggleAgain.Click(); + + // Children gone + var labels = cut.FindAll(".node-label"); + Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); + Assert.Empty(cut.FindAll("ul[role='group']")); + } + + [Fact] + public void DeepNesting_ExpandParentThenChild_ShowsGrandchildren() + { + var cut = RenderTreeView(); + + // Expand Alpha + cut.Find(".tv-toggle").Click(); + + // Now find Alpha-2's toggle (Alpha-2 is a branch) + var toggles = cut.FindAll(".tv-toggle"); + // toggles[0] = Alpha (now expanded, shows minus), toggles[1] = Alpha-2 + Assert.True(toggles.Count >= 2); + toggles[1].Click(); + + // Alpha-2-X should be visible + var labels = cut.FindAll(".node-label"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); + } + + [Fact] + public void InitiallyExpanded_ExpandsMatchingNodes() + { + var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); + + // Alpha and Alpha-2 should be expanded, so Alpha-2-X should be visible + var labels = cut.FindAll(".node-label"); + Assert.Contains(labels, l => l.TextContent == "Alpha-1"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2"); + Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); + } + + [Fact] + public void RootUl_HasRoleTree() + { + var cut = RenderTreeView(); + + var rootUl = cut.Find("ul[role='tree']"); + Assert.NotNull(rootUl); + } + + [Fact] + public void NodeLi_HasRoleTreeitem() + { + var cut = RenderTreeView(); + + var items = cut.FindAll("li[role='treeitem']"); + Assert.Equal(2, items.Count); // Two root nodes + } + + [Fact] + public void ExpandedBranch_HasAriaExpandedTrue() + { + var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); + + var alphaLi = cut.FindAll("li[role='treeitem']")[0]; + Assert.Equal("true", alphaLi.GetAttribute("aria-expanded")); + } + + [Fact] + public void ChildGroup_HasRoleGroup() + { + var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); + + var groups = cut.FindAll("ul[role='group']"); + Assert.Single(groups); + } + + [Fact] + public void Children_IndentedByIndentPxPerDepth() + { + var cut = RenderTreeView(indentPx: 30, initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); + + var rows = cut.FindAll(".tv-row"); + // Root nodes at depth 0: padding-left: 0px + // Children at depth 1: padding-left: 30px + // Grandchildren at depth 2: padding-left: 60px + + // Find Alpha row (depth 0) + var alphaRow = rows[0]; + Assert.Contains("padding-left: 0px", alphaRow.GetAttribute("style")); + + // Find Alpha-1 row (depth 1) + var alpha1Row = rows[1]; + Assert.Contains("padding-left: 30px", alpha1Row.GetAttribute("style")); + + // Find Alpha-2-X row (depth 2) - it's after Alpha-2 at index 3 + var alpha2xRow = rows[3]; + Assert.Contains("padding-left: 60px", alpha2xRow.GetAttribute("style")); + } +}