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

Modal

tier 2 — classes

A question that has to be answered before anything else happens. .modal is the panel; header, body and footer are three optional rows.

Put it on a <dialog> and open it with showModal() The top layer, the focus trap, Escape, inert content behind and returning focus on close all come from the platform. Write no .modal-backdrop either: ::backdrop is the platform's own.
Two routes: pick by where the dialog's markup lives
  • A few lines of this page's own markup — a confirmation, a one-field prompt, reading and writing this page's fields: write the <dialog class="modal" id="…"> in the page and await ISednaUi.ShowModalAsync(id). See Opening one from C#.
  • Its own .razor file — its own parameters, state, loading or validation, or opened from more than one page: present it with ISednaOverlays.ShowAsync<TComponent, TResult>, with one <SednaOverlayHost /> in the layout. See A modal that is its own component.
Do not render a dialog component inline in a page and open it through an @ref: it is rendered on every visit whether it is opened or not, its state outlives each opening, and every page that uses it repeats the wiring the host already does.

A real modal

Live — open it. The whole panel is wrapped in <form method="dialog">, which is what makes every button in it close the dialog with no JavaScript at all — value becomes dialog.returnValue. Point aria-labelledby at the heading, or a screen reader announces “dialog” and stops.

<button class="btn btn--warn btn--solid" type="button"
        onclick="document.getElementById('takeover').showModal()">
    <i class="ri-user-shared-line"></i> <span>Take over this order</span>
</button>

<dialog class="modal" id="takeover" aria-labelledby="takeover-title">
    <form method="dialog">
        <div class="modal-header">
            <h3 id="takeover-title">Take over this order?</h3>
            <button class="modal-close" value="cancel" aria-label="Close"><i class="ri-close-line"></i></button>
        </div>
        <div class="modal-body">
            <p>Automation stops handling <strong>ORD-4209</strong> and you become the owner. Any pending reservation on it expires.</p>
            <p class="form-hint">A note is written to the order's work log in your name.</p>
        </div>
        <div class="modal-footer">
            <button class="btn" value="cancel">Cancel</button>
            <button class="btn btn--warn btn--solid" value="accept"><i class="ri-user-shared-line"></i> <span>Accept &amp; take over</span></button>
        </div>
    </form>
</dialog>

Opening one from C#

Live — open it. ISednaUi.ShowModalAsync(id) is the one line of script the example above needs, without an inline onclick and without injecting IJSRuntime. It completes when the dialog closes, with its returnValue — what CloseModalAsync(id, returnValue) passed, or null for Escape — so the result reads as ordinary C# control flow. This route is for a dialog that is part of the page's own markup; a dialog with a file of its own is the next example.

Both routes out call CloseModalAsync, and the form is a plain <form> rather than <form method="dialog">: closing the dialog is that attribute's default action, and Blazor's event delegator preventDefaults a submit with an @onsubmit handler so the form does not navigate. Submit and it is cancelled with it, so the dialog stays open and nothing is logged. Handling it in C# is the honest shape here anyway — the work can fail, and a dialog that has already closed cannot show why.

No key created yet.
@inject ISednaUi Ui

<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
    <button class="btn btn--brand btn--solid" type="button" @onclick="Open">New API key</button>

    @if (_result is null)
    {
        <span class="form-hint">No key created yet.</span>
    }
    else
    {
        <span class="badge @(_result.Saved ? "badge--go" : "")">
            <i class="@(_result.Saved ? "ri-key-2-line" : "ri-close-circle-line")"></i>
            @_result.Label
        </span>
        <span class="form-hint">
            <code>returnValue</code> was <code>@_result.ReturnValue</code>
        </span>
    }
</div>

<dialog id="new-api-key" class="modal">
    <form @onsubmit="Save">
        <div class="modal-header">
            <h3>New API key</h3>
            <button class="modal-close" type="button" aria-label="Close" @onclick="Cancel">
                <i class="ri-close-line"></i>
            </button>
        </div>
        <div class="modal-body">
            <div class="form-field">
                <label class="form-label" for="key-name">Name</label>
                <input class="form-input" id="key-name" @bind="_name" />
            </div>
            <div class="form-field">
                <label class="form-label" for="key-expiry">Expires</label>
                <input class="form-input" id="key-expiry" type="date" @bind="_expires" />
            </div>
        </div>
        <div class="modal-footer">
            <button class="btn" type="button" @onclick="Cancel">Cancel</button>
            <button class="btn btn--brand btn--solid" type="submit">Create</button>
        </div>
    </form>
</dialog>

@code {
    private const string Id = "new-api-key";

    private string _name = "ci-publisher";
    private DateOnly _expires = new(2027, 3, 31);
    private Outcome? _result;

    private sealed record Outcome(bool Saved, string Label, string ReturnValue);

    // Completes when the dialog closes, with its returnValue: "save" or "cancel" from the
    // two buttons below, and null for Escape.
    private async Task Open()
    {
        _result = null;
        var returnValue = await Ui.ShowModalAsync(Id);

        _result = returnValue == "save"
            ? new Outcome(true, $"{_name} · expires {_expires:yyyy-MM-dd}", "save")
            : new Outcome(false, "Cancelled", returnValue ?? "null");
    }

    // Both routes out close the dialog through CloseModalAsync, and the form is a
    // plain <form> rather than <form method="dialog">.
    //
    // The platform's own dialog-method submit does not survive a Blazor handler:
    // closing the dialog and setting returnValue is the DEFAULT ACTION of the submit
    // event, and Blazor's event delegator preventDefaults every submit that has an
    // @onsubmit handler, so that the form does not navigate. So Create would run its
    // handler and the dialog would stay open, with nothing logged.
    //
    // Handling it in C# is also the honest shape for a dialog holding a form: the
    // work can fail, and a dialog that has already closed cannot show why.
    private Task Save() => Ui.CloseModalAsync(Id, "save");

    private Task Cancel() => Ui.CloseModalAsync(Id, "cancel");
}

A modal that is its own component

Live — open it. An edit form, a wizard, anything with its own parameters, state or validation is a component of its own, and ISednaOverlays.ShowAsync<TComponent, TResult> presents it: it renders the component into the one <SednaOverlayHost /> in your layout, opens the <dialog class="modal"> the component wrote, and completes with the typed result it closed with — Overlay.CloseAsync(result), or null for Escape and Overlay.CancelAsync(). The page that opens it renders nothing for it, and the component is removed when it closes, so every opening starts fresh.

The component takes a cascading SednaOverlay, puts its Id on the <dialog>, and renders that dialog on its first render. The same call presents a drawer or a sheet.

https://hooks.example.com/orders Order events
@inject ISednaOverlays Overlays

<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
    <button class="btn" type="button" @onclick="Edit">
        <i class="ri-edit-line"></i> <span>Edit webhook</span>
    </button>
    <code>@_webhook.Url</code>
    <span class="badge">@_webhook.Events</span>
</div>

@code {
    private EditWebhook.Settings _webhook = new("https://hooks.example.com/orders", "Order events");

    // EditWebhook is its own component and writes its own <dialog class="modal">. This
    // page renders nothing for it: the host in the layout renders it when asked, and it
    // is gone again once it closes, with the typed result or null.
    private async Task Edit()
    {
        var saved = await Overlays.ShowAsync<EditWebhook, EditWebhook.Settings>(
            new() { [nameof(EditWebhook.Current)] = _webhook });

        if (saved is not null) _webhook = saved;
    }
}

The panel

The three rows on their own: a .modal is an ordinary bordered panel until something puts it in the top layer.

<div class="modal">
    <div class="modal-header">
        <h3>Take over this order?</h3>
        <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
    </div>
    <div class="modal-body">
        <p>Automation stops handling <strong>ORD-4209</strong> and you become the owner. Any pending reservation on it expires.</p>
        <p class="form-hint">A note is written to the order's work log in your name.</p>
    </div>
    <div class="modal-footer">
        <button class="btn" type="button">Cancel</button>
        <button class="btn btn--warn btn--solid" type="button"><i class="ri-user-shared-line"></i> <span>Accept &amp; take over</span></button>
    </div>
</div>

Destructive confirm

Name the consequence in the body, and make the confirming button the destructive one. Put Cancel first in source order — showModal() focuses the first focusable element, so the safe choice is the one that takes focus. A dialog that is its own component and hands back a typed result is ISednaOverlays.

<div class="modal" style="max-width:420px">
    <div class="modal-header">
        <h3>Delete this warehouse?</h3>
        <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
    </div>
    <div class="modal-body">
        <div class="alert alert--danger" style="margin:0">
            <i class="ri-error-warning-line"></i>
            <span>This cannot be undone. 14 open orders currently route here.</span>
        </div>
        <p><strong>EU-West</strong> will be removed from every automatic reservation.</p>
    </div>
    <div class="modal-footer">
        <button class="btn" type="button">Keep it</button>
        <button class="btn btn--danger" type="button"><i class="ri-delete-bin-line"></i> <span>Delete</span></button>
    </div>
</div>

A confirmation, from C#

Live — open it. A confirmation is a .modal--sm you write, not something the library draws, so the question, the button labels and the language are yours. Wrap it in <form method="dialog"> with a value on each button and compare what ShowModalAsync returns with the one value that means yes: Escape comes back as null. Leave @onsubmit off this form — a Blazor submit handler cancels the submit, and closing the dialog is that submit's default action.

nothing yet
@inject ISednaUi Ui

<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
    <button class="btn btn--danger" type="button" @onclick="Discard">
        <i class="ri-delete-bin-line"></i> <span>Discard the reservation</span>
    </button>
    <span class="badge">@_outcome</span>
</div>

@* The confirmation is ordinary markup: its words, its buttons and their order are the
   app's. Each button's value becomes the dialog's returnValue, and the safe choice comes
   first in source order, which is the one showModal() focuses. *@
<dialog id="discard-reservation" class="modal modal--sm" aria-labelledby="discard-reservation-title">
    <form method="dialog">
        <div class="modal-header">
            <h3 id="discard-reservation-title">Discard the reservation?</h3>
        </div>
        <div class="modal-body">
            <p>The five lines on ORD-4209 go back into stock. This cannot be undone.</p>
        </div>
        <div class="modal-footer">
            <button class="btn" value="keep">Keep it</button>
            <button class="btn btn--danger" value="discard"><i class="ri-delete-bin-line"></i> <span>Discard</span></button>
        </div>
    </form>
</dialog>

@code {
    private string _outcome = "nothing yet";

    // Completes when the dialog closes, however it closed. Escape comes back as null,
    // so anything but the one value that means yes is a no.
    private async Task Discard()
    {
        var answer = await Ui.ShowModalAsync("discard-reservation");
        _outcome = answer == "discard" ? "discarded" : "kept";
    }
}

Asking before it closes

Live — write something, then press Escape. data-close-guard stops Escape closing a dialog that holds unsaved work — and a swipe down on a .sheet — and raises cancel instead. @oncancel asks, then calls Overlay.CancelAsync(); point the close button and Cancel at the same handler. It holds however often Escape is pressed, which a dialog refusing cancel on its own does not: the browser gives up on the refusal after a few presses in a row.

nothing yet
@inject ISednaOverlays Overlays

<div class="sedna-row-wrap sedna-gap-3" style="align-items:center">
    <button class="btn" type="button" @onclick="Reply">
        <i class="ri-reply-line"></i> <span>Reply on ORD-4209</span>
    </button>
    <span class="badge">@_outcome</span>
</div>

@code {
    private string _outcome = "nothing yet";

    // ReplyToOrder asks before it closes with a message still unsent, so null here
    // means nothing was written or the reader chose to discard it.
    private async Task Reply()
    {
        var message = await Overlays.ShowAsync<ReplyToOrder, string>();
        _outcome = message is null ? "not sent" : "sent";
    }
}

Scrolling body

The body scrolls on its own; there is nothing to add. The cap is --modal-max-height, calc(100dvh - 2 * var(--space-8)) by default and 240px here so the demo fits the page. One thing is still markup: a body that scrolls and holds nothing focusable needs tabindex="0" with a role and a name, as this one has, so a keyboard reader can scroll it.

<div class="modal" style="--modal-max-height: 240px">
    <div class="modal-header">
        <h3>Change history</h3>
        <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
    </div>
    <div class="modal-body" tabindex="0" role="group" aria-label="Change history">
        <div class="kv"><span class="k">03:15</span><span class="v">Reconciliation ran</span></div>
        <div class="kv"><span class="k">02:58</span><span class="v">Priority raised to P2</span></div>
        <div class="kv"><span class="k">02:41</span><span class="v">Rerouted to EU-West</span></div>
        <div class="kv"><span class="k">01:12</span><span class="v">Comment added by customer</span></div>
        <div class="kv"><span class="k">00:47</span><span class="v">Reservation created automatically</span></div>
        <div class="kv"><span class="k">00:31</span><span class="v">Order opened</span></div>
        <div class="kv"><span class="k">00:30</span><span class="v">E-mail received</span></div>
    </div>
    <div class="modal-footer">
        <button class="btn btn--brand btn--solid" type="button">Close</button>
    </div>
</div>

Widths

.modal--sm is 360px, .modal--lg 760px and .modal--xl 1140px, against the default 480px; .modal--full takes the window's width less a margin at each side and the safe-area insets. Pick by how wide the content has to be, not by how important the dialog is. Any other width is --modal-width on the dialog — style="--modal-width: min(1200px, 96vw)"; the panel never grows past the window.

<div class="sedna-col">
    <div class="modal modal--sm">
        <div class="modal-body"><p>Release the lock on ORD-4199?</p></div>
        <div class="modal-footer">
            <button class="btn" type="button">Cancel</button>
            <button class="btn btn--brand btn--solid" type="button">Release</button>
        </div>
    </div>
    <div class="modal modal--lg">
        <div class="modal-header">
            <h3>Order detail</h3>
            <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
        </div>
        <div class="modal-body"><p>A wider panel, for content that would wrap badly at 480px.</p></div>
    </div>
    <div class="modal modal--xl">
        <div class="modal-header">
            <h3>Compare revisions</h3>
            <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
        </div>
        <div class="modal-body"><p>Two columns side by side: a diff, a wide table, a form beside its preview.</p></div>
    </div>
    <div class="modal modal--full">
        <div class="modal-header">
            <h3>Sequence editor</h3>
            <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
        </div>
        <div class="modal-body"><p>As wide as the window allows, with a margin at each side.</p></div>
    </div>
</div>

A first-run deck

A short sequence of slides in a modal, with progress dots and Back/Next — the other half of first-run beside spotlight, which needs a page the reader has already seen. Sequencing stays yours, and the step element has to be keyed on the step number, or Blazor reuses the node and the entrance animation stops after slide one; --deck-height fixes the body's height so the footer does not jump between slides. Make each dot a <button> that jumps to its step, mark the current one with aria-current="step", which is what .deck-dot--active follows, and put the position in words as well — a row of circles is not announced as one.

<div class="modal deck">
    <div class="modal-header">
        <h3>Welcome to the dispatch console</h3>
        <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
    </div>

    <!-- One slide is rendered at a time. In Blazor this element carries a key
         bound to the step number, so the framework replaces it rather than
         patching it — which is what makes the entrance animation run again. -->
    <div class="deck-body">
        <div class="deck-step">
            <h4 style="margin:0 0 8px">Two lanes, and they never meet</h4>
            <p style="margin:0">
                Everything the agent could decide on its own is already gone. What is left is
                the work that asked for a person — and the work nobody has claimed yet.
            </p>
        </div>
    </div>

    <div class="deck-footer">
        <!-- Each dot jumps to its step, so a reader who has understood slide one can go
             back to it without pressing Back three times. aria-current marks the one
             showing; the group's label carries the position in words, which a row of
             circles cannot. -->
        <div class="deck-dots" role="group" aria-label="Step 2 of 4">
            <button class="deck-dot" type="button" aria-label="Go to step 1"></button>
            <button class="deck-dot" type="button" aria-label="Go to step 2" aria-current="step"></button>
            <button class="deck-dot" type="button" aria-label="Go to step 3"></button>
            <button class="deck-dot" type="button" aria-label="Go to step 4"></button>
        </div>
        <div class="btn-group">
            <button class="btn" type="button">Back</button>
            <button class="btn btn--brand btn--solid" type="button">Next</button>
        </div>
    </div>
</div>

Dots that only show the position

Where the dots are not controls — a setup the reader cannot skip through — they are plain <span class="deck-dot">s and .deck-dot--active marks the current one, with the position in the group's accessible name.

Connecting the warehouse
<div class="sedna-row sedna-gap-3">
    <div class="deck-dots" role="img" aria-label="Setting up: step 2 of 4">
        <span class="deck-dot"></span>
        <span class="deck-dot deck-dot--active"></span>
        <span class="deck-dot"></span>
        <span class="deck-dot"></span>
    </div>
    <span class="text-muted text-sm">Connecting the warehouse</span>
</div>

Closed by a click beside it

closedby="any" on the <dialog> closes it on a click on the backdrop, as Escape does — the platform's own light dismiss, in every browser this library supports, with nothing to script and no target === dialog test to get wrong. Keep it for a dialog that holds nothing to lose; with data-close-guard the click is refused like any other close request.

<!-- closedby="any": a click on the backdrop closes it, as Escape does. Leave it off a
     dialog that holds unsaved work, or add data-close-guard, which refuses this close too. -->
<dialog id="shortcuts" class="modal" closedby="any" aria-labelledby="shortcuts-title">
    <div class="modal-header">
        <h3 id="shortcuts-title">Keyboard shortcuts</h3>
        <form method="dialog"><button class="modal-close" aria-label="Close"><i class="ri-close-line"></i></button></form>
    </div>
    <div class="modal-body">
        <p><kbd>Ctrl</kbd> <kbd>K</kbd> opens the command palette.</p>
    </div>
</dialog>

Without a dialog

.modal-backdrop dims and centres a panel inside the page instead, at z-index 500, for markup that cannot be a <dialog> — you are already inside a form and cannot nest another, or the panel has to stay in the page's own stacking context. Then role="dialog", aria-modal="true", Escape, keeping Tab inside the panel, making the rest of the page unreachable and putting focus back where it came from are all on you. A drawer sits below at 480/490, so a modal opened from inside one still covers it.

<!-- Only when the panel cannot be a <dialog> — you are already inside a form and
     cannot nest another, or the panel has to stay in the page's own stacking
     context. Everything showModal() gives you has to be written by hand here:
     Escape, the focus trap, inert content behind, and returning focus on close. -->
<div class="modal-backdrop">
    <div class="modal" role="dialog" aria-modal="true" aria-labelledby="lock-title">
        <div class="modal-header">
            <h3 id="lock-title">Release the lock?</h3>
            <button class="modal-close" type="button" aria-label="Close"><i class="ri-close-line"></i></button>
        </div>
        <div class="modal-body">
            <p>ORD-4199 goes back to the queue.</p>
        </div>
        <div class="modal-footer">
            <button class="btn" type="button">Cancel</button>
            <button class="btn btn--brand btn--solid" type="button">Release</button>
        </div>
    </div>
</div>