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

Toasts

tier 3 — a surface

A confirmation that appears, says what happened, and goes away. Anything the reader still has to act on is an alert, which stays until the state changes.

Brand and secret

Beside go, warn, danger and info, a toast takes brand and secret, from script as kind too.

PublishedThe release notes are live.
Key copiedIt is shown once; keep it safe.
<div class="toast-stack" role="status" aria-live="polite" style="position:static; inset:auto;">
    <div class="toast toast--brand">
        <i class="ri-megaphone-line"></i>
        <span class="toast-body"><strong>Published</strong>The release notes are live.</span>
        <button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
    </div>
    <div class="toast toast--secret">
        <i class="ri-lock-line"></i>
        <span class="toast-body"><strong>Key copied</strong>It is shown once; keep it safe.</span>
        <button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
    </div>
</div>

Raising one

Live. sednaUi.toast(message, options) renders the stack and the toast, stacks a second under the first, and removes it after its timeout; timeout: 0 keeps it until dismissed, which is what a failure wants. From C# it is ToastAsync. kind is go, warn, danger or info, and info is the default.

<button class="btn btn--go btn--solid" type="button"
        onclick="sednaUi.toast('ORD-4182 is on its way to EU-West.', { kind: 'go', title: 'Order dispatched' })">
    Dispatch
</button>
<button class="btn btn--warn" type="button"
        onclick="sednaUi.toast('Two of five lines are awaiting stock.', { kind: 'warn', title: 'Partially fulfilled' })">
    Warn
</button>
<button class="btn btn--danger" type="button"
        onclick="sednaUi.toast('The endpoint returned 503. Nothing was written.', { kind: 'danger', title: 'Import failed', timeout: 0 })">
    Fail, stays until dismissed
</button>
<button class="btn" type="button"
        onclick="sednaUi.toast('Nightly reconciliation closed 4 stale orders.')">
    Info
</button>

Work that ends later

Live — upload it. ToastAsync returns a SednaToast: show Uploading… with timeoutMs: 0, and when the work ends ReplaceAsync the outcome into the same toast, in place. DismissAsync removes it early. A toast the reader already closed is not an error: replacing it shows a new one, because the outcome still has to reach them. The close button's name is SednaUiOptions.ToastDismissLabel — the one word the library writes itself — or dismissLabel for one toast.

@inject ISednaUi Ui

<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
    <button class="btn" type="button" disabled="@_busy" @onclick="Upload">
        <i class="ri-upload-2-line"></i> <span>Upload the packing list</span>
    </button>
    <label class="form-check"><input type="checkbox" @bind="_fail" /> <span>Make it fail</span></label>
</div>

@code {
    private bool _busy;
    private bool _fail;

    // One toast for the whole piece of work: it says "uploading" while it runs and is
    // replaced in place with the outcome, rather than one toast leaving and another arriving.
    private async Task Upload()
    {
        _busy = true;
        var toast = await Ui.ToastAsync("Uploading packing-list-4209.pdf…", timeoutMs: 0, dismissible: false);

        await Task.Delay(1500);     // the work

        if (_fail)
            await toast.ReplaceAsync("The warehouse did not answer. Nothing was uploaded.", ToastKind.Danger, timeoutMs: 0);
        else
            await toast.ReplaceAsync("packing-list-4209.pdf is attached to ORD-4209.", ToastKind.Go, title: "Uploaded");

        _busy = false;
    }
}

A toast you write

A notification that arrives while the reader is elsewhere — a task assigned, a value to check — is a toast with something to author: a body that opens the record, buttons of its own. Write it once in a <template> and present it in the library's stack, so it has the top layer above an open modal, the hold on its timer and one corner shared with every other toast. Never position a second stack of your own: it collides with this one and loses all three.

A notification card

Live. sednaUi.toast.present(templateId, data, options) clones the template's first element into the stack and fills its data-* slots with text from data — title fills [data-title] — and href becomes the href of its [data-href] link. A body that is the thing to click is an a.toast-body or a button.toast-body, so Tab reaches it apart from the close button; .toast-actions holds buttons under the text. A control with data-toast-value ends the toast with that value, which sednaUi.toast.result(id) resolves with; a .toast-close ends it with null, as a dismissal or the timeout do. A link still navigates.

Retention set to 400 daysSaved. Usually it is 30 to 365.
<!-- The cards, written once as templates. Every element and every word is yours. -->
<template id="toast-assigned">
    <div class="toast toast--info">
        <i class="ri-user-received-line" aria-hidden="true"></i>
        <a class="toast-body" data-href data-toast-value="open">
            <strong data-title></strong><span data-text></span>
        </a>
        <button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
    </div>
</template>
<template id="toast-review">
    <div class="toast toast--warn">
        <i class="ri-alert-line" aria-hidden="true"></i>
        <div class="toast-body">
            <strong data-title></strong><span data-text></span>
            <div class="toast-actions">
                <button class="btn btn--sm btn--warn btn--solid" type="button" data-toast-value="review">Review now</button>
                <button class="btn btn--sm" type="button" data-toast-value="later">Later</button>
            </div>
        </div>
        <button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
    </div>
</template>

<div class="sedna-col sedna-gap-4" style="max-width:420px">
    <div class="sedna-row-wrap sedna-gap-3">
        <button class="btn" type="button"
                onclick="sednaUi.toast.present('toast-assigned', { title: 'Assigned to you', text: 'ORD-4182 · Northwind Retail, by Alex Fischer', href: '/toast' }, { timeout: 0 })">
            <i class="ri-user-received-line"></i> <span>A task arrives</span>
        </button>
        <button class="btn" type="button"
                onclick="sednaUi.toast.result(sednaUi.toast.present('toast-review', { title: 'Retention set to 400 days', text: 'Saved. Usually it is 30 to 365.' }, { timeout: 0 })).then(v => { if (v === 'review') sednaUi.toast('Opened the retention setting.', { kind: 'go' }); })">
            <i class="ri-alert-line"></i> <span>A value to check</span>
        </button>
    </div>

    <!-- What they look like, for reading before a script runs. -->
    <div class="toast-stack" style="position:static; inset:auto">
        <div class="toast toast--info">
            <i class="ri-user-received-line" aria-hidden="true"></i>
            <a class="toast-body" href="#"><strong>Assigned to you</strong><span>ORD-4182 · Northwind Retail, by Alex Fischer</span></a>
            <button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
        </div>
        <div class="toast toast--warn">
            <i class="ri-alert-line" aria-hidden="true"></i>
            <div class="toast-body">
                <strong>Retention set to 400 days</strong><span>Saved. Usually it is 30 to 365.</span>
                <div class="toast-actions">
                    <button class="btn btn--sm btn--warn btn--solid" type="button">Review now</button>
                    <button class="btn btn--sm" type="button">Later</button>
                </div>
            </div>
            <button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
        </div>
    </div>
</div>

Notifications from C#, with +N more

Live — press it five times. PresentToastAsync returns a SednaToast, and ResultAsync waits for what the reader did with it — "open", "read", or null — for as long as that takes, the way ShowModalAsync waits for a dialog. Three cards show at once; the rest fold into one card whose count ReplaceFromTemplateAsync moves in place, keeping its position and the wait already on it. A template that is not there answers null rather than throwing into the circuit.

Press it a few times.
@inject ISednaUi Ui

<template id="note-card">
    <div class="toast toast--info">
        <i class="ri-notification-3-line" aria-hidden="true"></i>
        <div class="toast-body">
            <strong data-title></strong><span data-text></span>
            <div class="toast-actions">
                <button class="btn btn--sm btn--brand btn--solid" type="button" data-toast-value="open">Open</button>
                <button class="btn btn--sm" type="button" data-toast-value="read">Mark as read</button>
            </div>
        </div>
        <button class="toast-close" type="button" aria-label="Dismiss"><i class="ri-close-line" aria-hidden="true"></i></button>
    </div>
</template>
<template id="note-more">
    <div class="toast">
        <i class="ri-stack-line" aria-hidden="true"></i>
        <button class="toast-body" type="button" data-toast-value="all"><strong data-count></strong><span>Show every notification</span></button>
    </div>
</template>

<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
    <button class="btn" type="button" @onclick="Arrive"><i class="ri-notification-3-line"></i> <span>A notification arrives</span></button>
    <span class="text-muted" role="status">@_status</span>
</div>

@code {
    private const int Shown = 3;
    private readonly Queue<string> _waiting = new();
    private int _onScreen;
    private SednaToast? _more;
    private int _next = 4182;
    private string _status = "Press it a few times.";

    private async Task Arrive()
    {
        var order = $"ORD-{_next++}";
        if (_onScreen < Shown) await Show(order);
        else
        {
            _waiting.Enqueue(order);
            await UpdateMore();
        }
    }

    // One card per notification, up to three; each waits for what the reader does with it.
    private async Task Show(string order)
    {
        var card = await Ui.PresentToastAsync("note-card",
            new Dictionary<string, string?> { ["title"] = "Assigned to you", ["text"] = $"{order} · Northwind Retail" }, timeoutMs: 0);
        if (card is null) return;
        _onScreen++;
        _ = Handle(order, card);
    }

    private async Task Handle(string order, SednaToast card)
    {
        var choice = await card.ResultAsync();
        _status = choice switch
        {
            "open" => $"Opened {order}.",
            "read" => $"{order} marked as read.",
            _ => $"{order} put away.",
        };
        _onScreen--;
        if (_waiting.TryDequeue(out var next)) await Show(next);
        await UpdateMore();
        await InvokeAsync(StateHasChanged);
    }

    // The rest fold into one card that counts them, replaced in place as the count moves.
    private async Task UpdateMore()
    {
        if (_waiting.Count == 0)
        {
            if (_more is not null) await _more.DismissAsync();
            _more = null;
            return;
        }
        var data = new Dictionary<string, string?> { ["count"] = $"+{_waiting.Count} more" };
        if (_more is null)
        {
            _more = await Ui.PresentToastAsync("note-more", data, timeoutMs: 0);
            if (_more is not null) _ = ShowAll(_more);
        }
        else await _more.ReplaceFromTemplateAsync("note-more", data, timeoutMs: 0);
    }

    private async Task ShowAll(SednaToast more)
    {
        if (await more.ResultAsync() != "all") return;
        _status = $"Showing all {_waiting.Count} waiting notifications.";
        _waiting.Clear();
        _more = null;
        await InvokeAsync(StateHasChanged);
    }
}

The markup

You do not write this for sednaUi.toast, and a toast you write goes in a <template> for toast.present. It is here for an app that renders a stack inside its own page, such as a server-rendered one. The stack carries the live region, and on this path it is yours to write: role="status" plus aria-live="polite" on .toast-stack, or a toast an app rendered is never announced at all. Do not put role="alert" on an individual toast — two live regions announce the same text twice, and a failure that has to interrupt gets aria-live="assertive" on the stack while it is showing.

Order dispatchedORD-4182 is on its way to EU-West.
Partially fulfilledTwo of five lines are awaiting stock.
Import failedThe endpoint returned 503. Nothing was written.
Nightly reconciliation closed 4 stale orders.
<div class="toast-stack" role="status" aria-live="polite" style="position:static; inset:auto;">
    <div class="toast toast--go">
        <i class="ri-check-line"></i>
        <span class="toast-body"><strong>Order dispatched</strong>ORD-4182 is on its way to EU-West.</span>
        <button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
    </div>
    <div class="toast toast--warn">
        <i class="ri-alert-line"></i>
        <span class="toast-body"><strong>Partially fulfilled</strong>Two of five lines are awaiting stock.</span>
        <button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
    </div>
    <div class="toast toast--danger">
        <i class="ri-error-warning-line"></i>
        <span class="toast-body"><strong>Import failed</strong>The endpoint returned 503. Nothing was written.</span>
        <button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
    </div>
    <div class="toast toast--info">
        <i class="ri-information-line"></i>
        <span class="toast-body">Nightly reconciliation closed 4 stale orders.</span>
        <button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
    </div>
</div>

Toast or alert

The same event, told both ways. A toast is a receipt for something that already happened and needs nothing from the reader; an alert stays until the state changes, and is where an action the reader must take belongs. A notification card can offer one, but a toast can be dismissed, so nothing that has to happen may depend on it.

Nothing to do — a toast, gone in a few seconds.

Order dispatchedORD-4182 is on its way to EU-West.

Something to do — an alert, until the state changes.

The import wrote nothing. 41 of 128 rows were rejected.
<div class="sedna-col sedna-gap-3" style="max-width:460px">
    <div>
        <p class="form-hint sedna-mb-1">Nothing to do &mdash; a toast, gone in a few seconds.</p>
        <div class="toast-stack" style="position:static; inset:auto">
            <div class="toast toast--go">
                <i class="ri-check-line"></i>
                <span class="toast-body"><strong>Order dispatched</strong>ORD-4182 is on its way to EU-West.</span>
                <button class="toast-close" aria-label="Dismiss"><i class="ri-close-line"></i></button>
            </div>
        </div>
    </div>
    <div>
        <p class="form-hint sedna-mb-1">Something to do &mdash; an alert, until the state changes.</p>
        <div class="alert alert--danger">
            <i class="ri-error-warning-line"></i>
            <span>The import wrote nothing. 41 of 128 rows were rejected.</span>
            <button class="btn btn--sm sedna-push" type="button">Review the rejects</button>
        </div>
    </div>
</div>

The API

The JavaScript call returns a function that removes that toast early, whose id also names it for toast.dismiss(id) and toast.replace(id, message, options). toast.present and toast.result are a toast you write and how it ended. dismissLabel names the close button, and configure({ toastDismissLabel }) sets it once for every toast.

// JavaScript. Returns a function that removes this toast early.
const close = sednaUi.toast('ORD-4182 is on its way to EU-West.', {
    kind:        'go',            // 'go' | 'warn' | 'danger' | 'info', default neutral
    title:       'Order dispatched',
    timeout:     4000,            // 0 stays until dismissed
    dismissible: true,            // false removes the close button
});
close();                          // e.g. once the SignalR event confirms it

// C#. The remover cannot cross the JS boundary, so ToastAsync returns a handle
// that names the toast by its id: DismissAsync, ReplaceAsync.
var toast = await Ui.ToastAsync("ORD-4182 is on its way to EU-West.",
                                ToastKind.Go, title: "Order dispatched");

// A failure the reader has to see gets timeout: 0. It is announced through
// aria-live on the stack — assertive for danger, polite for everything else —
// so nothing steals focus from what is being typed.
await Ui.ToastAsync("The endpoint returned 503. Nothing was written.",
                    ToastKind.Danger, title: "Import failed", timeoutMs: 0);

// The stack is created on first use and found by data-sedna-toasts, so an app
// renders nothing and positions nothing. Only a stack the library created is
// ever appended to or removed.

// A toast you write: cloned from your <template>, its data-* slots filled as text,
// `href` on its [data-href] link. Returns the id, or 0 when there is no such template.
const id = sednaUi.toast.present('toast-assigned',
    { title: 'Assigned to you', text: 'ORD-4182', href: '/orders/4182' },
    { timeout: 0 });                       // replace: <id> puts it in that toast's place
const choice = await sednaUi.toast.result(id);   // 'open' — its data-toast-value — or null

// C#.
var card = await Ui.PresentToastAsync("toast-assigned",
    new Dictionary<string, string?> { ["title"] = "Assigned to you", ["href"] = "/orders/4182" },
    timeoutMs: 0);
if (card is not null && await card.ResultAsync() == "open") { /* mark it read */ }