diff --git a/Documentation/Blazorise.Docs/Resources/docs-api-index.json b/Documentation/Blazorise.Docs/Resources/docs-api-index.json index 4eda07aaaf..c6ae3f2ca9 100644 --- a/Documentation/Blazorise.Docs/Resources/docs-api-index.json +++ b/Documentation/Blazorise.Docs/Resources/docs-api-index.json @@ -1,5 +1,5 @@ { - "generatedUtc": "2026-04-23T08:55:14.4845835Z", + "generatedUtc": "2026-04-29T14:35:34.9231734Z", "components": [ { "type": "global::Blazorise.Abbreviation", @@ -63490,6 +63490,14 @@ "summary": "Represents a collection of options for message buttons. Each option is of type MessageOptionsChoice.", "isBlazoriseEnum": false }, + { + "name": "CloseOnEscape", + "type": "bool?", + "typeName": "bool?", + "defaultValue": "null", + "summary": "If defined the message dialog will be closed when the user presses the Escape key. Confirmation dialogs will treat Escape as a cancel action.", + "isBlazoriseEnum": false + }, { "name": "ConfirmButtonClass", "type": "string", @@ -63733,6 +63741,14 @@ "summary": "Determines whether to apply clearfix to manage floating children.", "isBlazoriseEnum": false }, + { + "name": "CloseOnEscape", + "type": "bool", + "typeName": "bool", + "defaultValue": "false", + "summary": "If true, the message dialog will be closed when the user presses the Escape key. Confirmation dialogs will treat Escape as a cancel action.", + "isBlazoriseEnum": false + }, { "name": "Display", "type": "global::Blazorise.IFluentDisplay", diff --git a/Source/Blazorise/Components/FocusTrap/FocusTrap.razor b/Source/Blazorise/Components/FocusTrap/FocusTrap.razor index a77acd55e2..244cb89bcc 100644 --- a/Source/Blazorise/Components/FocusTrap/FocusTrap.razor +++ b/Source/Blazorise/Components/FocusTrap/FocusTrap.razor @@ -1,11 +1,7 @@ @namespace Blazorise @inherits BaseFocusableContainerComponent -
-
-
+
@ChildContent -
-
\ No newline at end of file diff --git a/Source/Blazorise/Components/FocusTrap/FocusTrap.razor.cs b/Source/Blazorise/Components/FocusTrap/FocusTrap.razor.cs index db7db74129..a36e4728bc 100644 --- a/Source/Blazorise/Components/FocusTrap/FocusTrap.razor.cs +++ b/Source/Blazorise/Components/FocusTrap/FocusTrap.razor.cs @@ -1,8 +1,8 @@ #region Using directives using System.Threading.Tasks; +using Blazorise.Modules; using Blazorise.Utilities; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; #endregion namespace Blazorise; @@ -14,17 +14,7 @@ public partial class FocusTrap : BaseFocusableContainerComponent { #region Members - private ElementReference startFirstRef; - - private ElementReference startSecondRef; - - private ElementReference endFirstRef; - - private ElementReference endSecondRef; - - private bool shiftTabPressed; - - private bool shouldRender = true; + private bool jsInitialized; #endregion @@ -33,9 +23,16 @@ public partial class FocusTrap : BaseFocusableContainerComponent /// public override async Task SetParametersAsync( ParameterView parameters ) { - if ( Rendered && parameters.TryGetValue( nameof( Active ), out var activeParam ) && Active != activeParam && activeParam ) + if ( Rendered && parameters.TryGetValue( nameof( Active ), out var activeParam ) && Active != activeParam ) { - ExecuteAfterRender( SetFocus ); + if ( activeParam ) + { + ExecuteAfterRender( InitializeAndSetFocus ); + } + else + { + ExecuteAfterRender( DestroyFocusTrap ); + } } await base.SetParametersAsync( parameters ); @@ -46,12 +43,23 @@ protected override Task OnAfterRenderAsync( bool firstRender ) { if ( firstRender && Active ) { - ExecuteAfterRender( SetFocus ); + ExecuteAfterRender( InitializeAndSetFocus ); } return base.OnAfterRenderAsync( firstRender ); } + /// + protected override async ValueTask DisposeAsync( bool disposing ) + { + if ( disposing && Rendered ) + { + await DestroyFocusTrap(); + } + + await base.DisposeAsync( disposing ); + } + /// protected override void BuildClasses( ClassBuilder builder ) { @@ -69,62 +77,52 @@ public async Task SetFocus() if ( Rendered ) { if ( HasFocusableComponents ) + { await HandleFocusableComponent(); + } else - await startFirstRef.FocusAsync(); + { + await JSFocusTrapModule.Focus( ElementRef, ElementId ); + } } } /// - /// Handles the focus start event. + /// Initializes the focus trap and sets focus to the first focusable child. /// - /// Supplies information about a focus event that is being raised. /// A task that represents the asynchronous operation. - protected virtual async Task OnFocusStartHandler( FocusEventArgs args ) + private async Task InitializeAndSetFocus() { - if ( !shiftTabPressed ) - { - await startFirstRef.FocusAsync(); - } + await InitializeFocusTrap(); + await SetFocus(); } /// - /// Handles the focus end event. + /// Initializes the JavaScript focus trap handler. /// - /// Supplies information about a focus event that is being raised. /// A task that represents the asynchronous operation. - protected virtual async Task OnFocusEndHandler( FocusEventArgs args ) + private async Task InitializeFocusTrap() { - if ( shiftTabPressed ) + if ( !jsInitialized ) { - await endFirstRef.FocusAsync(); + jsInitialized = true; + + await JSFocusTrapModule.Initialize( ElementRef, ElementId ); } } /// - /// Handles the keyboard events. + /// Destroys the JavaScript focus trap handler. /// - /// Supplies information about a keyboard event that is being raised. /// A task that represents the asynchronous operation. - protected virtual void OnKeyPressedHandler( KeyboardEventArgs args ) + private async Task DestroyFocusTrap() { - shouldRender = false; - - if ( args.Key == "Tab" ) + if ( jsInitialized ) { - shiftTabPressed = args.ShiftKey; - } - } - - /// - protected override bool ShouldRender() - { - if ( shouldRender ) - return true; - - shouldRender = true; + jsInitialized = false; - return false; + await JSFocusTrapModule.Destroy( ElementRef, ElementId ); + } } #endregion @@ -132,9 +130,9 @@ protected override bool ShouldRender() #region Properties /// - /// Gets the focusable element tab index. + /// Specifies the instance. /// - protected int FocusableTabIndex => Active ? 0 : -1; + [Inject] protected IJSFocusTrapModule JSFocusTrapModule { get; set; } /// /// If true the TAB focus will be activated. @@ -147,4 +145,4 @@ protected override bool ShouldRender() [Parameter] public RenderFragment ChildContent { get; set; } #endregion -} +} \ No newline at end of file diff --git a/Source/Blazorise/Components/MessageProvider/MessageProvider.razor.cs b/Source/Blazorise/Components/MessageProvider/MessageProvider.razor.cs index 9a69ac9be4..1020735189 100644 --- a/Source/Blazorise/Components/MessageProvider/MessageProvider.razor.cs +++ b/Source/Blazorise/Components/MessageProvider/MessageProvider.razor.cs @@ -95,13 +95,7 @@ protected Task OnCancelClicked() return InvokeAsync( async () => { await ModalRef.Hide(); - - if ( IsConfirmation && Callback is not null && !Callback.Task.IsCompleted ) - { - await InvokeAsync( () => Callback.SetResult( false ) ); - } - - await Canceled.InvokeAsync(); + await NotifyCanceled(); } ); } @@ -129,12 +123,38 @@ protected Task OnChoiceClicked( MessageOptionsChoice choice ) /// Handles the closing event. /// /// Provides the data for the modal closing event. - protected virtual Task OnModalClosing( ModalClosingEventArgs eventArgs ) + protected virtual async Task OnModalClosing( ModalClosingEventArgs eventArgs ) { + var isEscapeClosing = eventArgs.CloseReason == CloseReason.EscapeClosing; + var isFocusLostClosing = eventArgs.CloseReason == CloseReason.FocusLostClosing; + + if ( isEscapeClosing && ( Options?.CloseOnEscape ?? CloseOnEscape ) ) + { + await NotifyCanceled(); + + return; + } + eventArgs.Cancel = ( Options?.BackgroundCancel ?? BackgroundCancel ) - && ( eventArgs.CloseReason == CloseReason.EscapeClosing || eventArgs.CloseReason == CloseReason.FocusLostClosing ); + && ( isEscapeClosing || isFocusLostClosing ); + } - return Task.CompletedTask; + /// + /// Notifies that the message dialog was canceled. + /// + /// A task that represents the asynchronous operation. + private async Task NotifyCanceled() + { + if ( IsConfirmation && Callback is not null && !Callback.Task.IsCompleted ) + { + await InvokeAsync( () => Callback.SetResult( false ) ); + } + else if ( IsChoice && Callback is not null && !Callback.Task.IsCompleted ) + { + await InvokeAsync( () => Callback.SetResult( null ) ); + } + + await Canceled.InvokeAsync(); } #endregion @@ -370,5 +390,11 @@ protected virtual IEnumerable Choices /// [Parameter] public bool BackgroundCancel { get; set; } = true; + /// + /// If true, the message dialog will be closed when the user presses the Escape key. + /// Confirmation dialogs will treat Escape as a cancel action. + /// + [Parameter] public bool CloseOnEscape { get; set; } + #endregion } \ No newline at end of file diff --git a/Source/Blazorise/Config.cs b/Source/Blazorise/Config.cs index 3cf1df52b4..d6ae927ccc 100644 --- a/Source/Blazorise/Config.cs +++ b/Source/Blazorise/Config.cs @@ -104,6 +104,7 @@ public static IServiceCollection AddBlazorise( this IServiceCollection serviceCo public static IDictionary JSModuleMap => new Dictionary { { typeof( IJSUtilitiesModule ), typeof( JSUtilitiesModule ) }, + { typeof( IJSFocusTrapModule ), typeof( JSFocusTrapModule ) }, { typeof( IJSButtonModule ), typeof( JSButtonModule ) }, { typeof( IJSClosableModule ), typeof( JSClosableModule ) }, { typeof( IJSBreakpointModule ), typeof( JSBreakpointModule ) }, diff --git a/Source/Blazorise/Interfaces/Modules/IJSFocusTrapModule.cs b/Source/Blazorise/Interfaces/Modules/IJSFocusTrapModule.cs new file mode 100644 index 0000000000..0b80c69845 --- /dev/null +++ b/Source/Blazorise/Interfaces/Modules/IJSFocusTrapModule.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; + +namespace Blazorise.Modules; + +/// +/// Contracts for the focus trap JS module. +/// +public interface IJSFocusTrapModule : IBaseJSModule, IJSDestroyableModule +{ + /// + /// Initializes the focus trap. + /// + /// Reference to the rendered element. + /// ID of the rendered element. + /// A task that represents the asynchronous operation. + ValueTask Initialize( ElementReference elementRef, string elementId ); + + /// + /// Focuses the first focusable element inside the focus trap. + /// + /// Reference to the rendered element. + /// ID of the rendered element. + /// A task that represents the asynchronous operation. + ValueTask Focus( ElementReference elementRef, string elementId ); +} \ No newline at end of file diff --git a/Source/Blazorise/Models/MessageOptions.cs b/Source/Blazorise/Models/MessageOptions.cs index f3740cd573..c76e959e03 100644 --- a/Source/Blazorise/Models/MessageOptions.cs +++ b/Source/Blazorise/Models/MessageOptions.cs @@ -155,6 +155,12 @@ public class MessageOptions /// public bool? BackgroundCancel { get; set; } + /// + /// If defined the message dialog will be closed when the user presses the Escape key. + /// Confirmation dialogs will treat Escape as a cancel action. + /// + public bool? CloseOnEscape { get; set; } + /// /// Creates the default message options. /// diff --git a/Source/Blazorise/Modules/JSFocusTrapModule.cs b/Source/Blazorise/Modules/JSFocusTrapModule.cs new file mode 100644 index 0000000000..fec2b178ea --- /dev/null +++ b/Source/Blazorise/Modules/JSFocusTrapModule.cs @@ -0,0 +1,51 @@ +#region Using directives +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +#endregion + +namespace Blazorise.Modules; + +/// +/// Default implementation of the focus trap JS module. +/// +public class JSFocusTrapModule : BaseJSModule, IJSFocusTrapModule +{ + #region Constructors + + /// + /// Default module constructor. + /// + /// JavaScript runtime instance. + /// Version provider. + /// Blazorise options. + public JSFocusTrapModule( IJSRuntime jsRuntime, IVersionProvider versionProvider, BlazoriseOptions options ) + : base( jsRuntime, versionProvider, options ) + { + } + + #endregion + + #region Methods + + /// + public virtual ValueTask Initialize( ElementReference elementRef, string elementId ) + => InvokeSafeVoidAsync( "initialize", elementRef, elementId ); + + /// + public virtual ValueTask Destroy( ElementReference elementRef, string elementId ) + => InvokeSafeVoidAsync( "destroy", elementRef, elementId ); + + /// + public virtual ValueTask Focus( ElementReference elementRef, string elementId ) + => InvokeSafeVoidAsync( "focus", elementRef, elementId ); + + #endregion + + #region Properties + + /// + public override string ModuleFileName => $"./_content/Blazorise/focusTrap.js?v={VersionProvider.Version}"; + + #endregion +} \ No newline at end of file diff --git a/Source/Blazorise/wwwroot/focusTrap.js b/Source/Blazorise/wwwroot/focusTrap.js new file mode 100644 index 0000000000..be43538061 --- /dev/null +++ b/Source/Blazorise/wwwroot/focusTrap.js @@ -0,0 +1,201 @@ +const focusTrapHandlers = new WeakMap(); + +const focusableSelector = [ + "a[href]", + "area[href]", + "button:not([disabled])", + "input:not([disabled]):not([type='hidden'])", + "select:not([disabled])", + "textarea:not([disabled])", + "iframe", + "object", + "embed", + "audio[controls]", + "video[controls]", + "summary", + "[contenteditable]:not([contenteditable='false'])", + "[tabindex]:not([tabindex='-1'])" +].join(","); + +export function initialize(element, elementId) { + element = resolveElement(element, elementId); + + if (!element) { + return; + } + + destroy(element, elementId); + + const keydownHandler = (event) => { + if (event.key !== "Tab" || event.defaultPrevented) { + return; + } + + const focusableElements = getFocusableElements(element); + + if (focusableElements.length === 0) { + event.preventDefault(); + focusElement(element); + return; + } + + const firstFocusable = focusableElements[0]; + const lastFocusable = focusableElements[focusableElements.length - 1]; + const activeElement = element.ownerDocument.activeElement; + + if (!element.contains(activeElement)) { + event.preventDefault(); + focusElement(event.shiftKey ? lastFocusable : firstFocusable); + } else if (event.shiftKey && activeElement === firstFocusable) { + event.preventDefault(); + focusElement(lastFocusable); + } else if (!event.shiftKey && activeElement === lastFocusable) { + event.preventDefault(); + focusElement(firstFocusable); + } + }; + + element.addEventListener("keydown", keydownHandler); + focusTrapHandlers.set(element, keydownHandler); +} + +export function destroy(element, elementId) { + element = resolveElement(element, elementId); + + if (!element) { + return; + } + + const keydownHandler = focusTrapHandlers.get(element); + + if (keydownHandler) { + element.removeEventListener("keydown", keydownHandler); + focusTrapHandlers.delete(element); + } +} + +export function focus(element, elementId) { + element = resolveElement(element, elementId); + + if (!element) { + return; + } + + const focusableElements = getFocusableElements(element); + focusElement(focusableElements[0] || element); +} + +function getFocusableElements(element) { + const focusableElements = Array.from(element.querySelectorAll(focusableSelector)); + + return focusableElements + .filter((focusableElement) => isFocusable(focusableElement, focusableElements)) + .map((focusableElement, index) => ({ + element: focusableElement, + index, + tabIndex: focusableElement.tabIndex + })) + .sort(compareTabOrder) + .map((entry) => entry.element); +} + +function compareTabOrder(left, right) { + const leftPositive = left.tabIndex > 0; + const rightPositive = right.tabIndex > 0; + + if (leftPositive && rightPositive && left.tabIndex !== right.tabIndex) { + return left.tabIndex - right.tabIndex; + } + + if (leftPositive !== rightPositive) { + return leftPositive ? -1 : 1; + } + + return left.index - right.index; +} + +function isFocusable(element, focusableElements) { + if (!element + || element.closest("[inert]") + || hasHiddenAncestor(element) + || isDisabled(element) + || !isTabbableRadio(element, focusableElements)) { + return false; + } + + const style = window.getComputedStyle(element); + + if (style.visibility === "hidden" || style.display === "none") { + return false; + } + + return element.tabIndex >= 0 && element.getClientRects().length > 0; +} + +function hasHiddenAncestor(element) { + for (let current = element; current; current = current.parentElement) { + if (current.getAttribute("aria-hidden")?.toLowerCase() === "true") { + return true; + } + } + + return false; +} + +function isDisabled(element) { + if (element.disabled) { + return true; + } + + const disabledFieldset = element.closest("fieldset[disabled]"); + + if (disabledFieldset) { + const firstLegend = Array.from(disabledFieldset.children).find((child) => child.tagName === "LEGEND"); + + if (!firstLegend || !firstLegend.contains(element)) { + return true; + } + } + + const closedDetails = element.closest("details:not([open])"); + + if (closedDetails) { + const firstSummary = Array.from(closedDetails.children).find((child) => child.tagName === "SUMMARY"); + + if (!firstSummary || !firstSummary.contains(element)) { + return true; + } + } + + return false; +} + +function isTabbableRadio(element, focusableElements) { + if (element.tagName !== "INPUT" || element.type !== "radio" || !element.name) { + return true; + } + + const radioGroup = focusableElements.filter((focusableElement) => + focusableElement.tagName === "INPUT" + && focusableElement.type === "radio" + && focusableElement.name === element.name + && focusableElement.form === element.form); + + const checkedRadio = radioGroup.find((radio) => radio.checked); + + if (checkedRadio) { + return checkedRadio === element; + } + + return radioGroup[0] === element; +} + +function focusElement(element) { + if (element && typeof element.focus === "function") { + element.focus({ preventScroll: true }); + } +} + +function resolveElement(element, elementId) { + return element || document.getElementById(elementId); +} \ No newline at end of file diff --git a/Source/Helpers/Blazorise.Tests.bUnit/Configuration.cs b/Source/Helpers/Blazorise.Tests.bUnit/Configuration.cs index 4c4acb291b..dc2f8c0a25 100644 --- a/Source/Helpers/Blazorise.Tests.bUnit/Configuration.cs +++ b/Source/Helpers/Blazorise.Tests.bUnit/Configuration.cs @@ -46,6 +46,7 @@ public static IServiceCollection AddBlazoriseTests( this IServiceCollection serv services.AddSingleton( sp => new BlazoriseOptions( sp, ( options ) => { } ) ); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Source/Helpers/Blazorise.Tests.bUnit/JSInterop.cs b/Source/Helpers/Blazorise.Tests.bUnit/JSInterop.cs index e96392e659..d8dcbedb24 100644 --- a/Source/Helpers/Blazorise.Tests.bUnit/JSInterop.cs +++ b/Source/Helpers/Blazorise.Tests.bUnit/JSInterop.cs @@ -26,6 +26,19 @@ public static BunitJSInterop AddBlazoriseButton( this BunitJSInterop jsInterop ) return jsInterop; } + public static BunitJSInterop AddBlazoriseFocusTrap( this BunitJSInterop jsInterop ) + { + AddBlazoriseUtilities( jsInterop ); + + var module = jsInterop.SetupModule( new JSFocusTrapModule( jsInterop.JSRuntime, new MockVersionProvider(), new( null, ( Options ) => { } ) ).ModuleFileName ); + module.SetupVoid( "import", _ => true ).SetVoidResult(); + module.SetupVoid( "initialize", _ => true ).SetVoidResult(); + module.SetupVoid( "destroy", _ => true ).SetVoidResult(); + module.SetupVoid( "focus", _ => true ).SetVoidResult(); + + return jsInterop; + } + public static BunitJSInterop AddBlazoriseBreakpoint( this BunitJSInterop jsInterop ) { AddBlazoriseUtilities( jsInterop ); @@ -231,6 +244,7 @@ public static BunitJSInterop AddBlazoriseRangeSlider( this BunitJSInterop jsInte public static BunitJSInterop AddBlazoriseModal( this BunitJSInterop jsInterop ) { AddBlazoriseUtilities( jsInterop ); + AddBlazoriseFocusTrap( jsInterop ); var module = jsInterop.SetupModule( new MockJsModalModule( jsInterop.JSRuntime, new MockVersionProvider(), new( null, ( Options ) => { } ) ).ModuleFileName ); module.SetupVoid( "import", _ => true ).SetVoidResult(); diff --git a/Tests/Blazorise.Tests/Components/FocusTrapComponentTest.cs b/Tests/Blazorise.Tests/Components/FocusTrapComponentTest.cs new file mode 100644 index 0000000000..e4b4cc8aa1 --- /dev/null +++ b/Tests/Blazorise.Tests/Components/FocusTrapComponentTest.cs @@ -0,0 +1,35 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Xunit; + +namespace Blazorise.Tests.Components; + +public class FocusTrapComponentTest : BunitContext +{ + public FocusTrapComponentTest() + { + Services.AddBlazoriseTests().AddBootstrapProviders().AddEmptyIconProvider(); + JSInterop + .AddBlazoriseButton() + .AddBlazoriseFocusTrap(); + } + + [Fact] + public void ActiveFocusTrapDoesNotRenderFocusableSentinels() + { + var component = Render( builder => + { + builder.OpenComponent( 0 ); + builder.AddAttribute( 1, nameof( FocusTrap.Active ), true ); + builder.AddAttribute( 2, nameof( FocusTrap.ChildContent ), (RenderFragment)( childBuilder => + { + childBuilder.OpenComponent