feat(centralui): DialogHost ShowAsync<T> custom-content + focus trap/restore + backdrop hook (T33a)
This commit is contained in:
@@ -8,14 +8,16 @@
|
|||||||
|
|
||||||
@if (Service is DialogService svc && svc.Current is { } state)
|
@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"
|
<div @ref="_modalRef"
|
||||||
class="modal fade show d-block"
|
class="modal fade show d-block"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@onkeydown="OnKeyDown">
|
@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-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">@state.Title</h5>
|
<h5 class="modal-title">@state.Title</h5>
|
||||||
@@ -26,6 +28,10 @@
|
|||||||
{
|
{
|
||||||
<p class="mb-0">@state.Body</p>
|
<p class="mb-0">@state.Body</p>
|
||||||
}
|
}
|
||||||
|
else if (state.Kind == DialogKind.Custom)
|
||||||
|
{
|
||||||
|
@state.Content
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<label class="form-label">@state.Body</label>
|
<label class="form-label">@state.Body</label>
|
||||||
@@ -36,14 +42,19 @@
|
|||||||
@oninput="OnPromptInput" />
|
@oninput="OnPromptInput" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
@* Custom dialogs supply their own action buttons inside the body,
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
|
so the standard footer is suppressed for that kind. *@
|
||||||
<button type="button"
|
@if (state.Kind != DialogKind.Custom)
|
||||||
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
|
{
|
||||||
@onclick="Confirm">
|
<div class="modal-footer">
|
||||||
@ConfirmLabel(state)
|
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
|
||||||
</button>
|
<button type="button"
|
||||||
</div>
|
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
|
||||||
|
@onclick="Confirm">
|
||||||
|
@ConfirmLabel(state)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +106,13 @@
|
|||||||
if (!ReferenceEquals(current, _focusedForState))
|
if (!ReferenceEquals(current, _focusedForState))
|
||||||
{
|
{
|
||||||
_focusedForState = current;
|
_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
|
try
|
||||||
{
|
{
|
||||||
if (current.Kind == DialogKind.Prompt)
|
if (current.Kind == DialogKind.Prompt)
|
||||||
@@ -110,15 +128,28 @@
|
|||||||
_focusedForState = null;
|
_focusedForState = null;
|
||||||
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
|
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
|
||||||
catch { /* prerender: no JS — ignore */ }
|
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")
|
if (e.Key == "Escape")
|
||||||
{
|
{
|
||||||
Cancel();
|
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)
|
private void OnPromptInput(ChangeEventArgs e)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,6 +56,41 @@ public class DialogService : IDialogService
|
|||||||
return Project(tcs.Task, static r => r as string);
|
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>
|
/// <summary>
|
||||||
/// Awaits the host's result and projects it to the caller's type. The
|
/// 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
|
/// <c>await</c> here resumes on the caller's captured context (the renderer
|
||||||
@@ -66,11 +103,14 @@ public class DialogService : IDialogService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called by the host component when the user dismisses or confirms the
|
/// Called by the host component (or a custom dialog's
|
||||||
/// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms
|
/// <see cref="DialogContext{TResult}"/>) when the user dismisses or confirms
|
||||||
/// and a <c>string?</c> for prompts (null = cancel).
|
/// 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>
|
/// </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)
|
internal void Resolve(object? result)
|
||||||
{
|
{
|
||||||
var tcs = _tcs;
|
var tcs = _tcs;
|
||||||
@@ -92,24 +132,33 @@ public class DialogService : IDialogService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Snapshot of a dialog's display state, exposed read-only on
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="Title">Modal title text.</param>
|
/// <param name="Title">Modal title text.</param>
|
||||||
/// <param name="Kind">Discriminates between confirm and prompt rendering.</param>
|
/// <param name="Kind">Discriminates between confirm, prompt, and custom rendering.</param>
|
||||||
/// <param name="Body">For confirm: the message; for prompt: the input label.</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="Danger">When true, the confirm button uses danger styling.</param>
|
||||||
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</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="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(
|
public record DialogState(
|
||||||
string Title,
|
string Title,
|
||||||
DialogKind Kind,
|
DialogKind Kind,
|
||||||
string Body,
|
string Body,
|
||||||
bool Danger,
|
bool Danger,
|
||||||
string PromptInitial,
|
string PromptInitial,
|
||||||
string? Placeholder);
|
string? Placeholder,
|
||||||
|
RenderFragment? Content = null,
|
||||||
|
string? Size = null);
|
||||||
|
|
||||||
public enum DialogKind
|
public enum DialogKind
|
||||||
{
|
{
|
||||||
Confirm,
|
Confirm,
|
||||||
Prompt
|
Prompt,
|
||||||
|
Custom
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Centralised dialog/modal service. Pages inject this service and call
|
/// Centralised dialog/modal service. Pages inject this service and call
|
||||||
/// <see cref="ConfirmAsync"/> or <see cref="PromptAsync"/> programmatically
|
/// <see cref="ConfirmAsync"/>, <see cref="PromptAsync"/>, or
|
||||||
/// instead of embedding per-page modal components. A single <c>DialogHost</c>
|
/// <see cref="ShowAsync{TResult}"/> programmatically instead of embedding
|
||||||
/// rendered in <c>MainLayout</c> displays the resulting dialog state.
|
/// per-page modal components. A single <c>DialogHost</c> rendered in
|
||||||
|
/// <c>MainLayout</c> displays the resulting dialog state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IDialogService
|
public interface IDialogService
|
||||||
{
|
{
|
||||||
@@ -31,4 +34,60 @@ public interface IDialogService
|
|||||||
/// <param name="placeholder">Optional placeholder shown when the input is empty.</param>
|
/// <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>
|
/// <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);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Focus management helpers for the centralised DialogHost (T33a). Small and
|
||||||
|
// dependency-free: capture/restore the trigger element's focus across a modal's
|
||||||
|
// lifetime, and trap Tab focus inside an open modal. Blazor calls these via
|
||||||
|
// JS interop (window.sbDialog.*); they are no-ops when there is nothing to act
|
||||||
|
// on, so prerender / loose-mode test calls do no harm.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// CSS selector for elements that can receive keyboard focus within the modal.
|
||||||
|
const FOCUSABLE =
|
||||||
|
"a[href],button:not([disabled]),input:not([disabled])," +
|
||||||
|
"select:not([disabled]),textarea:not([disabled])," +
|
||||||
|
"[tabindex]:not([tabindex='-1'])";
|
||||||
|
|
||||||
|
// The element that had focus when the most recent dialog opened. Module-level
|
||||||
|
// so it survives between the capture (on open) and restore (on close) calls.
|
||||||
|
let savedActiveElement = null;
|
||||||
|
|
||||||
|
window.sbDialog = {
|
||||||
|
// Stash the currently focused element so it can be restored on close.
|
||||||
|
// Called BEFORE the modal steals focus.
|
||||||
|
captureActiveElement: function () {
|
||||||
|
savedActiveElement = document.activeElement;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Return focus to the stashed element if it is still in the document,
|
||||||
|
// then clear the stash so a later dialog starts fresh.
|
||||||
|
restoreActiveElement: function () {
|
||||||
|
const el = savedActiveElement;
|
||||||
|
savedActiveElement = null;
|
||||||
|
if (el && typeof el.focus === 'function' && el.isConnected) {
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cycle focus within the modal: at the first focusable element a
|
||||||
|
// Shift+Tab wraps to the last; at the last a Tab wraps to the first. In
|
||||||
|
// both cases the default browser move (which would leave the modal) is
|
||||||
|
// prevented. Anywhere else, the native Tab order applies untouched.
|
||||||
|
focusTrap: function (modalEl, shiftKey) {
|
||||||
|
if (!modalEl) return;
|
||||||
|
const focusable = modalEl.querySelectorAll(FOCUSABLE);
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
// Blazor interop does not hand us the DOM event, but window.event is
|
||||||
|
// populated during the synchronous keydown dispatch on the engines we
|
||||||
|
// target — guard the preventDefault so the wrap still works if it is
|
||||||
|
// ever absent (the explicit .focus() override is the real trap).
|
||||||
|
const evt = window.event;
|
||||||
|
if (shiftKey && active === first) {
|
||||||
|
last.focus();
|
||||||
|
if (evt) evt.preventDefault();
|
||||||
|
} else if (!shiftKey && active === last) {
|
||||||
|
first.focus();
|
||||||
|
if (evt) evt.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/monaco-init.js"></script>
|
<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/monaco-init.js"></script>
|
||||||
<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/audit-grid.js"></script>
|
<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/audit-grid.js"></script>
|
||||||
<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/transport.js"></script>
|
<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/transport.js"></script>
|
||||||
|
<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/dialog.js"></script>
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -196,5 +196,11 @@ public class SchemaLibraryPageTests : BunitContext
|
|||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
string title, string label, string initialValue = "", string? placeholder = null)
|
string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
=> Task.FromResult<string?>(null);
|
=> Task.FromResult<string?>(null);
|
||||||
|
|
||||||
|
public Task<TResult?> ShowAsync<TResult>(
|
||||||
|
string title,
|
||||||
|
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||||
|
string? size = null)
|
||||||
|
=> Task.FromResult<TResult?>(default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,11 @@ public class NotificationListsPageTests : BunitContext
|
|||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
string title, string label, string initialValue = "", string? placeholder = null)
|
string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
=> Task.FromResult<string?>(null);
|
=> Task.FromResult<string?>(null);
|
||||||
|
|
||||||
|
public Task<TResult?> ShowAsync<TResult>(
|
||||||
|
string title,
|
||||||
|
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||||
|
string? size = null)
|
||||||
|
=> Task.FromResult<TResult?>(default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -289,5 +289,11 @@ public class NotificationReportDetailModalTests : BunitContext
|
|||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
string title, string label, string initialValue = "", string? placeholder = null)
|
string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
=> Task.FromResult<string?>(null);
|
=> Task.FromResult<string?>(null);
|
||||||
|
|
||||||
|
public Task<TResult?> ShowAsync<TResult>(
|
||||||
|
string title,
|
||||||
|
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||||
|
string? size = null)
|
||||||
|
=> Task.FromResult<TResult?>(default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,5 +277,11 @@ public class NotificationReportPageTests : BunitContext
|
|||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
string title, string label, string initialValue = "", string? placeholder = null)
|
string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
=> Task.FromResult<string?>(null);
|
=> Task.FromResult<string?>(null);
|
||||||
|
|
||||||
|
public Task<TResult?> ShowAsync<TResult>(
|
||||||
|
string title,
|
||||||
|
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||||
|
string? size = null)
|
||||||
|
=> Task.FromResult<TResult?>(default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ public sealed class QueryStringDrillInTests
|
|||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
=> Task.FromResult<string?>(null);
|
=> Task.FromResult<string?>(null);
|
||||||
|
|
||||||
|
public Task<TResult?> ShowAsync<TResult>(
|
||||||
|
string title,
|
||||||
|
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||||
|
string? size = null)
|
||||||
|
=> Task.FromResult<TResult?>(default);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class TransportImportFixture : BunitContext
|
private sealed class TransportImportFixture : BunitContext
|
||||||
|
|||||||
@@ -660,5 +660,11 @@ public class SiteCallsReportPageTests : BunitContext
|
|||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
string title, string label, string initialValue = "", string? placeholder = null)
|
string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
=> Task.FromResult<string?>(null);
|
=> Task.FromResult<string?>(null);
|
||||||
|
|
||||||
|
public Task<TResult?> ShowAsync<TResult>(
|
||||||
|
string title,
|
||||||
|
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
|
||||||
|
string? size = null)
|
||||||
|
=> Task.FromResult<TResult?>(default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for the third <c>DialogHost</c> dialog kind (T33a):
|
||||||
|
/// <see cref="IDialogService.ShowAsync{TResult}"/> renders arbitrary custom
|
||||||
|
/// body content, omits the standard Cancel/Confirm footer (the body supplies
|
||||||
|
/// its own buttons), and resolves a typed result through the
|
||||||
|
/// <see cref="DialogContext{TResult}"/> the body is handed. The tests render a
|
||||||
|
/// real <see cref="DialogService"/> behind a live <c>DialogHost</c> so the
|
||||||
|
/// interface contract is exercised end to end.
|
||||||
|
///
|
||||||
|
/// bUnit cannot run real JS interop, so the host's <c>sbDialog.*</c> focus-trap
|
||||||
|
/// / focus-restoration calls are exercised in <see cref="JSRuntimeMode.Loose"/>
|
||||||
|
/// mode where they become no-ops.
|
||||||
|
/// </summary>
|
||||||
|
public class DialogHostShowAsyncTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly DialogService _service = new();
|
||||||
|
|
||||||
|
public DialogHostShowAsyncTests()
|
||||||
|
{
|
||||||
|
// Register the concrete service as the interface so DialogHost (which
|
||||||
|
// injects IDialogService and casts to DialogService) resolves the same
|
||||||
|
// instance the test drives.
|
||||||
|
Services.AddSingleton<IDialogService>(_service);
|
||||||
|
// Focus trap / restoration / body-lock interop are no-ops under test.
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShowAsync_RendersCustomBody_AndResolvesTypedResult()
|
||||||
|
{
|
||||||
|
var cut = Render<DialogHost>();
|
||||||
|
|
||||||
|
// Open a custom dialog whose body renders a single button that closes
|
||||||
|
// the dialog with a typed string result via the supplied context.
|
||||||
|
Task<string?> pending = null!;
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
pending = _service.ShowAsync<string>("Title", ctx => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenElement(0, "button");
|
||||||
|
builder.AddAttribute(1, "data-test", "ok");
|
||||||
|
builder.AddAttribute(2, "onclick",
|
||||||
|
EventCallback.Factory.Create(this, () => ctx.Close("done")));
|
||||||
|
builder.CloseElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
// The custom body renders, and the standard footer is NOT present.
|
||||||
|
Assert.NotNull(cut.Find("button[data-test='ok']"));
|
||||||
|
Assert.DoesNotContain(cut.FindAll("button"),
|
||||||
|
b => b.TextContent.Trim() is "Cancel" or "Confirm");
|
||||||
|
|
||||||
|
Assert.False(pending.IsCompleted, "Dialog must stay open until the body acts.");
|
||||||
|
|
||||||
|
// Clicking the custom button resolves the awaited task with the value.
|
||||||
|
cut.Find("button[data-test='ok']").Click();
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.Same(pending, completed);
|
||||||
|
Assert.Equal("done", await pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShowAsync_CancelResolvesNull()
|
||||||
|
{
|
||||||
|
var cut = Render<DialogHost>();
|
||||||
|
|
||||||
|
Task<string?> pending = null!;
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
pending = _service.ShowAsync<string>("Title", ctx => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenElement(0, "button");
|
||||||
|
builder.AddAttribute(1, "data-test", "cancel");
|
||||||
|
builder.AddAttribute(2, "onclick",
|
||||||
|
EventCallback.Factory.Create(this, () => ctx.Cancel()));
|
||||||
|
builder.CloseElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
cut.Find("button[data-test='cancel']").Click();
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.Same(pending, completed);
|
||||||
|
Assert.Null(await pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShowAsync_EscapeResolvesNull()
|
||||||
|
{
|
||||||
|
var cut = Render<DialogHost>();
|
||||||
|
|
||||||
|
Task<string?> pending = null!;
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
pending = _service.ShowAsync<string>("Title", _ => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenElement(0, "span");
|
||||||
|
builder.AddContent(1, "no buttons");
|
||||||
|
builder.CloseElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
// The host's Escape handler cancels custom dialogs the same way.
|
||||||
|
cut.Find("div.modal").KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Escape" });
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.Same(pending, completed);
|
||||||
|
Assert.Null(await pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShowAsync_AppliesSizeClassToModalDialog()
|
||||||
|
{
|
||||||
|
var cut = Render<DialogHost>();
|
||||||
|
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_ = _service.ShowAsync<string>("Title", _ => builder =>
|
||||||
|
{
|
||||||
|
builder.AddContent(0, "body");
|
||||||
|
}, size: "modal-lg");
|
||||||
|
});
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
var dialog = cut.Find("div.modal-dialog");
|
||||||
|
Assert.Contains("modal-lg", dialog.ClassList);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Backdrop_HasTokenHookClass()
|
||||||
|
{
|
||||||
|
var cut = Render<DialogHost>();
|
||||||
|
|
||||||
|
await cut.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_ = _service.ShowAsync<string>("Title", _ => builder =>
|
||||||
|
{
|
||||||
|
builder.AddContent(0, "body");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
// The backdrop carries the tokenized hook class so a later task can
|
||||||
|
// attach the actual var-driven background rule without touching markup.
|
||||||
|
var backdrop = cut.Find("div.modal-backdrop");
|
||||||
|
Assert.Contains("sb-modal-backdrop", backdrop.ClassList);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Confirm_StillResolvesTrue_NoRegression()
|
||||||
|
{
|
||||||
|
var cut = Render<DialogHost>();
|
||||||
|
|
||||||
|
Task<bool> pending = null!;
|
||||||
|
await cut.InvokeAsync(() => { pending = _service.ConfirmAsync("Title", "Sure?"); });
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
// The standard footer's Confirm button is present for Confirm-kind dialogs.
|
||||||
|
cut.FindAll("button").First(b => b.TextContent.Trim() == "Confirm").Click();
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||||
|
Assert.Same(pending, completed);
|
||||||
|
Assert.True(await pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user