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.17.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.

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-solid" type="button"
        onclick="document.getElementById('takeover').showModal()">
    <i class="ri-user-shared-line"></i> Take over this order
</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-solid" value="accept"><i class="ri-user-shared-line"></i> Accept &amp; take over</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.

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-primary" 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-primary" 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");
}

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-solid" type="button"><i class="ri-user-shared-line"></i> Accept &amp; take over</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> Delete</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> Discard the reservation
    </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> Discard</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";
    }
}

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-primary" type="button">Close</button>
    </div>
</div>

Widths

.modal-sm is 360px and .modal-lg is 760px, against the default 480px. Pick by how wide the content has to be, not by how important the dialog is.

<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-primary" 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>

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-primary" type="button">Next</button>
        </div>
    </div>
</div>

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-primary" type="button">Release</button>
        </div>
    </div>
</div>