diff --git a/docs/components/overlays/SbDialog.md b/docs/components/overlays/SbDialog.md index ed34389..edec9a4 100644 --- a/docs/components/overlays/SbDialog.md +++ b/docs/components/overlays/SbDialog.md @@ -16,7 +16,7 @@ A modal dialog component built on the native HTML `` element with header | CloseOnEscape | bool | true | Whether to close on Escape key | | CloseOnBackdropClick | bool | true | Whether to close when clicking backdrop | | ShowCloseButton | bool | true | Whether to show the close button | -| OnCloseButtonClick | EventCallback | - | Optional. When set, the header close (X) button invokes this instead of the default close (e.g. pass your modal's Hide so @bind-Open stays in sync) | +| OnCloseButtonClick | EventCallback | - | Optional hook invoked before the standard header close (X) flow. `OpenChanged(false)` still fires. | | Class | string? | null | Additional CSS classes | | Style | string? | null | Inline styles | | AdditionalAttributes | Dictionary\? | null | Additional HTML attributes | @@ -176,19 +176,23 @@ Override the Size preset with inline dimensions. Inline styles override the size ``` -### Custom Close Button Handler (e.g. for @bind-Open in a wrapper modal) +### Custom Close Button Handler -When the dialog is used inside a modal that binds Open from a parent, the header X should call the same close logic as Cancel so the binding stays in sync: +Use `OnCloseButtonClick` for cleanup or confirmation-side effects that should run before the header X closes the dialog. The dialog still performs the standard close flow and fires `OpenChanged(false)` afterward: ```razor - + ... @code { - [Parameter] public bool Open { get; set; } - [Parameter] public EventCallback OpenChanged { get; set; } - private void Hide() => OpenChanged.InvokeAsync(false); + private bool isOpen; + + private Task OnHeaderClose() + { + // Optional cleanup before the dialog closes. + return Task.CompletedTask; + } } ``` diff --git a/src/SufiChain.SufiBlazor.Demo/wwwroot/docs/SbDialog.md b/src/SufiChain.SufiBlazor.Demo/wwwroot/docs/SbDialog.md index ed34389..edec9a4 100644 --- a/src/SufiChain.SufiBlazor.Demo/wwwroot/docs/SbDialog.md +++ b/src/SufiChain.SufiBlazor.Demo/wwwroot/docs/SbDialog.md @@ -16,7 +16,7 @@ A modal dialog component built on the native HTML `` element with header | CloseOnEscape | bool | true | Whether to close on Escape key | | CloseOnBackdropClick | bool | true | Whether to close when clicking backdrop | | ShowCloseButton | bool | true | Whether to show the close button | -| OnCloseButtonClick | EventCallback | - | Optional. When set, the header close (X) button invokes this instead of the default close (e.g. pass your modal's Hide so @bind-Open stays in sync) | +| OnCloseButtonClick | EventCallback | - | Optional hook invoked before the standard header close (X) flow. `OpenChanged(false)` still fires. | | Class | string? | null | Additional CSS classes | | Style | string? | null | Inline styles | | AdditionalAttributes | Dictionary\? | null | Additional HTML attributes | @@ -176,19 +176,23 @@ Override the Size preset with inline dimensions. Inline styles override the size ``` -### Custom Close Button Handler (e.g. for @bind-Open in a wrapper modal) +### Custom Close Button Handler -When the dialog is used inside a modal that binds Open from a parent, the header X should call the same close logic as Cancel so the binding stays in sync: +Use `OnCloseButtonClick` for cleanup or confirmation-side effects that should run before the header X closes the dialog. The dialog still performs the standard close flow and fires `OpenChanged(false)` afterward: ```razor - + ... @code { - [Parameter] public bool Open { get; set; } - [Parameter] public EventCallback OpenChanged { get; set; } - private void Hide() => OpenChanged.InvokeAsync(false); + private bool isOpen; + + private Task OnHeaderClose() + { + // Optional cleanup before the dialog closes. + return Task.CompletedTask; + } } ``` diff --git a/src/SufiChain.SufiBlazor/Components/Overlays/SbDialog.razor b/src/SufiChain.SufiBlazor/Components/Overlays/SbDialog.razor index 5640f25..4ac417d 100644 --- a/src/SufiChain.SufiBlazor/Components/Overlays/SbDialog.razor +++ b/src/SufiChain.SufiBlazor/Components/Overlays/SbDialog.razor @@ -8,6 +8,8 @@ style="@EffectiveStyle" @onkeydown="HandleKeyDown" @oncancel="HandleCancel" + @oncancel:preventDefault="true" + @onclose="HandleNativeClose" aria-labelledby="@(Title != null ? _titleId : null)" aria-modal="true" @attributes="FilteredAttributes"> @@ -50,6 +52,9 @@ private ElementReference _dialogRef; private readonly string _titleId = $"sb-dialog-title-{Guid.NewGuid():N}"; private bool _isOpen; + private bool _hasRendered; + private bool _pendingOpenSync; + private bool _suppressNextNativeClose; /// /// Whether the dialog is open. @@ -137,7 +142,7 @@ /// /// Optional callback when the header close (X) button is clicked. - /// If set, this runs instead of the default close behavior, so the parent can e.g. call its own Hide() to keep @bind-Open in sync. + /// Runs before the standard close flow; the dialog still invokes OpenChanged(false). /// [Parameter] public EventCallback OnCloseButtonClick { get; set; } @@ -262,14 +267,17 @@ if (Open != _isOpen) { _isOpen = Open; - if (Open) - { - await ShowAsync(); - } - else - { - await HideAsync(); - } + await SyncNativeOpenStateAsync(); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + _hasRendered = true; + + if (_pendingOpenSync) + { + await SyncNativeOpenStateAsync(); } } @@ -283,22 +291,41 @@ await JSRuntime.InvokeVoidAsync("SufiBlazor.dialog.close", _dialogRef); } + private async Task SyncNativeOpenStateAsync() + { + if (!_hasRendered) + { + _pendingOpenSync = true; + return; + } + + _pendingOpenSync = false; + + if (_isOpen) + { + await ShowAsync(); + } + else + { + await HideAsync(); + } + } + private async Task HandleCloseButtonClick() { if (OnCloseButtonClick.HasDelegate) { await OnCloseButtonClick.InvokeAsync(); } - else - { - await CloseAsync(SbDialogCloseReason.CloseButton); - } + + await CloseAsync(SbDialogCloseReason.CloseButton); } private async Task CloseAsync(SbDialogCloseReason reason) { + _suppressNextNativeClose = true; _isOpen = false; - await HideAsync(); + await SyncNativeOpenStateAsync(); await OnClose.InvokeAsync(reason); await OpenChanged.InvokeAsync(false); } @@ -320,6 +347,24 @@ } } + private async Task HandleNativeClose() + { + if (_suppressNextNativeClose) + { + _suppressNextNativeClose = false; + return; + } + + if (!_isOpen) + { + return; + } + + _isOpen = false; + await OnClose.InvokeAsync(SbDialogCloseReason.CloseButton); + await OpenChanged.InvokeAsync(false); + } + public async ValueTask DisposeAsync() { // Cleanup if needed diff --git a/src/SufiChain.SufiBlazor/wwwroot/sufiblazor.js b/src/SufiChain.SufiBlazor/wwwroot/sufiblazor.js index 8e2e2de..837b5ef 100644 --- a/src/SufiChain.SufiBlazor/wwwroot/sufiblazor.js +++ b/src/SufiChain.SufiBlazor/wwwroot/sufiblazor.js @@ -395,7 +395,7 @@ window.SufiBlazor = window.SufiBlazor || {}; * @param {HTMLDialogElement} element - Dialog element */ showModal: function (element) { - if (element && typeof element.showModal === "function") { + if (element && !element.open && typeof element.showModal === "function") { element.showModal(); } }, @@ -405,7 +405,7 @@ window.SufiBlazor = window.SufiBlazor || {}; * @param {HTMLDialogElement} element - Dialog element */ close: function (element) { - if (element && typeof element.close === "function") { + if (element && element.open && typeof element.close === "function") { element.close(); } }, diff --git a/tests/Components/Overlays/SbDialogTests.cs b/tests/Components/Overlays/SbDialogTests.cs index 98ef61f..dcfcc3a 100644 --- a/tests/Components/Overlays/SbDialogTests.cs +++ b/tests/Components/Overlays/SbDialogTests.cs @@ -256,26 +256,48 @@ public class SbDialogTests : BunitContext } [Fact] - public async Task OnCloseButtonClickTakesPrecedenceWhenSet() + public async Task NativeCloseEventInvokesOpenChangedToFalse() { - // Arrange - when OnCloseButtonClick is set, it runs instead of default close + // Arrange + var openChangedValue = true; + SbDialogCloseReason? closeReason = null; + var cut = RenderDialog(p => p + .Add(x => x.Open, true) + .Add(x => x.Title, "Dialog") + .Add(x => x.OpenChanged, EventCallback.Factory.Create(this, v => openChangedValue = v)) + .Add(x => x.OnClose, EventCallback.Factory.Create(this, r => closeReason = r))); + + // Act + var dialog = cut.Find("dialog.sb-dialog"); + await cut.InvokeAsync(() => dialog.TriggerEventAsync("onclose", EventArgs.Empty)); + + // Assert + Assert.False(openChangedValue); + Assert.Equal(SbDialogCloseReason.CloseButton, closeReason); + } + + [Fact] + public async Task OnCloseButtonClickRunsBeforeStandardCloseWhenSet() + { + // Arrange - custom close hooks should not bypass OpenChanged synchronization var closeBtnClicked = false; + var openChangedValue = true; SbDialogCloseReason? closeReason = null; var cut = RenderDialog(p => p .Add(x => x.Open, true) .Add(x => x.Title, "Dialog") .Add(x => x.OnCloseButtonClick, EventCallback.Factory.Create(this, () => closeBtnClicked = true)) + .Add(x => x.OpenChanged, EventCallback.Factory.Create(this, v => openChangedValue = v)) .Add(x => x.OnClose, EventCallback.Factory.Create(this, r => closeReason = r))); // Act var closeBtn = cut.Find(".sb-dialog__close-btn"); await cut.InvokeAsync(() => closeBtn.Click()); - // Assert - OnCloseButtonClick was invoked; parent typically handles close, so OnClose may not fire + // Assert Assert.True(closeBtnClicked); - // With OnCloseButtonClick delegate, CloseAsync is NOT called (see HandleCloseButtonClick logic) - // so OnClose and OpenChanged are NOT invoked by default - Assert.Null(closeReason); + Assert.Equal(SbDialogCloseReason.CloseButton, closeReason); + Assert.False(openChangedValue); } [Fact]