Fix FluentDataGrid column width popup focus on tab navigation - #5003
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes keyboard focus behavior when tabbing out of popover/popup-style UI in Fluent UI Blazor, addressing downstream accessibility and usability issues reported for FluentPopover (anchored-region keyboard traversal) and FluentDataGrid (column resize popup).
Changes:
- Update anchored-region keyboard navigation to resolve focusable descendants for composite/non-focusable anchors and avoid wrapping traversal to the first document focusable.
- Add Tab/Shift+Tab boundary handling to close the DataGrid column resize popup when focus leaves via keyboard navigation.
Show a summary per file
| File | Description |
|---|---|
| src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js | Improves anchor/popup keyboard navigation by resolving focus targets and refining “next focus after anchor” logic. |
| src/Core/Components/DataGrid/FluentDataGrid.razor.js | Adds keyboard Tab boundary handling for the column resize popup, including focus-target selection on close. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 2/2 changed files
- Comments generated: 4
- Review effort level: Low
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c0833666-e36b-4435-a085-97eb64deb98f
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).
… Tab Fluent web components delegate focus into their shadow root, so event.composedPath()[0] is the inner shadow control while getFocusableElements() collects the fluent-* host whenever the host itself is focusable (the resize buttons set tabindex="0"). indexOf() therefore returned -1, no Tab boundary was ever detected, and the resize popup stayed open when tabbing out of it. Walk up the composed tree to map the event origin onto the collected element, and guard the last-element check against -1 so an empty list can no longer look like a boundary.
|
Correction to my earlier reply on the Switching to The review comment described the case where the host has
Verified against the running demo site rather than a synthetic harness this time — the harness passed because its DOM happened to reproduce the |
The column resize popup in Exact mode renders a FluentTextField, whose input sits inside a wrapper in the shadow root rather than being a direct child of it. It was therefore never collected, so Shift+Tab out of the text field left the popup open and Shift+Tab out of the first button skipped the text field and closed the popup instead.
Summary
Closes the
FluentDataGridcolumn width popup when focus leaves it viaTab/Shift+Tab, preserving the expected previous/next focus target.Fixes #5002
Scope change
This PR originally also changed
FluentAnchoredRegion.razor.jsto fix #5001. Those changes have been dropped — #5041 rewrites the same focus traversal and covers everything this branch did there, so keeping both would only produce a conflict. #5041 now closes #5001.What remains here is a single file,
FluentDataGrid.razor.js, which is self-contained and imports nothing from the anchored region module.Two defects, both in how focusable elements are matched across shadow boundaries
getFocusableElements()returns thefluent-*host whenever that host is itself focusable, and substitutes the host's shadow descendants only when the host hastabIndex === -1. Both halves of that were wrong in a different way.1. The host was collected, but the event origin is never the host. The resize buttons in
ColumnResizeOptions.razorsettabindex="0"explicitly, so the collected list holds hosts.fluent-buttonusesdelegatesFocus, soevent.composedPath()[0]is the innerbutton.controlinside the shadow root — never the host.indexOf()returned-1, neither boundary matched, and the popup stayed open after focus had already left it.indexOfFocusableElement()now walks up the composed tree from the event origin until it finds an element that is actually in the list, which works whichever side of the shadow boundary the collected element sits on. The last-element check is also guarded against-1so an empty list can no longer look like a boundary.2. Nested shadow descendants were dropped entirely. When descending into a host's shadow root, only its direct children were considered.
fluent-buttonhappens to render<button class="control">at the top of its shadow root, so it survived — butfluent-text-fieldrenders<label>and<div class="root">, bothtabindex="-1", with its realinput.controlone level further down inside that wrapper. The text field the popup renders inExactresize mode was therefore never in the list at all, which produced two visible bugs:Shift+Tabout of the text field (the genuine first element) left the popup open with focus outside it — the exact symptom this PR is meant to fix.Shift+Tabon the Set column widths button closed the popup and jumped past the text field, because that button looked like the first element. The text field was unreachable going backwards.The shadow root is now queried with the focusable selectors instead of only its direct children.
📑 Test Plan
node --check src/Core/Components/DataGrid/FluentDataGrid.razor.js dotnet test tests/Core/Microsoft.FluentUI.AspNetCore.Components.Tests.csproj --filter FullyQualifiedName~DataGridThe filtered test run passed: 52 tests, 0 failures.
No automated test covers the new behavior. The change is entirely in JS, the DataGrid bUnit tests run with mocked JS interop and never execute the module, and the repository has no JS test runner — so a test there would pass identically with and without this fix.
Verified instead by driving the real demo site in a browser and asserting on the composed active element (
document.activeElementreports the host underdelegatesFocus, so it has to be resolved throughshadowRoot.activeElement).Discretemode,/datagrid-typical, Bronze column:TabTabTab(last element)Shift+Tab(first element)Exactmode,/datagrid-menu-header, Name column:TabTabTab(last element)Shift+Tabfrom Set column widthsShift+Tab(first element)Before this change every one of those
Tabpresses left the popup open, except theShift+Tabon Set column widths, which closed it and skipped the text field.Manual repro
Run the demo server with the Run Demo Server VS Code launch profile, or:
Discrete resize
http://localhost:5062/datagrid-typical. This grid setsResizableColumns="true",ResizeType="DataGridResizeType.Discrete"andHeaderCellAsButtonWithMenu="true".Tabto the Bronze column header button and pressEnter— it opens the.col-resizepopup directly. (On the other columns, pressEnterto open the menu and choose Resize.)Tabthree times to walk Shrink → Grow → Restore, then once more. Expected: the popup closes and focus advances to the next column header.Shift+Tabon the first button. Expected: the popup closes and focus returns to the originating header.Exact resize
http://localhost:5062/datagrid-menu-header, which setsResizeType="DataGridResizeType.Exact".Shift+Tab. Expected: the popup closes and focus returns to the header.Tabonce to reach Set column widths, thenShift+Tab. Expected: focus returns to the text field and the popup stays open.Before this change, steps 3, 4 and 7 left the popup open, and step 8 closed the popup and skipped the text field.
Follow-up
FluentDataGrid.razor.jsnow carries its own copy ofgetFocusableElements/ focusable-selector logic that duplicates the equivalent helpers inFluentAnchoredRegion.razor.js. Factoring out a shared module is a larger change than either bugfix warrants and is better handled as its own issue.