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);
}
}
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" 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);
}
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"))" />
<span><i class="ri-moon-line"></i> Dark</span>
</label>
<label class="segmented-option">
<input type="radio" name="ex-variant" checked="@(Settings.Current.Variant == "light")"
@onchange="@(() => Settings.SetVariantAsync("light"))" />
<span><i class="ri-sun-line"></i> Light</span>
</label>
<label class="segmented-option">
<input type="radio" name="ex-variant" checked="@(Settings.Current.Variant == "system")"
@onchange="@(() => Settings.SetVariantAsync("system"))" />
<span><i class="ri-computer-line"></i> 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">Compact</span><span class="v">@Settings.Current.Compact</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();
}
}
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 you wrote, a confirmation included |
ISednaOverlays, SednaOverlayHost | Drawers and sheets — a modal, drawer or sheet that is its own component, with a typed result |
SetTipsEnabledAsync | Hover hints — switching every hint off from a setting |
FollowSpotlightAsync | Spotlight — a tour step that stays attached to its target |
InitMarkdownAsync | Markdown — wiring the editor after first render |
RegisterSearchAsync, RegisterCommandsAsync | Topbar and the command palette — the index and the commands |
CopyTextAsync | The script — where data-copy needs no C# at all |
ConfigureAsync | The script — only for an app whose default theme is not the built-in one |