Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,5 @@ Microsoft.Fast.Components.FluentUI.xml
/tests/TemplateValidation/**/Data/*
/spelling.dic

# Mac temporary files
*.DS_Store
179 changes: 161 additions & 18 deletions src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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];
Expand Down
86 changes: 5 additions & 81 deletions src/Core/Components/Menu/FluentMenu.razor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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);
}
15 changes: 1 addition & 14 deletions src/Core/Components/Popover/FluentPopover.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,7 @@

@if (Open)
{
<FluentOverlay @bind-Visible="@Open" OnClose="@CloseAsync" Transparent=true FullScreen=true />
@if (CloseKeys != null && CloseKeys.Any())
{
// Button or AnchorId element
if (!string.IsNullOrEmpty(AnchorId))
{
<FluentKeyCode Anchor="@AnchorId" Only="@CloseAndTabKeys" OnKeyDown="@CloseOnKeyAsync" PreventDefaultOnly="@(AutoFocus ? CloseAndTabKeys : CloseKeys)" />
}
// Popover content
if (!string.IsNullOrEmpty(Id))
{
<FluentKeyCode Anchor="@Id" Only="@CloseAndTabKeys" OnKeyDown="@CloseOnKeyAsync" PreventDefaultOnly="@CloseAndTabKeys" />
}
}
<FluentOverlay @bind-Visible="@Open" OnClose="@CloseOverlayAsync" Transparent=true FullScreen=true />
<FluentAnchoredRegion @ref="AnchoredRegion"
Id="@Id"
Anchor="@AnchorId"
Expand Down
Loading
Loading