diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor
index 4a0f54d3..8a2ec9e6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor
@@ -8,14 +8,16 @@
@if (Service is DialogService svc && svc.Current is { } state)
{
-
-
+
-
+ @* Custom dialogs supply their own action buttons inside the body,
+ so the standard footer is suppressed for that kind. *@
+ @if (state.Kind != DialogKind.Custom)
+ {
+
+ }
@@ -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)
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs
index a1e1a312..3473f3da 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Components;
+
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
///
@@ -54,6 +56,41 @@ public class DialogService : IDialogService
return Project(tcs.Task, static r => r as string);
}
+ ///
+ public Task ShowAsync(string title, RenderFragment> body, string? size = null)
+ {
+ EnsureNoActiveDialog();
+ var tcs = new TaskCompletionSource(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(Resolve);
+
+ // Materialise the caller's RenderFragment> 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);
+ }
+
///
/// Awaits the host's result and projects it to the caller's type. The
/// await here resumes on the caller's captured context (the renderer
@@ -66,11 +103,14 @@ public class DialogService : IDialogService
}
///
- /// Called by the host component when the user dismisses or confirms the
- /// dialog. must be a bool for confirms
- /// and a string? for prompts (null = cancel).
+ /// Called by the host component (or a custom dialog's
+ /// ) when the user dismisses or confirms
+ /// the dialog. must be a bool for confirms,
+ /// a string? for prompts, or the boxed TResult (null = cancel)
+ /// for custom dialogs.
///
- /// The user's response: a bool for confirms or a string? for prompts.
+ /// The user's response: a bool for confirms, a
+ /// string? for prompts, or the boxed custom result (null = cancel).
internal void Resolve(object? result)
{
var tcs = _tcs;
@@ -92,24 +132,33 @@ public class DialogService : IDialogService
///
/// Snapshot of a dialog's display state, exposed read-only on
-/// for the host component to render.
+/// for the host component to render. New
+/// fields are appended as optional positional parameters so existing
+/// confirm/prompt construct calls keep compiling unchanged.
///
/// Modal title text.
-/// Discriminates between confirm and prompt rendering.
-/// For confirm: the message; for prompt: the input label.
+/// Discriminates between confirm, prompt, and custom rendering.
+/// For confirm: the message; for prompt: the input label; ignored for custom.
/// When true, the confirm button uses danger styling.
/// Initial value for prompt-kind dialogs.
/// Placeholder shown when the prompt input is empty.
+/// For : the body fragment to
+/// render inside .modal-body ; null for confirm/prompt.
+/// Optional Bootstrap modal-dialog size modifier class applied
+/// to the modal-dialog element (e.g. modal-lg ); null = default.
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
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs
index 9b9f9059..e174ed0f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs
@@ -1,10 +1,13 @@
+using Microsoft.AspNetCore.Components;
+
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
///
/// Centralised dialog/modal service. Pages inject this service and call
-/// or programmatically
-/// instead of embedding per-page modal components. A single DialogHost
-/// rendered in MainLayout displays the resulting dialog state.
+/// , , or
+/// programmatically instead of embedding
+/// per-page modal components. A single DialogHost rendered in
+/// MainLayout displays the resulting dialog state.
///
public interface IDialogService
{
@@ -31,4 +34,60 @@ public interface IDialogService
/// Optional placeholder shown when the input is empty.
/// A task that resolves to the entered string, or null if the user cancels.
Task PromptAsync(string title, string label, string initialValue = "", string? placeholder = null);
+
+ ///
+ /// Shows a modal hosting arbitrary custom body content and resolves to the
+ /// value the body closes with, or default (null for reference
+ /// types) if the user cancels (the body's own cancel path, the header close
+ /// button, or Escape). The body fragment receives a
+ /// 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.
+ ///
+ /// The type the body closes the dialog with.
+ /// Modal title text.
+ /// Renders the modal body; receives the context whose
+ /// Close /Cancel methods resolve the returned task.
+ /// Optional Bootstrap modal-dialog size modifier applied to
+ /// the modal-dialog element (e.g. modal-lg , modal-sm ).
+ /// null leaves the default width.
+ /// A task that resolves to the value passed to
+ /// , or default on cancel.
+ Task ShowAsync(string title, RenderFragment> body, string? size = null);
+}
+
+///
+/// Handed to the body fragment of a
+/// dialog so the body can close itself with a typed result or cancel. Both
+/// methods route back through the owning to complete
+/// the awaited ShowAsync task and tear down the modal.
+///
+/// The type the dialog resolves with.
+public sealed class DialogContext
+{
+ // 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 _resolve;
+
+ ///
+ /// Creates a context bound to the given resolve callback. The callback boxes
+ /// the result and forwards it to .
+ ///
+ /// Invoked with the boxed result (null = cancel).
+ internal DialogContext(Action resolve) => _resolve = resolve;
+
+ ///
+ /// Closes the dialog and resolves the awaited ShowAsync task with
+ /// .
+ ///
+ /// The value the caller awaits.
+ public void Close(TResult result) => _resolve(result);
+
+ ///
+ /// Cancels the dialog and resolves the awaited ShowAsync task with
+ /// default (null for reference types).
+ ///
+ public void Cancel() => _resolve(null);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/dialog.js b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/dialog.js
new file mode 100644
index 00000000..918989df
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/dialog.js
@@ -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();
+ }
+ }
+ };
+})();
diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor b/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor
index 79e37825..eb515924 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor
@@ -81,6 +81,7 @@
+