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

From C#

this site, not the package

ISednaUi is the typed way into the script from a component: every member is one JavaScript call, and none of them adds behaviour the script does not already have. This page is the members that are easiest to get wrong; the rest are on the page of the thing they drive.

Register it once, and never call it while prerendering builder.Services.AddSednaUi(), then @inject ISednaUi Ui. Every member is a JavaScript call, so none of them can run during prerendering — call them from an event handler, or from OnAfterRenderAsync(firstRender: true). The wrappers do not swallow that exception.

Something to say before the circuit is up

A toast decided in OnInitializedAsync — a record that failed to load — cannot be shown there: the page is still prerendering. Keep the outcome as state, and say it once the first interactive render has happened. That is the whole recipe; a flag on ISednaUi would only move the same if somewhere harder to see.

@inject ISednaUi Ui
@inject IOrderService Orders

@code {
    private Order? _order;
    private string? _failure;     // decided while prerendering, said once interactive

    protected override async Task OnInitializedAsync()
    {
        try { _order = await Orders.GetAsync(Id); }
        catch (OrderNotFoundException) { _failure = $"Order {Id} no longer exists."; }
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        // The first render with a circuit behind it. Interop works from here, and it
        // runs once, so the toast is shown once.
        if (!firstRender || _failure is null) return;
        await Ui.ToastAsync(_failure, ToastKind.Danger, timeoutMs: 0);
    }
}

Once per circuit, and after every navigation

The layout outlives every page, so it is where the app says what holds for the whole session: ConfigureAsync pushes the options given to AddSednaUi, and RegisterSearchAsync and RegisterCommandsAsync hand over the header search's index and the palette's commands, each replacing the last list. A navigation in Blazor is not a page load, so nothing the browser would reset is reset: CloseSearchAsync, ClosePaletteAsync, CloseMenusAsync and HideTipsAsync put away what belonged to the last page, and ScrollPageTopAsync starts the new one at its top. This site's own layout does the same.

@* MainLayout.razor — the layout lives as long as the circuit, so what is said once per
   browser is said here, and what has to follow a navigation is done here too. *@
@inherits LayoutComponentBase
@implements IDisposable
@inject ISednaUi Ui
@inject NavigationManager Nav

@code {
    protected override void OnInitialized() => Nav.LocationChanged += Navigated;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        // The options given to AddSednaUi: the default theme, the toast's close label, the
        // notification icon. The boot script reads its own data-theme-default before this.
        await Ui.ConfigureAsync();

        // Each replaces the whole list. Call again whenever what the reader may reach
        // changes — after signing in, or a permission check.
        await Ui.RegisterSearchAsync(
        [
            new SearchItem { Title = "Open orders", Href = "/orders", Code = "/orders", Meta = "Everything not yet dispatched" },
            new SearchItem { Title = "Held orders", Href = "/orders/held", Meta = "Waiting on stock", Tag = "3", Tone = "warn" },
            new SearchItem { Title = "Carriers", Href = "/settings/carriers", Meta = "Settings", Keywords = "courier shipping" },
        ]);
        await Ui.RegisterCommandsAsync(
        [
            new PaletteCommand { Label = "Open the queue", Href = "/orders", Icon = "ri-inbox-line", Group = "Go to" },
            new PaletteCommand { Label = "Account settings", Href = "/settings", Icon = "ri-user-line", Group = "Go to", Keywords = "profile theme" },
        ]);
    }

    // What a page opened over the last one must not inherit. A navigation in Blazor is not a
    // page load, so none of it is reset by the browser.
    private void Navigated(object? sender, LocationChangedEventArgs e)
    {
        _ = Ui.CloseSearchAsync();    // results for the page that has gone
        _ = Ui.ClosePaletteAsync();   // open across a back navigation
        _ = Ui.CloseMenusAsync();     // a dropdown can survive one too
        _ = Ui.HideTipsAsync();       // a hint about an element that is no longer there
        _ = Ui.ScrollPageTopAsync();  // .page is the scroller, and the router never moves it
    }

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

Toasts

ToastAsync takes the message and a ToastKind, whose members are the CSS modifier suffixes, so ToastKind.Go and .toast--go cannot drift apart. A toast is one of the two pieces of UI the library draws for you, because a line of text has nothing to author. It returns a handle for dismissing or replacing it.

@inject ISednaUi Ui

<div style="display:flex; flex-wrap:wrap; gap:8px">
    <button class="btn btn--go btn--solid" type="button" @onclick="Approved">Success toast</button>
    <button class="btn btn--warn" type="button" @onclick="Lowered">Warning toast</button>
    <button class="btn btn--danger" type="button" @onclick="Failed">Failure, stays until dismissed</button>
</div>

@code {
    private Task Approved() => Ui.ToastAsync(
        "Dispatched ORD-4209", ToastKind.Go, title: "Sent to the warehouse");

    private Task Lowered() => Ui.ToastAsync("Automation lowered to level 1", ToastKind.Warn);

    // timeout 0 stays until dismissed — right for a failure the reader has to act on.
    private Task Failed() => Ui.ToastAsync(
        "Could not reach the warehouse. Nothing was written.", ToastKind.Danger, timeoutMs: 0);
}

A menu at the pointer, opened by the app

Live — right-click a tile. OpenMenuAtAsync opens a .menu[popover] you wrote at the pointer, as data-menu-context does, for a menu your code decides to open: after selecting the record, checking the reader's rights, or not at all. It takes MouseEventArgs.ClientX and ClientY and the id of the element the menu is about, which carries data-menu-context-target while it is open; a scroll that moves that element closes the menu. Placement, flipping at the screen's edges, Escape and an outside click are the same as for a right-click.

orders-console-01 Running
build-runner-04 Locked by a deploy

Right-click a tile.

@using Microsoft.AspNetCore.Components.Web
@inject ISednaUi Ui

<div class="sedna-grid sedna-grid--sm sedna-grid--fit" style="max-width:560px; padding-bottom:160px">
    @foreach (var tile in _tiles)
    {
        <div class="card" id="@($"tile-{tile.Name}")" tabindex="0"
             @oncontextmenu="e => Open(tile, e)" @oncontextmenu:preventDefault="true">
            <div class="card-body sedna-col">
                <strong>@tile.Name</strong>
                <span class="text-muted">@(tile.Locked ? "Locked by a deploy" : "Running")</span>
            </div>
        </div>
    }
</div>

<div class="menu" id="tile-menu" popover>
    <button class="menu-item" type="button" @onclick="Restart">
        <i class="ri-restart-line"></i> <span>Restart</span>
    </button>
    <button class="menu-item" type="button" @onclick="OpenLog">
        <i class="ri-file-list-3-line"></i> <span>Open the log</span>
    </button>
</div>

<p class="text-muted" role="status">@_status</p>

@code {
    private sealed record Tile(string Name, bool Locked);

    private readonly Tile[] _tiles = [new("orders-console-01", false), new("build-runner-04", true)];
    private Tile? _chosen;
    private string _status = "Right-click a tile.";

    private void Restart() => _status = $"Restarting {_chosen?.Name}…";
    private void OpenLog() => _status = $"Opened the log of {_chosen?.Name}.";

    // The app decides whether there is a menu at all, and for which tile — then the library
    // places it at the pointer, flips it at the screen's edges and closes it on a scroll.
    private async Task Open(Tile tile, MouseEventArgs e)
    {
        if (tile.Locked)
        {
            _status = $"{tile.Name} is locked by a deploy, so there is nothing to do with it.";
            return;
        }
        _chosen = tile;
        await Ui.OpenMenuAtAsync("tile-menu", e.ClientX, e.ClientY, $"tile-{tile.Name}");
    }
}

Appearance settings

ISednaSettings is the applied settings as state, with a Changed event — where ISednaUi.LoadSettingsAsync only answers what they are now, which goes stale when a reader on “System” changes their OS between light and dark. Call StartAsync from OnAfterRenderAsync(firstRender), and check IsLive before drawing a toggle, or someone who chose light sees “dark” selected for one frame.

Variantdark
Themesedna
Densitycomfortable
Read from the browserFalse
@inject ISednaSettings Settings
@implements IDisposable

<div class="sedna-col sedna-gap-2">
    <div class="segmented">
        <label class="segmented-option">
            <input type="radio" name="ex-variant" checked="@(Settings.Current.Variant == "dark")"
                   @onchange="@(() => Settings.SetVariantAsync("dark"))" />
            <i class="ri-moon-line"></i> <span>Dark</span>
        </label>
        <label class="segmented-option">
            <input type="radio" name="ex-variant" checked="@(Settings.Current.Variant == "light")"
                   @onchange="@(() => Settings.SetVariantAsync("light"))" />
            <i class="ri-sun-line"></i> <span>Light</span>
        </label>
        <label class="segmented-option">
            <input type="radio" name="ex-variant" checked="@(Settings.Current.Variant == "system")"
                   @onchange="@(() => Settings.SetVariantAsync("system"))" />
            <i class="ri-computer-line"></i> <span>System</span>
        </label>
    </div>

    <div class="card">
        <div class="card-body">
            <div class="kv"><span class="k">Variant</span><span class="v">@Settings.Current.Variant</span></div>
            <div class="kv"><span class="k">Theme</span><span class="v">@Settings.Current.Theme</span></div>
            <div class="kv"><span class="k">Density</span><span class="v">@Settings.Current.Density</span></div>
            <div class="kv"><span class="k">Read from the browser</span><span class="v">@Settings.IsLive</span></div>
        </div>
    </div>
</div>

@code {
    // Subscribe before StartAsync, so the initial value arrives as a change like any other and
    // this component has one code path instead of two.
    protected override void OnInitialized() => Settings.Changed += Refresh;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender) await Settings.StartAsync();
    }

    // Raised from a JavaScript callback, so the render has to be marshalled onto the circuit's
    // thread. Calling StateHasChanged directly here throws.
    private void Refresh(SednaUiSettings settings) => InvokeAsync(StateHasChanged);

    public void Dispose() => Settings.Changed -= Refresh;
}

The browser's time zone

Live — this is your own zone. ISednaUi.GetTimeZoneAsync() reads Intl through the circuit and returns the IANA id, so a Blazor Server app gets the zone on the first session in a new browser — where the cookie boot.js writes is still absent and a navigation is not an HTTP request. The two are the same answer at two moments: the cookie serves the first server render, this serves the circuit. It is interop, so call it from OnAfterRenderAsync(firstRender: true), and keep the configured fallback for the render before it answers.

Browser time zonenot read yet
Stored instant2027-03-28 00:30 UTC
On this reader's clock2027-03-28 00:30 (UTC)
@inject ISednaUi Ui

<div class="card">
    <div class="card-body">
        <div class="kv"><span class="k">Browser time zone</span><span class="v">@(_zone ?? "not read yet")</span></div>
        <div class="kv"><span class="k">Stored instant</span><span class="v">@Stored</span></div>
        <div class="kv"><span class="k">On this reader's clock</span><span class="v">@Local</span></div>
    </div>
</div>

@code {
    // What an app stores: an instant in UTC. The zone is the one part of rendering it
    // that only the browser knows. This one is half an hour before Europe's spring
    // transition, which is why an offset would not do — it is true until it is not.
    private static readonly DateTime Instant = new(2027, 3, 28, 0, 30, 0, DateTimeKind.Utc);

    // The configured fallback: what the first server render has to use, because it has
    // no browser to ask and no cookie on a first visit.
    private TimeZoneInfo _zoneInfo = TimeZoneInfo.Utc;
    private string? _zone;

    private string Stored => Instant.ToString("yyyy-MM-dd HH:mm") + " UTC";

    private string Local =>
        TimeZoneInfo.ConvertTimeFromUtc(Instant, _zoneInfo).ToString("yyyy-MM-dd HH:mm")
        + " (" + _zoneInfo.Id + ")";

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        // Interop, so never during prerendering. An IANA id — TryFindSystemTimeZoneById
        // takes those on every platform, and a browser that reports nothing leaves the
        // fallback standing rather than guessing.
        _zone = await Ui.GetTimeZoneAsync();
        if (_zone is not null && TimeZoneInfo.TryFindSystemTimeZoneById(_zone, out var found))
        {
            _zoneInfo = found;
        }

        StateHasChanged();
    }
}

A settings panel

Live — these are this site's settings. SetThemeAsync takes a theme registered with AddSednaUi; SetDensityAsync takes comfortable, compact or touch; SetColourBlindAsync switches the colour-vision palette; SetLanguageAsync sets the document's lang, for the app to render its own words in; and SetDirectionAsync takes ltr or rtl. Each stores the choice and lets Changed come back, so the controls are drawn from Current and nothing else.

ISednaUi reaches the same settings without listening. LoadSettingsAsync is right for a value taken once, such as what a problem report attaches. SaveSettingAsync stores one key — theme, variant, cvd, density, lang, or an app's own name listed in the boot script's data-app-attrs — and the panel follows it all the same.

@inject ISednaSettings Settings
@inject ISednaUi Ui
@implements IDisposable

<div class="card" style="max-width:560px">
    <div class="card-body sedna-col sedna-gap-3">
        <div class="form-field">
            <label class="form-label" for="ex-theme">Theme</label>
            <select class="form-input form-select" id="ex-theme" value="@Settings.Current.Theme"
                    @onchange="@(e => Settings.SetThemeAsync(e.Value?.ToString() ?? "sedna"))">
                <option value="sedna">Sedna</option>
                <option value="forest">Forest</option>
                <option value="cobalt">Cobalt</option>
                <option value="graphite">Graphite</option>
            </select>
        </div>

        <div class="segmented" role="radiogroup" aria-label="Density">
            @foreach (var density in Densities)
            {
                <label class="segmented-option">
                    <input type="radio" name="ex-density" checked="@(Settings.Current.Density == density.Value)"
                           @onchange="@(() => Settings.SetDensityAsync(density.Value))" />
                    <span>@density.Label</span>
                </label>
            }
        </div>

        <label class="switch">
            <input type="checkbox" checked="@Settings.Current.ColourBlind"
                   @onchange="@(e => Settings.SetColourBlindAsync(e.Value is true))" />
            <span>Colours for colour-blind readers</span>
        </label>

        <div class="sedna-row-wrap sedna-gap-3">
            <select class="form-input form-select sedna-w-fit" aria-label="Language" value="@Settings.Current.Language"
                    @onchange="@(e => Settings.SetLanguageAsync(e.Value?.ToString() ?? "en"))">
                <option value="en">English</option>
                <option value="de">Deutsch</option>
            </select>
            <div class="segmented" role="radiogroup" aria-label="Direction">
                <label class="segmented-option">
                    <input type="radio" name="ex-dir" checked="@(Settings.Current.Direction == "ltr")"
                           @onchange="@(() => Settings.SetDirectionAsync("ltr"))" />
                    <span>Left to right</span>
                </label>
                <label class="segmented-option">
                    <input type="radio" name="ex-dir" checked="@(Settings.Current.Direction == "rtl")"
                           @onchange="@(() => Settings.SetDirectionAsync("rtl"))" />
                    <span>Right to left</span>
                </label>
            </div>
        </div>
    </div>
    <div class="card-foot card-foot--split">
        <button class="btn btn--sm" type="button" @onclick="AttachToReport"><i class="ri-bug-line"></i> <span>Attach to a problem report</span></button>
        <button class="btn btn--ghost btn--sm" type="button" @onclick="Defaults">Back to the defaults</button>
    </div>
    @if (_report is not null)
    {
        <div class="card-section">
            <p class="text-sm text-muted sedna-mt-0 sedna-mb-0">@_report</p>
        </div>
    }
</div>

@code {
    private static readonly (string Value, string Label)[] Densities =
        [("comfortable", "Comfortable"), ("compact", "Compact"), ("touch", "Touch")];

    private string? _report;

    protected override void OnInitialized() => Settings.Changed += Refresh;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender) await Settings.StartAsync();
    }

    private void Refresh(SednaUiSettings settings) => InvokeAsync(StateHasChanged);

    // A value taken once, at the moment it is asked for: ISednaUi reads it without listening.
    private async Task AttachToReport()
    {
        var s = await Ui.LoadSettingsAsync();
        _report = $"Theme {s.Theme}, {s.Variant}, density {s.Density}, colour-blind {(s.ColourBlind ? "on" : "off")}, "
                  + $"language {s.Language}, {s.Direction}.";
    }

    // The raw keys, stored and applied one by one. The panel above redraws from Changed,
    // which fires whoever stored the value.
    private async Task Defaults()
    {
        await Ui.SaveSettingAsync("theme", "sedna");
        await Ui.SaveSettingAsync("variant", "dark");
        await Ui.SaveSettingAsync("density", "comfortable");
        await Ui.SaveSettingAsync("cvd", "0");
    }

    public void Dispose() => Settings.Changed -= Refresh;
}

The browser, from C#

Live. The small calls a component cannot make by itself. CopyTextAsync copies a value the server has only just made, where data-copy needs a value written in the markup, and answers whether the browser allowed it. ViewportWidthAsync is for a decision the server has to make — how many lines to fetch — and never for layout, which the stylesheet's breakpoints keep. GetItemAsync and SetItemAsync read and write localStorage under the key exactly as given, with no prefix.

SelectTabAsync selects a data-tabs tab by the id of the panel it controls, as a click would; in a data-tabs="managed" tablist it clicks the tab, and the app's handler answers. OpenTabAsync opens an address in a new tab with noopener.

Lines per pagenot decided yet
Pinned ordernone
@inject ISednaUi Ui

<div class="card" style="max-width:560px">
    <div class="card-head card-head--tabs">
        <div class="tabs" role="tablist" data-tabs aria-label="ORD-4209">
            <button class="tab" role="tab" aria-selected="true" id="br-lines" aria-controls="br-lines-panel" type="button">Lines</button>
            <button class="tab" role="tab" aria-selected="false" id="br-parcel" aria-controls="br-parcel-panel" type="button" tabindex="-1">Parcel</button>
        </div>
    </div>
    <div class="card-body">
        <div class="tab-panel" role="tabpanel" id="br-lines-panel" aria-labelledby="br-lines">
            <div class="kv"><span class="k">Lines per page</span><span class="v">@(_pageSize?.ToString() ?? "not decided yet")</span></div>
            <div class="kv"><span class="k">Pinned order</span><span class="v">@(_pinned ?? "none")</span></div>
        </div>
        <div class="tab-panel" role="tabpanel" id="br-parcel-panel" aria-labelledby="br-parcel" hidden>
            <div class="kv"><span class="k">Carrier</span><span>Next-day courier</span></div>
            <div class="kv"><span class="k">Tracking number</span><span class="v">PKG-58301-224</span></div>
        </div>
    </div>
    <div class="card-foot card-foot--start">
        <button class="btn btn--sm" type="button" @onclick="Share"><i class="ri-link"></i> <span>Copy a share link</span></button>
        <button class="btn btn--sm" type="button" @onclick="Pin"><i class="ri-pushpin-line"></i> <span>Pin this order</span></button>
        <button class="btn btn--sm" type="button" @onclick="Track"><i class="ri-truck-line"></i> <span>Where is it?</span></button>
        <span class="form-hint sedna-push" role="status">@_status</span>
    </div>
</div>

@code {
    // The app's own key. GetItemAsync and SetItemAsync take it as it is: the library's
    // storage prefix is for its own settings, not for this.
    private const string PinKey = "orders.pinned";

    private int? _pageSize;
    private string? _pinned;
    private string _status = "";

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        // A decision only the server can make — how many lines to fetch — on a number only
        // the browser knows. Layout is the stylesheet's, and stays there.
        _pageSize = await Ui.ViewportWidthAsync() < 720 ? 10 : 50;
        _pinned = await Ui.GetItemAsync(PinKey);
        StateHasChanged();
    }

    // A value the server has only just made, so there is no data-copy attribute to write.
    private async Task Share()
    {
        var token = Guid.NewGuid().ToString("N")[..12];
        var link = $"https://orders.example.com/s/{token}";
        _status = await Ui.CopyTextAsync(link) ? "Link copied." : "The browser did not allow the copy.";
    }

    private async Task Pin()
    {
        await Ui.SetItemAsync(PinKey, "ORD-4209");
        _pinned = "ORD-4209";
        _status = "Pinned in this browser.";
    }

    // Selects the tab by the panel it controls, as a click on it would, then opens the carrier's
    // page beside this one.
    private async Task Track()
    {
        await Ui.SelectTabAsync("br-parcel-panel");
        await Ui.OpenTabAsync("https://tracking.example.org/PKG-58301-224");
    }
}

Desktop notifications

Live — your browser asks first. RequestNotifyAsync asks for permission, and only works from a click: a browser refuses the question outside a user gesture. NotifyAsync shows a notification by the system, outside the page, and PingAsync plays a short tone, with no file to ship. Both are best effort — without permission nothing is shown — so say the same on the page, with a toast or a status line.

@inject ISednaUi Ui

<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
    <button class="btn" type="button" @onclick="Watch"><i class="ri-notification-badge-line"></i> <span>Tell me when ORD-4209 leaves</span></button>
    <button class="btn btn--go" type="button" disabled="@(!_watching)" @onclick="Dispatched"><i class="ri-truck-line"></i> <span>The warehouse dispatches it</span></button>
    <span class="text-muted" role="status">@_status</span>
</div>

@code {
    private bool _watching;
    private string _status = "";

    // From the click itself: a browser refuses to ask for permission outside a user gesture.
    private async Task Watch()
    {
        var allowed = await Ui.RequestNotifyAsync();
        _watching = true;
        _status = allowed ? "You will be told, even from another tab." : "You will be told on this page only.";
    }

    // What the server hears from the warehouse. Both calls are best effort: without permission
    // the notification is skipped, and the tone still plays, so say it on the page as well.
    private async Task Dispatched()
    {
        await Ui.NotifyAsync("ORD-4209 has left the warehouse", "Next-day courier · PKG-58301-224");
        await Ui.PingAsync();
        _status = "ORD-4209 left the warehouse at " + DateTime.UtcNow.ToString("HH:mm") + " UTC.";
    }
}

The rest of the surface

MemberShown on
Nav.CssClass, Nav.AriaCurrent, ActiveLink.IsActiveSidebar and nav — which link is the current page, and which group is open
ShowModalAsync, CloseModalAsyncModal — awaiting a <dialog> written in the page's own markup, such as a confirmation
ISednaOverlays, SednaOverlayHostModal and drawers and sheets — a modal, drawer or sheet that is its own component, with a typed result. A dialog with a .razor file of its own goes this way, not inline in the page
SetTipsEnabledAsyncHover hints — switching every hint off from a setting
FollowSpotlightAsyncSpotlight — a tour step that stays attached to its target
InitMarkdownAsync, InsertMarkdownAsyncMarkdown — wiring the editor after first render, and inserting at the caret
RankCommandsAsync, OpenPaletteAsyncThe command palette — its ranking in a list of the app's own
RankSearchAsyncTopbar — the header search's ranking, for pages near a missing address
PresentToastAsync, SednaToastToast — a handle for work that ends later, and notifications written in a template
ISednaGraphsGraph in Blazor
ISednaEditorsRich-text editor