From C#
this site, not the packageISednaUi 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.
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.
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.
@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.
@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.
@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
| Member | Shown on |
|---|---|
Nav.CssClass, Nav.AriaCurrent, ActiveLink.IsActive | Sidebar and nav — which link is the current page, and which group is open |
ShowModalAsync, CloseModalAsync | Modal — awaiting a <dialog> written in the page's own markup, such as a confirmation |
ISednaOverlays, SednaOverlayHost | Modal 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 |
SetTipsEnabledAsync | Hover hints — switching every hint off from a setting |
FollowSpotlightAsync | Spotlight — a tour step that stays attached to its target |
InitMarkdownAsync, InsertMarkdownAsync | Markdown — wiring the editor after first render, and inserting at the caret |
RankCommandsAsync, OpenPaletteAsync | The command palette — its ranking in a list of the app's own |
RankSearchAsync | Topbar — the header search's ranking, for pages near a missing address |
PresentToastAsync, SednaToast | Toast — a handle for work that ends later, and notifications written in a template |
ISednaGraphs | Graph in Blazor |
ISednaEditors | Rich-text editor |