Refactor SbDialog component to enhance OnCloseButtonClick behavior, ensuring it runs before the standard close flow while still invoking OpenChanged. Update JavaScript functions to prevent closing when already closed. Improve tests to validate new close behavior and synchronization.
Release NuGet Packages / pack-and-push (release) Successful in 5m1s

This commit is contained in:
2026-06-07 14:10:29 +03:30
parent cf13f4faae
commit b0015a5b60
5 changed files with 111 additions and 36 deletions
+11 -7
View File
@@ -16,7 +16,7 @@ A modal dialog component built on the native HTML `<dialog>` element with header
| CloseOnEscape | bool | true | Whether to close on Escape key | | CloseOnEscape | bool | true | Whether to close on Escape key |
| CloseOnBackdropClick | bool | true | Whether to close when clicking backdrop | | CloseOnBackdropClick | bool | true | Whether to close when clicking backdrop |
| ShowCloseButton | bool | true | Whether to show the close button | | 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 | | Class | string? | null | Additional CSS classes |
| Style | string? | null | Inline styles | | Style | string? | null | Inline styles |
| AdditionalAttributes | Dictionary\<string, object\>? | null | Additional HTML attributes | | AdditionalAttributes | Dictionary\<string, object\>? | null | Additional HTML attributes |
@@ -176,19 +176,23 @@ Override the Size preset with inline dimensions. Inline styles override the size
</SbDialog> </SbDialog>
``` ```
### 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 ```razor
<SbDialog @bind-Open="Open" OnCloseButtonClick="Hide" Title="Create Item"> <SbDialog @bind-Open="isOpen" OnCloseButtonClick="OnHeaderClose" Title="Create Item">
<ChildContent>...</ChildContent> <ChildContent>...</ChildContent>
</SbDialog> </SbDialog>
@code { @code {
[Parameter] public bool Open { get; set; } private bool isOpen;
[Parameter] public EventCallback<bool> OpenChanged { get; set; }
private void Hide() => OpenChanged.InvokeAsync(false); private Task OnHeaderClose()
{
// Optional cleanup before the dialog closes.
return Task.CompletedTask;
}
} }
``` ```
@@ -16,7 +16,7 @@ A modal dialog component built on the native HTML `<dialog>` element with header
| CloseOnEscape | bool | true | Whether to close on Escape key | | CloseOnEscape | bool | true | Whether to close on Escape key |
| CloseOnBackdropClick | bool | true | Whether to close when clicking backdrop | | CloseOnBackdropClick | bool | true | Whether to close when clicking backdrop |
| ShowCloseButton | bool | true | Whether to show the close button | | 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 | | Class | string? | null | Additional CSS classes |
| Style | string? | null | Inline styles | | Style | string? | null | Inline styles |
| AdditionalAttributes | Dictionary\<string, object\>? | null | Additional HTML attributes | | AdditionalAttributes | Dictionary\<string, object\>? | null | Additional HTML attributes |
@@ -176,19 +176,23 @@ Override the Size preset with inline dimensions. Inline styles override the size
</SbDialog> </SbDialog>
``` ```
### 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 ```razor
<SbDialog @bind-Open="Open" OnCloseButtonClick="Hide" Title="Create Item"> <SbDialog @bind-Open="isOpen" OnCloseButtonClick="OnHeaderClose" Title="Create Item">
<ChildContent>...</ChildContent> <ChildContent>...</ChildContent>
</SbDialog> </SbDialog>
@code { @code {
[Parameter] public bool Open { get; set; } private bool isOpen;
[Parameter] public EventCallback<bool> OpenChanged { get; set; }
private void Hide() => OpenChanged.InvokeAsync(false); private Task OnHeaderClose()
{
// Optional cleanup before the dialog closes.
return Task.CompletedTask;
}
} }
``` ```
@@ -8,6 +8,8 @@
style="@EffectiveStyle" style="@EffectiveStyle"
@onkeydown="HandleKeyDown" @onkeydown="HandleKeyDown"
@oncancel="HandleCancel" @oncancel="HandleCancel"
@oncancel:preventDefault="true"
@onclose="HandleNativeClose"
aria-labelledby="@(Title != null ? _titleId : null)" aria-labelledby="@(Title != null ? _titleId : null)"
aria-modal="true" aria-modal="true"
@attributes="FilteredAttributes"> @attributes="FilteredAttributes">
@@ -50,6 +52,9 @@
private ElementReference _dialogRef; private ElementReference _dialogRef;
private readonly string _titleId = $"sb-dialog-title-{Guid.NewGuid():N}"; private readonly string _titleId = $"sb-dialog-title-{Guid.NewGuid():N}";
private bool _isOpen; private bool _isOpen;
private bool _hasRendered;
private bool _pendingOpenSync;
private bool _suppressNextNativeClose;
/// <summary> /// <summary>
/// Whether the dialog is open. /// Whether the dialog is open.
@@ -137,7 +142,7 @@
/// <summary> /// <summary>
/// Optional callback when the header close (X) button is clicked. /// 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).
/// </summary> /// </summary>
[Parameter] [Parameter]
public EventCallback OnCloseButtonClick { get; set; } public EventCallback OnCloseButtonClick { get; set; }
@@ -262,14 +267,17 @@
if (Open != _isOpen) if (Open != _isOpen)
{ {
_isOpen = Open; _isOpen = Open;
if (Open) await SyncNativeOpenStateAsync();
{
await ShowAsync();
} }
else
{
await HideAsync();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
_hasRendered = true;
if (_pendingOpenSync)
{
await SyncNativeOpenStateAsync();
} }
} }
@@ -283,22 +291,41 @@
await JSRuntime.InvokeVoidAsync("SufiBlazor.dialog.close", _dialogRef); 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() private async Task HandleCloseButtonClick()
{ {
if (OnCloseButtonClick.HasDelegate) if (OnCloseButtonClick.HasDelegate)
{ {
await OnCloseButtonClick.InvokeAsync(); await OnCloseButtonClick.InvokeAsync();
} }
else
{
await CloseAsync(SbDialogCloseReason.CloseButton); await CloseAsync(SbDialogCloseReason.CloseButton);
} }
}
private async Task CloseAsync(SbDialogCloseReason reason) private async Task CloseAsync(SbDialogCloseReason reason)
{ {
_suppressNextNativeClose = true;
_isOpen = false; _isOpen = false;
await HideAsync(); await SyncNativeOpenStateAsync();
await OnClose.InvokeAsync(reason); await OnClose.InvokeAsync(reason);
await OpenChanged.InvokeAsync(false); 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() public async ValueTask DisposeAsync()
{ {
// Cleanup if needed // Cleanup if needed
@@ -395,7 +395,7 @@ window.SufiBlazor = window.SufiBlazor || {};
* @param {HTMLDialogElement} element - Dialog element * @param {HTMLDialogElement} element - Dialog element
*/ */
showModal: function (element) { showModal: function (element) {
if (element && typeof element.showModal === "function") { if (element && !element.open && typeof element.showModal === "function") {
element.showModal(); element.showModal();
} }
}, },
@@ -405,7 +405,7 @@ window.SufiBlazor = window.SufiBlazor || {};
* @param {HTMLDialogElement} element - Dialog element * @param {HTMLDialogElement} element - Dialog element
*/ */
close: function (element) { close: function (element) {
if (element && typeof element.close === "function") { if (element && element.open && typeof element.close === "function") {
element.close(); element.close();
} }
}, },
+28 -6
View File
@@ -256,26 +256,48 @@ public class SbDialogTests : BunitContext
} }
[Fact] [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<bool>(this, v => openChangedValue = v))
.Add(x => x.OnClose, EventCallback.Factory.Create<SbDialogCloseReason>(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 closeBtnClicked = false;
var openChangedValue = true;
SbDialogCloseReason? closeReason = null; SbDialogCloseReason? closeReason = null;
var cut = RenderDialog(p => p var cut = RenderDialog(p => p
.Add(x => x.Open, true) .Add(x => x.Open, true)
.Add(x => x.Title, "Dialog") .Add(x => x.Title, "Dialog")
.Add(x => x.OnCloseButtonClick, EventCallback.Factory.Create(this, () => closeBtnClicked = true)) .Add(x => x.OnCloseButtonClick, EventCallback.Factory.Create(this, () => closeBtnClicked = true))
.Add(x => x.OpenChanged, EventCallback.Factory.Create<bool>(this, v => openChangedValue = v))
.Add(x => x.OnClose, EventCallback.Factory.Create<SbDialogCloseReason>(this, r => closeReason = r))); .Add(x => x.OnClose, EventCallback.Factory.Create<SbDialogCloseReason>(this, r => closeReason = r)));
// Act // Act
var closeBtn = cut.Find(".sb-dialog__close-btn"); var closeBtn = cut.Find(".sb-dialog__close-btn");
await cut.InvokeAsync(() => closeBtn.Click()); await cut.InvokeAsync(() => closeBtn.Click());
// Assert - OnCloseButtonClick was invoked; parent typically handles close, so OnClose may not fire // Assert
Assert.True(closeBtnClicked); Assert.True(closeBtnClicked);
// With OnCloseButtonClick delegate, CloseAsync is NOT called (see HandleCloseButtonClick logic) Assert.Equal(SbDialogCloseReason.CloseButton, closeReason);
// so OnClose and OpenChanged are NOT invoked by default Assert.False(openChangedValue);
Assert.Null(closeReason);
} }
[Fact] [Fact]