From 2e1a4baf84060fd318bb08f090ef1a796ea14ff2 Mon Sep 17 00:00:00 2001 From: Mark Novak Date: Wed, 20 Aug 2025 14:32:55 +0200 Subject: [PATCH 1/3] feat: add support for disabling Escape key functionality --- src/Blazored.Modal/BlazoredModal.razor | 55 ++++++++++++++++--- src/Blazored.Modal/BlazoredModal.razor.js | 35 ++++++++++++ .../BlazoredModalInstance.razor.cs | 13 ++++- .../CascadingBlazoredModal.razor | 2 + .../Configuration/ModalOptions.cs | 1 + 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/Blazored.Modal/BlazoredModal.razor b/src/Blazored.Modal/BlazoredModal.razor index fe16c9d0..9d1abba2 100644 --- a/src/Blazored.Modal/BlazoredModal.razor +++ b/src/Blazored.Modal/BlazoredModal.razor @@ -20,6 +20,7 @@ [Parameter] public bool? HideHeader { get; set; } [Parameter] public bool? HideCloseButton { get; set; } [Parameter] public bool? DisableBackgroundCancel { get; set; } + [Parameter] public bool? DisableEscapeKey { get; set; } [Parameter] public string? OverlayCustomClass { get; set; } [Parameter] public ModalPosition? Position { get; set; } [Parameter] public string? PositionCustomClass { get; set; } @@ -33,6 +34,7 @@ private readonly Collection _modals = new(); private readonly ModalOptions _globalModalOptions = new(); private IJSObjectReference? _styleFunctions; + private DotNetObjectReference? _dotNetObjRef; private bool _haveActiveModals; internal event Action? OnModalClosed; @@ -44,12 +46,13 @@ throw new InvalidOperationException($"{GetType()} requires a cascading parameter of type {nameof(IModalService)}."); } - ((ModalService) CascadedModalService).OnModalInstanceAdded += Update; - ((ModalService) CascadedModalService).OnModalCloseRequested += CloseInstance; + ((ModalService)CascadedModalService).OnModalInstanceAdded += Update; + ((ModalService)CascadedModalService).OnModalCloseRequested += CloseInstance; NavigationManager.LocationChanged += CancelModals; _globalModalOptions.Class = Class; _globalModalOptions.DisableBackgroundCancel = DisableBackgroundCancel; + _globalModalOptions.DisableEscapeKey = DisableEscapeKey; _globalModalOptions.HideCloseButton = HideCloseButton; _globalModalOptions.HideHeader = HideHeader; _globalModalOptions.Position = Position; @@ -68,6 +71,24 @@ if (firstRender) { _styleFunctions = await JsRuntime.InvokeAsync("import", "./_content/Blazored.Modal/BlazoredModal.razor.js"); + + _dotNetObjRef = DotNetObjectReference.Create(this); + if (DisableEscapeKey is not true && _dotNetObjRef is not null) + await _styleFunctions.InvokeVoidAsync("addEscapeKeyHandler", _dotNetObjRef); + } + } + + [JSInvokable("HandleEscapeKey")] + public async Task HandleEscapeKeyAsync() + { + if (DisableEscapeKey is true || !_modals.Any()) + return; + + if (_modals.LastOrDefault() is { } lastModalReference) + { + if (lastModalReference.ModalInstanceRef?.Options.DisableEscapeKey is true) + return; + await CloseInstance(lastModalReference, ModalResult.Cancel()); } } @@ -129,12 +150,12 @@ if (!_haveActiveModals) { _haveActiveModals = true; - if (_styleFunctions is not null) - { - await _styleFunctions.InvokeVoidAsync("setBodyStyle"); - } + if (_styleFunctions is null) + return; + + await _styleFunctions.InvokeVoidAsync("setBodyStyle"); } - + await InvokeAsync(StateHasChanged); } @@ -150,8 +171,25 @@ } } + private async Task RemoveEscapeKeyHandler() + { + if (_styleFunctions is null) + return; + + try + { + await _styleFunctions.InvokeVoidAsync("removeEscapeKeyHandler"); + } + catch (JSDisconnectedException) + { + // Ignored. Browser is not there any more. + } + } + async ValueTask IAsyncDisposable.DisposeAsync() { + await RemoveEscapeKeyHandler(); + _dotNetObjRef?.Dispose(); if (_styleFunctions is not null) { try @@ -163,6 +201,5 @@ // If the browser is gone, we don't need it to clean up any browser-side state } } - } - + } } \ No newline at end of file diff --git a/src/Blazored.Modal/BlazoredModal.razor.js b/src/Blazored.Modal/BlazoredModal.razor.js index 106d3d81..dcd0ace0 100644 --- a/src/Blazored.Modal/BlazoredModal.razor.js +++ b/src/Blazored.Modal/BlazoredModal.razor.js @@ -1,6 +1,10 @@ const el = document.body; const computedBodyStyle = getComputedStyle(el); const originalProps = { overflow: computedBodyStyle.overflow, paddingRight: computedBodyStyle.paddingRight }; + +let keyupHandler = null; +let dotNetRef = null; + const getScrollBarWidth = () => { let el = document.createElement("div"); el.style.cssText = "overflow:scroll; visibility:hidden; position:absolute;"; @@ -18,6 +22,37 @@ const isScrollbarPresent = () => { return beforeScrollbarHidden !== afterScrollbarHidden; }; +/** + * Adds event listener for the Escape key and invokes .NET method + * @param {object} dotNetObjectReference Reference to .NET object that handles the escape key + */ +export function addEscapeKeyHandler(dotNetObjectReference) { + // Clear state before adding the handler + removeEscapeKeyHandler() + + dotNetRef = dotNetObjectReference; + keyupHandler = function (event) { + if(event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + dotNetRef.invokeMethodAsync('HandleEscapeKey') + } + } + + document.addEventListener('keyup', keyupHandler, true) +} + +/** + * Clears the event listener for the Escape key and resets state + */ +export function removeEscapeKeyHandler() { + if(keyupHandler) { + document.removeEventListener('keyup', keyupHandler, true) + keyupHandler = null + dotNetRef = null + } +} + export function setBodyStyle() { if (isScrollbarPresent()) { el.style.paddingRight = `${getScrollBarWidth()}px`; diff --git a/src/Blazored.Modal/BlazoredModalInstance.razor.cs b/src/Blazored.Modal/BlazoredModalInstance.razor.cs index bca1b089..dea58558 100644 --- a/src/Blazored.Modal/BlazoredModalInstance.razor.cs +++ b/src/Blazored.Modal/BlazoredModalInstance.razor.cs @@ -19,6 +19,7 @@ public partial class BlazoredModalInstance : IDisposable private bool HideHeader { get; set; } private bool HideCloseButton { get; set; } private bool DisableBackgroundCancel { get; set; } + private bool DisableEscapeKey { get; set; } private string? OverlayAnimationClass { get; set; } private string? OverlayCustomClass { get; set; } private ModalAnimationType? AnimationType { get; set; } @@ -133,6 +134,7 @@ private void ConfigureInstance() HideHeader = SetHideHeader(); HideCloseButton = SetHideCloseButton(); DisableBackgroundCancel = SetDisableBackgroundCancel(); + DisableEscapeKey = SetDisableEscapeKey(); UseCustomLayout = SetUseCustomLayout(); OverlayCustomClass = SetOverlayCustomClass(); ActivateFocusTrap = SetActivateFocusTrap(); @@ -207,7 +209,7 @@ private string SetPosition() return ""; } } - + private string SetSize() { ModalSize size; @@ -354,6 +356,15 @@ private async Task HandleBackgroundClick() } } + private bool SetDisableEscapeKey() + { + if (Options.DisableEscapeKey.HasValue) + return Options.DisableEscapeKey.Value; + + return GlobalModalOptions.DisableEscapeKey.HasValue && + GlobalModalOptions.DisableEscapeKey.Value; + } + private void ListenToBackgroundClick() => _listenToBackgroundClicks = true; diff --git a/src/Blazored.Modal/CascadingBlazoredModal.razor b/src/Blazored.Modal/CascadingBlazoredModal.razor index 15357ad6..c382bdbd 100644 --- a/src/Blazored.Modal/CascadingBlazoredModal.razor +++ b/src/Blazored.Modal/CascadingBlazoredModal.razor @@ -4,6 +4,7 @@ Date: Wed, 20 Aug 2025 14:33:34 +0200 Subject: [PATCH 2/3] test: add test cases for Escape key handling in modals --- tests/Blazored.Modal.Tests/DisplayTests.cs | 34 +++++++++++++++++++ .../Blazored.Modal.Tests/ModalOptionsTests.cs | 21 ++++++++++++ 2 files changed, 55 insertions(+) diff --git a/tests/Blazored.Modal.Tests/DisplayTests.cs b/tests/Blazored.Modal.Tests/DisplayTests.cs index 1a920083..da05bd22 100644 --- a/tests/Blazored.Modal.Tests/DisplayTests.cs +++ b/tests/Blazored.Modal.Tests/DisplayTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components; using Blazored.Modal.Tests.Assets; using Blazored.Modal.Services; +using System.Threading.Tasks; using static Bunit.ComponentParameterFactory; namespace Blazored.Modal.Tests @@ -124,5 +125,38 @@ public void ModalHidesWhenReferenceCloseCalled() // Assert Assert.Empty(cut.FindAll(".bm-container")); } + + [Fact] + public async Task ModalHidesWhenEscapeKeyPressed() + { + // Arrange + var modalService = Services.GetService(); + var cut = RenderComponent(CascadingValue(modalService)); + modalService.Show(); + + // Act + await cut.InvokeAsync( () => cut.Instance.HandleEscapeKeyAsync()); + + // Assert + Assert.Empty(cut.FindAll(".bm-container")); + } + + [Fact] + public async Task TopMostModalHidesOnEscapeKeyPressedWhenMultipleAreVisible() + { + // Arrange + var modalService = Services.GetService(); + var cut = RenderComponent(CascadingValue(modalService)); + modalService.Show("First"); + modalService.Show("Last"); + + // Act + await cut.InvokeAsync( () => cut.Instance.HandleEscapeKeyAsync()); + + // Assert + var instances = cut.FindAll(".bm-container"); + Assert.DoesNotContain("Last", cut.Find(".bm-title").InnerHtml); + Assert.Single(instances); + } } } \ No newline at end of file diff --git a/tests/Blazored.Modal.Tests/ModalOptionsTests.cs b/tests/Blazored.Modal.Tests/ModalOptionsTests.cs index bca456f6..2db8ce59 100644 --- a/tests/Blazored.Modal.Tests/ModalOptionsTests.cs +++ b/tests/Blazored.Modal.Tests/ModalOptionsTests.cs @@ -3,6 +3,7 @@ using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; using Xunit; using static Bunit.ComponentParameterFactory; @@ -266,5 +267,25 @@ public void ModalDisplaysCustomSizeClassWhenSizeIsCustom() // Assert Assert.NotNull(cut.Find(".my-custom-size")); } + + [Fact] + public async Task ModalDoesNotCloseWhenDisableEscapeKeySetToTrueInOptions() + { + // Arrange + var options = new ModalOptions + { + DisableEscapeKey = true + }; + var modalService = Services.GetService(); + var cut = RenderComponent(CascadingValue(modalService)); + + modalService.Show("", options); + + // Act + await cut.InvokeAsync( () => cut.Instance.HandleEscapeKeyAsync()); + + // Assert + Assert.NotEmpty(cut.FindAll(".bm-container")); + } } } From d6ab402792da9ebb1f7e1d1e30ca7c4224e2f753 Mon Sep 17 00:00:00 2001 From: Mark Novak Date: Wed, 20 Aug 2025 14:40:35 +0200 Subject: [PATCH 3/3] refactor(BlazoredModal): rename javascript module for clarity --- src/Blazored.Modal/BlazoredModal.razor | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Blazored.Modal/BlazoredModal.razor b/src/Blazored.Modal/BlazoredModal.razor index 9d1abba2..c3bc01ff 100644 --- a/src/Blazored.Modal/BlazoredModal.razor +++ b/src/Blazored.Modal/BlazoredModal.razor @@ -33,7 +33,7 @@ private readonly Collection _modals = new(); private readonly ModalOptions _globalModalOptions = new(); - private IJSObjectReference? _styleFunctions; + private IJSObjectReference? _jsModule; private DotNetObjectReference? _dotNetObjRef; private bool _haveActiveModals; @@ -70,11 +70,11 @@ { if (firstRender) { - _styleFunctions = await JsRuntime.InvokeAsync("import", "./_content/Blazored.Modal/BlazoredModal.razor.js"); + _jsModule = await JsRuntime.InvokeAsync("import", "./_content/Blazored.Modal/BlazoredModal.razor.js"); _dotNetObjRef = DotNetObjectReference.Create(this); if (DisableEscapeKey is not true && _dotNetObjRef is not null) - await _styleFunctions.InvokeVoidAsync("addEscapeKeyHandler", _dotNetObjRef); + await _jsModule.InvokeVoidAsync("addEscapeKeyHandler", _dotNetObjRef); } } @@ -150,10 +150,10 @@ if (!_haveActiveModals) { _haveActiveModals = true; - if (_styleFunctions is null) + if (_jsModule is null) return; - await _styleFunctions.InvokeVoidAsync("setBodyStyle"); + await _jsModule.InvokeVoidAsync("setBodyStyle"); } await InvokeAsync(StateHasChanged); @@ -165,20 +165,20 @@ private async Task ClearBodyStyles() { _haveActiveModals = false; - if (_styleFunctions is not null) + if (_jsModule is not null) { - await _styleFunctions.InvokeVoidAsync("removeBodyStyle"); + await _jsModule.InvokeVoidAsync("removeBodyStyle"); } } private async Task RemoveEscapeKeyHandler() { - if (_styleFunctions is null) + if (_jsModule is null) return; try { - await _styleFunctions.InvokeVoidAsync("removeEscapeKeyHandler"); + await _jsModule.InvokeVoidAsync("removeEscapeKeyHandler"); } catch (JSDisconnectedException) { @@ -190,11 +190,11 @@ { await RemoveEscapeKeyHandler(); _dotNetObjRef?.Dispose(); - if (_styleFunctions is not null) + if (_jsModule is not null) { try { - await _styleFunctions.DisposeAsync(); + await _jsModule.DisposeAsync(); } catch (JSDisconnectedException) {