-
-
+
\ 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