TreeView — Navigation parity

This example recreates the navigation at the left from the same catalog through a typed model and bs-treeview. Expand branches, use arrow keys, follow links and reload to restore expansion. Both versions use the same navigation JavaScript; the example has its own saved expansion state.

The single BCL logo is omitted from the example's nodes. The website branding remains separate.

Razor

aspnet
@model TreeViewExampleModel
<div class="view-example-nav-viewport docs-sticky">
    <nav class="docs-nav-tree" aria-label="TagHelper navigation example">
        <bs-treeview model="@Model.Navigation" class="docs-nav-treeview treeview-indent-1_25rem"
                     data-docs-treeview data-docs-selected-node-id="@TreeViewExampleModel.SelectedNodeId"
                     data-docs-storage-key="copelandsyst-docs-tree-example-expanded:v1" />
    </nav>
</div>
<button id="navigation-example-reset" type="button" class="btn btn-outline-secondary mt-3">Reset this example</button>
<script type="module" src="/js/treeview-navigation-example.js"></script>

Model and hierarchy

csharp
using CopelandSyst.BootstrapComponents.ViewModels;
using CopelandSyst.Docs.DocsNavigation;

namespace CopelandSyst.Docs.Models;

public sealed class TreeViewExampleModel
{
    public const string SelectedNodeId = "bcl:components:treeview:navigation";

    public BootstrapTreeViewModel Navigation { get; } = new()
    {
        Id = "docsNavTagSample", Source = "dom", Size = "compact", Search = false,
        SelectionMode = "single", Virtualize = false, StartAdornmentLayout = "adaptive",
    };

    public TreeViewExampleModel(DocsNavigationCatalog catalog)
    {
        foreach (var node in catalog.GetNodes(SelectedNodeId))
            Navigation.NodesCapture.Add(ToModel(node));
    }

    private static BootstrapTreeViewNodeModel ToModel(DocsNavNode node)
    {
        var model = new BootstrapTreeViewNodeModel
        {
            NodeId = node.Id, Label = node.Label, BoundValue = node.Data.Href,
            Kind = node.Kind, Expanded = node.Expanded,
            Data = new { href = node.Data.Href, productId = node.Data.ProductId },
        };
        // The sample omits the single BCL logo; website branding is independent.
        if (node.Id != "product:bcl" && node.Avatar is { } avatar)
        {
            model.AvatarSrc = avatar.Src;
            model.AvatarAlt = avatar.Alt;
            model.AvatarShape = avatar.Shape;
            model.AvatarSize = avatar.Size;
            model.AvatarLoading = avatar.Loading;
        }
        foreach (var child in node.Children)
            model.ChildrenCapture.Add(ToModel(child));
        return model;
    }
}
Shared navigation behavior
javascript
import { TreeView } from '/_content/CopelandSyst.BootstrapComponents/lib/Bootstrap/dist/treeview/bs-treeview.js';

(function () {
    function readExpanded(storageKey) {
        try {
            var stored = sessionStorage.getItem(storageKey);
            if (!stored) {
                return null;
            }

            var parsed = JSON.parse(stored);
            return Array.isArray(parsed) ? parsed : [];
        } catch {
            return null;
        }
    }

    function writeExpanded(tree, storageKey) {
        var state = tree.getState();
        sessionStorage.setItem(storageKey, JSON.stringify(state.expandedIds || []));
    }

    function selectedEventIsUserOriginated(event) {
        var detail = event.detail || {};
        return detail.inputType !== 'api';
    }

    function mergeSelectedPath(tree, expandedIds, selectedNodeId) {
        var merged = new Set(expandedIds || []);
        if (selectedNodeId && typeof tree.getAncestors === 'function') {
            tree.getAncestors(selectedNodeId).forEach(function (id) {
                merged.add(id);
            });
        }

        return Array.from(merged);
    }

    function selectedRowIsVisible(host, selectedNodeId) {
        if (!host || !selectedNodeId) {
            return true;
        }

        var row = host.querySelector('.treeview-row[data-bs-id="' + CSS.escape(selectedNodeId) + '"]');
        var scroller = host.closest('.docs-sticky');
        if (!row || !scroller) {
            return false;
        }

        var rowRect = row.getBoundingClientRect();
        var scrollerRect = scroller.getBoundingClientRect();
        return rowRect.top >= scrollerRect.top && rowRect.bottom <= scrollerRect.bottom;
    }

    function getNodeHref(node) {
        return node && (node.boundValue || (node.data && node.data.href));
    }

    function openHref(href, originalEvent) {
        if (!href) {
            return;
        }

        if (originalEvent && (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.button === 1)) {
            window.open(href, '_blank', 'noopener');
            return;
        }

        window.location.href = href;
    }

    function initDocsNavigation() {
        document.querySelectorAll('[data-docs-treeview]').forEach(function (host) {
            var storageKey = host.getAttribute('data-docs-storage-key') || 'copelandsyst-docs-tree-expanded:v1';
            var tree = TreeView.getOrCreateInstance(host, {
                source: 'dom',
                selectionMode: 'single',
                size: 'compact',
                virtualize: false
            });

            var selectedNodeId = host.getAttribute('data-docs-selected-node-id');
            var savedExpandedIds = readExpanded(storageKey);
            var expandedIds = savedExpandedIds ? mergeSelectedPath(tree, savedExpandedIds, selectedNodeId) : null;

            tree.beginUpdate();
            try {
                if (expandedIds && expandedIds.length > 0) {
                    tree.setState({ expandedIds: expandedIds });
                }
                if (selectedNodeId) {
                    tree.setSelection([selectedNodeId], {
                        primaryId: selectedNodeId,
                        inputType: 'api',
                        focus: false,
                        reveal: false,
                        announce: false
                    });
                }
            } finally {
                tree.endUpdate();
            }

            if (selectedNodeId && !selectedRowIsVisible(host, selectedNodeId)) {
                tree.reveal(selectedNodeId);
            }

            host.addEventListener('expanded.bs.treeview', function () {
                writeExpanded(tree, storageKey);
            });

            host.addEventListener('collapsed.bs.treeview', function () {
                writeExpanded(tree, storageKey);
            });

            host.addEventListener('selected.bs.treeview', function (event) {
                if (!selectedEventIsUserOriginated(event)) {
                    return;
                }

                var node = event.detail && event.detail.node;
                var href = getNodeHref(node);
                openHref(href, event.detail && event.detail.originalEvent);
            });

            host.addEventListener('auxclick', function (event) {
                if (event.button !== 1) {
                    return;
                }

                var row = event.target.closest && event.target.closest('.treeview-row[data-bs-id]');
                if (!row || !host.contains(row)) {
                    return;
                }

                var node = tree.getNode(row.getAttribute('data-bs-id'));
                var href = getNodeHref(node);
                if (!href) {
                    return;
                }

                event.preventDefault();
                openHref(href, event);
            });
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initDocsNavigation);
    } else {
        initDocsNavigation();
    }
})();
javascript
document.getElementById('navigation-example-reset').addEventListener('click', () => {
    sessionStorage.removeItem('copelandsyst-docs-tree-example-expanded:v1');
    location.reload();
});
Viewport CSS
css
.view-example-nav-viewport.docs-sticky {
    position: relative;
    top: auto;
    height: 28rem;
    max-height: 65vh;
    overflow: auto;
    border: 1px solid var(--bs-border-color);
    border-radius: var(--bs-border-radius);
}

#tasks-example {
    max-height: 400px;
    overflow-y: auto;
}

#tasks-example-output {
    max-height: 24rem;
    overflow: auto;
}

#ajax-demo { max-height: 400px; overflow: auto; }
#ajax-example pre { max-height: 24rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
#ajax-example[data-state="error"] #ajax-status { color: var(--bs-danger-text-emphasis); }
#ajax-example[data-state="saving"] #ajax-status { color: var(--bs-primary-text-emphasis); }
Original hand-written navigation markup
aspnet
@model CopelandSyst.Docs.DocsNavigation.DocsNavViewModel
@inject INonceService nonceService
@using System.Text
@using System.Text.Encodings.Web
@using CopelandSyst.Docs.DocsNavigation

@functions {
    private static string RenderNodes(IReadOnlyList<DocsNavNode> nodes, string? selectedNodeId)
    {
        var builder = new StringBuilder();
        builder.AppendLine("<ul>");

        foreach (var node in nodes)
        {
            RenderNode(builder, node, selectedNodeId, level: 1);
        }

        builder.AppendLine("</ul>");
        return builder.ToString();
    }

    private static void RenderNode(StringBuilder builder, DocsNavNode node, string? selectedNodeId, int level)
    {
        var hasChildren = node.Children.Count > 0;
        var isSelected = string.Equals(node.Id, selectedNodeId, StringComparison.Ordinal);

        builder.Append("<li");
        AppendAttribute(builder, "data-bs-id", node.Id);
        AppendAttribute(builder, "data-bs-label", node.Label);
        AppendAttribute(builder, "data-bs-kind", node.Kind);
        AppendAttribute(builder, "data-docs-product-id", node.Data.ProductId);

        if (!string.IsNullOrWhiteSpace(node.Data.Href))
        {
            AppendAttribute(builder, "data-bs-bound-value", node.Data.Href);
            AppendAttribute(builder, "data-docs-href", node.Data.Href);
        }

        if (hasChildren)
        {
            AppendAttribute(builder, "data-bs-expanded", node.Expanded ? "true" : "false");
        }

        if (isSelected)
        {
            AppendAttribute(builder, "data-docs-selected", "true");
        }

        if (node.Avatar is not null)
        {
            AppendAttribute(builder, "data-bs-avatar-src", node.Avatar.Src);
            AppendAttribute(builder, "data-bs-avatar-alt", node.Avatar.Alt);
            AppendAttribute(builder, "data-bs-avatar-shape", node.Avatar.Shape);
            AppendAttribute(builder, "data-bs-avatar-size", node.Avatar.Size);
            AppendAttribute(builder, "data-bs-avatar-loading", node.Avatar.Loading);
        }

        builder.Append('>');
        RenderNodePreview(builder, node, hasChildren, isSelected, level);

        if (hasChildren)
        {
            builder.AppendLine();
            builder.AppendLine("<ul>");
            foreach (var child in node.Children)
            {
                RenderNode(builder, child, selectedNodeId, level + 1);
            }

            builder.AppendLine("</ul>");
        }

        builder.AppendLine("</li>");
    }

    private static void RenderNodePreview(StringBuilder builder, DocsNavNode node, bool hasChildren, bool isSelected, int level)
    {
        builder.Append("<div class=\"treeview-row");
        if (isSelected)
        {
            builder.Append(" treeview-row-selected");
        }

        builder.Append(" treeview-level-");
        builder.Append(level);
        builder.Append('"');

        if (hasChildren)
        {
            AppendAttribute(builder, "aria-expanded", node.Expanded ? "true" : "false");
        }

        builder.Append('>');

        if (hasChildren)
        {
            builder.Append("<button type=\"button\" class=\"treeview-toggle\" data-bs-treeview-toggle=\"\" tabindex=\"-1\" aria-label=\"");
            builder.Append(HtmlEncoder.Default.Encode(node.Expanded ? $"Collapse {node.Label}" : $"Expand {node.Label}"));
            builder.Append("\"><span class=\"treeview-toggle-icon\" aria-hidden=\"true\"></span></button>");
        }
        else
        {
            builder.Append("<span class=\"treeview-toggle\" aria-hidden=\"true\"><span class=\"treeview-toggle-icon treeview-toggle-icon-empty\" aria-hidden=\"true\"></span></span>");
        }

        builder.Append("<span class=\"treeview-label-wrap\">");

        if (node.Avatar is not null)
        {
            builder.Append("<img data-bs-treeview-avatar=\"\" src=\"");
            builder.Append(HtmlEncoder.Default.Encode(node.Avatar.Src));
            builder.Append("\" alt=\"");
            builder.Append(HtmlEncoder.Default.Encode(node.Avatar.Alt));
            builder.Append("\" loading=\"");
            builder.Append(HtmlEncoder.Default.Encode(node.Avatar.Loading));
            builder.Append("\" class=\"treeview-avatar treeview-avatar-");
            builder.Append(HtmlEncoder.Default.Encode(node.Avatar.Shape));
            builder.Append(" treeview-avatar-");
            builder.Append(HtmlEncoder.Default.Encode(node.Avatar.Size));
            builder.Append("\">");
        }

        builder.Append("<span class=\"treeview-label-stack\"><span class=\"treeview-label\">");
        if (!string.IsNullOrWhiteSpace(node.Data.Href))
        {
            builder.Append("<a href=\"");
            builder.Append(HtmlEncoder.Default.Encode(node.Data.Href));
            builder.Append("\">");
            builder.Append(HtmlEncoder.Default.Encode(node.Label));
            builder.Append("</a>");
        }
        else
        {
            builder.Append(HtmlEncoder.Default.Encode(node.Label));
        }

        builder.Append("</span><span class=\"treeview-subtitle\" hidden></span></span><span class=\"treeview-defer-icon\" aria-hidden=\"true\" hidden></span></span></div>");
    }

    private static void AppendAttribute(StringBuilder builder, string name, string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return;
        }

        builder.Append(' ');
        builder.Append(name);
        builder.Append("=\"");
        builder.Append(HtmlEncoder.Default.Encode(value));
        builder.Append('"');
    }
}

<nav class="docs-nav-tree" aria-label="Docs navigation">
    <div id="docsNavTreeHost"
         class="docs-nav-treeview treeview treeview-indent-1_25rem treeview-size-compact"
         data-docs-treeview
         data-bs-source="dom"
         data-docs-selected-node-id="@Model.SelectedNodeId"
        data-docs-selected-product-id="@Model.SelectedProductId">
        @Html.Raw(RenderNodes(Model.Nodes, Model.SelectedNodeId))
    </div>
    <script nonce="@nonceService.Nonce">
        (function () {
            var storageKey = 'copelandsyst-docs-tree-expanded:v1';
            var host = document.getElementById('docsNavTreeHost');
            if (!host) {
                return;
            }

            function getDirectChild(element, selector) {
                for (var i = 0; i < element.children.length; i += 1) {
                    if (element.children[i].matches(selector)) {
                        return element.children[i];
                    }
                }

                return null;
            }

            function readExpandedIds() {
                try {
                    var stored = sessionStorage.getItem(storageKey);
                    if (!stored) {
                        return null;
                    }

                    var parsed = JSON.parse(stored);
                    return Array.isArray(parsed) ? parsed : null;
                } catch {
                    return null;
                }
            }

            function addSelectedAncestors(expandedIds) {
                var selectedNode = host.querySelector('[data-docs-selected="true"]');
                var parentNode = selectedNode && selectedNode.parentElement
                    ? selectedNode.parentElement.closest('li[data-bs-id]')
                    : null;

                while (parentNode) {
                    expandedIds.add(parentNode.getAttribute('data-bs-id'));
                    parentNode = parentNode.parentElement
                        ? parentNode.parentElement.closest('li[data-bs-id]')
                        : null;
                }
            }

            function applyExpandedIds(expandedIds) {
                host.querySelectorAll('li[data-bs-id]').forEach(function (node) {
                    var group = getDirectChild(node, 'ul');
                    if (!group) {
                        return;
                    }

                    var id = node.getAttribute('data-bs-id');
                    var expanded = expandedIds.has(id);
                    var row = getDirectChild(node, '.treeview-row');
                    var toggle = row ? getDirectChild(row, '.treeview-toggle') : null;
                    var label = node.getAttribute('data-bs-label') || '';

                    node.setAttribute('data-bs-expanded', expanded ? 'true' : 'false');
                    group.hidden = !expanded;

                    if (row) {
                        row.setAttribute('aria-expanded', expanded ? 'true' : 'false');
                    }

                    if (toggle) {
                        toggle.setAttribute('aria-label', (expanded ? 'Collapse ' : 'Expand ') + label);
                    }
                });
            }

            var savedExpandedIds = readExpandedIds();
            if (savedExpandedIds) {
                var expandedIds = new Set(savedExpandedIds);
                addSelectedAncestors(expandedIds);
                applyExpandedIds(expandedIds);
            }

            var selected = host.querySelector('[data-docs-selected="true"] > .treeview-row');
            var scroller = host && host.closest('.docs-sticky');
            if (!selected || !scroller) {
                return;
            }

            var selectedRect = selected.getBoundingClientRect();
            var scrollerRect = scroller.getBoundingClientRect();
            var targetOffset = selectedRect.top - scrollerRect.top - Math.max(0, (scroller.clientHeight - selectedRect.height) / 2);
            scroller.scrollTop = Math.max(0, scroller.scrollTop + targetOffset);
        })();
    </script>
</nav>
Navigation data and types
csharp
namespace CopelandSyst.Docs.DocsNavigation;

public sealed record DocsProduct(
    string Id,
    string Label,
    string Category,
    string RootNodeId,
    string? Href,
    string? BrandMarkUrl,
    string BrandMarkAlt,
    string Description);

public sealed record DocsNavAvatar(
    string Src,
    string Alt,
    string Shape,
    string Size,
    string Loading);

public sealed record DocsNavNodeData(
    string? Href,
    string ProductId);

public sealed record DocsNavNode(
    string Id,
    string Label,
    DocsNavNodeData Data,
    bool Expanded,
    string Kind,
    DocsNavAvatar? Avatar,
    IReadOnlyList<DocsNavNode> Children);

public sealed record DocsNavViewModel(
    IReadOnlyList<DocsNavNode> Nodes,
    IReadOnlyList<DocsProduct> Products,
    string? SelectedNodeId,
    string? SelectedProductId,
    DocsProduct? SelectedProduct);
csharp
namespace CopelandSyst.Docs.DocsNavigation;

public sealed class DocsNavigationCatalog
{
    private static readonly DocsNavAvatar BclAvatar = new(
        Src: "/images/logo.png",
        Alt: "Bootstrap Component Library",
        Shape: "rounded",
        Size: "sm",
        Loading: "lazy");

    public IReadOnlyList<DocsProduct> GetProducts()
        =>
        [
            new(
                Id: "bcl",
                Label: "Bootstrap Component Library",
                Category: "Libraries",
                RootNodeId: "product:bcl",
                Href: "/Bcl/GettingStarted",
                BrandMarkUrl: "~/images/logo.png",
                BrandMarkAlt: "Bootstrap Component Library",
                Description: "Bootstrap-based components, tag helpers, and docs examples."),
            new(
                Id: "aegisentry",
                Label: "AegiSentry",
                Category: "Libraries",
                RootNodeId: "product:aegisentry",
                Href: null,
                BrandMarkUrl: null,
                BrandMarkAlt: "AegiSentry",
                Description: "Secure column, key ring, visitor, and protected upload services."),
            new(
                Id: "standard-tags",
                Label: "Standard Tags Library",
                Category: "Libraries",
                RootNodeId: "product:standard-tags",
                Href: null,
                BrandMarkUrl: null,
                BrandMarkAlt: "Standard Tags Library",
                Description: "Standardized tag helper infrastructure and supporting UI primitives."),
            new(
                Id: "configuration",
                Label: "Configuration",
                Category: "Libraries",
                RootNodeId: "product:configuration",
                Href: "/Configuration/GettingStarted",
                BrandMarkUrl: null,
                BrandMarkAlt: "Configuration",
                Description: "Credential-authority-aware configuration graphs and option models."),
            new(
                Id: "azure-services-management",
                Label: "Azure Services Management",
                Category: "Libraries",
                RootNodeId: "product:azure-services-management",
                Href: null,
                BrandMarkUrl: null,
                BrandMarkAlt: "Azure Services Management",
                Description: "Azure service configuration, resource option, and credential helpers."),
            new(
                Id: "csaed",
                Label: "csaed",
                Category: "Tools",
                RootNodeId: "tool:csaed",
                Href: "/Csaed/GettingStarted",
                BrandMarkUrl: null,
                BrandMarkAlt: "csaed",
                Description: "CopelandSyst Azure Environment Descriptor command-line tool."),
            new(
                Id: "dirty-bits",
                Label: "DirtyBits",
                Category: "Tools",
                RootNodeId: "tool:dirty-bits",
                Href: null,
                BrandMarkUrl: null,
                BrandMarkAlt: "DirtyBits",
                Description: "Dirty bits generator command-line tool."),
        ];

    public IReadOnlyList<DocsNavNode> GetNodes(string? selectedNodeId)
        =>
        [
            CategoryNode("category:libraries", "Libraries", expanded: true,
            [
                ProductNode(
                    id: "product:bcl",
                    label: "Bootstrap Component Library",
                    href: "/Bcl/GettingStarted",
                    expanded: selectedNodeId?.StartsWith("bcl:", StringComparison.Ordinal) == true,
                    avatar: BclAvatar,
                    children:
                    [
                        Node("bcl:getting-started", "Getting Started", "/Bcl/GettingStarted", "bcl"),
                        Node("bcl:debug-diagnostics", "Debug Diagnostics", "/Bcl/DebugDiagnostics", "bcl"),
                        Branch("bcl:components", "Components", "bcl",
                        [
                            Node("bcl:components:accordion", "Accordion", "/Bcl/Accordion", "bcl"),
                            Node("bcl:components:alerts", "Alerts", "/Bcl/Alerts", "bcl"),
                            Node("bcl:components:badges", "Badges", "/Bcl/Badges", "bcl"),
                            Node("bcl:components:breadcrumbs", "Breadcrumbs", "/Bcl/Breadcrumbs", "bcl"),
                            Node("bcl:components:buttons", "Buttons", "/Bcl/Buttons", "bcl"),
                            Node("bcl:components:button-group", "Button Group", "/Bcl/ButtonGroup", "bcl"),
                            Node("bcl:components:cards", "Cards", "/Bcl/Cards", "bcl"),
                            Node("bcl:components:carousel", "Carousel", "/Bcl/Carousel", "bcl"),
                            Node("bcl:components:close-button", "Close Button", "/Bcl/CloseButton", "bcl"),
                            Node("bcl:components:collapse", "Collapse", "/Bcl/Collapse", "bcl"),
                            Node("bcl:components:dropdowns", "Dropdowns", "/Bcl/Dropdowns", "bcl"),
                            Node("bcl:components:list-group", "List Group", "/Bcl/ListGroup", "bcl"),
                            Branch("bcl:components:listview", "ListView", "bcl",
                            [
                                Node("bcl:components:listview:general", "General", "/Bcl/ListView", "bcl"),
                                Node("bcl:components:listview:checking", "Ajax (Checking)", "/Bcl/ListView/Ajax/Checking", "bcl"),
                                Node("bcl:components:listview:reordering", "Ajax (Drag and drop)", "/Bcl/ListView/Ajax/Reordering", "bcl"),
                                Node("bcl:components:listview:renaming", "Ajax (Renaming)", "/Bcl/ListView/Ajax/Renaming", "bcl"),
                                Node("bcl:components:listview:selection", "Ajax (Selection)", "/Bcl/ListView/Ajax/Selection", "bcl"),
                                Node("bcl:components:listview:scrolling", "Scrolling (100 items)", "/Bcl/ListView/Scrolling", "bcl"),
                            ]),
                            Branch("bcl:components:treeview", "TreeView", "bcl",
                            [
                                Node("bcl:components:treeview:general", "General", "/Bcl/TreeView", "bcl"),
                                Node("bcl:components:treeview:checking", "Ajax (Checking)", "/Bcl/TreeView/Ajax/Checking", "bcl"),
                                Node("bcl:components:treeview:reordering", "Ajax (Drag and drop)", "/Bcl/TreeView/Ajax/Reordering", "bcl"),
                                Node("bcl:components:treeview:renaming", "Ajax (Renaming)", "/Bcl/TreeView/Ajax/Renaming", "bcl"),
                                Node("bcl:components:treeview:selection", "Ajax (Selection)", "/Bcl/TreeView/Ajax/Selection", "bcl"),
                                Node("bcl:components:treeview:lazy-loading", "Ajax (Lazy loading)", "/Bcl/TreeView/Ajax/LazyLoading", "bcl"),
                                Node("bcl:components:treeview:navigation", "Navigation parity", "/Bcl/TreeView/Navigation", "bcl"),
                            ]),
                            Branch("bcl:components:treeview-dropdown", "TreeViewDropdown", "bcl",
                            [
                                Node("bcl:components:treeview-dropdown:general", "General", "/Bcl/TreeViewDropdown", "bcl"),
                                Node("bcl:components:treeview-dropdown:selection", "Ajax (Selection)", "/Bcl/TreeViewDropdown/Ajax/Selection", "bcl"),
                                Node("bcl:components:treeview-dropdown:checking", "Ajax (Checking)", "/Bcl/TreeViewDropdown/Ajax/Checking", "bcl"),
                                Node("bcl:components:treeview-dropdown:reordering", "Ajax (Drag and drop)", "/Bcl/TreeViewDropdown/Ajax/Reordering", "bcl"),
                                Node("bcl:components:treeview-dropdown:renaming", "Ajax (Renaming)", "/Bcl/TreeViewDropdown/Ajax/Renaming", "bcl"),
                                Node("bcl:components:treeview-dropdown:lazy-loading", "Ajax (Lazy loading)", "/Bcl/TreeViewDropdown/Ajax/LazyLoading", "bcl"),
                                Node("bcl:components:treeview-dropdown:modal", "Inside a modal / Mobile", "/Bcl/TreeViewDropdown/Modal", "bcl"),
                            ]),
                            Branch("bcl:components:listview-dropdown", "ListViewDropdown", "bcl",
                            [
                                Node("bcl:components:listview-dropdown:general", "General", "/Bcl/ListViewDropdown", "bcl"),
                                Node("bcl:components:listview-dropdown:selection", "Ajax (Selection)", "/Bcl/ListViewDropdown/Ajax/Selection", "bcl"),
                                Node("bcl:components:listview-dropdown:checking", "Ajax (Checking)", "/Bcl/ListViewDropdown/Ajax/Checking", "bcl"),
                                Node("bcl:components:listview-dropdown:reordering", "Ajax (Drag and drop)", "/Bcl/ListViewDropdown/Ajax/Reordering", "bcl"),
                                Node("bcl:components:listview-dropdown:renaming", "Ajax (Renaming)", "/Bcl/ListViewDropdown/Ajax/Renaming", "bcl"),
                                Node("bcl:components:listview-dropdown:modal", "Inside a modal / Mobile", "/Bcl/ListViewDropdown/Modal", "bcl"),
                            ]),
                            Node("bcl:components:view-model-merging", "TreeView / ListView Model Merging", "/Bcl/ViewModelMerging", "bcl"),
                            Node("bcl:components:modal", "Modal", "/Bcl/Modal", "bcl"),
                            Node("bcl:components:navbar", "Navbar", "/Bcl/Navbar", "bcl"),
                            Node("bcl:components:navs-tabs", "Navs and Tabs", "/Bcl/NavsTabs", "bcl"),
                            Node("bcl:components:offcanvas", "Offcanvas", "/Bcl/Offcanvas", "bcl"),
                            Node("bcl:components:pagination", "Pagination", "/Bcl/Pagination", "bcl"),
                            Node("bcl:components:placeholders", "Placeholders", "/Bcl/Placeholders", "bcl"),
                            Node("bcl:components:popovers", "Popovers", "/Bcl/Popovers", "bcl"),
                            Node("bcl:components:progress", "Progress", "/Bcl/Progress", "bcl"),
                            Node("bcl:components:scrollspy", "Scrollspy", "/Bcl/Scrollspy", "bcl"),
                            Node("bcl:components:spinners", "Spinners", "/Bcl/Spinners", "bcl"),
                            Node("bcl:components:toasts", "Toasts", "/Bcl/Toasts", "bcl"),
                            Node("bcl:components:tooltips", "Tooltips", "/Bcl/Tooltips", "bcl"),
                        ]),
                        Branch("bcl:forms", "Forms", "bcl",
                        [
                            Node("bcl:forms:overview", "Overview", "/Bcl/Forms", "bcl"),
                            Node("bcl:forms:checks-radios", "Checks and Radios", "/Bcl/ChecksRadios", "bcl"),
                            Node("bcl:forms:floating-labels", "Floating Labels", "/Bcl/FloatingLabels", "bcl"),
                            Node("bcl:forms:input-groups", "Input Groups", "/Bcl/InputGroups", "bcl"),
                            Node("bcl:forms:validation", "Validation", "/Bcl/Validation", "bcl"),
                        ]),
                        Branch("bcl:layout", "Layout and Content", "bcl",
                        [
                            Node("bcl:layout:additive-elements", "Additive Elements", "/Bcl/AdditiveElements", "bcl"),
                            Node("bcl:layout:grid", "Grid", "/Bcl/Grid", "bcl"),
                            Node("bcl:layout:images-figures", "Images and Figures", "/Bcl/ImagesFigures", "bcl"),
                            Node("bcl:layout:tables", "Tables", "/Bcl/Tables", "bcl"),
                            Node("bcl:layout:icons", "Icons", "/Bcl/Icons", "bcl"),
                        ]),
                        Branch("bcl:helpers", "Helpers", "bcl",
                        [
                            Node("bcl:helpers:clearfix", "Clearfix", "/Bcl/Helpers/Clearfix", "bcl"),
                            Node("bcl:helpers:color-background", "Color and Background", "/Bcl/Helpers/ColorBackground", "bcl"),
                            Node("bcl:helpers:colored-links", "Colored Links", "/Bcl/Helpers/ColoredLinks", "bcl"),
                            Node("bcl:helpers:focus-ring", "Focus Ring", "/Bcl/Helpers/FocusRing", "bcl"),
                            Node("bcl:helpers:icon-link", "Icon Link", "/Bcl/Helpers/IconLink", "bcl"),
                            Node("bcl:helpers:position", "Position", "/Bcl/Helpers/Position", "bcl"),
                            Node("bcl:helpers:ratio", "Ratio", "/Bcl/Helpers/Ratio", "bcl"),
                            Node("bcl:helpers:stacks", "Stacks", "/Bcl/Helpers/Stacks", "bcl"),
                            Node("bcl:helpers:stretched-link", "Stretched Link", "/Bcl/Helpers/StretchedLink", "bcl"),
                            Node("bcl:helpers:text-truncation", "Text Truncation", "/Bcl/Helpers/TextTruncation", "bcl"),
                            Node("bcl:helpers:vertical-rule", "Vertical Rule", "/Bcl/Helpers/VerticalRule", "bcl"),
                            Node("bcl:helpers:visually-hidden", "Visually Hidden", "/Bcl/Helpers/VisuallyHidden", "bcl"),
                        ]),
                        Branch("bcl:utilities", "Utilities", "bcl",
                        [
                            Node("bcl:utilities:api", "API", "/Bcl/Utilities/Api", "bcl"),
                            Node("bcl:utilities:background", "Background", "/Bcl/Utilities/Background", "bcl"),
                            Node("bcl:utilities:borders", "Borders", "/Bcl/Utilities/Borders", "bcl"),
                            Node("bcl:utilities:colors", "Colors", "/Bcl/Utilities/Colors", "bcl"),
                            Node("bcl:utilities:display", "Display", "/Bcl/Utilities/Display", "bcl"),
                            Node("bcl:utilities:flex", "Flex", "/Bcl/Utilities/Flex", "bcl"),
                            Node("bcl:utilities:float", "Float", "/Bcl/Utilities/Float", "bcl"),
                            Node("bcl:utilities:interactions", "Interactions", "/Bcl/Utilities/Interactions", "bcl"),
                            Node("bcl:utilities:link", "Link", "/Bcl/Utilities/Link", "bcl"),
                            Node("bcl:utilities:object-fit", "Object Fit", "/Bcl/Utilities/ObjectFit", "bcl"),
                            Node("bcl:utilities:opacity", "Opacity", "/Bcl/Utilities/Opacity", "bcl"),
                            Node("bcl:utilities:overflow", "Overflow", "/Bcl/Utilities/Overflow", "bcl"),
                            Node("bcl:utilities:position", "Position", "/Bcl/Utilities/Position", "bcl"),
                            Node("bcl:utilities:shadows", "Shadows", "/Bcl/Utilities/Shadows", "bcl"),
                            Node("bcl:utilities:sizing", "Sizing", "/Bcl/Utilities/Sizing", "bcl"),
                            Node("bcl:utilities:spacing", "Spacing", "/Bcl/Utilities/Spacing", "bcl"),
                            Node("bcl:utilities:text", "Text", "/Bcl/Utilities/Text", "bcl"),
                            Node("bcl:utilities:vertical-align", "Vertical Align", "/Bcl/Utilities/VerticalAlign", "bcl"),
                            Node("bcl:utilities:visibility", "Visibility", "/Bcl/Utilities/Visibility", "bcl"),
                            Node("bcl:utilities:z-index", "Z-Index", "/Bcl/Utilities/ZIndex", "bcl"),
                        ]),
                    ]),
                ProductNode("product:aegisentry", "AegiSentry", null, expanded: false, avatar: null, children: []),
                ProductNode("product:standard-tags", "Standard Tags Library", null, expanded: false, avatar: null, children: []),
                ProductNode(
                    id: "product:configuration",
                    label: "Configuration",
                    href: "/Configuration/GettingStarted",
                    expanded: selectedNodeId?.StartsWith("configuration:", StringComparison.Ordinal) == true,
                    avatar: null,
                    children:
                    [
                        Node("configuration:getting-started", "Getting Started", "/Configuration/GettingStarted", "configuration"),
                        Node("configuration:credential-authority", "Credential Authority", "/Configuration/CredentialAuthority", "configuration"),
                        Node("configuration:configuration-graphs", "Configuration Graphs", "/Configuration/ConfigurationGraphs", "configuration"),
                        Node("configuration:break-glass", "Break-Glass", "/Configuration/BreakGlass", "configuration"),
                        Node("configuration:compatibility", "Compatibility", "/Configuration/Compatibility", "configuration"),
                    ]),
                ProductNode("product:azure-services-management", "Azure Services Management", null, expanded: false, avatar: null, children: []),
            ]),
            CategoryNode("category:tools", "Tools", expanded: true,
            [
                ProductNode(
                    id: "tool:csaed",
                    label: "csaed",
                    href: "/Csaed/GettingStarted",
                    expanded: selectedNodeId?.StartsWith("csaed:", StringComparison.Ordinal) == true,
                    avatar: null,
                    children:
                    [
                        Node("csaed:getting-started", "Getting Started", "/Csaed/GettingStarted", "csaed"),
                    ]),
                ToolNode("tool:dirty-bits", "DirtyBits", "dirty-bits"),
            ]),
        ];

    public DocsProduct? FindProductForNode(string? nodeId)
    {
        if (string.IsNullOrWhiteSpace(nodeId))
            return null;

        var products = GetProducts();
        var product = products.FirstOrDefault(candidate => candidate.RootNodeId == nodeId);
        if (product is not null)
            return product;

        var prefix = nodeId.Split(':', 2)[0];
        return products.FirstOrDefault(candidate => candidate.Id == prefix);
    }

    private static DocsNavNode ProductNode(
        string id,
        string label,
        string? href,
        bool expanded,
        DocsNavAvatar? avatar,
        IReadOnlyList<DocsNavNode> children)
        => new(id, label, new DocsNavNodeData(href, ProductIdFromNodeId(id)), expanded, "container", avatar, children);

    private static DocsNavNode CategoryNode(
        string id,
        string label,
        bool expanded,
        IReadOnlyList<DocsNavNode> children)
        => new(id, label, new DocsNavNodeData(null, ProductIdFromNodeId(id)), expanded, "container", null, children);

    private static DocsNavNode ToolNode(string id, string label, string productId)
        => new(id, label, new DocsNavNodeData(null, productId), false, "leaf", null, []);

    private static DocsNavNode Branch(string id, string label, string productId, IReadOnlyList<DocsNavNode> children)
        => new(id, label, new DocsNavNodeData(null, productId), true, "container", null, children);

    private static DocsNavNode Node(string id, string label, string href, string productId)
        => new(id, label, new DocsNavNodeData(href, productId), false, "leaf", null, []);

    private static string ProductIdFromNodeId(string nodeId)
        => nodeId.StartsWith("product:", StringComparison.Ordinal)
            ? nodeId["product:".Length..]
            : nodeId.Split(':', 2)[0];
}

Model merging checks also demonstrate nested bs-treeview-node captures and a supplied node model with additional tag traits. ListView has its own example.