From 2c6f9f5ae3813676a4843e5268359662875381bf Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 8 Jul 2026 21:07:46 -0600 Subject: [PATCH] Fix popover focus return on tab navigation --- .../FluentAnchoredRegion.razor.js | 66 ++++++++++++++----- .../DataGrid/FluentDataGrid.razor.js | 56 ++++++++++++++++ 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js index 39184bd29f..9748a045c7 100644 --- a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js +++ b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js @@ -44,6 +44,50 @@ export function goToNextFocusableElement(forContainer, toOriginal, delay) { // --------------------------------------------------------------------------- const keyboardNavigationState = new Map(); +const focusableSelectors = "input, select, textarea, button, object, a[href], area[href], [tabindex]"; + +function getAnchorFocusTarget(anchorElement) { + if (isFocusableElement(anchorElement)) { + return anchorElement; + } + + return new FocusableElement(anchorElement).findNextFocusableElement() ?? anchorElement; +} + +function getAnchorTraversalStart(anchorElement) { + const focusTarget = getAnchorFocusTarget(anchorElement); + return getFluentShadowFocusTarget(focusTarget) ?? focusTarget; +} + +function getFluentShadowFocusTarget(element) { + if (!element?.tagName?.startsWith("FLUENT-")) { + return null; + } + + return Array.from(element.shadowRoot?.children ?? []) + .find(child => child.tabIndex !== -1 && child.checkVisibility()) ?? null; +} + +function findNextFocusableElementAfterAnchor(anchorElement, popupElement) { + const startFrom = getAnchorTraversalStart(anchorElement); + const focusableElements = new FocusableElement(anchorElement.getRootNode()) + .getFocusableElements() + .filter(element => !popupElement.contains(element)); + const currentIndex = focusableElements.indexOf(startFrom); + + return currentIndex === -1 ? null : focusableElements[currentIndex + 1] ?? null; +} + +function isFocusableElement(element) { + if (!element) { + return false; + } + + const isFocusableCandidate = element.matches(focusableSelectors) + || element.tagName.toLowerCase().startsWith("fluent-"); + + return isFocusableCandidate && element.tabIndex !== -1 && element.checkVisibility(); +} /** * Attaches keyboard navigation listeners to an anchor+popup pair. @@ -85,16 +129,10 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl 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(); + findNextFocusableElementAfterAnchor(anchorElement, popupElement)?.focus(); } else { // Case 2: Shift+Tab → focus anchor - anchorElement.focus(); + getAnchorFocusTarget(anchorElement).focus(); } dotNetHelper.invokeMethodAsync('CloseAsync'); } else { @@ -107,19 +145,13 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl // 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(); + findNextFocusableElementAfterAnchor(anchorElement, popupElement)?.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(); + getAnchorFocusTarget(anchorElement).focus(); dotNetHelper.invokeMethodAsync('CloseAsync'); } // Otherwise: middle element — let browser handle Tab/Shift+Tab naturally @@ -174,7 +206,7 @@ export function disposeKeyboardNavigation(anchorId) { * Focusable Element */ export class FocusableElement { - FOCUSABLE_SELECTORS = "input, select, textarea, button, object, a[href], area[href], [tabindex]"; + FOCUSABLE_SELECTORS = focusableSelectors; _originalActiveElement; _container; diff --git a/src/Core/Components/DataGrid/FluentDataGrid.razor.js b/src/Core/Components/DataGrid/FluentDataGrid.razor.js index 37f8f501eb..0da668a2de 100644 --- a/src/Core/Components/DataGrid/FluentDataGrid.razor.js +++ b/src/Core/Components/DataGrid/FluentDataGrid.razor.js @@ -1,5 +1,45 @@ let grids = []; +const focusableSelectors = "input, select, textarea, button, object, a[href], area[href], [tabindex]"; + +function getFocusableElements(container) { + const queriedElements = Array.from(container.querySelectorAll("*")).filter(el => { + return el.matches(focusableSelectors) || el.tagName.toLowerCase().startsWith("fluent-"); + }); + + const focusableElements = []; + 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 { + focusableElements.push(el); + } + }); + + return focusableElements.filter(el => !!el && el.tabIndex !== -1 && el.checkVisibility()); +} + +function findAdjacentFocusableElement(currentElement, reverse = false) { + const focusableElements = getFocusableElements(document.body); + const currentIndex = focusableElements.indexOf(currentElement); + if (currentIndex === -1) { + return null; + } + + return focusableElements[currentIndex + (reverse ? -1 : 1)] ?? null; +} + +function closeColumnResizeAndFocus(gridElement, columnResizeElement, focusTarget) { + gridElement.dispatchEvent(new CustomEvent('closecolumnresize', { bubbles: true })); + + const columnHeaderButton = columnResizeElement.closest('.column-header')?.querySelector('.col-sort-button'); + (focusTarget ?? columnHeaderButton ?? gridElement).focus(); +} + export function init(gridElement, autoFocus) { if (gridElement === undefined || gridElement === null) { return; @@ -62,6 +102,22 @@ export function init(gridElement, autoFocus) { event.stopPropagation(); return; } + + if (event.key === "Tab") { + const resizeFocusables = getFocusableElements(columnResizeElement); + const activeIndex = resizeFocusables.indexOf(document.activeElement); + const isFirstElement = activeIndex === 0; + const isLastElement = activeIndex === resizeFocusables.length - 1; + + if ((event.shiftKey && isFirstElement) || (!event.shiftKey && isLastElement)) { + const focusTarget = findAdjacentFocusableElement(document.activeElement, event.shiftKey); + event.preventDefault(); + event.stopPropagation(); + closeColumnResizeAndFocus(gridElement, columnResizeElement, focusTarget); + } + + return; + } } if (document.activeElement.tagName.toLowerCase() != 'table' && document.activeElement.tagName.toLowerCase() != 'td' && document.activeElement.tagName.toLowerCase() != 'th') {