diff --git a/.gitignore b/.gitignore
index f7f9d644ea..9a97dad227 100644
--- a/.gitignore
+++ b/.gitignore
@@ -413,3 +413,5 @@ Microsoft.Fast.Components.FluentUI.xml
/tests/TemplateValidation/**/Data/*
/spelling.dic
+# Mac temporary files
+*.DS_Store
diff --git a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
index 290ba15809..39184bd29f 100644
--- a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
+++ b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
@@ -33,6 +33,143 @@ export function goToNextFocusableElement(forContainer, toOriginal, delay) {
}
+// ---------------------------------------------------------------------------
+// Shared keyboard navigation for anchor+popup pairs (popovers, menus, etc.)
+// Implements the standard 4-case ARIA keyboard pattern:
+// 1. Tab on anchor → focus first element in popup (stay open)
+// 2. Shift+Tab on popup → focus anchor, close popup
+// 3. Tab on popup → focus next page element after anchor, close popup
+// 4. Escape on popup → focus anchor, close popup
+// (Shift+Tab on anchor while popup open → close popup, browser handles focus)
+// ---------------------------------------------------------------------------
+
+const keyboardNavigationState = new Map();
+
+/**
+ * Attaches keyboard navigation listeners to an anchor+popup pair.
+ * @param {string} anchorId - Id of the anchor element.
+ * @param {string} popupId - Id of the popup/overlay element.
+ * @param {object} dotNetHelper - DotNetObjectReference; must expose CloseAsync().
+ * @param {number[]} closeKeyCodes - Additional key codes (besides Tab) that close the popup. Defaults to [27] (Escape).
+ */
+export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, closeKeyCodes = [27], tabExitsAlways = false) {
+ disposeKeyboardNavigation(anchorId);
+
+ const popupElement = document.getElementById(popupId);
+ const anchorElement = document.getElementById(anchorId);
+
+ if (!popupElement || !anchorElement) {
+ return;
+ }
+
+ // Listeners on the popup content (cases 2, 3, 4)
+ const popupKeydownListener = function (ev) {
+ const keyCode = ev.which || ev.keyCode;
+ const isCloseKey = closeKeyCodes.includes(keyCode);
+
+ if (ev.key !== "Tab" && !isCloseKey) return;
+
+ if (isCloseKey) {
+ // Case 4: close key → return focus to anchor, close
+ ev.preventDefault();
+ ev.stopPropagation();
+ anchorElement.focus();
+ dotNetHelper.invokeMethodAsync('CloseAsync');
+ return;
+ }
+
+ // Tab / Shift+Tab
+ if (tabExitsAlways) {
+ // Menu pattern: Tab on any element exits immediately
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (!ev.shiftKey) {
+ // Case 3: move to element after anchor in page
+ let startFrom;
+ if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot?.children.length > 0) {
+ startFrom = anchorElement.shadowRoot.children[0];
+ } else {
+ startFrom = anchorElement;
+ }
+ new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(startFrom)?.focus();
+ } else {
+ // Case 2: Shift+Tab → focus anchor
+ anchorElement.focus();
+ }
+ dotNetHelper.invokeMethodAsync('CloseAsync');
+ } else {
+ // Popover pattern: only intercept Tab at the first/last boundary;
+ // let the browser handle Tab naturally for elements in between.
+ const focusables = new FocusableElement(popupElement).getFocusableElements();
+ const activeIndex = focusables.indexOf(document.activeElement);
+
+ if (!ev.shiftKey && (focusables.length === 0 || activeIndex === focusables.length - 1)) {
+ // Case 3: Tab on last element → next page element after anchor, close
+ ev.preventDefault();
+ ev.stopPropagation();
+ let startFrom;
+ if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot?.children.length > 0) {
+ startFrom = anchorElement.shadowRoot.children[0];
+ } else {
+ startFrom = anchorElement;
+ }
+ new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(startFrom)?.focus();
+ dotNetHelper.invokeMethodAsync('CloseAsync');
+ } else if (ev.shiftKey && (focusables.length === 0 || activeIndex === 0)) {
+ // Case 2: Shift+Tab on first element → focus anchor, close
+ ev.preventDefault();
+ ev.stopPropagation();
+ anchorElement.focus();
+ dotNetHelper.invokeMethodAsync('CloseAsync');
+ }
+ // Otherwise: middle element — let browser handle Tab/Shift+Tab naturally
+ }
+ };
+
+ // Listener on the anchor (case 1 and Shift+Tab-while-open)
+ const anchorKeydownListener = function (ev) {
+ if (ev.key !== "Tab") return;
+
+ if (!ev.shiftKey) {
+ // Case 1: Tab on anchor → focus first focusable element in popup
+ const firstFocusable = new FocusableElement(popupElement).findNextFocusableElement();
+ if (!firstFocusable) return;
+
+ firstFocusable.focus();
+ ev.preventDefault();
+ ev.stopPropagation();
+ } else {
+ // Shift+Tab on anchor while popup open → close, browser handles focus naturally
+ dotNetHelper.invokeMethodAsync('CloseAsync');
+ }
+ };
+
+ popupElement.addEventListener("keydown", popupKeydownListener);
+ anchorElement.addEventListener("keydown", anchorKeydownListener);
+
+ keyboardNavigationState.set(anchorId, {
+ popupKeydownListener,
+ anchorKeydownListener,
+ popupElement,
+ anchorElement
+ });
+}
+
+/**
+ * Removes keyboard navigation listeners registered by initializeKeyboardNavigation.
+ * @param {string} anchorId
+ */
+export function disposeKeyboardNavigation(anchorId) {
+ const state = keyboardNavigationState.get(anchorId);
+ if (!state) return;
+
+ const { popupKeydownListener, anchorKeydownListener, popupElement, anchorElement } = state;
+ popupElement.removeEventListener("keydown", popupKeydownListener);
+ anchorElement.removeEventListener("keydown", anchorKeydownListener);
+
+ keyboardNavigationState.delete(anchorId);
+}
+
/**
* Focusable Element
*/
@@ -57,46 +194,52 @@ export class FocusableElement {
}
/**
- * Find the next focusable element, after the optional current element, in the specified container.
- * @param container
- * @param currentElement
- * @returns
+ * Returns all focusable elements within the container, resolving Fluent web component shadow roots.
+ * @returns {Element[]}
*/
- findNextFocusableElement(currentElement) {
- // Fluent web components may have children that are focusable, but they are not
- // focusable themselves. Thus, we unfortunately need to query every element or provide
- // a list of all fluent elements that have focusable children.
+ getFocusableElements() {
const queriedElements = Array.from(this._container.querySelectorAll("*")).filter(el => {
return el.matches(this.FOCUSABLE_SELECTORS) || el.tagName.toLowerCase().startsWith("fluent-");
});
const focusableElements = [];
-
- // If an element is a fluent web component and is not focusable, replace with its inner focusable element
- // if one exists.
- queriedElements.forEach((el, index) => {
+ queriedElements.forEach(el => {
if (el.tagName.toLowerCase().startsWith("fluent-") && el.tabIndex === -1 && !!el.shadowRoot) {
Array.from(el.shadowRoot.children).forEach(child => {
if (child.tabIndex !== -1 && child.checkVisibility()) {
focusableElements.push(child);
}
});
- }
- else {
+ } else {
focusableElements.push(el);
}
});
- // Filter out elements with tabindex="-1" and elements that are not visible
- const filteredElements = focusableElements.filter(el => !!el && el.tabIndex !== -1 && el.checkVisibility());
+ return focusableElements.filter(el => !!el && el.tabIndex !== -1 && el.checkVisibility());
+ }
+
+ /**
+ * Find the next focusable element, after the optional current element, in the specified container.
+ * @param currentElement
+ * @param reverse - If true, find the previous focusable element instead of the next.
+ * @returns
+ */
+ findNextFocusableElement(currentElement, reverse = false) {
+ const filteredElements = this.getFocusableElements();
+
+ if (filteredElements.length === 0) {
+ return null;
+ }
// Find the index of the current element
const current = currentElement ?? document.activeElement;
if (current != null) {
const currentIndex = filteredElements.indexOf(current);
- // Calculate the index of the next element
- const nextIndex = (currentIndex + 1) % filteredElements.length;
+ // Calculate the index of the next (or previous) element
+ const nextIndex = reverse
+ ? (currentIndex - 1 + filteredElements.length) % filteredElements.length
+ : (currentIndex + 1) % filteredElements.length;
// Return the next focusable element
return filteredElements[nextIndex];
diff --git a/src/Core/Components/Menu/FluentMenu.razor.js b/src/Core/Components/Menu/FluentMenu.razor.js
index c12348b0be..e8401145ed 100644
--- a/src/Core/Components/Menu/FluentMenu.razor.js
+++ b/src/Core/Components/Menu/FluentMenu.razor.js
@@ -42,11 +42,8 @@ export async function initialize(anchorId, menuId, menuOpen, anchoredRegionModul
return;
}
- const FocusableElement = anchoredRegionModule
- ? anchoredRegionModule.FocusableElement
- : null;
- if (!FocusableElement) {
- throw new Error("FocusableElement not available from AnchoredRegion module");
+ if (!anchoredRegionModule) {
+ throw new Error("AnchoredRegion module is required for keyboard navigation");
}
const menuElement = document.getElementById(menuId);
@@ -57,79 +54,9 @@ export async function initialize(anchorId, menuId, menuOpen, anchoredRegionModul
return;
}
- // We need to handle four cases to be fully accessible:
- // 1. When Tab is pressed on the anchor, focus must be moved to the first focusable element in the menu
- // 2. When Shift+Tab is pressed on any focusable element in the menu, focus must be moved back to the anchor. This will also close the menu.
- // 3. When Tab is pressed on any focusable element in the menu, focus should continue to the next focusable element in the element's root. This will also close the menu.
- // 4. When Escape is pressed on any focusable element in the menu, focus must be moved back to the anchor. This will also close the menu.
- const menuItemKeydownListener = function (ev) {
- if (ev.key === "Tab" || ev.key === "Escape") {
- try {
- ev.preventDefault && ev.preventDefault();
- ev.stopPropagation && ev.stopPropagation();
+ anchoredRegionModule.initializeKeyboardNavigation(anchorId, menuId, dotNetHelper, undefined, true);
- if (!ev.shiftKey && ev.key === "Tab") {
- // When Tab is pressed on a focusable element, we should continue to the next focusable element
- // If this element is a fluent element, we should try to find the next focusable element within the shadow DOM of the fluent element if one exists,
- // as that is the focusable element.
- let element;
- if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot && anchorElement.shadowRoot.children.length > 0) {
- element = anchorElement.shadowRoot.children[0];
- }
- else {
- element = anchorElement;
- }
-
- new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(element)?.focus();
- }
- else {
- // When Shift+Tab is pressed on a focusable element, move focus back to the anchor
- anchorElement.focus();
- }
-
- dotNetHelper.invokeMethodAsync('CloseAsync');
- } catch (ex) {
- console.error("Failed to focus anchor:", ex);
- }
- }
- };
-
- menuElement.addEventListener("keydown", menuItemKeydownListener);
-
- // Add keydown listener to the anchor for Tab (no shift) to focus first element
- const anchorKeyDownListener = function (ev) {
- if (ev.key === "Tab") {
- if (ev.shiftKey) {
- dotNetHelper.invokeMethodAsync('CloseAsync');
- }
- else {
- const focusableHelper = new FocusableElement(menuElement);
- const firstFocusable = focusableHelper.findNextFocusableElement();
-
- if (!firstFocusable) {
- return; // no element to attach listener to
- }
-
- // When Tab is pressed on the anchor, move focus to the first focusable element
- try {
- firstFocusable.focus();
- ev.preventDefault && ev.preventDefault();
- ev.stopPropagation && ev.stopPropagation();
- } catch (ex) {
- console.error("Failed to focus first focusable element:", ex);
- }
- }
- }
- };
-
- anchorElement.addEventListener("keydown", anchorKeyDownListener);
-
- menuState.set(anchorId, {
- menuItemKeydownListener,
- anchorKeyDownListener,
- menuElement,
- anchorElement
- });
+ menuState.set(anchorId, { anchoredRegionModule });
}
// Called to cleanup listeners when component is disposed
@@ -139,9 +66,6 @@ export function dispose(anchorId) {
return;
}
- const { menuItemKeydownListener, anchorKeyDownListener, menuElement, anchorElement } = state;
- menuElement.removeEventListener("keydown", menuItemKeydownListener);
- anchorElement.removeEventListener("keydown", anchorKeyDownListener);
-
+ state.anchoredRegionModule?.disposeKeyboardNavigation(anchorId);
menuState.delete(anchorId);
}
diff --git a/src/Core/Components/Popover/FluentPopover.razor b/src/Core/Components/Popover/FluentPopover.razor
index 62837bce19..66f41c2dd7 100644
--- a/src/Core/Components/Popover/FluentPopover.razor
+++ b/src/Core/Components/Popover/FluentPopover.razor
@@ -3,20 +3,7 @@
@if (Open)
{
-
- @if (CloseKeys != null && CloseKeys.Any())
- {
- // Button or AnchorId element
- if (!string.IsNullOrEmpty(AnchorId))
- {
-
- }
- // Popover content
- if (!string.IsNullOrEmpty(Id))
- {
-
- }
- }
+
? _dotNetHelper;
+ private IJSObjectReference? _anchoredRegionModule;
+ private bool _disposed;
+ private bool _previousOpen;
protected string? ClassValue => new CssBuilder(Class)
.Build();
@@ -17,6 +25,14 @@ public partial class FluentPopover : FluentComponentBase
protected string? StyleValue => new StyleBuilder(Style)
.Build();
+ ///
+ [Inject]
+ private LibraryConfiguration LibraryConfiguration { get; set; } = default!;
+
+ ///
+ [Inject]
+ private IJSRuntime JSRuntime { get; set; } = default!;
+
///
/// Gets or sets the id of the component the popover is positioned relative to.
///
@@ -109,13 +125,11 @@ public partial class FluentPopover : FluentComponentBase
[Parameter]
public KeyCode[]? CloseKeys { get; set; } = new[] { KeyCode.Escape };
- ///
- private KeyCode[] CloseAndTabKeys => CloseKeys?.Any() == true ? CloseKeys.Union(new[] { KeyCode.Tab }).ToArray() : new[] { KeyCode.Tab };
-
///
protected override void OnInitialized()
{
- if (CloseKeys != null && CloseKeys.Any() && string.IsNullOrEmpty(Id))
+ // Id is always needed to identify the popover content element.
+ if (string.IsNullOrEmpty(Id))
{
Id = Identifier.NewId();
}
@@ -131,7 +145,47 @@ protected override void OnParametersSet()
}
///
- protected virtual async Task CloseAsync()
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ _anchoredRegionModule = await JSRuntime.InvokeAsync("import", ANCHORED_REGION_JAVASCRIPT_FILE.FormatCollocatedUrl(LibraryConfiguration));
+ _dotNetHelper = DotNetObjectReference.Create(this);
+ }
+
+ if (!_disposed && _anchoredRegionModule is not null && Open != _previousOpen)
+ {
+ _previousOpen = Open;
+ if (Open)
+ {
+ var closeKeyCodes = CloseKeys?.Select(k => (int)k).ToArray() ?? Array.Empty();
+ await _anchoredRegionModule.InvokeVoidAsync("initializeKeyboardNavigation", AnchorId, Id, _dotNetHelper, closeKeyCodes);
+ }
+ else
+ {
+ await _anchoredRegionModule.InvokeVoidAsync("disposeKeyboardNavigation", AnchorId);
+ }
+ }
+ }
+
+ ///
+ /// Closes the popover. Called from JavaScript keyboard navigation.
+ ///
+ [JSInvokable]
+ public async Task CloseAsync()
+ {
+ Open = false;
+ if (OpenChanged.HasDelegate)
+ {
+ await OpenChanged.InvokeAsync(Open);
+ }
+ StateHasChanged();
+ }
+
+ ///
+ /// Closes the popover and returns focus to the original element (used by the overlay on outside-click).
+ ///
+ protected virtual async Task CloseOverlayAsync()
{
Open = false;
if (OpenChanged.HasDelegate)
@@ -142,16 +196,33 @@ protected virtual async Task CloseAsync()
}
///
- protected virtual async Task CloseOnKeyAsync(FluentKeyCodeEventArgs e)
+ public async ValueTask DisposeAsync()
{
- if (CloseKeys != null && CloseKeys.Contains(e.Key))
+ if (_disposed)
{
- await CloseAsync();
+ return;
}
- if (AutoFocus && e.Key == KeyCode.Tab)
+ _disposed = true;
+ _dotNetHelper?.Dispose();
+
+ try
+ {
+ if (_anchoredRegionModule is not null)
+ {
+ await _anchoredRegionModule.InvokeVoidAsync("disposeKeyboardNavigation", AnchorId);
+ await _anchoredRegionModule.DisposeAsync();
+ }
+ }
+ catch (Exception ex) when (ex is JSDisconnectedException ||
+ ex is OperationCanceledException)
+ {
+ // The JSRuntime side may routinely be gone already if the reason we're disposing is that
+ // the client disconnected. This is not an error.
+ }
+ finally
{
- await AnchoredRegion.FocusToNextElementAsync();
+ GC.SuppressFinalize(this);
}
}
}
diff --git a/tests/Core/_ToDo/Popover/FluentPopoverTests.cs b/tests/Core/_ToDo/Popover/FluentPopoverTests.cs
index 25381d2da2..ba1164f701 100644
--- a/tests/Core/_ToDo/Popover/FluentPopoverTests.cs
+++ b/tests/Core/_ToDo/Popover/FluentPopoverTests.cs
@@ -2,11 +2,18 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
using Bunit;
+using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Popover;
public class FluentPopoverTests : TestBase
{
+ public FluentPopoverTests()
+ {
+ TestContext.JSInterop.Mode = JSRuntimeMode.Loose;
+ TestContext.Services.AddSingleton(LibraryConfiguration.ForUnitTests);
+ }
+
[Fact]
public void FluentPopover_Default()
{