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)
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Components;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
@@ -54,6 +56,41 @@ public class DialogService : IDialogService
return Project(tcs.Task, static r => r as string);
}
/// <inheritdoc />
public Task<TResult?> ShowAsync<TResult>(string title, RenderFragment<DialogContext<TResult>> body, string? size = null)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs = tcs;
// The context routes Close/Cancel back through the same boxed Resolve()
// path the host uses for confirm/prompt — Close boxes the typed value,
// Cancel passes null. Capturing Resolve (not a service reference) keeps
// the body's surface narrow and the resolution path uniform.
var context = new DialogContext<TResult>(Resolve);
// Materialise the caller's RenderFragment<DialogContext<TResult>> into a
// plain RenderFragment bound to this context, so DialogState stays
// generic-free and the host can render it without knowing TResult.
var content = body(context);
Current = new DialogState(
title,
DialogKind.Custom,
Body: string.Empty,
Danger: false,
PromptInitial: string.Empty,
Placeholder: null,
Content: content,
Size: size);
OnChange?.Invoke();
// Project the boxed result to TResult?: a Close value unboxes to the
// typed result; a Cancel (null) yields default(TResult?). The cast is
// safe because only Close (with a TResult) ever boxes a non-null value.
return Project(tcs.Task, static r => r is TResult typed ? typed : default);
}
/// <summary>
/// Awaits the host's result and projects it to the caller's type. The
/// <c>await</c> here resumes on the caller's captured context (the renderer
@@ -66,11 +103,14 @@ public class DialogService : IDialogService
}
/// <summary>
/// Called by the host component when the user dismisses or confirms the
/// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms
/// and a <c>string?</c> for prompts (null = cancel).
/// Called by the host component (or a custom dialog's
/// <see cref="DialogContext{TResult}"/>) when the user dismisses or confirms
/// the dialog. <paramref name="result"/> must be a <c>bool</c> for confirms,
/// a <c>string?</c> for prompts, or the boxed <c>TResult</c> (null = cancel)
/// for custom dialogs.
/// </summary>
/// <param name="result">The user's response: a <c>bool</c> for confirms or a <c>string?</c> for prompts.</param>
/// <param name="result">The user's response: a <c>bool</c> for confirms, a
/// <c>string?</c> for prompts, or the boxed custom result (null = cancel).</param>
internal void Resolve(object? result)
{
var tcs = _tcs;
@@ -92,24 +132,33 @@ public class DialogService : IDialogService
/// <summary>
/// Snapshot of a dialog's display state, exposed read-only on
/// <see cref="DialogService.Current"/> for the host component to render.
/// <see cref="DialogService.Current"/> for the host component to render. New
/// fields are appended as optional positional parameters so existing
/// confirm/prompt construct calls keep compiling unchanged.
/// </summary>
/// <param name="Title">Modal title text.</param>
/// <param name="Kind">Discriminates between confirm and prompt rendering.</param>
/// <param name="Body">For confirm: the message; for prompt: the input label.</param>
/// <param name="Kind">Discriminates between confirm, prompt, and custom rendering.</param>
/// <param name="Body">For confirm: the message; for prompt: the input label; ignored for custom.</param>
/// <param name="Danger">When true, the confirm button uses danger styling.</param>
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</param>
/// <param name="Placeholder">Placeholder shown when the prompt input is empty.</param>
/// <param name="Content">For <see cref="DialogKind.Custom"/>: the body fragment to
/// render inside <c>.modal-body</c>; <c>null</c> for confirm/prompt.</param>
/// <param name="Size">Optional Bootstrap modal-dialog size modifier class applied
/// to the <c>modal-dialog</c> element (e.g. <c>modal-lg</c>); <c>null</c> = default.</param>
public record DialogState(
string Title,
DialogKind Kind,
string Body,
bool Danger,
string PromptInitial,
string? Placeholder);
string? Placeholder,
RenderFragment? Content = null,
string? Size = null);
public enum DialogKind
{
Confirm,
Prompt
Prompt,
Custom
}
@@ -1,10 +1,13 @@
using Microsoft.AspNetCore.Components;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Centralised dialog/modal service. Pages inject this service and call
/// <see cref="ConfirmAsync"/> or <see cref="PromptAsync"/> programmatically
/// instead of embedding per-page modal components. A single <c>DialogHost</c>
/// rendered in <c>MainLayout</c> displays the resulting dialog state.
/// <see cref="ConfirmAsync"/>, <see cref="PromptAsync"/>, or
/// <see cref="ShowAsync{TResult}"/> programmatically instead of embedding
/// per-page modal components. A single <c>DialogHost</c> rendered in
/// <c>MainLayout</c> displays the resulting dialog state.
/// </summary>
public interface IDialogService
{
@@ -31,4 +34,60 @@ public interface IDialogService
/// <param name="placeholder">Optional placeholder shown when the input is empty.</param>
/// <returns>A task that resolves to the entered string, or <c>null</c> if the user cancels.</returns>
Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null);
/// <summary>
/// Shows a modal hosting arbitrary custom body content and resolves to the
/// value the body closes with, or <c>default</c> (<c>null</c> for reference
/// types) if the user cancels (the body's own cancel path, the header close
/// button, or Escape). The body fragment receives a
/// <see cref="DialogContext{TResult}"/> and supplies its own action buttons —
/// the standard Cancel/Confirm footer is NOT rendered for this kind. Use this
/// to centralise modal chrome (backdrop, focus trap, focus restoration) while
/// keeping page-specific form content inside the body.
/// </summary>
/// <typeparam name="TResult">The type the body closes the dialog with.</typeparam>
/// <param name="title">Modal title text.</param>
/// <param name="body">Renders the modal body; receives the context whose
/// <c>Close</c>/<c>Cancel</c> methods resolve the returned task.</param>
/// <param name="size">Optional Bootstrap modal-dialog size modifier applied to
/// the <c>modal-dialog</c> element (e.g. <c>modal-lg</c>, <c>modal-sm</c>).
/// <c>null</c> leaves the default width.</param>
/// <returns>A task that resolves to the value passed to
/// <see cref="DialogContext{TResult}.Close"/>, or <c>default</c> on cancel.</returns>
Task<TResult?> ShowAsync<TResult>(string title, RenderFragment<DialogContext<TResult>> body, string? size = null);
}
/// <summary>
/// Handed to the body fragment of a <see cref="IDialogService.ShowAsync{TResult}"/>
/// dialog so the body can close itself with a typed result or cancel. Both
/// methods route back through the owning <see cref="DialogService"/> to complete
/// the awaited <c>ShowAsync</c> task and tear down the modal.
/// </summary>
/// <typeparam name="TResult">The type the dialog resolves with.</typeparam>
public sealed class DialogContext<TResult>
{
// Routes the close back to the owning service's Resolve(). Held as a typed
// delegate rather than a service reference so the context stays a small,
// self-contained closure handle and never widens the body's surface.
private readonly Action<object?> _resolve;
/// <summary>
/// Creates a context bound to the given resolve callback. The callback boxes
/// the result and forwards it to <see cref="DialogService.Resolve"/>.
/// </summary>
/// <param name="resolve">Invoked with the boxed result (<c>null</c> = cancel).</param>
internal DialogContext(Action<object?> resolve) => _resolve = resolve;
/// <summary>
/// Closes the dialog and resolves the awaited <c>ShowAsync</c> task with
/// <paramref name="result"/>.
/// </summary>
/// <param name="result">The value the caller awaits.</param>
public void Close(TResult result) => _resolve(result);
/// <summary>
/// Cancels the dialog and resolves the awaited <c>ShowAsync</c> task with
/// <c>default</c> (<c>null</c> for reference types).
/// </summary>
public void Cancel() => _resolve(null);
}