Skip to content

[material_ui] Escape doesn't close a MenuAnchor menu opened by a tap or click #195059

Closed
flutter/packages
#13430

Description

@LahaLuhem

What package does this bug report belong to?

material_ui

What target platforms are you seeing this bug on?

Android, iOS, macOS, Linux, Windows

Have you already upgraded your packages?

Yes

Dependency versions

pubspec.lock
  material_ui:
    dependency: "direct main"
    description:
      name: material_ui
      sha256: e4031746f96c8316864d9b92bce375f1320a36ed56e166307e1e1bda9d90b4ab
      url: "https://pub.dev"
    source: hosted
    version: "1.8.0"

Steps to reproduce

  1. Run the code sample with a hardware keyboard.
  2. Click or tap More.
  3. Press Escape.

Expected results

The menu closes, as it does when it was opened from the keyboard, and as PopupMenuButton's menu does after a tap.

Actual results

The menu stays open. A tap moves focus nowhere, so Escape never reaches the anchor's DismissMenuAction. Opening from the keyboard works because Tab has already focused the button.

Same on every platform, and the framework's own MenuAnchor (package:flutter/material.dart, Flutter 3.47.7) behaves identically.

What we found

RawMenuAnchor's open path moves focus exactly once:

if (_isRootOverlayAnchor) {
  widget.childFocusNode?.requestFocus();
}

That is the only thing putting focus under the anchor's DismissMenuAction. childFocusNode is optional, and its doc only mentions arrow-key traversal.

Escape is not unhandled. It resolves to _DismissModalAction, which only pops routes, so it ends as a silent no-op. A menu inside a pushed route leaves that route alone too.

focus after tap Escape
MenuAnchor the route's scope stays open
PopupMenuButton the route's scope closes

Same focus, different outcome. PopupMenuButton pushes a real route, so _DismissModalAction has something to pop. Focus location is not the difference.

Workaround: pass childFocusNode, wired to the anchor's own button.

Code sample

Code sample
import 'package:flutter/widgets.dart';
import 'package:material_ui/material_ui.dart';

void main() => runApp(const MaterialApp(home: MenuPage()));

class MenuPage extends StatelessWidget {
  const MenuPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
    body: Center(
      child: MenuAnchor(
        menuChildren: [MenuItemButton(onPressed: () {}, child: const Text('Share'))],
        builder: (context, controller, _) => TextButton(
          onPressed: () => controller.isOpen ? controller.close() : controller.open(),
          child: const Text('More'),
        ),
      ),
    ),
  );
}
Widget test
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';

// MenuPage from the code sample above.

bool isOpen() => find.text('Share').evaluate().isNotEmpty;

void main() {
  testWidgets('Escape closes a menu opened by a tap', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: MenuPage()));
    await tester.tap(find.text('More'));
    await tester.pumpAndSettle();
    expect(isOpen(), isTrue);

    await tester.sendKeyEvent(LogicalKeyboardKey.escape);
    await tester.pumpAndSettle();

    expect(isOpen(), isFalse); // Fails: the menu is still open.
  }, variant: TargetPlatformVariant.all());

  testWidgets('Escape closes a menu opened from the keyboard', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: MenuPage()));
    await tester.sendKeyEvent(LogicalKeyboardKey.tab);
    await tester.sendKeyEvent(LogicalKeyboardKey.enter);
    await tester.pumpAndSettle();
    expect(isOpen(), isTrue);

    await tester.sendKeyEvent(LogicalKeyboardKey.escape);
    await tester.pumpAndSettle();

    expect(isOpen(), isFalse); // Passes.
  }, variant: TargetPlatformVariant.all());
}

Screenshots or Videos

None, the widget test shows it.

Logs

Logs
$ flutter test
Escape closes a menu opened by a tap (variant: TargetPlatform.android)
Expected: false
  Actual: <true>

The same failure on every other variant. The keyboard test passes on all of them.

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.47.7, on macOS 15.7.9 24G830 darwin-arm64, locale en-US)
    • Framework revision abaf9c5237 (2 weeks ago), 2026-10-08 09:51:21 -0700
    • Engine revision deb287481e
    • Dart version 3.13.5

Activity

  1. added
    p: material_uimaterial_ui package in flutter/packages
    packageflutter/packages repository. See also p: labels.
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-designOwned by Design Languages team
    on Oct 22, 2026
  2. mbcorona commented on Oct 22, 2026

    @mbcorona
    Member

    Hi @LahaLuhem, thanks for the widget test and the precise focus analysis.
    Escape maps to DismissIntent in the menu's traversal shortcuts, wired through Shortcuts and a FocusScope that only takes focus while the menu is open (menu_anchor.dart). Shortcuts only fire when focus is inside that subtree, so a tap or click that never moves focus there leaves Escape with nothing to handle it, exactly as you found.

    Routing to team-design.

  3. LahaLuhem commented on Oct 23, 2026

    @LahaLuhem
    Author

    Refined the body with what the probes showed.

    RawMenuAnchor moves focus on open only through childFocusNode?.requestFocus(), so the whole mechanism is opt-in. And focus does not need to be inside the menu: the keyboard path works with focus on the button. A MenuAnchor whose child is a TextField already works for the same reason.

    That leaves two directions:

    1. Focus the anchor on open unless focus is already under it. Small, no new API, and text-field anchors keep typing.
    2. Move focus into the menu, per the APG menu pattern. Principled, but it needs a combobox opt-out designed first, since it would take focus off a text-field anchor.

    Is 2. already in progress under team-design? I have some ideas otherwise

  4. added
    P2Important issues not at the top of the work list
    triaged-designTriaged by Design Languages team
    on Oct 27, 2026
  5. github-actions commented on Nov 13, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  6. locked as resolved and limited conversation to collaborators on Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

P2Important issues not at the top of the work listhas reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: material_uimaterial_ui package in flutter/packagespackageflutter/packages repository. See also p: labels.team-designOwned by Design Languages teamtriaged-designTriaged by Design Languages team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions