From 1625cb539baccfd1eac2274728a2caa35ab9f75e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 23 Feb 2026 12:59:23 -0500 Subject: [PATCH] allow tabbing inside open anchored regions, support keyboard accessible navigation for popover --- .gitignore | 2 + .../FluentAnchoredRegion.razor.js | 179 ++++++++++++++++-- src/Core/Components/Menu/FluentMenu.razor.js | 86 +-------- .../Components/Popover/FluentPopover.razor | 15 +- .../Components/Popover/FluentPopover.razor.cs | 93 +++++++-- .../Core/_ToDo/Popover/FluentPopoverTests.cs | 7 + 6 files changed, 258 insertions(+), 124 deletions(-) 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() {