Skip to content
Merged
2 changes: 1 addition & 1 deletion src/BootstrapBlazor/BootstrapBlazor.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">

<PropertyGroup>
<Version>10.3.1-beta06</Version>
<Version>10.3.1</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
7 changes: 4 additions & 3 deletions src/BootstrapBlazor/Components/Dialog/Dialog.razor
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
@namespace BootstrapBlazor.Components
@namespace BootstrapBlazor.Components
@inherits BootstrapComponentBase

<Modal @ref="_modal" IsBackdrop="_isBackdrop" IsKeyboard="@_isKeyboard" IsFade="@_isFade"
OnShownAsync="@_onShownAsync" OnCloseAsync="@_onCloseAsync" class="@ClassString">
<Modal @ref="_modal" IsBackdrop="_isBackdrop" IsKeyboard="@_isKeyboard" IsFade="_isFade"
OnShownAsync="_onShownAsync" OnCloseAsync="_onCloseAsync"
class="@ClassString">
@for (var index = 0; index < DialogParameters.Keys.Count; index++)
{
if (index != 0 && index == DialogParameters.Keys.Count - 1)
Expand Down
4 changes: 0 additions & 4 deletions src/BootstrapBlazor/Components/Dialog/Dialog.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ public partial class Dialog : IDisposable

[NotNull]
private Modal? _modal = null;

[NotNull]
private Func<Task>? _onShownAsync = null;

[NotNull]
private Func<Task>? _onCloseAsync = null;

private readonly Dictionary<Dictionary<string, object>, (bool IsKeyboard, bool IsBackdrop, Func<Task>? OnCloseCallback)> DialogParameters = [];
Expand Down
60 changes: 59 additions & 1 deletion src/BootstrapBlazor/Components/Modal/Modal.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ public partial class Modal
[Parameter]
public Func<Task>? OnCloseAsync { get; set; }

/// <summary>
/// <para lang="zh">关闭之前回调方法 返回 true 时关闭弹窗 返回 false 时阻止关闭弹窗</para>
/// <para lang="en">Callback Method Before Closing. Return true to close, false to prevent closing</para>
/// </summary>
[Parameter]
public Func<Task<bool>>? OnClosingAsync { get; set; }

Comment on lines +86 to +92
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new OnClosingAsync parameter and related functionality lack test coverage. The existing test file has comprehensive tests for other callbacks like OnShownAsync (see ShownCallbackAsync_Ok test) and RegisterShownCallback (see RegisterShownCallback_Ok test). Consider adding tests to verify: (1) OnClosingAsync is invoked when closing the modal, (2) returning false prevents the modal from closing, (3) returning true allows the modal to close, (4) the callback is invoked for all close triggers (escape key, backdrop click, close button), and (5) RegisterOnClosingCallback and UnRegisterOnClosingCallback work correctly.

Copilot uses AI. Check for mistakes.
/// <summary>
/// <para lang="zh">获得后台关闭弹出窗口的设置</para>
/// <para lang="en">Gets the background close popup setting</para>
Expand Down Expand Up @@ -190,6 +197,21 @@ public async Task CloseCallback()
}
}

/// <summary>
/// <para lang="zh">弹出窗口关闭前回调方法,由 JSInvoke 调用</para>
/// <para lang="en">Callback method when the popup before close, called by JSInvoke</para>
/// </summary>
[JSInvokable]
public async Task<bool> BeforeCloseCallback()
{
var result = true;
if (OnClosingAsync != null)
{
result = await OnClosingAsync();
}
return result;
}

/// <summary>
/// <para lang="zh">切换弹出窗口状态的方法</para>
/// <para lang="en">Method to toggle the popup state</para>
Expand All @@ -214,7 +236,14 @@ public async Task Show()
/// <para lang="zh">关闭当前弹出窗口的方法</para>
/// <para lang="en">Method to close the current popup</para>
/// </summary>
public Task Close() => InvokeVoidAsync("execute", Id, "hide");
public async Task Close()
{
var result = await BeforeCloseCallback();
if (result)
{
await InvokeVoidAsync("execute", Id, "hide");
}
}

/// <summary>
/// <para lang="zh">设置标题文本的方法</para>
Expand Down Expand Up @@ -247,4 +276,33 @@ public void UnRegisterShownCallback(IComponent component)
{
_shownCallbackCache.TryRemove(component, out _);
}

/// <summary>
/// <para lang="zh">注册弹出窗口关闭前调用的回调方法,允许自定义逻辑来决定是否继续关闭操作</para>
/// <para lang="en">Registers a callback that is invoked asynchronously when a closing event is triggered, allowing custom logic to determine whether the closing operation should proceed.</para>
/// </summary>
/// <param name="onClosingCallback">
/// <para lang="zh">返回包含布尔值的任务的函数。当关闭事件发生时执行该回调,返回 <see langword="true"/> 允许继续关闭操作,返回 <see langword="false"/> 取消关闭操作</para>
/// <para lang="en">A function that returns a task containing a Boolean value. The callback is executed when the closing event
/// occurs, and should return <see langword="true"/> to allow the closing operation to continue, or <see
/// langword="false"/> to cancel it.</para>
/// </param>
public void RegisterOnClosingCallback(Func<Task<bool>> onClosingCallback)
{
OnClosingAsync += onClosingCallback;
}
Comment on lines +290 to +293
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using delegate composition with OnClosingAsync += onClosingCallback, C# multicast delegates will only return the result of the last delegate in the invocation list. If multiple callbacks are registered using RegisterOnClosingCallback, only the last registered callback's return value will determine whether the modal closes. All callbacks will execute, but intermediate return values are discarded. This could lead to unexpected behavior where an earlier callback returns false to prevent closing, but a later callback returns true and allows it. Consider using a cache-based approach similar to _shownCallbackCache where you can iterate through all callbacks and properly aggregate their results (e.g., close only if all callbacks return true).

Copilot uses AI. Check for mistakes.

/// <summary>
/// <para lang="zh">注销弹出窗口关闭前调用的回调方法</para>
/// <para lang="en">Unregisters a previously registered callback that is invoked when a closing event occurs.</para>
/// </summary>
/// <param name="onClosingCallback">
/// <para lang="zh">要从关闭事件中移除的回调函数。该函数应返回一个布尔值的任务,指示是否继续关闭操作</para>
/// <para lang="en">The callback function to remove from the closing event. The function should return a task that evaluates to a
/// Boolean value indicating whether the closing operation should proceed.</para>
/// </param>
public void UnRegisterOnClosingCallback(Func<Task<bool>> onClosingCallback)
{
OnClosingAsync -= onClosingCallback;
}
}
15 changes: 11 additions & 4 deletions src/BootstrapBlazor/Components/Modal/Modal.razor.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Data from "../../modules/data.js"
import Data from "../../modules/data.js"
import EventHandler from "../../modules/event-handler.js"

export function init(id, invoke, shownCallback, closeCallback) {
Expand Down Expand Up @@ -73,15 +73,22 @@ export function init(id, invoke, shownCallback, closeCallback) {
}
}

modal.close = async () => {
const close = await invoke.invokeMethodAsync("BeforeCloseCallback");
if (close) {
modal.hide();
}
}

modal.handlerKeyboardAndBackdrop = () => {
if (!modal.hook_keyboard_backdrop) {
modal.hook_keyboard_backdrop = true;

modal.handlerEscape = e => {
modal.handlerEscape = async e => {
if (e.key === 'Escape') {
const keyboard = el.getAttribute('data-bs-keyboard')
if (keyboard === 'true') {
modal.hide()
modal.close();
}
}
}
Expand All @@ -91,7 +98,7 @@ export function init(id, invoke, shownCallback, closeCallback) {
if (e.target.closest('.modal-dialog') === null) {
const backdrop = el.getAttribute('data-bs-backdrop')
if (backdrop !== 'static') {
modal.hide()
modal.close();
}
}
})
Expand Down
18 changes: 12 additions & 6 deletions src/BootstrapBlazor/Components/Modal/ModalDialog.razor
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
}
@if (ShowPrintButtonInHeader)
{
<PrintButton Color="PrintButtonColor" class="btn-print" Text="@PrintButtonText" Icon="@PrintButtonIcon"></PrintButton>
<PrintButton Color="PrintButtonColor" Text="@PrintButtonText" Icon="@PrintButtonIcon"
class="btn-print"></PrintButton>
}
@if (ShowExportPdfButtonInHeader)
{
Expand All @@ -32,11 +33,13 @@
}
@if (ShowMaximizeButton)
{
<Button Color="Color.None" class="btn-maximize" aria-label="@MaximizeAriaLabel" OnClick="@OnToggleMaximize" Icon="@MaximizeIconString"></Button>
<Button Color="Color.None" OnClick="@OnToggleMaximize" Icon="@MaximizeIconString"
class="btn-maximize" aria-label="@MaximizeAriaLabel"></Button>
}
@if (ShowHeaderCloseButton)
{
<Button Color="Color.None" class="btn-close" aria-label="Close" OnClickWithoutRender="@OnClickCloseAsync"></Button>
<Button Color="Color.None" class="btn-close" aria-label="Close"
OnClickWithoutRender="@OnClickCloseAsync"></Button>
}
</div>
</div>
Expand All @@ -58,11 +61,13 @@
}
@if (ShowCloseButton)
{
<Button Color="Color.Secondary" Text="@CloseButtonText" Icon="@CloseButtonIcon" OnClickWithoutRender="OnClickCloseAsync"></Button>
<Button Color="Color.Secondary" Text="@CloseButtonText" Icon="@CloseButtonIcon"
OnClickWithoutRender="OnClickCloseAsync"></Button>
}
@if (ShowPrintButton)
{
<PrintButton Color="PrintButtonColor" class="btn-print" Text="@PrintButtonText" Icon="@PrintButtonIcon"></PrintButton>
<PrintButton Color="PrintButtonColor" Text="@PrintButtonText" Icon="@PrintButtonIcon"
class="btn-print"></PrintButton>
}
@if (ShowExportPdfButton)
{
Expand All @@ -72,7 +77,8 @@
}
@if (ShowSaveButton)
{
<Button Color="Color.Primary" Text="@SaveButtonText" Icon="@SaveButtonIcon" IsAsync="true" OnClickWithoutRender="OnClickSave"></Button>
<Button Color="Color.Primary" Text="@SaveButtonText" Icon="@SaveButtonIcon"
IsAsync="true" OnClickWithoutRender="OnClickSave"></Button>
}
@if (FooterTemplate != null)
{
Expand Down
45 changes: 45 additions & 0 deletions test/UnitTest/Components/ModalTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,51 @@ public async Task RegisterShownCallback_Ok()
Assert.True(component.Instance.Pass);
}

[Fact]
public async Task OnClosingAsync_Ok()
{
var closing = false;
var cut = Context.Render<Modal>(builder =>
{
builder.Add(a => a.OnClosingAsync, async () =>
{
closing = true;
await Task.Yield();
return true;
});
builder.AddChildContent<ModalDialog>(pb =>
{

});
});
await cut.InvokeAsync(() => cut.Instance.Close());
Assert.True(closing);
}

[Fact]
public async Task RegisterOnClosingAsync_Ok()
{
var closing = false;
var cut = Context.Render<Modal>(builder =>
{
builder.AddChildContent<ModalDialog>(pb =>
{

});
});

var closingHandler = async () =>
{
closing = true;
await Task.Yield();
return true;
};
cut.Instance.RegisterOnClosingCallback(closingHandler);
await cut.InvokeAsync(() => cut.Instance.Close());
cut.Instance.UnRegisterOnClosingCallback(closingHandler);
Assert.True(closing);
}

private class MockComponent : ComponentBase
{
public bool Value { get; set; }
Expand Down