Skip to content

Fix FluentDataGrid column width popup focus on tab navigation - #5003

Merged
vnbaaij merged 7 commits into
microsoft:devfrom
adamint:adamint/fix-popover-focus-return
Jul 28, 2026
Merged

Fix FluentDataGrid column width popup focus on tab navigation#5003
vnbaaij merged 7 commits into
microsoft:devfrom
adamint:adamint/fix-popover-focus-return

Conversation

@adamint

@adamint adamint commented Jul 9, 2026

Copy link
Copy Markdown
Member

Summary

Closes the FluentDataGrid column width popup when focus leaves it via Tab / Shift+Tab, preserving the expected previous/next focus target.

Fixes #5002

Scope change

This PR originally also changed FluentAnchoredRegion.razor.js to 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 the fluent-* host whenever that host is itself focusable, and substitutes the host's shadow descendants only when the host has tabIndex === -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.razor set tabindex="0" explicitly, so the collected list holds hosts. fluent-button uses delegatesFocus, so event.composedPath()[0] is the inner button.control inside 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 -1 so 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-button happens to render <button class="control"> at the top of its shadow root, so it survived — but fluent-text-field renders <label> and <div class="root">, both tabindex="-1", with its real input.control one level further down inside that wrapper. The text field the popup renders in Exact resize mode was therefore never in the list at all, which produced two visible bugs:

  • Shift+Tab out 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+Tab on 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~DataGrid

The 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.activeElement reports the host under delegatesFocus, so it has to be resolved through shadowRoot.activeElement).

Discrete mode, /datagrid-typical, Bronze column:

Action Popup Composed focus
Open resize open Shrink column width
Tab open Grow column width
Tab open Restore
Tab (last element) closed next column header button
Shift+Tab (first element) closed originating column header button

Exact mode, /datagrid-menu-header, Name column:

Action Popup Composed focus
Open resize open Column width text field
Tab open Set column widths
Tab open Reset column widths
Tab (last element) closed next column header button
Shift+Tab from Set column widths open Column width text field
Shift+Tab (first element) closed originating column header button

Before this change every one of those Tab presses left the popup open, except the Shift+Tab on 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:

dotnet run --project examples/Demo/Server/FluentUI.Demo.Server.csproj --framework net9.0 --launch-profile FluentUI.Demo.Server

Discrete resize

  1. Open http://localhost:5062/datagrid-typical. This grid sets ResizableColumns="true", ResizeType="DataGridResizeType.Discrete" and HeaderCellAsButtonWithMenu="true".
  2. Tab to the Bronze column header button and press Enter — it opens the .col-resize popup directly. (On the other columns, press Enter to open the menu and choose Resize.)
  3. Press Tab three times to walk Shrink → Grow → Restore, then once more. Expected: the popup closes and focus advances to the next column header.
  4. Reopen it and press Shift+Tab on the first button. Expected: the popup closes and focus returns to the originating header.

Exact resize

  1. Open http://localhost:5062/datagrid-menu-header, which sets ResizeType="DataGridResizeType.Exact".
  2. Open the Name column menu and choose Resize. Focus lands in the width text field.
  3. Press Shift+Tab. Expected: the popup closes and focus returns to the header.
  4. Reopen it, press Tab once to reach Set column widths, then Shift+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.js now carries its own copy of getFocusableElements / focusable-selector logic that duplicates the equivalent helpers in FluentAnchoredRegion.razor.js. Factoring out a shared module is a larger change than either bugfix warrants and is better handled as its own issue.

Copilot AI review requested due to automatic review settings July 9, 2026 03:08

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 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

Comment thread src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
Comment thread src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js Outdated
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.js Outdated
Comment thread src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js Outdated
Comment thread src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
Comment thread src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
Comment thread src/Core/Components/DataGrid/FluentDataGrid.razor.js Outdated
Comment thread src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js
adamint and others added 3 commits July 21, 2026 15:23
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).
@adamint adamint changed the title Fix popover focus return on tab navigation Fix FluentDataGrid column width popup focus on tab navigation Jul 27, 2026
@adamint
adamint marked this pull request as ready for review July 27, 2026 19:13
… 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.
@adamint

adamint commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Correction to my earlier reply on the resizeFocusables.indexOf(...) review thread — resolving it was premature.

Switching to event.composedPath()[0] was necessary but not sufficient, and testing on /datagrid-typical showed the popup still never closed on Tab.

The review comment described the case where the host has tabIndex === -1, so getFocusableElements() substitutes the host's shadow children and document.activeElement (the host) is missing from the list. The resize popup hits the inverse case: ColumnResizeOptions.razor sets tabindex="0" on the buttons, so the host stays in the list, while composedPath()[0] is the inner button.control inside the shadow root. indexOf() returned -1 either way — I had only moved which side of the boundary was wrong.

b3bcdeec replaces the lookup with indexOfFocusableElement(), which walks up the composed tree from the event origin until it finds an element that is actually in the collected list. That covers both directions. The isLastElement check is now also guarded against -1, so an empty list can no longer be mistaken for a boundary.

Verified against the running demo site rather than a synthetic harness this time — the harness passed because its DOM happened to reproduce the tabIndex === -1 shape rather than the one the component actually renders. Results are in the updated PR description.

adamint and others added 2 commits July 27, 2026 18:49
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.
@vnbaaij
vnbaaij merged commit 160de03 into microsoft:dev Jul 28, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FluentDataGrid column width popup stays open after tabbing out FluentOverflow MoreButtonTemplate popover loses focus return on Tab/Shift+Tab

3 participants