feat(ui): add TreeView<TItem> component with core rendering, expand/collapse, ARIA (R1-R4, R14)

This commit is contained in:
Joseph Doherty
2026-03-23 02:23:48 -04:00
parent 4db93cae2b
commit 75648c0c76
2 changed files with 347 additions and 0 deletions

View File

@@ -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
{
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
@foreach (var item in _items)
{
RenderNode(item, 0);
}
</ul>
}
@{ void RenderNode(TItem item, int depth)
{
var key = KeySelector(item);
var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item);
var isExpanded = _expandedKeys.Contains(key);
<li role="treeitem" @key="key"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)">
<div class="tv-row" style="padding-left: @(depth * IndentPx)px">
@if (isBranch)
{
<span class="tv-toggle" @onclick="() => ToggleExpand(key)">@(isExpanded ? "\u2212" : "+")</span>
}
else
{
<span class="tv-spacer"></span>
}
<span class="tv-content">
@NodeContent(item)
</span>
</div>
@if (isBranch && isExpanded && children is { Count: > 0 })
{
<ul role="group">
@foreach (var child in children)
{
RenderNode(child, depth + 1);
}
</ul>
}
</li>
}
}
@code {
private IReadOnlyList<TItem>? _items;
private HashSet<object> _expandedKeys = new();
private bool _initialExpansionApplied;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, object> KeySelector { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TItem> 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<TItem, bool>? 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<TItem> 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);
}
}
}