fix(react-dialog): respect autoFocus element over first focusable on dialog open#36038
fix(react-dialog): respect autoFocus element over first focusable on dialog open#36038
autoFocus element over first focusable on dialog open#36038Conversation
#35749) Agent-Logs-Url: https://github.com/microsoft/fluentui/sessions/32dab34c-d281-4bef-af30-8c263a2a57d6 Co-authored-by: tudorpopams <97875118+tudorpopams@users.noreply.github.com>
Agent-Logs-Url: https://github.com/microsoft/fluentui/sessions/32dab34c-d281-4bef-af30-8c263a2a57d6 Co-authored-by: tudorpopams <97875118+tudorpopams@users.noreply.github.com>
autoFocus element over first focusable on dialog open
|
@copilot fix CI |
Agent-Logs-Url: https://github.com/microsoft/fluentui/sessions/c48f37af-fb63-4e6c-8a5e-f1f3c5437885 Co-authored-by: tudorpopams <97875118+tudorpopams@users.noreply.github.com>
PR Review: #36038 — fix(react-dialog): respect
|
| Category | Status | Notes |
|---|---|---|
| Change file | PASS | @fluentui-react-dialog-890df3cd-...json, type patch. Description correctly cites #35749 (the real issue — avoiding a prior Copilot session's wrong-issue-reference mistake). |
| V9 patterns | PASS | Hook is an internal utility; no React.FC, no styles touched, existing useFluent_unstable() targetDocument usage preserved for the dev-warning branch. |
| Dep layers | PASS | No package.json or new-import changes. |
| SSR safety | PASS | querySelector is called on dialogRef.current (a ref to a real DOM node that only exists after mount); no global document/window access. The existing no-focusable-element fallback path and its dev warning are unchanged. |
| Testing | PASS | New useFocusFirstElement.test.ts covers both paths — the no-autofocus case asserts findFirstFocusable is called and the first button is focused; the autofocus case asserts both that the autofocus input is focused and that findFirstFocusable is NOT called and the first button is NOT focused. The negative assertions are the ones that matter — they're what actually proves the preference order. |
| API surface | PASS | Internal hook; no public API change. etc/react-dialog.api.md untouched (correctly). |
| Accessibility | PASS | Makes React's autoFocus={true} behave per its documented contract inside dialogs. The existing empty-dialog fallback (dialogRef.current?.focus() with the dev warning for #25150) is preserved. |
| Security/Quality | PASS | No eval/dangerouslySetInnerHTML/console.log/@ts-ignore/any. |
Recommendation
APPROVE
Clean minimal fix. The reshape from a single assignment to two lines preserves the existing "no focusable element at all" warning path exactly, and the test file explicitly asserts the wrong element is NOT focused in the autofocus case — which is the assertion that proves the fix, not just the positive path. Drawer coverage is handled automatically via DialogSurface composition, avoiding a duplicate fix on the drawer side.
Posted via the /review-pr skill.
📊 Bundle size reportUnchanged fixtures
|
|
Pull request demo site: URL |
| @@ -0,0 +1,7 @@ | |||
| { | |||
There was a problem hiding this comment.
🕵🏾♀️ visual changes to review in the Visual Change Report
vr-tests-react-components/Charts-DonutChart 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Charts-DonutChart.Dynamic - Dark Mode.default.chromium.png | 7530 | Changed |
| vr-tests-react-components/Charts-DonutChart.Dynamic.default.chromium.png | 5581 | Changed |
vr-tests-react-components/Menu Converged - submenuIndicator slotted content 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png | 404 | Changed |
| vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png | 413 | Changed |
vr-tests-react-components/Positioning 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png | 852 | Changed |
| vr-tests-react-components/Positioning.Positioning end.chromium.png | 778 | Changed |
vr-tests-react-components/ProgressBar converged 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png | 32 | Changed |
| vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png | 26 | Changed |
vr-tests-react-components/TagPicker 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/TagPicker.disabled.chromium.png | 677 | Changed |
There were 1 duplicate changes discarded. Check the build logs for more information.
|
@copilot fix CI |
…eference behavior Agent-Logs-Url: https://github.com/microsoft/fluentui/sessions/6905bb7a-2a2f-43ea-b9c5-446aff8cef8d Co-authored-by: tudorpopams <97875118+tudorpopams@users.noreply.github.com>
The previous CI failure ( I pushed a JSDoc improvement (42ea05e) to trigger a new CI run. |
| } | ||
| const element = dialogRef.current && findFirstFocusable(dialogRef.current); | ||
| const autoFocusElement = dialogRef.current?.querySelector<HTMLElement>('[autofocus]'); | ||
| const element = autoFocusElement || (dialogRef.current && findFirstFocusable(dialogRef.current)); |
There was a problem hiding this comment.
Shouldn't tabster (focusable) handle this?
There was a problem hiding this comment.
@copilot why isn't this done directly with Tabster instead of finding the autofocus element yourself?
There was a problem hiding this comment.
Tabster's focusable APIs (findFirst, findDefault) work exclusively with elements that have the data-tabster attribute set via useTabsterAttributes. They have no knowledge of the HTML autofocus attribute.
Specifically:
findFirst()returns the first focusable element by DOM order (by tabster rules)findDefault()returns elements where tabster'sFocusableProps.isDefaultis set via adata-tabsterattribute
React's autoFocus={true} lowers to the native HTML autofocus attribute at mount time, which tabster never reads. So there is no tabster API that can detect "this element has autoFocus={true}" without requiring every consumer to also add a data-tabster attribute with isDefault: true on the same element — a different API contract that would be a breaking change.
The querySelector('[autofocus]') approach reads exactly the signal React already wrote to the DOM, without requiring consumers to change anything.
There was a problem hiding this comment.
@copilot so in order for this to be fully handled by Tabster (so no custom code in the Dialog component), would it require an update within findFirst() / findDefault() to have them work with autofocus attribute too? What would be the downsides to this?
There was a problem hiding this comment.
Yes, that's the right intuition. For Tabster to handle this completely, either findFirst() would need to scan for [autofocus] before returning the DOM-order-first element, or a new findAutofocus() helper would be needed. Here are the downsides:
-
Tabster is an external package (
tabsternpm, not in this repo). Any change needs a PR there, a new release, and then a dep bump in@fluentui/react-tabster— affecting every component that uses tabster, not just Dialog. -
Breaking change to
findFirst()semantics — it currently guarantees "first focusable by DOM order". Making it priority-sort byautofocuschanges those semantics for all tabster consumers (Angular, Vue, vanilla JS, etc.), not just Fluent UI. -
isDefault/findDefault()are the wrong semantic —FocusableProps.isDefaultis explicitly for Mover group preferred-focus, set viadata-tabsterattributes. It's not the same concept as the browser'sautofocus. Conflating them would mislead developers. -
It would still require caller code — even if Tabster had a
findAutofocus(),useFocusFirstElementwould still need to call it, so the Dialog-level code doesn't disappear entirely.
The querySelector('[autofocus]') in useFocusFirstElement is the right scope: two lines of standard DOM code that addresses Dialog's specific open-focus contract without requiring an external package change, API versioning, or any behavior change outside Dialog.
The CI workflows on the latest commit (
Same situation as above — the CI workflows are in |

When a Dialog/Drawer contained an element with
autoFocus={true}preceded by any other focusable element, the autofocus was silently ignored —findFirstFocusablealways won by DOM order.Changes
useFocusFirstElement.ts: Before callingfindFirstFocusable, query the dialog root for[autofocus]. React lowersautoFocus={true}to the DOMautofocusattribute on mount, soquerySelector('[autofocus]')reliably finds it. Falls back tofindFirstFocusable(and the existing dev warning) when none is present.useFocusFirstElement.test.ts(new): Unit tests covering the autofocus-wins path and the no-autofocus fallback path, with mockeduseFocusFindersanduseFluent_unstable.Since
OverlayDrawerusesDialogSurfaceinternally, this fix covers both Dialog and Drawer without any changes to drawer code.