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.31.0 · main

Topbar and user

tier 1 — the frame

The header strip: the search, the status and version chips on the right, and the user widget with its menu.

Version tag

.topbar-version — which build the reader is looking at, in the topbar's right cluster beside the status indicator. Put a version the app already knows in it: an assembly informational version, a CI build number, a short commit sha; an <a> to a release page carries the class as well as the <span> does.

v4.2.1
v4.3.0-rc.2
<header class="topbar">
    <div class="topbar-spacer"></div>
    <div class="topbar-section">
        <span class="topbar-version">v4.2.1</span>
    </div>
    <div class="user-widget">
        <button class="user-trigger" type="button">
            <span class="user-avatar">JW</span>
            <span class="user-info">
                <span class="user-name">Jordan Weiss</span>
                <span class="user-email">jordan.weiss@example.com</span>
            </span>
        </button>
    </div>
</header>

<header class="topbar" style="margin-top:16px">
    <div class="topbar-spacer"></div>
    <a class="topbar-version topbar-version--pre" href="#" data-tip="Preview deployment — not the released build.">v4.3.0-rc.2</a>
    <div class="user-widget">
        <button class="user-trigger" type="button">
            <span class="user-avatar">JW</span>
            <span class="user-info">
                <span class="user-name">Jordan Weiss</span>
                <span class="user-email">jordan.weiss@example.com</span>
            </span>
        </button>
    </div>
</header>

A header in the brand colour

--topbar-bg, --topbar-fg, --topbar-muted and --topbar-hover recolour the header and what sits straight on it: a .topbar-btn and a neutral .btn--ghost that is a child of the bar or of a .topbar-section. --topbar-height sets its height and the sidebar brand's with it. .topbar--sticky keeps it at the top while the document scrolls, for a page without the frame's own scroll container.

<div style="--topbar-bg: var(--brand); --topbar-fg: var(--on-solid); --topbar-muted: var(--on-solid); --topbar-hover: var(--brand-hover); --topbar-height: 56px">
    <header class="topbar topbar--sticky">
        <button class="topbar-btn topbar-btn--start" type="button" aria-label="Menu"><i class="ri-menu-line"></i></button>
        <div class="topbar-spacer"></div>
        <div class="topbar-section">
            <button class="btn btn--ghost btn--icon" type="button" aria-label="Send feedback"><i class="ri-feedback-line"></i></button>
            <button class="btn btn--ghost btn--icon" type="button" aria-label="Help"><i class="ri-question-line"></i></button>
        </div>
    </header>
</div>

Quiet text in the header

Your own words in a .topbar-section — a context label, a line of state — take .text-muted or .eyebrow like anywhere else, and they paint in --topbar-muted there, so they follow a coloured header. The sidebar and the bottom bar do the same with --sidebar-muted and --bottombar-muted; a menu or a panel opened from the header is a surface of its own and keeps the page's muted. Give a header your own colour, and set --topbar-muted beside it at 4.5:1 or more.

Technical overview Server running since 08:14
Last sync 2 minutes ago
<div style="--topbar-bg: var(--brand); --topbar-fg: var(--on-solid); --topbar-muted: var(--on-solid); --topbar-hover: var(--brand-hover)">
    <header class="topbar">
        <div class="topbar-section">
            <span class="sedna-col">
                <span class="eyebrow">Technical overview</span>
                <span>Server running since 08:14</span>
            </span>
        </div>
        <div class="topbar-spacer"></div>
        <div class="topbar-section">
            <span class="text-muted">Last sync 2 minutes ago</span>
            <button class="btn btn--ghost btn--icon" type="button" aria-label="Refresh"><i class="ri-refresh-line"></i></button>
        </div>
    </header>
</div>

Live — type in it. .search goes at the start of the topbar, after the nav toggles and before .topbar-spacer, and is hidden below 720px. .topbar-btn--search is the way in there: a topbar button that shows exactly while the box is hidden and opens the app's own search — a page, a sheet, the palette. Under .layout--bottombar it steps back below 560px, where the bottom bar's end cell is the search. data-search hands the box to sednaUi.search, which ranks against the index the app registered, implements the keyboard and fills the result panel you wrote — below; narrow it with style="--search-max: 340px" on the box, and keep the input's placeholder — the clear button is shown by CSS on :placeholder-shown.

Live
<header class="topbar topbar--responsive">
    <button class="topbar-btn topbar-btn--start" aria-label="Open navigation"><i class="ri-menu-line"></i></button>

    <div class="search">
        <i class="ri-search-line search-icon" aria-hidden="true"></i>
        <input class="search-input" type="search" data-search="header-results" autocomplete="off" spellcheck="false"
               placeholder="Search…" aria-label="Search" />
        <button class="search-clear" type="button" aria-label="Clear the search" tabindex="-1">
            <i class="ri-close-line"></i>
        </button>
    </div>

    <div class="topbar-spacer"></div>
    <div class="topbar-section">
        <span class="health-badge health-badge--healthy"><span class="health-dot"></span> <span>Live</span></span>
    </div>
    <button class="topbar-btn topbar-btn--search" type="button" aria-label="Search"><i class="ri-search-line"></i></button>
</header>

<div class="search-panel sedna-scroll" id="header-results" data-search-panel hidden>
    <div data-search-list aria-label="Results"></div>
    <template data-search-item>
        <a class="search-item">
            <span class="search-item-title" data-title></span>
            <span class="search-item-meta">
                <span class="text-mono" data-code></span><span data-meta></span>
                <span class="search-tag" data-tag></span>
            </span>
        </a>
    </template>
    <template data-search-empty>
        <div class="search-status">Nothing matches &ldquo;<span data-query></span><span>&rdquo;.</span></div>
    </template>
    <template data-search-more>
        <div class="search-status"><span data-count></span> <span>more. Keep typing to narrow it down.</span></div>
    </template>
</div>

Result panel

The panel is yours to write, once, outside .topbar, with a <template> per row shape; data-search="<panel id>" on the input names it. The script clones the templates, fills their data-* slots with text and places the panel under the box, so the empty and the cut-off lines are in your language. One .search-item per result — a .search-item-title and a .search-item-meta line, with .search-item--sel where the keyboard is and .search-status for the busy and empty line. Put .sedna-scroll on the panel, use an <a> rather than a button so middle-click and “open in a new tab” work, and keep role="option" inside role="listbox" — that is what makes aria-selected legal while the box keeps focus through aria-activedescendant.

<!-- The panel an app writes for data-search, once, outside .topbar — at the end of the
     layout. sednaUi.search fills the list from the templates, positions the panel
     under the box, and draws nothing itself. data-search="order-results" on the input
     names it. A panel nested inside .topbar would be trapped in its stacking context
     at z-index 60, under every modal on the page. -->
<div class="search-panel sedna-scroll" id="order-results" data-search-panel hidden>
    <div data-search-list aria-label="Results"></div>

    <!-- One result. Each data-* slot takes text, and a slot with nothing to say is
         removed. An <a>, so middle-click and "open in a new tab" work; a result with no
         href loses the attribute. tone: 'warn' adds .search-tag--warn. -->
    <template data-search-item>
        <a class="search-item">
            <span class="search-item-title" data-title></span>
            <span class="search-item-meta">
                <span class="text-mono" data-code></span><span data-meta></span>
                <span class="search-tag" data-tag></span>
            </span>
        </a>
    </template>
    <template data-search-empty>
        <div class="search-status">No order matches &ldquo;<span data-query></span><span>&rdquo;.</span></div>
    </template>
    <template data-search-more>
        <div class="search-status"><span data-count></span> <span>more. Keep typing to narrow it down.</span></div>
    </template>
</div>

<!-- And what the list holds once results are in, for an app rendering its own. -->
<div class="search-panel sedna-scroll" role="listbox" aria-label="Results">

    <a class="search-item search-item--sel" role="option" aria-selected="true" tabindex="-1" href="/order/4182">
        <span class="search-item-title">Mailbox quota exceeded on EXCH-02</span>
        <span class="search-item-meta">
            <span class="text-mono">TCK-4182</span>
            <span>Contoso Logistics</span>
            <span class="search-tag search-tag--warn">needs a human</span>
        </span>
    </a>

    <a class="search-item" role="option" aria-selected="false" tabindex="-1" href="/order/3907">
        <span class="search-item-title">VPN certificate renewal</span>
        <span class="search-item-meta">
            <span class="text-mono">TCK-3907</span>
            <span>Fabrikam Energy</span>
            <span class="search-tag">closed</span>
        </span>
    </a>

    <!-- While the results are being fetched, and when there are none. -->
    <div class="search-status">No order matches “quota”.</div>
</div>

A panel the app renders

For an app searching a database itself: leave data-search off the input and add .search-panel--anchored, which nests the panel under the box — .search is already position: relative, so nothing is measured. The trade: a nested panel joins .topbar's stacking context at z-index 60, so it sits under a modal backdrop however high its own z-index.

<!-- An app whose results come from a database renders the panel itself, INSIDE
     .search, and adds .search-panel--anchored. .search is already
     position: relative, so the modifier is the whole of the positioning: no
     measuring, no inline top/left, nothing for a re-render to revert.

     Leave data-search off the input. With it, sednaUi.search would adopt the box
     and open its own panel over this one — and its index is registered up front,
     which a database-backed search does not have. -->
<div class="search">
    <i class="ri-search-line search-icon"></i>
    <input class="search-input" type="search" placeholder="Search orders…"
           role="combobox" aria-expanded="true" aria-controls="order-results"
           aria-autocomplete="list">
    <button class="search-clear" type="button" aria-label="Clear"><i class="ri-close-line"></i></button>

    <div id="order-results" class="search-panel search-panel--anchored sedna-scroll"
         role="listbox" aria-label="Orders">
        <a class="search-item search-item--sel" role="option" aria-selected="true" tabindex="-1" href="/order/4182">
            <span class="search-item-title">Mailbox quota exceeded on EXCH-02</span>
            <span class="search-item-meta">
                <span class="text-mono">TCK-4182</span>
                <span class="search-tag search-tag--warn">needs a human</span>
            </span>
        </a>
        <div class="search-status">2 more matches. Keep typing to narrow it down.</div>
    </div>
</div>

The search's ranking, from C#

Live — this ranks this site's own index. RankSearchAsync ranks what RegisterSearchAsync registered against a query, in the browser, and answers every match best first; the dropdown's own cut is not applied, and an empty query answers nothing. A page that was not found can offer the pages nearest the words of its address. Register the index and close the panel after a navigation with CloseSearchAsync from the layout, as From C# shows.

Nothing at /drawers-and-sheet

Pages like it appear once the page is live.

@inject ISednaUi Ui

<div class="card" style="max-width:520px">
    <div class="card-head">
        <strong>Nothing at <code>@_missing</code></strong>
    </div>
    <div class="card-body">
        <label class="form-label" for="ex-missing">The address that was asked for</label>
        <input class="form-input form-input--sm" id="ex-missing" type="text" spellcheck="false"
               @bind="_missing" @bind:event="oninput" @bind:after="Suggest" />
        <p class="form-hint">@(_pages is null ? "Pages like it appear once the page is live." : $"{_pages.Count} pages match. The closest:")</p>
        @if (_pages is { Count: > 0 })
        {
            <ul class="list list--cards" aria-label="Pages like it">
                @foreach (var match in _pages.Take(3))
                {
                    <li>
                        <a class="list-row" href="@match.Href">
                            <span class="list-main">
                                <span class="list-title">@match.Title</span>
                                <span class="list-sub">@match.Meta</span>
                            </span>
                        </a>
                    </li>
                }
            </ul>
        }
    </div>
</div>

@code {
    private string _missing = "/drawers-and-sheet";
    private IReadOnlyList<SearchItem>? _pages;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        await Suggest();
        StateHasChanged();
    }

    // The header search's own index and ranking, against the words of the address. Every
    // match comes back, best first, with no cut: the cut is the app's.
    private async Task Suggest()
    {
        var words = string.Join(' ', _missing.Split('/', '-', '_', '.').Where(w => w.Length > 3));
        _pages = await Ui.RankSearchAsync(words);
    }
}

User menu

The panel is the library's one dropdown style, .menu — see Menus for its items, labels and separators. Only the anchoring belongs to the frame, and .user-widget is position: relative so nothing is measured; opening it, the outside click and Escape are your app's state, .menu-scrim goes immediately before the panel because coming first in the markup is what puts the panel above it (the demo omits it), and do not declare role="menu" — the items are ordinary links and buttons that tab.

Live
<div style="padding-bottom:200px">
    <header class="topbar topbar--responsive">
        <button class="topbar-btn topbar-btn--start" type="button" aria-label="Collapse navigation"
                data-tip="Collapse the sidebar to an icon rail.">
            <i class="ri-side-bar-line"></i>
        </button>
        <div class="search" style="max-width:240px">
            <i class="ri-search-line search-icon" aria-hidden="true"></i>
            <input class="search-input" type="search" placeholder="Search orders…" aria-label="Search orders" />
        </div>
        <div class="topbar-spacer"></div>
        <span class="health-badge health-badge--live"><span class="health-dot"></span> <span>Live</span></span>
        <button class="topbar-btn" type="button" aria-label="Light theme" data-tip="Switch theme.">
            <i class="ri-sun-line"></i>
        </button>
        <div class="user-widget">
            <button class="user-trigger" type="button" aria-expanded="true">
                <span class="user-avatar">AF</span>
                <span class="user-info">
                    <span class="user-name">Alex Fischer</span>
                    <span class="user-email">alex.fischer@example.com</span>
                </span>
            </button>
            <div class="menu">
                <span class="menu-label">Signed in as admin</span>
                <a class="menu-item" href="#"><i class="ri-user-settings-line"></i> <span>Profile</span></a>
                <a class="menu-item" href="#"><i class="ri-settings-3-line"></i> <span>Preferences</span></a>
                <hr class="menu-sep" />
                <button class="menu-item menu-item--danger" type="button">
                    <i class="ri-logout-box-r-line"></i> <span>Sign out</span>
                </button>
            </div>
        </div>
    </header>
</div>