Skip to content

fix: focus stealing on render of Menu Trigger button - #4970

Open
SleeplessByte wants to merge 1 commit into
callstack:mainfrom
SleeplessByte:patch-2
Open

SleeplessByte wants to merge 1 commit into
callstack:mainfrom
SleeplessByte:patch-2

Conversation

@SleeplessByte

Copy link
Copy Markdown
Contributor

When the menu is rendered, the portal is updated async. This steals the focus as hide() focuses the button. Instead only focus when requested, for example when the menu was visible and hiding should focus the trigger.

Motivation

Stealing the focus is bad for me as a keyboard user and bad for assistive technology

Test plan

Before: open any page with a menu rendered in the appbar. The focus should move automatically to the trigger button, even without opening.
After: open any page with a menu rendered in the appbar. The focus should not move automatically to the trigger button, unless the menu is opened and then closed.

Workaround before this is merged

react-native-paper-npm-5.15.3-9a600de527.patch

diff --git a/lib/commonjs/components/Menu/Menu.js b/lib/commonjs/components/Menu/Menu.js
index dea22ecd5960f77463b27196ec1d2fb892f44d50..c45bf480d260ae73c3c4114f06bd6f4a22f61963 100644
--- a/lib/commonjs/components/Menu/Menu.js
+++ b/lib/commonjs/components/Menu/Menu.js
@@ -250,7 +250,7 @@ const Menu = ({
       });
     });
   }, [anchor, attachListeners, measureAnchorLayout, theme]);
-  const hide = React.useCallback(() => {
+  const hide = React.useCallback((focus = true) => {
     removeListeners();
     const {
       animation
@@ -267,7 +267,7 @@ const Menu = ({
       });
       setRendered(false);
       prevRendered.current = false;
-      focusFirstDOMNode(anchorRef.current);
+      if (focus) { focusFirstDOMNode(anchorRef.current) };
     });
   }, [removeListeners, theme]);
   const updateVisibility = React.useCallback(async display => {
@@ -279,7 +279,7 @@ const Menu = ({
         return;
       }
       if (!display) {
-        hide();
+        hide(prevRendered.current);
       }
       return;
     });
diff --git a/lib/module/components/Menu/Menu.js b/lib/module/components/Menu/Menu.js
index f0c9330d3809654b404bf2f65950905667935ac4..913bd74f863b8e9723a8ac92b79c8a282ab887ee 100644
--- a/lib/module/components/Menu/Menu.js
+++ b/lib/module/components/Menu/Menu.js
@@ -242,7 +242,7 @@ const Menu = ({
       });
     });
   }, [anchor, attachListeners, measureAnchorLayout, theme]);
-  const hide = React.useCallback(() => {
+  const hide = React.useCallback((focus = true) => {
     removeListeners();
     const {
       animation
@@ -259,7 +259,7 @@ const Menu = ({
       });
       setRendered(false);
       prevRendered.current = false;
-      focusFirstDOMNode(anchorRef.current);
+      if (focus) { focusFirstDOMNode(anchorRef.current); }
     });
   }, [removeListeners, theme]);
   const updateVisibility = React.useCallback(async display => {
@@ -271,7 +271,7 @@ const Menu = ({
         return;
       }
       if (!display) {
-        hide();
+        hide(prevRendered.current);
       }
       return;
     });
diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx
index 669656ca652799f9a377acf68eb2d281c45fb9c4..b07e76d3f38b3e554009af7a66392dd57b753224 100644
--- a/src/components/Menu/Menu.tsx
+++ b/src/components/Menu/Menu.tsx
@@ -367,7 +367,7 @@ const Menu = ({
     });
   }, [anchor, attachListeners, measureAnchorLayout, theme]);
 
-  const hide = React.useCallback(() => {
+  const hide = React.useCallback((focus = true) => {
     removeListeners();
 
     const { animation } = theme;
@@ -381,7 +381,7 @@ const Menu = ({
       setMenuLayout({ width: 0, height: 0 });
       setRendered(false);
       prevRendered.current = false;
-      focusFirstDOMNode(anchorRef.current);
+      if (focus) { focusFirstDOMNode(anchorRef.current) };
     });
   }, [removeListeners, theme]);
 
@@ -396,7 +396,7 @@ const Menu = ({
         }
 
         if (!display) {
-          hide();
+          hide(prevRendered.current);
         }
 
         return;

When the menu is rendered, the portal is updated async. This steals the focus as `hide()` focuses the button. Instead only focus when requested, for example when the menu was visible and hiding should focus the trigger.
@SleeplessByte SleeplessByte changed the title Fix focus stealing on render Fix focus stealing on render of Menu Trigger button Jun 6, 2026
@SleeplessByte SleeplessByte changed the title Fix focus stealing on render of Menu Trigger button fix: focus stealing on render of Menu Trigger button Jun 6, 2026
@SleeplessByte

Copy link
Copy Markdown
Contributor Author

FYI this also auto scrolls any scroll container if there is a menu somewhere lower on the page :')

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Rapid open-close transitions can still leave focus on a hidden menu, and regression coverage is missing.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Prevents Menu from stealing focus when initially rendered hidden.

Changes:

  • Makes trigger focusing optional during hide.
  • Focuses the trigger only after a previously rendered menu closes.
File summaries
File Description
src/components/Menu/Menu.tsx Adjusts focus restoration during menu hiding.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


if (!display) {
hide();
hide(prevRendered.current);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If the animation is interrupted, the callback should not run. If the callback still runs on an interrupted animation, that's a separate bug unrelated to this PR and should be solved in a different PR.

setRendered(false);
prevRendered.current = false;
focusFirstDOMNode(anchorRef.current);
if (focus) { focusFirstDOMNode(anchorRef.current) };

if (!display) {
hide();
hide(prevRendered.current);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll leave this one for someone at the company who can take over this PR. It's impossible to run this repository on my windows machine, so cannot run the tests locally nor develop.

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thanks for the PR. can you fix the CI errors and target the PR to the 5.0 branch?

setRendered(false);
prevRendered.current = false;
focusFirstDOMNode(anchorRef.current);
if (focus) { focusFirstDOMNode(anchorRef.current) };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

on a quick open and close, the focus could be lost:

  • menu is opened
  • then closed before open animation finishes
  • both hide and show animation callbacks run
  • since prevRendered.current is read before hide starts and stops the animation, it won't reflect the change to prevRendered.current = true in the show callback, using old value for show

we should check for finished in the animation callbacks to avoid changing focus when animation is interrupted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

See my comment on the AI review regarding the +quick open and close+.

I currently don't have any bandwidth to update this PR, but I left "edits allowed from maintainers" checked, so someone can take it over.

This branch has not been deployed

No deployments
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.

3 participants