diff --git a/src/Blazored.Modal/BlazoredModal.razor b/src/Blazored.Modal/BlazoredModal.razor index fe16c9d..c3bc01f 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; } @@ -32,7 +33,8 @@ private readonly Collection _modals = new(); private readonly ModalOptions _globalModalOptions = new(); - private IJSObjectReference? _styleFunctions; + private IJSObjectReference? _jsModule; + 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; @@ -67,7 +70,25 @@ { 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 _jsModule.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 (_jsModule is null) + return; + + await _jsModule.InvokeVoidAsync("setBodyStyle"); } - + await InvokeAsync(StateHasChanged); } @@ -144,25 +165,41 @@ 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 (_jsModule is null) + return; + + try + { + await _jsModule.InvokeVoidAsync("removeEscapeKeyHandler"); + } + catch (JSDisconnectedException) + { + // Ignored. Browser is not there any more. } } async ValueTask IAsyncDisposable.DisposeAsync() { - if (_styleFunctions is not null) + await RemoveEscapeKeyHandler(); + _dotNetObjRef?.Dispose(); + if (_jsModule is not null) { try { - await _styleFunctions.DisposeAsync(); + await _jsModule.DisposeAsync(); } catch (JSDisconnectedException) { // 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 106d3d8..dcd0ace 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 bca1b08..dea5855 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 15357ad..c382bdb 100644 --- a/src/Blazored.Modal/CascadingBlazoredModal.razor +++ b/src/Blazored.Modal/CascadingBlazoredModal.razor @@ -4,6 +4,7 @@ (); + 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 bca456f..2db8ce5 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")); + } } }