The script
tier 2 — classesdrSimpleUi holds generic UI behaviour only: hover hints, theme settings,
clipboard, toasts, confirmations, and delegated wiring for menus and tabs. App-specific
interop stays in the app's own script.
IDrSimpleUi. Register it once with
builder.Services.AddDrSimpleUi(). 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:
a call that silently did nothing would be far harder to find than one that threw.
Toasts from C#
ToastAsync takes the message and a ToastKind — the enum maps to the
script's own family names, which are also the CSS modifier suffixes, so
ToastKind.Go and .toast-go cannot drift apart.
There is no C# equivalent of the remover function toast() returns: it is a
JavaScript function and cannot cross the boundary. Dismissing a toast early stays a
JavaScript concern.
@inject IDrSimpleUi 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);
}
Confirmation from C#
await Ui.ConfirmAsync(…) returns a bool, so the decision reads as
ordinary C# control flow. It is built on <dialog>.showModal() rather than
window.confirm(), which in Blazor Server blocks the circuit for as long as it
is open.
@inject IDrSimpleUi Ui
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center">
<button class="btn" type="button" @onclick="Apply">Confirm an action</button>
<button class="btn btn-danger" type="button" @onclick="Discard">Confirm something destructive</button>
<span class="badge">@_outcome</span>
</div>
@code {
private string _outcome = "nothing yet";
private async Task Apply()
{
var ok = await Ui.ConfirmAsync(
"Apply the reservation?",
"The order will be rerouted to EU-West.",
confirmLabel: "Apply");
_outcome = ok ? "applied" : "cancelled";
}
// danger reddens the confirm button and focuses Cancel, so a stray Enter does
// not delete anything.
private async Task Discard()
{
var ok = await Ui.ConfirmAsync(
"Discard the reservation?",
"This cannot be undone.",
confirmLabel: "Discard",
danger: true);
_outcome = ok ? "discarded" : "kept";
}
}
Loading it
Three files, and the order matters. boot.js goes in
<head> so the stored theme is applied before first paint — loaded at the
end of the body it would produce a dark flash on a light-theme app.
<head>
<link rel="stylesheet" href="_content/DR.Simple_UI/lib/remixicon/remixicon.css" />
<link rel="stylesheet" href="_content/DR.Simple_UI/css/DR.Simple_UI.css" />
<script src="_content/DR.Simple_UI/js/DR.Simple_UI.boot.js"></script>
</head>
<body>
<!-- … -->
<script src="_content/DR.Simple_UI/js/DR.Simple_UI.js"></script>
</body>
Toasts
drSimpleUi.toast(message, opts) creates the stack on first use, so the app
renders nothing and positions nothing. It returns a function that removes that toast early.
timeout: 0 means it stays until dismissed — right for a failure the reader has
to act on. The message is inserted as text, never as markup: it usually carries a
value from the server, and this is the one place an app hands the library one.
<div style="display:flex; flex-wrap:wrap; gap:8px">
<button class="btn btn-go" type="button"
onclick="drSimpleUi.toast('Dispatched ORD-4209', { kind: 'go', title: 'Sent to the warehouse' })">
Success toast
</button>
<button class="btn btn-warn" type="button"
onclick="drSimpleUi.toast('Automation lowered to level 1', { kind: 'warn' })">
Warning toast
</button>
<button class="btn btn-danger" type="button"
onclick="drSimpleUi.toast('Could not reach the warehouse. Nothing was written.', { kind: 'danger', timeout: 0 })">
Failure, stays until dismissed
</button>
</div>
Confirmation
await drSimpleUi.confirm({ … }) resolves true or
false. It is built on <dialog>.showModal(), which is the
point: the platform supplies the top layer, a focus trap, Escape-to-close and inert content
behind, none of which a div-based overlay gets without a lot of code that is usually
subtly wrong.
Use it instead of window.confirm(), which cannot be styled, blocks the thread,
and in Blazor Server blocks the circuit for as long as it is open. With
danger: true the confirm button turns red and focus starts on
Cancel, so a stray Enter does not delete anything.
<div style="display:flex; flex-wrap:wrap; gap:8px">
<button class="btn" type="button"
onclick="drSimpleUi.confirm({ title: 'Apply the reservation?', message: 'The order will be rerouted to EU-West.', confirm: 'Apply' }).then(ok => drSimpleUi.toast(ok ? 'Applied' : 'Cancelled', { kind: ok ? 'go' : 'info' }))">
Confirm an action
</button>
<button class="btn btn-danger" type="button"
onclick="drSimpleUi.confirm({ title: 'Discard the reservation?', message: 'This cannot be undone.', confirm: 'Discard', danger: true }).then(ok => drSimpleUi.toast(ok ? 'Discarded' : 'Kept', { kind: ok ? 'danger' : 'info' }))">
Confirm something destructive
</button>
</div>
Copy without a handler
data-copy="…" copies a literal; data-copy-target="#sel" copies
that element's text; an empty data-copy-target inside a
.code-block copies its
<pre>. The click is delegated from document, so a button
rendered by a later Blazor render works with nothing wired — and nothing is re-bound on
every render, which is how per-element handlers leak.
np_live_8f2c1d
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center">
<button class="btn" type="button" data-copy="dotnet add package DR.Simple_UI">
<i class="ri-file-copy-line"></i><span>Copy the install command</span>
</button>
<code id="the-token">np_live_8f2c1d</code>
<button class="btn btn-sm" type="button" data-copy-target="#the-token">
<i class="ri-file-copy-line"></i><span>Copy</span>
</button>
</div>
Menus, wired for you
data-menu-toggle on the trigger inside a .menu-anchor handles
opening, one panel at a time, the outside click, Escape, and returning focus to
the trigger. The closed state is the hidden attribute, not a class: a hidden
panel is out of the tab order and out of the accessibility tree, which an
opacity: 0 one is not.
.menu-anchor hugs its trigger and the panel hangs off the anchor's inline-end
edge. An app that wants a full-width anchor sets align-self: stretch on it.
<div style="padding-bottom:170px">
<div class="menu-anchor">
<button class="btn" type="button" data-menu-toggle aria-expanded="false">
<i class="ri-more-2-fill"></i> Actions
</button>
<div class="menu menu--start" hidden>
<button class="menu-item" type="button"><i class="ri-pencil-line"></i> Rename</button>
<button class="menu-item" type="button"><i class="ri-file-copy-line"></i> Duplicate</button>
<hr class="menu-sep" />
<button class="menu-item menu-item--danger" type="button"><i class="ri-delete-bin-line"></i> Delete</button>
</div>
</div>
</div>
Tabs, wired for you
data-tabs on a .tabs container is mostly there for the keyboard:
arrow keys, Home and End move between tabs, and only the selected tab
is a tab stop, so Tab steps past the tablist instead of through every tab in it.
It also shows the panel named by aria-controls and hides the rest.
Leave data-tabs off when the component drives aria-selected from
C#, or two things set it.
<div>
<div class="tabs" role="tablist" data-tabs aria-label="Views">
<button class="tab" role="tab" aria-selected="true" aria-controls="js-p1" type="button">Open</button>
<button class="tab" role="tab" aria-selected="false" aria-controls="js-p2" type="button" tabindex="-1">Decided</button>
<button class="tab" role="tab" aria-selected="false" aria-controls="js-p3" type="button" tabindex="-1">Everything</button>
</div>
<div class="tab-panel" role="tabpanel" id="js-p1">Twelve waiting on a first response.</div>
<div class="tab-panel" role="tabpanel" id="js-p2" hidden>Decided in the last 24 hours.</div>
<div class="tab-panel" role="tabpanel" id="js-p3" hidden>Everything, archived included.</div>
</div>
Hover hints
data-tip on any control renders one bubble for the whole page through event
delegation, so content rendered later needs no wiring.
data-tip-pos="left|right|top|bottom" pins a side. Write what the control does
and its consequence — a tip that repeats the label earns nothing.
The bubble itself is a single .dr-tip appended to
<body> — fixed, so a card's or a table's overflow never
clips it — and .dr-tip--visible is what fades it in. You never write either
class; they are listed here because they are in the stylesheet and an app may need to see
them in DevTools.
Elements inside .sidebar are skipped on purpose: the collapsed rail has a CSS
flyout, and both firing draws two tooltips. An app suppresses hints of its own with
drSimpleUi.tips.gate; the library has no knowledge of what is suppressing
them.
<div class="dr-row-wrap dr-gap-2">
<button class="btn" type="button" data-tip="Reload the list from the server. Unsaved notes are kept.">
<i class="ri-refresh-line"></i> Refresh
</button>
<button class="btn btn-icon" type="button" aria-label="Export"
data-tip="Downloads every row that matches the current filters, as CSV.">
<i class="ri-download-2-line"></i>
</button>
<button class="btn btn-danger" type="button"
data-tip="Deletes the reservation. The order goes back to unreserved." data-tip-pos="right">
<i class="ri-delete-bin-line"></i> Discard
</button>
<span class="badge badge-warn" data-tip="Two of five lines have no stock in any warehouse."
data-tip-pos="bottom">Awaiting stock</span>
<span class="health-badge health-badge--degraded" data-tip="One of three warehouses is not answering."
data-tip-pos="top"><span class="health-dot"></span> Degraded</span>
</div>
Suppressing hints
Assign a predicate to drSimpleUi.tips.gate and it is asked before every hint. It
stays JavaScript-only, because a predicate cannot cross the interop boundary.
// Suppress hints while something else owns the reader's attention — a tour, a modal,
// a drag in progress. The library has no knowledge of what is suppressing them.
drSimpleUi.tips.gate = el => !document.body.classList.contains('tour-active');