Connection lost. Reconnecting… attempt 1 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
An unhandled error has occurred.
Sedna.UI
v0.17.0 · main

Sidebar and nav

tier 1 — the frame

Everything inside the sidebar: the links and what can hang off them, which one is current, the groups that collapse, and the icon rail the whole thing shrinks to.

Every kind of row a sidebar nav can hold, in one nav — copy the rows you need and delete the rest. Every link carries an icon, because the collapsed rail drops the label and leaves a link without one as a blank 56px box (a .nav-group's children excepted — the group's own icon stands for them); .nav-count and .nav-link-ext both take margin-inline-start: auto, so a row carries one or the other and never both.

<aside class="sidebar" style="height:560px; overflow:hidden">
    <a class="brand" href="#">
        <span class="brand-logo" style="background:var(--brand)"></span>
        <span class="brand-text"><strong>Acme Ops</strong><span class="brand-sub">fulfilment</span></span>
    </a>
    <nav class="nav">
        <div class="nav-scroll">
            <a class="nav-link" href="#"><i class="ri-dashboard-line"></i><span>Overview</span></a>
            <a class="nav-link active" aria-current="page" href="#">
                <i class="ri-inbox-line"></i><span>Orders</span><span class="nav-count">12</span>
            </a>
            <a class="nav-link" href="#"><i class="ri-truck-line"></i><span>Shipments</span></a>

            <details class="nav-group" open>
                <summary><i class="ri-building-line"></i><span>Warehouses</span></summary>
                <a class="nav-link" href="#"><span>Rotterdam</span><span class="nav-count">4</span></a>
                <a class="nav-link" href="#"><span>Duisburg</span></a>
                <a class="nav-link" href="#"><span>Lyon</span></a>
            </details>

            <div class="nav-section">
                <span class="nav-section-label">Administration</span>
                <a class="nav-link" href="#"><i class="ri-key-2-line"></i><span>API keys</span></a>
                <a class="nav-link" href="#"><i class="ri-team-line"></i><span>Members</span></a>
                <details class="nav-group">
                    <summary><i class="ri-refresh-line"></i><span>Directory sync</span></summary>
                    <a class="nav-link" href="#"><span>Field mapping</span></a>
                    <a class="nav-link" href="#"><span>Change log</span></a>
                </details>
            </div>

            <a class="nav-status-card nav-status-card--ok" href="#">
                <span class="nav-status-label">Carrier API</span>
                <span class="nav-status-row">
                    <span class="nav-status-dot"></span>
                    <span class="nav-status-state">Connected</span>
                </span>
            </a>
            <a class="nav-status-card nav-status-card--fail" href="#">
                <span class="nav-status-label">Label printer</span>
                <span class="nav-status-row">
                    <span class="nav-status-dot"></span>
                    <span class="nav-status-state">Offline</span>
                </span>
            </a>
        </div>

        <div class="nav-tools">
            <a class="nav-link nav-link-tool" href="#"><i class="ri-question-line"></i><span>Support</span></a>
            <hr class="nav-tools-sep" />
            <a class="nav-link nav-link-tool" href="#">
                <i class="ri-book-2-line"></i><span>Documentation</span><i class="ri-external-link-line nav-link-ext"></i>
            </a>
        </div>
    </nav>
</aside>

A logo image in the brand tile

.brand-logo sizes and clips whatever is in it, so an app's own logo goes in as a plain <img> carrying no width, height or radius of its own — the tile is 30px and rounded, and the image fills it whatever its intrinsic size is. An image that is not square is cropped rather than squashed, so supply a square asset. Leave alt empty where the brand text beside it already names the app, or the name is announced twice.

<aside class="sidebar" style="height:132px; overflow:hidden">
    <a class="brand" href="#">
        <span class="brand-logo">
            <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Crect width='120' height='120' fill='%231E293B'/%3E%3Cpath d='M22 86 60 26l38 60z' fill='%2359C3FF'/%3E%3C/svg%3E"
                 alt="" width="120" height="120" />
        </span>
        <span class="brand-text"><strong>Northwind Retail</strong><span class="brand-sub">fulfilment</span></span>
    </a>
    <nav class="nav">
        <div class="nav-scroll">
            <a class="nav-link" href="#"><i class="ri-dashboard-line"></i><span>Overview</span></a>
        </div>
    </nav>
</aside>

Which link is the current page is the one thing this markup cannot express, so the package ships ActiveLink: Nav.CssClass(href) appends active and Nav.AriaCurrent(href) returns "page" or null, which Blazor omits. The link to the root needs NavLinkMatch.All or it is active everywhere, and a sidebar that survives navigation subscribes to LocationChanged in the component that renders the links, not in the layout around it — Blazor skips a child whose parameters have not changed.

@inject NavigationManager Nav

<nav class="nav" style="max-width:260px">
    <div class="nav-scroll">
        <div class="nav-section">
            <span class="nav-section-label">Frame</span>
            <a class="@Nav.CssClass("/frame")" aria-current="@Nav.AriaCurrent("/frame")" href="/frame">
                <i class="ri-side-bar-line"></i><span>Shell &amp; nav</span>
            </a>
            <a class="@Nav.CssClass("/layouts")" aria-current="@Nav.AriaCurrent("/layouts")" href="/layouts">
                <i class="ri-layout-3-line"></i><span>Layouts</span>
            </a>
            @* The root link needs NavLinkMatch.All. With the default Prefix it is
               active on every page — the same trap the framework's NavLink has. *@
            <a class="@Nav.CssClass("", match: NavLinkMatch.All)"
               aria-current="@Nav.AriaCurrent("", NavLinkMatch.All)" href="">
                <i class="ri-home-4-line"></i><span>Overview</span>
            </a>
        </div>
    </div>
</nav>

Collapsible nav group

A <details>/<summary> pair, so the open state, the keyboard operation and the announcement come from the platform and the group survives scripting being blocked. Put open on the group containing the current page, or a reader lands on a page whose own nav entry is hidden; the depth every row in the nav reads is one knob, --nav-indent.

<aside class="sidebar" style="height:460px">
    <nav class="nav">
        <div class="nav-scroll">
            <a class="nav-link" href="#"><i class="ri-inbox-line"></i><span>Orders</span></a>
            <details class="nav-group" open>
                <summary><i class="ri-building-line"></i><span>Fulfilment</span></summary>
                <a class="nav-link active" href="#"><span>Ready to pick</span><span class="nav-count">41</span></a>
                <a class="nav-link" href="#"><span>On hold</span></a>
            </details>
            <div class="nav-section">
                <span class="nav-section-label">Administration</span>
                <a class="nav-link" href="#"><i class="ri-key-2-line"></i><span>API keys</span></a>
                <details class="nav-group" open>
                    <summary><i class="ri-refresh-line"></i><span>Directory sync</span></summary>
                    <a class="nav-link" href="#"><span>Field mapping</span></a>
                    <a class="nav-link" href="#"><span>Change log</span></a>
                </details>
                <a class="nav-link" href="#"><i class="ri-file-list-line"></i><span>Audit log</span></a>
            </div>
        </div>
        <div class="nav-tools">
            <a class="nav-link nav-link-tool" href="#"><i class="ri-question-line"></i><span>Support</span></a>
            <hr class="nav-tools-sep" />
            <a class="nav-link nav-link-tool" href="#"><i class="ri-book-2-line"></i><span>Documentation</span><i class="ri-external-link-line nav-link-ext"></i></a>
        </div>
    </nav>
</aside>

Which group is open

Live — click a link and watch the groups. open is an attribute and no selector can set one, so the group asks ActiveLink.IsActive over its own children — the same call the links themselves use, so the highlighted link and the open group cannot disagree.

@implements IDisposable
@inject NavigationManager Nav

<aside class="sidebar" style="height:260px">
    <nav class="nav">
        <div class="nav-scroll">
            <details class="nav-group" open="@AnyActive(FramePages)">
                <summary><i class="ri-side-bar-line"></i><span>Frame</span></summary>
                @foreach (var (href, label) in FramePages)
                {
                    <a class="@Nav.CssClass(href, match: NavLinkMatch.All)"
                       aria-current="@Nav.AriaCurrent(href, NavLinkMatch.All)"
                       href="@href"><span>@label</span></a>
                }
            </details>
            <details class="nav-group" open="@AnyActive(DataPages)">
                <summary><i class="ri-table-line"></i><span>Data</span></summary>
                @foreach (var (href, label) in DataPages)
                {
                    <a class="@Nav.CssClass(href, match: NavLinkMatch.All)"
                       aria-current="@Nav.AriaCurrent(href, NavLinkMatch.All)"
                       href="@href"><span>@label</span></a>
                }
            </details>
        </div>
    </nav>
</aside>

@code {
    private static readonly (string Href, string Label)[] FramePages =
        [("/frame", "Shell and nav"), ("/layouts", "Layouts")];

    private static readonly (string Href, string Label)[] DataPages =
        [("/table", "Tables"), ("/stat", "Stats")];

    // CSS cannot express this. `:has(.active)` selects the group, but `open` is an
    // ATTRIBUTE and no selector can set one — so which group is open is a value the
    // app computes, from the same ActiveLink the links themselves use.
    private bool AnyActive((string Href, string Label)[] pages) =>
        pages.Any(p => Nav.IsActive(p.Href, NavLinkMatch.All));

    // The subscription belongs to the component that RENDERS the group, not to the
    // layout around it: Blazor skips re-rendering a child whose parameters have not
    // changed, so a LocationChanged handler one level up leaves the group showing the
    // previous page's state.
    protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;

    private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => StateHasChanged();

    public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
}

Collapsed rail

Add .collapsed to .sidebar — no other markup changes. Labels and status text drop out, the count pill moves onto the icon, and each item's data-tip becomes a CSS flyout that escapes the rail; hover an icon to see it.

Content sits beside the rail.

<div class="layout" style="height:300px">
    <aside class="sidebar collapsed">
        <div class="brand">
            <div class="brand-logo" style="background:var(--brand)"></div>
            <div class="brand-text">
                <strong>Dispatch Console</strong>
                <span class="brand-sub">Northwind Retail</span>
            </div>
        </div>
        <nav class="nav">
            <div class="nav-scroll">
                <a class="nav-link active" href="#" data-tip="Queue — items waiting on a decision."><i class="ri-inbox-line"></i><span>Queue</span><span class="nav-count">3</span></a>
                <a class="nav-link" href="#" data-tip="Orders — every open conversation."><i class="ri-chat-3-line"></i><span>Orders</span></a>
                <a class="nav-link" href="#" data-tip="Decided — the audit trail."><i class="ri-check-double-line"></i><span>Decided</span></a>
                <a class="nav-status-card nav-status-card--ok" href="#" data-tip="Workflow API: connected.">
                    <span class="nav-status-label">Workflow API</span>
                    <span class="nav-status-row">
                        <span class="nav-status-dot"></span>
                        <span class="nav-status-state">Connected</span>
                    </span>
                </a>
            </div>
        </nav>
    </aside>
    <div class="content">
        <header class="topbar">
            <button class="topbar-btn topbar-btn--start" aria-label="Expand sidebar"><i class="ri-side-bar-line"></i></button>
            <div class="topbar-spacer"></div>
        </header>
        <div class="page"><p class="lede">Content sits beside the rail.</p></div>
    </div>
</div>