Skip to content

Fix anchored region focus across shadow DOM and inline popups - #5041

Open
adamint wants to merge 6 commits into
microsoft:devfrom
adamint:fix/anchored-region-shadow-focus
Open

Fix anchored region focus across shadow DOM and inline popups#5041
adamint wants to merge 6 commits into
microsoft:devfrom
adamint:fix/anchored-region-shadow-focus

Conversation

@adamint

@adamint adamint commented Jul 22, 2026

Copy link
Copy Markdown
Member

Pull Request

📖 Description

Anchored region keyboard navigation currently treats the light DOM focus list as the complete tab order. This causes focus to wrap to the first page control when a menu anchor or popup control is represented through a Fluent component shadow DOM.

This updates focus discovery and active-element mapping to follow the composed tree, including nested open shadow roots and composite anchors.

It also excludes the still-open popup from that traversal. CloseAsync is invoked after focus() and resolves asynchronously, so the popup is still in the DOM while the next element is being located. When a popup is rendered inline and sits between its anchor and the following control — FluentPopover, and FluentMenu with DrawMenuWithoutService — the next focusable element in document order after the anchor is the popup's own content, so Tab from the last item moved focus back into the popup it had just closed, which then closed underneath it and dropped focus to <body>.

🎫 Issues

Fixes #5027
Fixes #5001

Related: microsoft/aspire#17469

👩‍💻 Reviewer Notes

The fix is isolated to FluentAnchoredRegion.razor.js. The only other change is a new Popover demo example, which gives the inline-popup case a permanent home in the demo site.

Two notes for re-review:

This PR now also covers #5001, which was previously addressed by #5003. #5003 has been reduced to the FluentDataGrid fix (#5002) so the two no longer overlap.

📑 Test Plan

Completed validation:

  • npm run build
  • dotnet test tests/Core/Microsoft.FluentUI.AspNetCore.Components.Tests.csproj --configuration Release --no-restore --filter FullyQualifiedName~AnchoredRegion --verbosity minimal (36 passed)
  • Focused AnchoredRegion plus Menu test run (37 passed)
  • dotnet build Microsoft.FluentUI.sln --configuration Debug
  • node --check src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js

Automated coverage

None accompanies this change, and it is worth being explicit about why. The logic is entirely in JS; FluentAnchoredRegionTests runs with JSRuntimeMode.Loose, so bUnit never executes the module and a test there would pass identically with and without the fix. The repository has no JS test runner.

Browser evidence

I drove this module directly in Chromium from a temporary harness. The DOM places the popup between the anchor and the following control, and the anchor is a custom element with an open shadow root and delegatesFocus: true, mirroring fluent-button:

<button id="before">before</button>
<my-anchor id="anchor"></my-anchor>          <!-- open shadow root, delegatesFocus -->
<div id="popup"><button id="p1"></button><button id="p2"></button></div>
<button id="after">after</button>
Case Before After
Popover: Tab from last popup item p1 (back into the popup) after
Menu (tabExitsAlways): Tab from any item p1 after
Anchor is last control on page p1 stays put, no wrap
Popover: Shift+Tab from first item anchor's shadow button anchor's shadow button
Popover: Escape anchor's shadow button anchor's shadow button
CloseAsync still requested yes yes

One trap worth flagging for anyone reproducing this: assert against the composed active element, not document.activeElement. Because the anchor uses delegatesFocus, document.activeElement reports the host element while anchor.shadowRoot.activeElement reports the real focused control — which makes a correct result look like a failure.

let element = document.activeElement;
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
element;

Manual repro — regression check (#5027)

Run the demo server with the Run Demo Server VS Code launch profile, or:

dotnet run --project examples/Demo/Server/FluentUI.Demo.Server.csproj --framework net9.0 --launch-profile FluentUI.Demo.Server
  1. Open http://localhost:5062/datagrid-menu-header.
  2. Tab to the PersonId column-header menu button and press Enter to open it.
  3. Press Tab from a menu item. The menu should close and focus should advance to the next logical control instead of wrapping to the first page control.
  4. Reopen and verify Shift+Tab and Escape close it and return focus to the column-header button.
  5. Repeat with Name and BirthDate.

Note this page renders through FluentMenuProvider, which places the menu at the end of the layout — far from the anchor in document order. It confirms the shadow-DOM traversal but the popup filter is a no-op here, so it cannot exercise the second fix.

Manual repro — inline popup (#5001)

This PR adds a Keyboard navigation example to /Popover where the popover is declared between its anchor and the following control, matching the harness above. No existing demo page covered that arrangement: on /Popover both FluentPopover elements are declared after both buttons, so the next control after callout 1's anchor is callout 2's button, and /Overflow has no MoreButtonTemplate example.

  1. Open http://localhost:5062/Popover and scroll to Keyboard navigation.
  2. Tab to Open Callout and press Enter.
  3. Tab through Name and Save, then press Tab once more.
  4. Expected: the callout closes and focus lands on Next control.
  5. Before this change focus moved back into the closing callout instead, ending on <body> once CloseAsync completed.
  6. Also confirm Shift+Tab from Name and Escape both return focus to Open Callout.

✅ Checklist

General

  • I have tested my changes.
  • I have updated the project documentation to reflect my changes.
  • I have read the CONTRIBUTING documentation and followed the standards for this project.

Component-specific

  • I have added a new component
  • I have added Unit Tests for my new component
  • I have modified an existing component
  • I have validated the Unit Tests for an existing component

⏭ Next Steps

None.

Copilot AI review requested due to automatic review settings July 22, 2026 16:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes AnchoredRegion keyboard focus handling when anchors/popups involve Shadow DOM by resolving focus and focusable-element discovery in the composed tree, and adds real browser (Chromium) coverage via Playwright with CI wiring.

Changes:

  • Update AnchoredRegion focus logic to follow the composed tree (deep active element, composed containment, composite anchor handling) and avoid wrapping when the “current” element can’t be located.
  • Rework focusable-element discovery to traverse nested open shadow roots for more accurate focus navigation.
  • Add Playwright Chromium tests for Shadow DOM focus behavior, and wire Playwright installation/test runs into GitHub Actions and Azure Pipelines.
Show a summary per file
File Description
src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js Implements composed-tree focus resolution and shadow-root-aware focusable traversal/mapping for anchored region keyboard navigation.
src/Core.Assets/tests/FluentAnchoredRegion.spec.js Adds Playwright browser tests covering Shadow DOM/composite anchor focus exit/return scenarios.
src/Core.Assets/playwright.config.js Introduces Playwright test configuration for Core.Assets.
src/Core.Assets/package.json Adds Playwright test scripts and @playwright/test dev dependency.
src/Core.Assets/package-lock.json Locks Playwright-related dependencies.
src/Core.Assets/.npmrc Adds replace-registry-host=never to npm configuration.
eng/pipelines/build-core-lib.yml Adds Node install, npm ci, Chromium install, and Playwright browser test execution in Azure Pipelines.
eng/pipelines/build-all-lib.yml Same as above for the “all lib” Azure pipeline.
.github/workflows/build-core-lib.yml Adds Node setup, npm ci, Chromium install, and Playwright browser test execution in GitHub Actions.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@vnbaaij
vnbaaij enabled auto-merge (squash) July 22, 2026 20:30
@vnbaaij
vnbaaij disabled auto-merge July 22, 2026 20:36
@vnbaaij
vnbaaij enabled auto-merge (squash) July 22, 2026 20:36
@vnbaaij vnbaaij added this to the v4.13.4 milestone Jul 22, 2026
@adamint

adamint commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

@vnbaaij will this fix be included in the next release?

…nchor

CloseAsync is invoked after focus() and resolves asynchronously, so the popup is
still in the DOM during traversal. For popups rendered inline next to their
anchor (FluentPopover, and FluentMenu with DrawMenuWithoutService), the next
focusable element in document order after the anchor is the popup's own content,
so Tab from the last item looped back into the popup instead of leaving it.

Filter the popup's composed subtree out of the candidate list before locating
the anchor, and take the following candidate without wrapping.
auto-merge was automatically disabled July 27, 2026 19:10

Head branch was pushed to by a user without write access

adamint added a commit to adamint/fluentui-blazor that referenced this pull request Jul 27, 2026
microsoft#5041 rewrites the same focus traversal in FluentAnchoredRegion.razor.js and
covers everything this branch did there, so keeping both would just conflict.
This branch now carries only the FluentDataGrid column resize popup fix (microsoft#5002).
@adamint

adamint commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Heads up @vnbaaij — I pushed a commit after your approval, so this needs another look before merge. Auto-merge is currently off.

While double-checking this against the cases in microsoft/aspire#17469 I found one the PR didn't cover. CloseAsync is invoked after focus() and resolves asynchronously, so the popup is still in the DOM while we're locating the next element. When the popup is rendered inline next to its anchor — FluentPopover, and FluentMenu with DrawMenuWithoutService — the next focusable element in document order after the anchor is the popup's own content. So Tab from the last item moved focus back into the popup, which then closed underneath it and dropped focus to <body>.

The fix filters the popup's composed subtree out of the candidate list before locating the anchor:

const candidates = focusable.getFocusableElements()
    .filter(element => !containsComposedElement(popupElement, element));

Measured in Chromium against this module, with the popup placed between the anchor and the following control:

Case Before After
Popover: Tab from last popup item p1 (back into the popup) after
Menu (tabExitsAlways): Tab from any item p1 after
Anchor is last control on page p1 stays put, no wrap
Shift+Tab from first item anchor's shadow button anchor's shadow button
Escape anchor's shadow button anchor's shadow button

Why the original manual testing missed it: I validated on datagrid-menu-header, which renders through FluentMenuProvider at the end of the layout. There the anchor and popup are nowhere near each other in document order, so the filter is a no-op and the old code looked correct. It only breaks for inline popups.

Two things worth your judgement:

No test accompanies this. The logic is entirely in JS, and FluentAnchoredRegionTests runs with JSRuntimeMode.Loose, so bUnit never executes the module — a test there would pass identically with or without the fix. The only suite that could catch this was the Playwright one removed earlier in this PR, and re-adding it isn't in scope here.

@adamint
adamint requested a review from vnbaaij July 27, 2026 19:12
@adamint adamint changed the title Fix anchored region focus across shadow DOM Fix anchored region focus across shadow DOM and inline popups Jul 27, 2026
Adds an example where the popover is declared between its anchor and the
next control, which is the arrangement the existing Popover examples do
not cover and the one the anchored region focus fix addresses.
@adamint

adamint commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Added a Keyboard navigation example to the Popover demo page so the inline-popup case has a permanent repro instead of a paste-in snippet. The popover is declared between its anchor and the following control, which is the arrangement none of the existing examples covered.

Verified against the running demo server on /Popover, reading the composed active element at each step:

Action Composed focus Callout
Open, then Tab input (Name) open
Tab Save button open
Tab Next control closed
Shift+Tab from Name anchor closed
Escape anchor closed

Before the fix the third Tab re-entered the closing callout and focus ended on <body>.

The PR is now three files: the one-file fix plus the demo example and its registration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants