Fix anchored region focus across shadow DOM and inline popups - #5041
Fix anchored region focus across shadow DOM and inline popups#5041adamint wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
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
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
@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.
Head branch was pushed to by a user without write access
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).
|
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. 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:
Why the original manual testing missed it: I validated on Two things worth your judgement:
No test accompanies this. The logic is entirely in JS, and |
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.
|
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
Before the fix the third The PR is now three files: the one-file fix plus the demo example and its registration. |
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.
CloseAsyncis invoked afterfocus()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, andFluentMenuwithDrawMenuWithoutService— the next focusable element in document order after the anchor is the popup's own content, soTabfrom 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:
findNextPageElementAfterAnchordeliberately does not reuse the modulo wrap infindNextFocusableElement. Wrapping to index 0 is precisely the symptom reported in [waiting for fluentui-blazor release] Pressing Tab key collapses “View option” popup and causes unexpected focus reset:A11y_Aspire Dashboard_Resources_Keyboard aspire#17469, so when the anchor is the last control on the page this returnsnulland focus stays put.This PR now also covers #5001, which was previously addressed by #5003. #5003 has been reduced to the
FluentDataGridfix (#5002) so the two no longer overlap.📑 Test Plan
Completed validation:
npm run builddotnet test tests/Core/Microsoft.FluentUI.AspNetCore.Components.Tests.csproj --configuration Release --no-restore --filter FullyQualifiedName~AnchoredRegion --verbosity minimal(36 passed)dotnet build Microsoft.FluentUI.sln --configuration Debugnode --check src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.jsAutomated coverage
None accompanies this change, and it is worth being explicit about why. The logic is entirely in JS;
FluentAnchoredRegionTestsruns withJSRuntimeMode.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, mirroringfluent-button:Tabfrom last popup itemp1(back into the popup)aftertabExitsAlways):Tabfrom any itemp1afterp1Shift+Tabfrom first itemEscapeCloseAsyncstill requestedOne trap worth flagging for anyone reproducing this: assert against the composed active element, not
document.activeElement. Because the anchor usesdelegatesFocus,document.activeElementreports the host element whileanchor.shadowRoot.activeElementreports the real focused control — which makes a correct result look like a failure.Manual repro — regression check (#5027)
Run the demo server with the Run Demo Server VS Code launch profile, or:
http://localhost:5062/datagrid-menu-header.Tabto thePersonIdcolumn-header menu button and pressEnterto open it.Tabfrom a menu item. The menu should close and focus should advance to the next logical control instead of wrapping to the first page control.Shift+TabandEscapeclose it and return focus to the column-header button.NameandBirthDate.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
/Popoverwhere the popover is declared between its anchor and the following control, matching the harness above. No existing demo page covered that arrangement: on/PopoverbothFluentPopoverelements are declared after both buttons, so the next control after callout 1's anchor is callout 2's button, and/Overflowhas noMoreButtonTemplateexample.http://localhost:5062/Popoverand scroll to Keyboard navigation.Tabto Open Callout and pressEnter.Tabthrough Name and Save, then pressTabonce more.<body>onceCloseAsynccompleted.Shift+Tabfrom Name andEscapeboth return focus to Open Callout.✅ Checklist
General
Component-specific
⏭ Next Steps
None.