feat(centralui): DialogHost ShowAsync<T> custom-content + focus trap/restore + backdrop hook (T33a)

This commit is contained in:
Joseph Doherty
2026-06-18 19:24:15 -04:00
parent c0aaba17ea
commit 4755ceee81
12 changed files with 438 additions and 23 deletions
@@ -8,14 +8,16 @@
@if (Service is DialogService svc && svc.Current is { } state)
{
<div class="modal-backdrop fade show"></div>
@* `sb-modal-backdrop` is a tokenized hook: a separate task attaches the
var-driven background rule to it. No inline style here. *@
<div class="modal-backdrop fade show sb-modal-backdrop"></div>
<div @ref="_modalRef"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-modal="true"
@onkeydown="OnKeyDown">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-dialog modal-dialog-centered @state.Size" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@state.Title</h5>
@@ -26,6 +28,10 @@
{
<p class="mb-0">@state.Body</p>
}
else if (state.Kind == DialogKind.Custom)
{
@state.Content
}
else
{
<label class="form-label">@state.Body</label>
@@ -36,14 +42,19 @@
@oninput="OnPromptInput" />
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
<button type="button"
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
@onclick="Confirm">
@ConfirmLabel(state)
</button>
</div>
@* Custom dialogs supply their own action buttons inside the body,
so the standard footer is suppressed for that kind. *@
@if (state.Kind != DialogKind.Custom)
{
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
<button type="button"
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
@onclick="Confirm">
@ConfirmLabel(state)
</button>
</div>
}
</div>
</div>
</div>
@@ -95,6 +106,13 @@
if (!ReferenceEquals(current, _focusedForState))
{
_focusedForState = current;
// Stash the element that had focus when the dialog opened so it
// can be restored on close. Must run BEFORE the modal steals
// focus below, otherwise we'd capture a modal element.
try { await JS.InvokeVoidAsync("sbDialog.captureActiveElement"); }
catch { /* prerender: no JS — ignore */ }
try
{
if (current.Kind == DialogKind.Prompt)
@@ -110,15 +128,28 @@
_focusedForState = null;
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
// Return focus to whatever element triggered the dialog so keyboard
// users are not dumped at the top of the document on close.
try { await JS.InvokeVoidAsync("sbDialog.restoreActiveElement"); }
catch { /* prerender: no JS — ignore */ }
}
}
private void OnKeyDown(KeyboardEventArgs e)
private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
Cancel();
}
else if (e.Key == "Tab")
{
// Trap Tab/Shift+Tab inside the modal so focus cannot escape to the
// (inert) page behind the backdrop. The JS helper wraps focus at the
// first/last focusable element and prevents the default move there.
try { await JS.InvokeVoidAsync("sbDialog.focusTrap", _modalRef, e.ShiftKey); }
catch { /* prerender / no JS: skip — native Tab order still applies */ }
}
}
private void OnPromptInput(ChangeEventArgs e)