@* Reusable diff/comparison dialog using Bootstrap modal. Mirrors the ConfirmDialog API: callers invoke ShowAsync(title, before, after) via @ref to display a side-by-side or simple before/after comparison. z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@ @inject IJSRuntime JS @inject ILogger Logger @implements IAsyncDisposable @if (_visible) { } @code { private bool _visible; private bool _bodyLocked; private TaskCompletionSource? _tcs; private ElementReference _modalRef; [Parameter] public string Title { get; set; } = "Diff"; [Parameter] public string Before { get; set; } = string.Empty; [Parameter] public string After { get; set; } = string.Empty; /// /// Optional custom body content. When supplied, it replaces the default /// before/after panes — useful when the caller wants to render a richer /// comparison (e.g. metadata badges, file lists, etc.). /// [Parameter] public RenderFragment? BodyContent { get; set; } /// /// Show the dialog with the supplied title and before/after text. /// Returns when the user dismisses the dialog. /// public Task ShowAsync(string title, string before, string after) { Title = title; Before = before; After = after; BodyContent = null; return OpenAsync(); } /// /// Show the dialog with a custom body. Useful when the diff is not a /// simple before/after string pair (e.g. a deployment comparison summary). /// public Task ShowAsync(string title, RenderFragment body) { Title = title; BodyContent = body; return OpenAsync(); } private Task OpenAsync() { _visible = true; _tcs = new TaskCompletionSource(); StateHasChanged(); return _tcs.Task; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (_visible && !_bodyLocked) { _bodyLocked = true; await TryLockBodyAsync(); try { await _modalRef.FocusAsync(); } catch (InvalidOperationException) { // Prerender: the element reference is not attached yet — the // next interactive render focuses it. Expected, not logged. } catch (JSDisconnectedException) { // Circuit gone before focus could run — nothing to do. } catch (JSException ex) { // A genuine focus interop failure (CentralUI-023) — log it. Logger.LogWarning(ex, "DiffDialog: failed to focus the modal."); } } } private async Task OnKeyDownAsync(KeyboardEventArgs e) { if (e.Key == "Escape") { Close(); await Task.CompletedTask; } } private void Close() { _visible = false; _ = TryUnlockBodyAsync(); _tcs?.TrySetResult(true); } private async Task TryLockBodyAsync() { try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); } catch (JSDisconnectedException) { // Circuit gone — the body scroll lock is moot. Expected, silent. } catch (JSException ex) { // CentralUI-023: a genuine interop failure — log instead of doing // another (also-failing) JS call inside a bare catch. Logger.LogWarning(ex, "DiffDialog: failed to apply body scroll lock."); } } private async Task TryUnlockBodyAsync() { _bodyLocked = false; try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); } catch (JSDisconnectedException) { // Circuit gone — the body scroll lock is moot. Expected, silent. } catch (JSException ex) { Logger.LogWarning(ex, "DiffDialog: failed to remove body scroll lock."); } } public async ValueTask DisposeAsync() { // CentralUI-011: if the dialog is disposed while still open (the user // navigated away), complete the pending task so the awaiting caller // resumes deterministically instead of hanging forever. _tcs?.TrySetResult(false); if (_bodyLocked) { await TryUnlockBodyAsync(); } } }