From b29f8f7fb9b4a5ba8f0406bf7c1e3dfe944c3686 Mon Sep 17 00:00:00 2001 From: davidhicks980 <59215665+davidhicks980@users.noreply.github.com> Date: Mon, 3 Feb 2025 18:55:03 -0500 Subject: [PATCH] Implement RawMenuAnchor (#158255) This PR adds a `RawMenuAnchor()` widget to widgets.dart. The purpose of this widget is to provide a menu primitive for the Material and Cupertino libraries (and others) to build upon. Additionally, this PR makes MenuController an inherited widget to simplify nested access to the menu (e.g. if you want to launch a context menu from a deeply-nested widget). This PR: * Centralizes core menu logic to a private class,` _RawMenuAnchor()`, * Provides the internals for interacting with menus: * TapRegion interop * DismissMenuAction handler * Close on scroll/resize * Focus traversal information, if applicable * Subclasses override `_open`, `_close`, `_isOpen`, `_buildAnchor`, and `_menuScopeNode` * State is accessible by descendents via `MenuController.maybeOf(context)._anchor` * Adds 2 public constructors, backed by a `_RawMenuAnchor()` that contains shared logic. * `RawMenuAnchor()` * Users build the overlay from scratch. * Provides anchor/overlay position information and TapRegionGroupId to builder * Does not provide FocusScope management. * `RawMenuAnchorGroup()` * A primitive for menus that do not have overlays (menu bars). * This was previously called RawMenuAnchor.node(), but @dkwingsmt made a good case for splitting out the constructor. Documentation examples have been added, and can be viewed at https://menu-anchor.web.app/ https://github.com/user-attachments/assets/25d35f23-2aad-4d07-9172-5c3fd65d53cf @dkwingsmt List which issues are fixed by this PR. https://github.com/flutter/flutter/pull/143712 Some issues that need to be addressed: Semantics: image I'm basing the menu semantics off of the comment [here](https://github.com/flutter/engine/blob/ef3ca70db20230436b6defe5b4cdb0d242deea96/lib/web_ui/lib/src/engine/semantics/semantics.dart#L382), but I'm unsure whether the route should be given a name. There is no menubar/menu/menuitem role in Flutter, so I'm assuming the menu should be composed of nested dialogs Unlike the menubar pattern from [W3C](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/), the RawMenuAnchor - does not close on tab/shift-tab. I left this behavior out of the menu so that users could customize tab behavior, but I'm not opinionated either way - does not open on ArrowUp/ArrowDown, because this could interfere with user focus behavior in unconventional menu setups (e.g. a vertical menu). - does not automatically focus the first item in a menu overlay when activated via enter/spacebar, but does focus the first item when horizontal traversal opens a submenu. Automatically focusing the first item whenever an overlay opens interferes with hover traversal, and I couldn't think of a good way to only focus the first item when an overlay is triggered via enter/spacebar. - doesn't focus disabled items (I wasn't sure how to address this without editing MenuItemButton) While it is possible to nest menus -- for example, a dropdown anchor within a full-app context menu area -- nested menus behave as a single group. I was considering adding an additional parameter that separates nested root menus from their parents, and am interested to hear your feedback. *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].* ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Bruno Leroux Co-authored-by: chunhtai <47866232+chunhtai@users.noreply.github.com> --- .../raw_menu_anchor/raw_menu_anchor.0.dart | 182 ++ .../raw_menu_anchor/raw_menu_anchor.1.dart | 262 ++ .../raw_menu_anchor.0_test.dart | 101 + .../raw_menu_anchor.1_test.dart | 153 + .../flutter/lib/src/material/menu_anchor.dart | 727 ++--- .../lib/src/widgets/raw_menu_anchor.dart | 900 ++++++ packages/flutter/lib/widgets.dart | 1 + .../test/material/menu_anchor_test.dart | 1845 ++++++------ .../test/widgets/raw_menu_anchor_test.dart | 2577 +++++++++++++++++ 9 files changed, 5399 insertions(+), 1349 deletions(-) create mode 100644 examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart create mode 100644 examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart create mode 100644 examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.0_test.dart create mode 100644 examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.1_test.dart create mode 100644 packages/flutter/lib/src/widgets/raw_menu_anchor.dart create mode 100644 packages/flutter/test/widgets/raw_menu_anchor_test.dart diff --git a/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart new file mode 100644 index 0000000000..44642060d7 --- /dev/null +++ b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart @@ -0,0 +1,182 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for a [RawMenuAnchor] that demonstrates +/// how to create a simple menu. +void main() { + runApp(const RawMenuAnchorApp()); +} + +enum Animal { + cat('Cat', leading: Text('🦁')), + kitten('Kitten', leading: Text('🐱')), + felisCatus('Felis catus', leading: Text('🐈')), + dog('Dog', leading: Text('🐕')); + + const Animal(this.label, {this.leading}); + final String label; + final Widget? leading; +} + +class RawMenuAnchorExample extends StatefulWidget { + const RawMenuAnchorExample({super.key}); + + @override + State createState() => _RawMenuAnchorExampleState(); +} + +class _RawMenuAnchorExampleState extends State { + final FocusNode focusNode = FocusNode(); + final MenuController controller = MenuController(); + Animal? _selectedAnimal; + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return UnconstrainedBox( + clipBehavior: Clip.hardEdge, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Favorite Animal:', style: theme.textTheme.titleMedium), + const SizedBox(width: 8), + CustomMenu( + controller: controller, + focusNode: focusNode, + anchor: FilledButton( + focusNode: focusNode, + style: FilledButton.styleFrom(fixedSize: const Size(172, 36)), + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(flex: 3, child: Text(_selectedAnimal?.label ?? 'Select One')), + const Flexible(child: Icon(Icons.arrow_drop_down, size: 16)), + ], + ), + ), + children: [ + for (final Animal animal in Animal.values) + MenuItemButton( + autofocus: _selectedAnimal == animal, + onPressed: () { + setState(() { + _selectedAnimal = animal; + }); + controller.close(); + }, + leadingIcon: SizedBox(width: 24, child: Center(child: animal.leading)), + trailingIcon: + _selectedAnimal == animal ? const Icon(Icons.check, size: 20) : null, + child: Text(animal.label), + ), + ], + ), + ], + ), + ); + } +} + +class CustomMenu extends StatelessWidget { + const CustomMenu({ + super.key, + required this.children, + required this.anchor, + required this.controller, + required this.focusNode, + }); + + final List children; + final Widget anchor; + final MenuController controller; + final FocusNode focusNode; + + static const Map _shortcuts = { + SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + }; + + @override + Widget build(BuildContext context) { + return RawMenuAnchor( + controller: controller, + childFocusNode: focusNode, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) { + return Positioned( + top: info.anchorRect.bottom + 4, + left: info.anchorRect.left, + // The overlay will be treated as a dialog. + child: Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: TapRegion( + groupId: info.tapRegionGroupId, + onTapOutside: (PointerDownEvent event) { + MenuController.maybeOf(context)?.close(); + }, + child: FocusScope( + child: IntrinsicWidth( + child: Container( + clipBehavior: Clip.antiAlias, + constraints: const BoxConstraints(minWidth: 168), + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6), + boxShadow: kElevationToShadow[4], + ), + child: Shortcuts(shortcuts: _shortcuts, child: Column(children: children)), + ), + ), + ), + ), + ), + ); + }, + child: anchor, + ); + } +} + +class RawMenuAnchorApp extends StatelessWidget { + const RawMenuAnchorApp({super.key}); + + static const ButtonStyle menuButtonStyle = ButtonStyle( + overlayColor: WidgetStatePropertyAll(Color.fromARGB(55, 139, 195, 255)), + iconSize: WidgetStatePropertyAll(17), + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)), + ); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.from( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + dynamicSchemeVariant: DynamicSchemeVariant.vibrant, + ), + ).copyWith(menuButtonTheme: const MenuButtonThemeData(style: menuButtonStyle)), + home: const Scaffold(body: Center(child: RawMenuAnchorExample())), + ); + } +} diff --git a/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart new file mode 100644 index 0000000000..cbf44c8567 --- /dev/null +++ b/examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart @@ -0,0 +1,262 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Flutter code sample for a [RawMenuAnchorGroup] that demonstrates +/// how to create a menu bar for a document editor. +void main() => runApp(const RawMenuAnchorGroupApp()); + +class MenuItem { + const MenuItem(this.label, {this.leading, this.children}); + final String label; + final Widget? leading; + final List? children; +} + +const List menuItems = [ + MenuItem( + 'File', + children: [ + MenuItem('New', leading: Icon(Icons.edit_document)), + MenuItem('Open', leading: Icon(Icons.folder)), + MenuItem('Print', leading: Icon(Icons.print)), + MenuItem('Share', leading: Icon(Icons.share)), + ], + ), + MenuItem( + 'Edit', + children: [ + MenuItem('Undo', leading: Icon(Icons.undo)), + MenuItem('Redo', leading: Icon(Icons.redo)), + MenuItem('Cut', leading: Icon(Icons.cut)), + MenuItem('Copy', leading: Icon(Icons.copy)), + MenuItem('Paste', leading: Icon(Icons.paste)), + ], + ), + MenuItem( + 'View', + children: [ + MenuItem('Zoom In', leading: Icon(Icons.zoom_in)), + MenuItem('Zoom Out', leading: Icon(Icons.zoom_out)), + MenuItem('Fit', leading: Icon(Icons.fullscreen)), + ], + ), + MenuItem( + 'Tools', + children: [ + MenuItem('Spelling', leading: Icon(Icons.spellcheck)), + MenuItem('Grammar', leading: Icon(Icons.text_format)), + MenuItem('Thesaurus', leading: Icon(Icons.book_outlined)), + MenuItem('Dictionary', leading: Icon(Icons.book)), + ], + ), +]; + +class RawMenuAnchorGroupExample extends StatefulWidget { + const RawMenuAnchorGroupExample({super.key}); + + @override + State createState() => _RawMenuAnchorGroupExampleState(); +} + +class _RawMenuAnchorGroupExampleState extends State { + final MenuController controller = MenuController(); + MenuItem? _selected; + List focusNodes = List.generate( + menuItems.length, + (int index) => FocusNode(), + ); + + @override + void dispose() { + for (final FocusNode focusNode in focusNodes) { + focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TextStyle titleStyle = theme.textTheme.titleMedium!; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_selected != null) Text('Selected: ${_selected!.label}', style: titleStyle), + UnconstrainedBox( + clipBehavior: Clip.hardEdge, + child: RawMenuAnchorGroup( + controller: controller, + child: Row( + children: [ + for (int i = 0; i < menuItems.length; i++) + CustomSubmenu( + focusNode: focusNodes[i], + anchor: Builder( + builder: (BuildContext context) { + final MenuController submenuController = MenuController.maybeOf(context)!; + final MenuItem item = menuItems[i]; + final ButtonStyle openBackground = MenuItemButton.styleFrom( + backgroundColor: const Color(0x0D1A1A1A), + ); + return MergeSemantics( + child: Semantics( + expanded: controller.isOpen, + child: MenuItemButton( + style: submenuController.isOpen ? openBackground : null, + onHover: (bool value) { + // If any submenu in the menu bar is already open, other + // submenus should open on hover. Otherwise, blur the menu item + // button if the menu button is no longer hovered. + if (controller.isOpen) { + if (value) { + submenuController.open(); + } + } else if (!value) { + Focus.of(context).unfocus(); + } + }, + onPressed: () { + if (submenuController.isOpen) { + submenuController.close(); + } else { + submenuController.open(); + } + }, + leadingIcon: item.leading, + child: Text(item.label), + ), + ), + ); + }, + ), + children: [ + for (final MenuItem child in menuItems[i].children ?? []) + MenuItemButton( + onPressed: () { + setState(() { + _selected = child; + }); + + // Close the menu bar after a selection. + controller.close(); + }, + leadingIcon: child.leading, + child: Text(child.label), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class CustomSubmenu extends StatefulWidget { + const CustomSubmenu({ + super.key, + required this.children, + required this.anchor, + required this.focusNode, + }); + + final List children; + final Widget anchor; + final FocusNode focusNode; + + static const Map _shortcuts = { + SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + }; + + @override + State createState() => _CustomSubmenuState(); +} + +class _CustomSubmenuState extends State { + final MenuController menuController = MenuController(); + + @override + Widget build(BuildContext context) { + return RawMenuAnchor( + controller: menuController, + childFocusNode: widget.focusNode, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) { + return Positioned( + top: info.anchorRect.bottom + 4, + left: info.anchorRect.left, + // The overlay will be treated as a dialog. + child: Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: TapRegion( + groupId: info.tapRegionGroupId, + onTapOutside: (PointerDownEvent event) { + MenuController.maybeOf(context)?.close(); + }, + child: FocusScope( + child: IntrinsicWidth( + child: Container( + clipBehavior: Clip.antiAlias, + constraints: const BoxConstraints(minWidth: 160), + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6), + boxShadow: kElevationToShadow[4], + ), + child: Shortcuts( + shortcuts: CustomSubmenu._shortcuts, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.children, + ), + ), + ), + ), + ), + ), + ), + ); + }, + child: widget.anchor, + ); + } +} + +class RawMenuAnchorGroupApp extends StatelessWidget { + const RawMenuAnchorGroupApp({super.key}); + + static const ButtonStyle menuButtonStyle = ButtonStyle( + splashFactory: InkSparkle.splashFactory, + iconSize: WidgetStatePropertyAll(17), + overlayColor: WidgetStatePropertyAll(Color(0x0D1A1A1A)), + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)), + textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)), + visualDensity: VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + ); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.from( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ).copyWith(menuButtonTheme: const MenuButtonThemeData(style: menuButtonStyle)), + home: const Scaffold(body: RawMenuAnchorGroupExample()), + ); + } +} diff --git a/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.0_test.dart b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.0_test.dart new file mode 100644 index 0000000000..890be1b1fe --- /dev/null +++ b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.0_test.dart @@ -0,0 +1,101 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_api_samples/widgets/raw_menu_anchor/raw_menu_anchor.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +String get catLabel => example.Animal.cat.label; +String get kittenLabel => example.Animal.kitten.label; +String get felisCatusLabel => example.Animal.felisCatus.label; +String get dogLabel => example.Animal.dog.label; + +void main() { + testWidgets('Menu opens and closes', (WidgetTester tester) async { + await tester.pumpWidget(const example.RawMenuAnchorApp()); + final Finder button = find.text('Select One'); + + // Open the menu. + await tester.tap(button); + await tester.pump(); + + expect(find.text(catLabel), findsOneWidget); + expect(find.text(kittenLabel), findsOneWidget); + expect(find.text(felisCatusLabel), findsOneWidget); + expect(find.text(dogLabel), findsOneWidget); + expect( + tester.getRect(find.ancestor(of: find.text(catLabel), matching: find.byType(TapRegion))), + rectMoreOrLessEquals(const Rect.fromLTRB(447.2, 328.0, 662.3, 532.0), epsilon: 0.1), + ); + + // Close the menu. + await tester.tap(button); + await tester.pump(); + + expect(find.text(catLabel), findsNothing); + expect(find.text(kittenLabel), findsNothing); + expect(find.text(felisCatusLabel), findsNothing); + expect(find.text(dogLabel), findsNothing); + }); + + testWidgets('Can traverse menu', (WidgetTester tester) async { + await tester.pumpWidget(const example.RawMenuAnchorApp()); + + await tester.tap(find.text('Select One')); + await tester.pump(); + + expect(find.text(catLabel), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(primaryFocus?.debugLabel, contains(kittenLabel)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Felis catus + + expect(primaryFocus?.debugLabel, contains(felisCatusLabel)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // Dog + + expect(primaryFocus?.debugLabel, contains(dogLabel)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // Felis catus + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // Kitten + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // Cat + await tester.sendKeyEvent(LogicalKeyboardKey.enter); // Select Cat + await tester.pump(); + await tester.pump(); + + expect(find.text(catLabel), findsOneWidget); + expect(find.text(felisCatusLabel), findsNothing); + }); + + testWidgets('Check appears next to selected item', (WidgetTester tester) async { + await tester.pumpWidget(const example.RawMenuAnchorApp()); + + await tester.tap(find.text('Select One')); + await tester.pump(); + + // Select Kitten + await tester.tap(find.text(kittenLabel)); + await tester.pump(); + await tester.pump(); + + expect(find.text(catLabel), findsNothing); + + await tester.tap(find.text(kittenLabel)); + await tester.pump(); + + expect( + tester.getRect(find.ancestor(of: find.text(catLabel), matching: find.byType(TapRegion))), + rectMoreOrLessEquals(const Rect.fromLTRB(447.2, 328.0, 662.3, 532.0), epsilon: 0.1), + ); + + expect( + tester.widget(find.widgetWithIcon(MenuItemButton, Icons.check)), + equals(tester.widget(find.widgetWithText(MenuItemButton, kittenLabel))), + ); + }); +} diff --git a/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.1_test.dart b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.1_test.dart new file mode 100644 index 0000000000..c50b26f31e --- /dev/null +++ b/examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.1_test.dart @@ -0,0 +1,153 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_api_samples/widgets/raw_menu_anchor/raw_menu_anchor.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +Future hoverOver(WidgetTester tester, Offset location) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.moveTo(location); + await tester.pumpAndSettle(); + return gesture; +} + +void main() { + testWidgets('Initializes with correct number of menu items in expected position', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RawMenuAnchorGroupApp()); + expect(find.byType(RawMenuAnchorGroup).evaluate().length, 1); + for (final example.MenuItem item in example.menuItems) { + expect(find.text(item.label), findsOneWidget); + } + expect(find.byType(RawMenuAnchor).evaluate().length, 4); + expect( + tester.getRect(find.byType(RawMenuAnchorGroup).first), + const Rect.fromLTRB(233.0, 284.0, 567.0, 316.0), + ); + }); + testWidgets('Menu can be traversed', (WidgetTester tester) async { + await tester.pumpWidget(const example.RawMenuAnchorGroupApp()); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + expect(primaryFocus?.debugLabel, contains('File')); + expect(find.text('New'), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(); + + expect(primaryFocus?.debugLabel, contains('File')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(primaryFocus?.debugLabel, contains('New')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(primaryFocus?.debugLabel, contains('New')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(primaryFocus?.debugLabel, contains('New')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(primaryFocus?.debugLabel, contains('New')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(primaryFocus?.debugLabel, contains('Share')); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(primaryFocus?.debugLabel, contains('File')); + expect(find.text('Share'), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(primaryFocus?.debugLabel, contains('Tools')); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(primaryFocus?.debugLabel, contains('Spelling')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(primaryFocus?.debugLabel, contains('Grammar')); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(); + expect(find.text('Selected: Grammar'), findsOneWidget); + }); + + testWidgets('Hover traversal opens submenus when the root menu is open', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RawMenuAnchorGroupApp()); + + await hoverOver(tester, tester.getCenter(find.text('File'))); + await tester.pump(); + + expect(find.text('New'), findsNothing); + + await tester.tap(find.text('File')); + await tester.pump(); + await tester.pump(); + + expect(find.text('New'), findsOneWidget); + + await hoverOver(tester, tester.getCenter(find.text('Tools'))); + await tester.pump(); + + expect(find.text('Spelling'), findsOneWidget); + + await hoverOver(tester, Offset.zero); + await tester.pump(); + + expect(find.text('Spelling'), findsOneWidget); + expect( + WidgetsBinding.instance.focusManager.primaryFocus?.debugLabel, + 'MenuItemButton(Text("Tools"))', + ); + + await hoverOver(tester, tester.getCenter(find.text('Tools'))); + await tester.tap(find.text('Tools')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Spelling'), findsNothing); + expect( + WidgetsBinding.instance.focusManager.primaryFocus?.debugLabel, + 'MenuItemButton(Text("Tools"))', + ); + + await hoverOver(tester, Offset.zero); + await tester.pump(); + + expect( + WidgetsBinding.instance.focusManager.primaryFocus?.debugLabel, + isNot('MenuItemButton(Text("Tools"))'), + ); + }); +} diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index 737dd88689..e64cbb4c52 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -98,6 +98,18 @@ const double _kTopLevelMenuHorizontalMinPadding = 4; typedef MenuAnchorChildBuilder = Widget Function(BuildContext context, MenuController controller, Widget? child); +class _MenuAnchorScope extends InheritedWidget { + const _MenuAnchorScope({required this.state, required super.child}); + + final _MenuAnchorState state; + + @override + bool updateShouldNotify(_MenuAnchorScope oldWidget) { + assert(oldWidget.state == state, 'The state of a MenuAnchor should not change.'); + return false; + } +} + /// A widget used to mark the "anchor" for a set of submenus, defining the /// rectangle used to position the menu, which can be done either with an /// explicit location, or with an alignment. @@ -152,6 +164,7 @@ class MenuAnchor extends StatefulWidget { this.onOpen, this.onClose, this.crossAxisUnconstrained = true, + this.useRootOverlay = false, required this.menuChildren, this.builder, this.child, @@ -266,6 +279,11 @@ class MenuAnchor extends StatefulWidget { /// be constrained in both main axis and cross axis, such as a [DropdownMenu]. final bool crossAxisUnconstrained; + /// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay} + /// + /// Defaults to false. + final bool useRootOverlay; + /// A list of children containing the menu items that are the contents of the /// menu surrounded by this [MenuAnchor]. /// @@ -314,191 +332,73 @@ class MenuAnchor extends StatefulWidget { } class _MenuAnchorState extends State { - // This is the global key that is used later to determine the bounding rect - // for the anchor's region that the CustomSingleChildLayout's delegate - // uses to determine where to place the menu on the screen and to avoid the - // view's edges. - final GlobalKey<_MenuAnchorState> _anchorKey = GlobalKey<_MenuAnchorState>( - debugLabel: kReleaseMode ? null : 'MenuAnchor', - ); - _MenuAnchorState? _parent; - late final FocusScopeNode _menuScopeNode; - MenuController? _internalMenuController; - final List<_MenuAnchorState> _anchorChildren = <_MenuAnchorState>[]; - ScrollPosition? _scrollPosition; - Size? _viewSize; - final OverlayPortalController _overlayController = OverlayPortalController( - debugLabel: kReleaseMode ? null : 'MenuAnchor controller', - ); - Offset? _menuPosition; Axis get _orientation => Axis.vertical; - bool get _isOpen => _overlayController.isShowing; - bool get _isRoot => _parent == null; MenuController get _menuController => widget.controller ?? _internalMenuController!; + MenuController? _internalMenuController; + final FocusScopeNode _menuScopeNode = FocusScopeNode(); + _MenuAnchorState? get _parent => _MenuAnchorState._maybeOf(context); @override void initState() { super.initState(); - _menuScopeNode = FocusScopeNode( - debugLabel: kReleaseMode ? null : '${describeIdentity(this)} Sub Menu', - ); if (widget.controller == null) { _internalMenuController = MenuController(); } - _menuController._attach(this); - } - - @override - void dispose() { - assert(_debugMenuInfo('Disposing of $this')); - if (_isOpen) { - _close(inDispose: true); - } - - _parent?._removeChild(this); - _parent = null; - _anchorChildren.clear(); - _menuController._detach(this); - _internalMenuController = null; - _menuScopeNode.dispose(); - super.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final _MenuAnchorState? newParent = _MenuAnchorState._maybeOf(context); - if (newParent != _parent) { - _parent?._removeChild(this); - _parent = newParent; - _parent?._addChild(this); - } - _scrollPosition?.isScrollingNotifier.removeListener(_handleScroll); - _scrollPosition = Scrollable.maybeOf(context)?.position; - _scrollPosition?.isScrollingNotifier.addListener(_handleScroll); - final Size newSize = MediaQuery.sizeOf(context); - if (_viewSize != null && newSize != _viewSize) { - // Close the menus if the view changes size. - _root._close(); - } - _viewSize = newSize; } @override void didUpdateWidget(MenuAnchor oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { - oldWidget.controller?._detach(this); - if (widget.controller != null) { - _internalMenuController?._detach(this); - _internalMenuController = null; - widget.controller?._attach(this); - } else { - assert(_internalMenuController == null); - _internalMenuController = MenuController().._attach(this); - } + _internalMenuController = widget.controller != null ? MenuController() : null; } - assert(_menuController._anchor == this); + } + + @override + void dispose() { + assert(_debugMenuInfo('Disposing of $this')); + _internalMenuController = null; + _menuScopeNode.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - Widget contents = _buildContents(context); - if (widget.layerLink != null) { - contents = CompositedTransformTarget(link: widget.layerLink!, child: contents); - } - - Widget child = OverlayPortal( - controller: _overlayController, - overlayChildBuilder: (BuildContext context) { - return _Submenu( - anchor: this, - layerLink: widget.layerLink, - menuStyle: widget.style, - alignmentOffset: widget.alignmentOffset ?? Offset.zero, - menuPosition: _menuPosition, - clipBehavior: widget.clipBehavior, - menuChildren: widget.menuChildren, - crossAxisUnconstrained: widget.crossAxisUnconstrained, - ); - }, - child: contents, - ); - - if (!widget.anchorTapClosesMenu) { - child = TapRegion( - groupId: _root, - consumeOutsideTaps: _root._isOpen && widget.consumeOutsideTap, - onTapOutside: (PointerDownEvent event) { - assert(_debugMenuInfo('Tapped Outside ${widget.controller}')); - _closeChildren(); - }, - child: child, - ); - } - - // This `Shortcuts` is needed so that shortcuts work when the focus is on - // MenuAnchor (specifically, the root menu, since submenus have their own - // `Shortcuts`). - return Shortcuts( - shortcuts: _kMenuTraversalShortcuts, - // Ignore semantics here and since the same information is typically - // also provided by the children. - includeSemantics: false, - child: _MenuAnchorScope(anchorKey: _anchorKey, anchor: this, isOpen: _isOpen, child: child), - ); - } - - Widget _buildContents(BuildContext context) { - return Actions( - actions: >{ - DismissIntent: DismissMenuAction(controller: _menuController), - }, - child: Builder( - key: _anchorKey, - builder: (BuildContext context) { - return widget.builder?.call(context, _menuController, widget.child) ?? - widget.child ?? - const SizedBox(); - }, + final Widget child = _MenuAnchorScope( + state: this, + child: RawMenuAnchor( + useRootOverlay: widget.useRootOverlay, + onOpen: widget.onOpen, + onClose: widget.onClose, + consumeOutsideTaps: widget.consumeOutsideTap, + controller: _menuController, + childFocusNode: widget.childFocusNode, + overlayBuilder: _buildOverlay, + builder: widget.builder, + child: widget.child, ), ); - } - // Returns the first focusable item in the submenu, where "first" is - // determined by the focus traversal policy. - FocusNode? get _firstItemFocusNode { - if (_menuScopeNode.context == null) { - return null; + if (widget.layerLink == null) { + return child; } - final FocusTraversalPolicy policy = - FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy(); - return policy.findFirstFocus(_menuScopeNode, ignoreCurrentFocus: true); + + return CompositedTransformTarget(link: widget.layerLink!, child: child); } - FocusNode? get _lastItemFocusNode { - if (_menuScopeNode.context == null) { - return null; - } - final FocusTraversalPolicy policy = - FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy(); - return policy.findLastFocus(_menuScopeNode, ignoreCurrentFocus: true); - } - - void _addChild(_MenuAnchorState child) { - assert(_isRoot || _debugMenuInfo('Added root child: $child')); - assert(!_anchorChildren.contains(child)); - _anchorChildren.add(child); - assert(_debugMenuInfo('Added:\n${child.widget.toStringDeep()}')); - assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); - } - - void _removeChild(_MenuAnchorState child) { - assert(_isRoot || _debugMenuInfo('Removed root child: $child')); - assert(_anchorChildren.contains(child)); - assert(_debugMenuInfo('Removing:\n${child.widget.toStringDeep()}')); - _anchorChildren.remove(child); - assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); + Widget _buildOverlay(BuildContext context, RawMenuOverlayInfo position) { + return _Submenu( + layerLink: widget.layerLink, + consumeOutsideTaps: widget.consumeOutsideTap, + menuScopeNode: _menuScopeNode, + menuStyle: widget.style, + clipBehavior: widget.clipBehavior, + menuChildren: widget.menuChildren, + crossAxisUnconstrained: widget.crossAxisUnconstrained, + menuPosition: position, + anchor: this, + alignmentOffset: widget.alignmentOffset ?? Offset.zero, + ); } _MenuAnchorState get _root { @@ -509,22 +409,6 @@ class _MenuAnchorState extends State { return anchor; } - void _childChangedOpenState() { - _parent?._childChangedOpenState(); - assert(mounted); - if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { - setState(() { - // Mark dirty now, but only if not in a build. - }); - } else { - SchedulerBinding.instance.addPostFrameCallback((Duration _) { - setState(() { - // Mark dirty after this frame, but only if in a build. - }); - }); - } - } - void _focusButton() { if (widget.childFocusNode == null) { return; @@ -533,102 +417,30 @@ class _MenuAnchorState extends State { widget.childFocusNode!.requestFocus(); } - void _handleScroll() { - // If an ancestor scrolls, and we're a root anchor, then close the menus. - // Don't just close it on *any* scroll, since we want to be able to scroll - // menus themselves if they're too big for the view. - if (_isRoot) { - _close(); - } - } - - /// Open the menu, optionally at a position relative to the [MenuAnchor]. - /// - /// Call this when the menu should be shown to the user. - /// - /// The optional `position` argument will specify the location of the menu in - /// the local coordinates of the [MenuAnchor], ignoring any - /// [MenuStyle.alignment] and/or [MenuAnchor.alignmentOffset] that were - /// specified. - void _open({Offset? position}) { - assert(_menuController._anchor == this); - if (_isOpen && position == null) { - assert(_debugMenuInfo("Not opening $this because it's already open")); + void _focusFirstMenuItem() { + if (_menuScopeNode.context?.mounted != true) { return; } - if (_isOpen && position != null) { - // The menu is already open, but we need to move to another location, so - // close it first. - _close(); - } - assert( - _debugMenuInfo( - 'Opening $this at ${position ?? Offset.zero} with alignment offset ${widget.alignmentOffset ?? Offset.zero}', - ), - ); - _parent?._closeChildren(); // Close all siblings. - assert(!_overlayController.isShowing); - - _parent?._childChangedOpenState(); - _menuPosition = position; - _overlayController.show(); - - if (_isRoot) { - _focusButton(); - } - - widget.onOpen?.call(); - if (mounted && SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { - setState(() { - // Mark dirty to ensure UI updates - }); + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy(); + final FocusNode? firstFocus = policy.findFirstFocus(_menuScopeNode, ignoreCurrentFocus: true); + if (firstFocus != null) { + firstFocus.requestFocus(); } } - /// Close the menu. - /// - /// Call this when the menu should be closed. Has no effect if the menu is - /// already closed. - void _close({bool inDispose = false}) { - assert(_debugMenuInfo('Closing $this')); - if (!_isOpen) { + void _focusLastMenuItem() { + if (_menuScopeNode.context?.mounted != true) { return; } - _closeChildren(inDispose: inDispose); - // Don't hide if we're in the middle of a build. - if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { - _overlayController.hide(); - } else if (!inDispose) { - SchedulerBinding.instance.addPostFrameCallback((_) { - _overlayController.hide(); - }, debugLabel: 'MenuAnchor.hide'); - } - if (!inDispose) { - // Notify that _childIsOpen changed state, but only if not - // currently disposing. - _parent?._childChangedOpenState(); - widget.onClose?.call(); - if (mounted && - SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { - setState(() { - // Mark dirty, but only if mounted and not in a build. - }); - } - } + final FocusTraversalPolicy policy = + FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy(); + final FocusNode lastFocus = policy.findLastFocus(_menuScopeNode, ignoreCurrentFocus: true); + lastFocus.requestFocus(); } - void _closeChildren({bool inDispose = false}) { - assert(_debugMenuInfo('Closing children of $this${inDispose ? ' (dispose)' : ''}')); - for (final _MenuAnchorState child in List<_MenuAnchorState>.from(_anchorChildren)) { - child._close(inDispose: inDispose); - } - } - - // Returns the active anchor in the given context, if any, and creates a - // dependency relationship that will rebuild the context when the node - // changes. static _MenuAnchorState? _maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType<_MenuAnchorScope>()?.anchor; + return context.getInheritedWidgetOfExactType<_MenuAnchorScope>()?.state; } @override @@ -637,71 +449,6 @@ class _MenuAnchorState extends State { } } -/// A controller to manage a menu created by a [MenuBar] or [MenuAnchor]. -/// -/// A [MenuController] is used to control and interrogate a menu after it has -/// been created, with methods such as [open] and [close], and state accessors -/// like [isOpen]. -/// -/// See also: -/// -/// * [MenuAnchor], a widget that defines a region that has submenu. -/// * [MenuBar], a widget that creates a menu bar, that can take an optional -/// [MenuController]. -/// * [SubmenuButton], a widget that has a button that manages a submenu. -class MenuController { - /// The anchor that this controller controls. - /// - /// This is set automatically when a [MenuController] is given to the anchor - /// it controls. - _MenuAnchorState? _anchor; - - /// Whether or not the associated menu is currently open. - bool get isOpen { - return _anchor?._isOpen ?? false; - } - - /// Close the menu that this menu controller is associated with. - /// - /// Associating with a menu is done by passing a [MenuController] to a - /// [MenuAnchor]. A [MenuController] is also be received by the - /// [MenuAnchor.builder] when invoked. - /// - /// If the menu's anchor point (either a [MenuBar] or a [MenuAnchor]) is - /// scrolled by an ancestor, or the view changes size, then any open menu will - /// automatically close. - void close() { - _anchor?._close(); - } - - /// Opens the menu that this menu controller is associated with. - /// - /// If `position` is given, then the menu will open at the position given, in - /// the coordinate space of the [MenuAnchor] this controller is attached to. - /// - /// If given, the `position` will override the [MenuAnchor.alignmentOffset] - /// given to the [MenuAnchor]. - /// - /// If the menu's anchor point (either a [MenuBar] or a [MenuAnchor]) is - /// scrolled by an ancestor, or the view changes size, then any open menu will - /// automatically close. - void open({Offset? position}) { - assert(_anchor != null); - _anchor!._open(position: position); - } - - // ignore: use_setters_to_change_properties - void _attach(_MenuAnchorState anchor) { - _anchor = anchor; - } - - void _detach(_MenuAnchorState anchor) { - if (_anchor == anchor) { - _anchor = null; - } - } -} - /// A menu bar that manages cascading child menus. /// /// This is a Material Design menu bar that typically resides above the main @@ -1199,7 +946,7 @@ class _MenuItemButtonState extends State { void _handleFocusChange() { if (!_focusNode.hasPrimaryFocus) { // Close any child menus of this button's menu. - _MenuAnchorState._maybeOf(context)?._closeChildren(); + MenuController.maybeOf(context)?.closeChildren(); } } @@ -1230,7 +977,7 @@ class _MenuItemButtonState extends State { void _handleSelect() { assert(_debugMenuInfo('Selected ${widget.child} menu')); if (widget.closeOnActivate) { - _MenuAnchorState._maybeOf(context)?._root._close(); + _anchor?._root._menuController.close(); } // Delay the call to onPressed until post-frame so that the focus is // restored to what it was before the menu was opened before the action is @@ -1693,6 +1440,7 @@ class SubmenuButton extends StatefulWidget { this.leadingIcon, this.trailingIcon, this.submenuIcon, + this.useRootOverlay = false, required this.menuChildren, required this.child, }); @@ -1772,6 +1520,11 @@ class SubmenuButton extends StatefulWidget { /// An optional icon to display after the [child]. final Widget? trailingIcon; + /// {@macro flutter.widgets.RawMenuAnchor.useRootOverlay} + /// + /// Defaults to false. + final bool useRootOverlay; + /// The list of widgets that appear in the menu when it is opened. /// /// These can be any widget, but are typically either [MenuItemButton] or @@ -1928,7 +1681,9 @@ class _SubmenuButtonState extends State { MenuController? _internalMenuController; MenuController get _menuController => widget.controller ?? _internalMenuController!; _MenuAnchorState? get _parent => _MenuAnchorState._maybeOf(context); + _MenuAnchorState? get _anchorState => _anchorKey.currentState; FocusNode? _internalFocusNode; + final GlobalKey<_MenuAnchorState> _anchorKey = GlobalKey<_MenuAnchorState>(); FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!; bool get _enabled => widget.menuChildren.isNotEmpty; bool _isHovered = false; @@ -2011,6 +1766,7 @@ class _SubmenuButtonState extends State { return Actions( actions: actions, child: MenuAnchor( + key: _anchorKey, controller: _menuController, childFocusNode: _buttonFocusNode, alignmentOffset: menuPaddingOffset, @@ -2018,6 +1774,7 @@ class _SubmenuButtonState extends State { onClose: _onClose, onOpen: _onOpen, style: widget.menuStyle, + useRootOverlay: widget.useRootOverlay, builder: (BuildContext context, MenuController controller, Widget? child) { // Since we don't want to use the theme style or default style from the // TextButton, we merge the styles, merging them in the right order when @@ -2029,7 +1786,7 @@ class _SubmenuButtonState extends State { mergedStyle = widget.style?.merge(mergedStyle) ?? mergedStyle; void toggleShowMenu() { - if (controller._anchor == null) { + if (!mounted) { return; } if (controller.isOpen) { @@ -2054,17 +1811,17 @@ class _SubmenuButtonState extends State { if (!_isHovered) { _isHovered = true; widget.onHover?.call(true); + final _MenuAnchorState root = _MenuAnchorState._maybeOf(context)!._root; // Don't open the root menu bar menus on hover unless something else // is already open. This means that the user has to first click to // open a menu on the menu bar before hovering allows them to traverse // it. - if (controller._anchor!._root._orientation == Axis.horizontal && - !controller._anchor!._root._isOpen) { + if (root._orientation == Axis.horizontal && !root._menuController.isOpen) { return; } controller.open(); - controller._anchor!._focusButton(); + _buttonFocusNode.requestFocus(); } } @@ -2081,9 +1838,7 @@ class _SubmenuButtonState extends State { leadingIcon: widget.leadingIcon, trailingIcon: widget.trailingIcon, hasSubmenu: true, - showDecoration: - (controller._anchor!._parent?._orientation ?? Axis.horizontal) == - Axis.vertical, + showDecoration: (_parent?._orientation ?? Axis.horizontal) == Axis.vertical, submenuIcon: submenuIcon, child: child, ), @@ -2132,8 +1887,10 @@ class _SubmenuButtonState extends State { void _onOpen() { if (!_waitingToFocusMenu) { SchedulerBinding.instance.addPostFrameCallback((_) { - _menuController._anchor?._focusButton(); - _waitingToFocusMenu = false; + if (mounted) { + _buttonFocusNode.requestFocus(); + _waitingToFocusMenu = false; + } }, debugLabel: 'MenuAnchor.focus'); _waitingToFocusMenu = true; } @@ -2159,7 +1916,7 @@ class _SubmenuButtonState extends State { _menuController.open(); } } else { - if (!_menuController._anchor!._menuScopeNode.hasFocus && _menuController.isOpen) { + if (!_anchorState!._menuScopeNode.hasFocus && _menuController.isOpen) { _menuController.close(); } } @@ -2170,17 +1927,16 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { _SubmenuDirectionalFocusAction({required this.submenu}); final _SubmenuButtonState submenu; - - _MenuAnchorState get _anchor => submenu._menuController._anchor!; - FocusNode get _buttonFocusNode => submenu._buttonFocusNode; - _MenuAnchorState? get _parent => _anchor._parent; - bool get _isParentRoot => _parent?._isRoot ?? false; + _MenuAnchorState? get _parent => submenu._parent; + _MenuAnchorState? get _anchorState => submenu._anchorState; + MenuController get _controller => submenu._menuController; /// The orientation of the menu that contains this submenu button. Axis? get _orientation => _parent?._orientation; /// Whether the anchor that intercepted this DirectionalFocusAction is a submenu. bool get isSubmenu => submenu._buttonFocusNode.hasPrimaryFocus; + FocusNode get _button => submenu._buttonFocusNode; @override void invoke(DirectionalFocusIntent intent) { @@ -2192,7 +1948,7 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { assert(_debugMenuInfo('Moving to previous $MenuBar item')); // Focus this MenuBar SubmenuButton, then move focus to the previous focusable // MenuBar item. - _buttonFocusNode + _button ..requestFocus() ..previousFocus(); return; @@ -2201,7 +1957,7 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { assert(_debugMenuInfo('Moving to next $MenuBar item')); // Focus this MenuBar SubmenuButton, then move focus to the next focusable // MenuBar item. - _buttonFocusNode + _button ..requestFocus() ..nextFocus(); return; @@ -2209,10 +1965,7 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { if (isSubmenu) { // If this is a top-level (horizontal) button in a menubar, focus the // first item in this button's submenu. - final FocusNode? firstItem = _anchor._firstItemFocusNode; - if (firstItem?.canRequestFocus ?? false) { - firstItem!.requestFocus(); - } + _anchorState?._focusFirstMenuItem(); return; } case (Axis.horizontal, _, TraversalDirection.up): @@ -2220,10 +1973,7 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { // If this is a top-level (horizontal) button in a menubar, focus the // last item in this button's submenu. This makes navigating into // upward-oriented submenus more intuitive. - final FocusNode? lastItem = _anchor._lastItemFocusNode; - if (lastItem?.canRequestFocus ?? false) { - lastItem!.requestFocus(); - } + _anchorState?._focusLastMenuItem(); return; } case (Axis.vertical, TextDirection.ltr, TraversalDirection.left): @@ -2237,23 +1987,22 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { assert(_debugMenuInfo('Exiting submenu')); // MenuBar SubmenuButton => SubmenuButton => child // Focus the parent SubmenuButton anchor attached to this child. - _buttonFocusNode.requestFocus(); + _anchorState?._focusButton(); } } else { if (isSubmenu) { - if (_isParentRoot) { + if (_parent?._parent == null) { // Moving in the closing direction while focused on a // SubmenuButton within a root MenuAnchor menu should not close // the menu. return; } - _parent - ?.._focusButton() - .._close(); + _parent?._focusButton(); + _parent?._menuController.close(); } else { // If focus is not on a submenu button, closing the anchor this item // presides in will close the menu and focus the anchor button. - _anchor._close(); + _controller.close(); } assert(_debugMenuInfo('Exiting submenu')); } @@ -2261,13 +2010,13 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { case (Axis.vertical, TextDirection.ltr, TraversalDirection.right) when isSubmenu: case (Axis.vertical, TextDirection.rtl, TraversalDirection.left) when isSubmenu: assert(_debugMenuInfo('Entering submenu')); - if (_anchor._isOpen) { - _anchor._firstItemFocusNode?.requestFocus(); + if (_controller.isOpen) { + _anchorState?._focusFirstMenuItem(); } else { - _anchor._open(); + _controller.open(); SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { - if (_anchor._isOpen) { - _anchor._firstItemFocusNode?.requestFocus(); + if (_controller.isOpen) { + _anchorState?._focusFirstMenuItem(); } }); } @@ -2280,32 +2029,6 @@ class _SubmenuDirectionalFocusAction extends DirectionalFocusAction { } } -/// An action that closes all the menus associated with the given -/// [MenuController]. -/// -/// See also: -/// -/// * [MenuAnchor], a widget that hosts a cascading submenu. -/// * [MenuBar], a widget that defines a menu bar with cascading submenus. -class DismissMenuAction extends DismissAction { - /// Creates a [DismissMenuAction]. - DismissMenuAction({required this.controller}); - - /// The [MenuController] associated with the menus that should be closed. - final MenuController controller; - - @override - void invoke(DismissIntent intent) { - assert(_debugMenuInfo('$runtimeType: Dismissing all open menus.')); - controller._anchor!._root._close(); - } - - @override - bool isEnabled(DismissIntent intent) { - return controller.isOpen; - } -} - /// A helper class used to generate shortcut labels for a /// [MenuSerializableShortcut] (a subset of the subclasses of /// [ShortcutActivator]). @@ -2563,26 +2286,6 @@ class _LocalizedShortcutLabeler { } } -class _MenuAnchorScope extends InheritedWidget { - const _MenuAnchorScope({ - required super.child, - required this.anchorKey, - required this.anchor, - required this.isOpen, - }); - - final GlobalKey anchorKey; - final _MenuAnchorState anchor; - final bool isOpen; - - @override - bool updateShouldNotify(_MenuAnchorScope oldWidget) { - return anchorKey != oldWidget.anchorKey || - anchor != oldWidget.anchor || - isOpen != oldWidget.isOpen; - } -} - /// MenuBar-specific private specialization of [MenuAnchor] so that it can act /// differently in regards to orientation, how open works, and what gets built. class _MenuBarAnchor extends MenuAnchor { @@ -2602,51 +2305,41 @@ class _MenuBarAnchorState extends _MenuAnchorState { DismissIntent: DismissMenuAction(controller: _menuController), }; - @override - bool get _isOpen { - // If it's a bar, then it's "open" if any of its children are open. - for (final _MenuAnchorState child in _anchorChildren) { - if (child._isOpen) { - return true; - } - } - return false; - } - @override Axis get _orientation => Axis.horizontal; @override - Widget _buildContents(BuildContext context) { - final bool isOpen = _isOpen; - return FocusScope( - node: _menuScopeNode, - skipTraversal: !isOpen, - canRequestFocus: isOpen, - descendantsAreFocusable: true, - child: ExcludeFocus( - excluding: !isOpen, - child: Shortcuts( - shortcuts: _kMenuTraversalShortcuts, - child: Actions( - actions: actions, - child: _MenuPanel( - menuStyle: widget.style, - clipBehavior: widget.clipBehavior, - orientation: Axis.horizontal, - children: widget.menuChildren, - ), - ), + Widget build(BuildContext context) { + final Actions child = Actions( + actions: actions, + child: Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: _MenuPanel( + menuStyle: widget.style, + clipBehavior: widget.clipBehavior, + orientation: _orientation, + children: widget.menuChildren, + ), + ), + ); + return _MenuAnchorScope( + state: this, + child: RawMenuAnchorGroup( + controller: _menuController, + child: Builder( + builder: (BuildContext context) { + final bool isOpen = MenuController.maybeIsOpenOf(context) ?? false; + return FocusScope( + node: _menuScopeNode, + skipTraversal: !isOpen, + canRequestFocus: isOpen, + descendantsAreFocusable: true, + child: ExcludeFocus(excluding: !isOpen, child: child), + ); + }, ), ), ); - } - - @override - void _open({Offset? position}) { - assert(_menuController._anchor == this); - // Menu bars can't be opened, because they're already always open. - return; } } @@ -2948,7 +2641,7 @@ class _MenuAcceleratorLabelState extends State { late String _displayLabel; int _acceleratorIndex = -1; MenuAcceleratorCallbackBinding? _binding; - _MenuAnchorState? _anchor; + MenuController? _menuController; ShortcutRegistry? _shortcutRegistry; ShortcutRegistryEntry? _shortcutRegistryEntry; bool _showAccelerators = false; @@ -2971,7 +2664,7 @@ class _MenuAcceleratorLabelState extends State { _shortcutRegistryEntry?.dispose(); _shortcutRegistryEntry = null; _shortcutRegistry = null; - _anchor = null; + _menuController = null; HardwareKeyboard.instance.removeHandler(_listenToKeyEvent); } super.dispose(); @@ -2984,7 +2677,7 @@ class _MenuAcceleratorLabelState extends State { return; } _binding = MenuAcceleratorCallbackBinding.maybeOf(context); - _anchor = _MenuAnchorState._maybeOf(context); + _menuController = MenuController.maybeOf(context); _shortcutRegistry = ShortcutRegistry.maybeOf(context); _updateAcceleratorShortcut(); } @@ -3032,7 +2725,7 @@ class _MenuAcceleratorLabelState extends State { if (_showAccelerators && _acceleratorIndex != -1 && _binding?.onInvoke != null && - (!_binding!.hasSubmenu || !(_anchor?._isOpen ?? false))) { + (!_binding!.hasSubmenu || !(_menuController?.isOpen ?? false))) { final String acceleratorCharacter = _displayLabel[_acceleratorIndex].toLowerCase(); _shortcutRegistryEntry = _shortcutRegistry?.addAll({ CharacterActivator(acceleratorCharacter, alt: true): VoidCallbackIntent( @@ -3587,15 +3280,19 @@ class _Submenu extends StatelessWidget { required this.menuStyle, required this.menuPosition, required this.alignmentOffset, + required this.consumeOutsideTaps, required this.clipBehavior, this.crossAxisUnconstrained = true, required this.menuChildren, + required this.menuScopeNode, }); + final FocusScopeNode menuScopeNode; + final RawMenuOverlayInfo menuPosition; final _MenuAnchorState anchor; final LayerLink? layerLink; final MenuStyle? menuStyle; - final Offset? menuPosition; + final bool consumeOutsideTaps; final Offset alignmentOffset; final Clip clipBehavior; final bool crossAxisUnconstrained; @@ -3640,61 +3337,41 @@ class _Submenu extends StatelessWidget { final EdgeInsetsGeometry resolvedPadding = padding .add(EdgeInsets.fromLTRB(dx, dy, dx, dy)) .clamp(EdgeInsets.zero, EdgeInsetsGeometry.infinity); - final BuildContext anchorContext = anchor._anchorKey.currentContext!; - final RenderBox overlay = Overlay.of(anchorContext).context.findRenderObject()! as RenderBox; - Offset upperLeft = Offset.zero; - Offset bottomRight = Offset.zero; - if (layerLink == null) { - final RenderBox anchorBox = anchorContext.findRenderObject()! as RenderBox; - upperLeft = anchorBox.localToGlobal(Offset(dx, -dy), ancestor: overlay); - bottomRight = anchorBox.localToGlobal(anchorBox.paintBounds.bottomRight, ancestor: overlay); - } - final Rect anchorRect = Rect.fromPoints(upperLeft, bottomRight); + final Rect anchorRect = + layerLink == null + ? Rect.fromLTRB( + menuPosition.anchorRect.left + dx, + menuPosition.anchorRect.top - dy, + menuPosition.anchorRect.right, + menuPosition.anchorRect.bottom, + ) + : Rect.zero; - Widget child = Theme( - data: Theme.of(context).copyWith(visualDensity: visualDensity), - child: ConstrainedBox( - constraints: BoxConstraints.loose(overlay.paintBounds.size), - child: CustomSingleChildLayout( - delegate: _MenuLayout( - anchorRect: anchorRect, - textDirection: textDirection, - avoidBounds: DisplayFeatureSubScreen.avoidBounds(MediaQuery.of(context)).toSet(), - menuPadding: resolvedPadding, - alignment: alignment, - alignmentOffset: alignmentOffset, - menuPosition: menuPosition, - orientation: anchor._orientation, - parentOrientation: anchor._parent?._orientation ?? Axis.horizontal, - ), - child: TapRegion( - groupId: anchor._root, - consumeOutsideTaps: anchor._root._isOpen && anchor.widget.consumeOutsideTap, - onTapOutside: (PointerDownEvent event) { - anchor._close(); + final Widget menuPanel = TapRegion( + groupId: menuPosition.tapRegionGroupId, + consumeOutsideTaps: anchor._root._menuController.isOpen && anchor.widget.consumeOutsideTap, + onTapOutside: (PointerDownEvent event) { + anchor._menuController.close(); + }, + child: MouseRegion( + cursor: mouseCursor, + hitTestBehavior: HitTestBehavior.deferToChild, + child: FocusScope( + node: anchor._menuScopeNode, + skipTraversal: true, + child: Actions( + actions: >{ + DismissIntent: DismissMenuAction(controller: anchor._menuController), }, - child: MouseRegion( - cursor: mouseCursor, - hitTestBehavior: HitTestBehavior.deferToChild, - child: FocusScope( - node: anchor._menuScopeNode, - skipTraversal: true, - child: Actions( - actions: >{ - DismissIntent: DismissMenuAction(controller: anchor._menuController), - }, - child: Shortcuts( - shortcuts: _kMenuTraversalShortcuts, - child: _MenuPanel( - menuStyle: menuStyle, - clipBehavior: clipBehavior, - orientation: anchor._orientation, - crossAxisUnconstrained: crossAxisUnconstrained, - children: menuChildren, - ), - ), - ), + child: Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: _MenuPanel( + menuStyle: menuStyle, + clipBehavior: clipBehavior, + orientation: anchor._orientation, + crossAxisUnconstrained: crossAxisUnconstrained, + children: menuChildren, ), ), ), @@ -3702,15 +3379,41 @@ class _Submenu extends StatelessWidget { ), ); - if (layerLink != null) { - child = CompositedTransformFollower( - link: layerLink!, - targetAnchor: Alignment.bottomLeft, - child: child, - ); + final Widget layout = Theme( + data: Theme.of(context).copyWith(visualDensity: visualDensity), + child: ConstrainedBox( + constraints: BoxConstraints.loose(menuPosition.overlaySize), + child: Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQuery = MediaQuery.of(context); + return CustomSingleChildLayout( + delegate: _MenuLayout( + anchorRect: anchorRect, + textDirection: textDirection, + avoidBounds: DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(), + menuPadding: resolvedPadding, + alignment: alignment, + alignmentOffset: alignmentOffset, + menuPosition: menuPosition.position, + orientation: anchor._orientation, + parentOrientation: anchor._parent?._orientation ?? Axis.horizontal, + ), + child: menuPanel, + ); + }, + ), + ), + ); + + if (layerLink == null) { + return layout; } - return child; + return CompositedTransformFollower( + link: layerLink!, + targetAnchor: Alignment.bottomLeft, + child: layout, + ); } } diff --git a/packages/flutter/lib/src/widgets/raw_menu_anchor.dart b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart new file mode 100644 index 0000000000..c7aeec6084 --- /dev/null +++ b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart @@ -0,0 +1,900 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +import 'actions.dart'; +import 'basic.dart'; +import 'focus_manager.dart'; +import 'focus_traversal.dart'; +import 'framework.dart'; +import 'media_query.dart'; +import 'overlay.dart'; +import 'scroll_position.dart'; +import 'scrollable.dart'; +import 'shortcuts.dart'; +import 'tap_region.dart'; + +// Examples can assume: +// late BuildContext context; +// late List menuItems; +// late RawMenuOverlayInfo info; + +const bool _kDebugMenus = false; + +const Map _kMenuTraversalShortcuts = { + SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right), +}; + +/// Anchor and menu information passed to [RawMenuAnchor]. +@immutable +class RawMenuOverlayInfo { + /// Creates a [RawMenuOverlayInfo]. + const RawMenuOverlayInfo({ + required this.anchorRect, + required this.overlaySize, + required this.tapRegionGroupId, + this.position, + }); + + /// The position of the anchor widget that the menu is attached to, relative to + /// the nearest ancestor [Overlay] when [RawMenuAnchor.useRootOverlay] is false, + /// or the root [Overlay] when [RawMenuAnchor.useRootOverlay] is true. + final ui.Rect anchorRect; + + /// The [Size] of the overlay that the menu is being shown in. + final ui.Size overlaySize; + + /// The `position` argument passed to [MenuController.open]. + /// + /// The position should be used to offset the menu relative to the top-left + /// corner of the anchor. + final Offset? position; + + /// The [TapRegion.groupId] of the [TapRegion] that wraps widgets in this menu + /// system. + final Object tapRegionGroupId; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + return other is RawMenuOverlayInfo && + other.anchorRect == anchorRect && + other.overlaySize == overlaySize && + other.position == position && + other.tapRegionGroupId == tapRegionGroupId; + } + + @override + int get hashCode { + return Object.hash(anchorRect, overlaySize, position, tapRegionGroupId); + } +} + +/// The type of builder function used by [RawMenuAnchor] to build +/// the overlay attached to a [RawMenuAnchor]. +/// +/// The `context` is the context that the overlay is being built in. +/// +/// The `info` describes the info of the menu overlay for the +/// [RawMenuAnchor] constructor. +typedef RawMenuAnchorOverlayBuilder = + Widget Function(BuildContext context, RawMenuOverlayInfo info); + +/// The type of builder function used by [RawMenuAnchor.builder] to build the +/// widget that the [RawMenuAnchor] surrounds. +/// +/// The `context` is the context in which the anchor is being built. +/// +/// The `controller` is the [MenuController] that can be used to open and close +/// the menu. +/// +/// The `child` is an optional child supplied as the [RawMenuAnchor.child] +/// attribute. The child is intended to be incorporated in the result of the +/// function. +typedef RawMenuAnchorChildBuilder = + Widget Function(BuildContext context, MenuController controller, Widget? child); + +// An inherited widget that provides the [RawMenuAnchor] to its descendants. +// +// Used to notify anchor descendants when the menu opens and closes, and to +// access the anchor's controller. +class _MenuControllerScope extends InheritedWidget { + const _MenuControllerScope({ + required this.isOpen, + required this.controller, + required super.child, + }); + + final bool isOpen; + final MenuController controller; + + @override + bool updateShouldNotify(_MenuControllerScope oldWidget) { + return isOpen != oldWidget.isOpen; + } +} + +/// A widget that wraps a child and anchors a floating menu. +/// +/// The child can be any widget, but is typically a button, a text field, or, in +/// the case of context menus, the entire screen. +/// +/// The menu overlay of a [RawMenuAnchor] is shown by calling +/// [MenuController.open] on an attached [MenuController]. +/// +/// When a [RawMenuAnchor] is opened, [overlayBuilder] is called to construct +/// the menu contents within an [Overlay]. The [Overlay] allows the menu to +/// "float" on top of other widgets. The `info` argument passed to +/// [overlayBuilder] provides the anchor's [Rect], the [Size] of the overlay, +/// the [TapRegion.groupId] used by members of the menu system, and the +/// `position` argument passed to [MenuController.open]. +/// +/// If [MenuController.open] is called with a `position` argument, it will be +/// passed to the `info` argument of the `overlayBuilder` function. +/// +/// Users are responsible for managing the positioning, semantics, and focus of +/// the menu. +/// +/// {@tool dartpad} +/// +/// This example uses a [RawMenuAnchor] to build an a basic select menu with +/// four items. +/// +/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart ** +/// {@end-tool} +class RawMenuAnchor extends StatefulWidget { + /// A [RawMenuAnchor] that delegates overlay construction to an [overlayBuilder]. + /// + /// The [overlayBuilder] should not be null. + const RawMenuAnchor({ + super.key, + this.childFocusNode, + this.consumeOutsideTaps = false, + this.onOpen, + this.onClose, + this.useRootOverlay = false, + this.builder, + required this.controller, + required this.overlayBuilder, + this.child, + }); + + /// A callback that is invoked when the menu is opened. + final VoidCallback? onOpen; + + /// A callback that is invoked when the menu is closed. + final VoidCallback? onClose; + + /// A builder that builds the widget that this [RawMenuAnchor] surrounds. + /// + /// Typically, this is a button used to open the menu by calling + /// [MenuController.open] on the `controller` passed to the builder. + /// + /// If not supplied, then the [RawMenuAnchor] will be the size that its parent + /// allocates for it. + final RawMenuAnchorChildBuilder? builder; + + /// The optional child to be passed to the [builder]. + /// + /// Supply this child if there is a portion of the widget tree built in + /// [builder] that doesn't depend on the `controller` or `context` supplied to + /// the [builder]. It will be more efficient, since Flutter doesn't then need + /// to rebuild this child when those change. + final Widget? child; + + /// The [overlayBuilder] function is passed a [RawMenuOverlayInfo] object that + /// defines the anchor's [Rect], the [Size] of the overlay, the + /// [TapRegion.groupId] for the menu system, and the position [Offset] passed + /// to [MenuController.open]. + /// + /// To ensure taps are properly consumed, the + /// [RawMenuOverlayInfo.tapRegionGroupId] should be passed to a [TapRegion] + /// widget that wraps the menu panel. + /// + /// ```dart + /// TapRegion( + /// groupId: info.tapRegionGroupId, + /// onTapOutside: (PointerDownEvent event) { + /// MenuController.maybeOf(context)?.close(); + /// }, + /// child: Column(children: menuItems), + /// ) + /// ``` + final RawMenuAnchorOverlayBuilder overlayBuilder; + + /// {@template flutter.widgets.RawMenuAnchor.useRootOverlay} + /// Whether the menu panel should be rendered in the root [Overlay]. + /// + /// When true, the menu is mounted in the root overlay. Rendering the menu in + /// the root overlay prevents the menu from being obscured by other widgets. + /// + /// When false, the menu is rendered in the nearest ancestor [Overlay]. + /// + /// Submenus will always use the same overlay as their top-level ancestor, so + /// setting a [useRootOverlay] value on a submenu will have no effect. + /// {@endtemplate} + /// + /// Defaults to false on overlay menus. + final bool useRootOverlay; + + /// The [FocusNode] attached to the widget that takes focus when the + /// menu is opened or closed. + /// + /// If not supplied, the anchor will not retain focus when the menu is opened. + final FocusNode? childFocusNode; + + /// Whether or not a tap event that closes the menu will be permitted to + /// continue on to the gesture arena. + /// + /// If false, then tapping outside of a menu when the menu is open will both + /// close the menu, and allow the tap to participate in the gesture arena. + /// + /// If true, then it will only close the menu, and the tap event will be + /// consumed. + /// + /// Defaults to false. + final bool consumeOutsideTaps; + + /// A [MenuController] that allows opening and closing of the menu from other + /// widgets. + final MenuController controller; + + @override + State createState() => _RawMenuAnchorState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ObjectFlagProperty.has('focusNode', childFocusNode)); + properties.add( + FlagProperty( + 'useRootOverlay', + value: useRootOverlay, + ifFalse: 'use nearest overlay', + ifTrue: 'use root overlay', + ), + ); + } +} + +// Base mixin that provides the common interface and state for both types of +// [RawMenuAnchor]s, [RawMenuAnchor] and [RawMenuAnchorGroup]. +@optionalTypeArgs +mixin _RawMenuAnchorBaseMixin on State { + final List<_RawMenuAnchorBaseMixin> _anchorChildren = <_RawMenuAnchorBaseMixin>[]; + _RawMenuAnchorBaseMixin? _parent; + ScrollPosition? _scrollPosition; + Size? _viewSize; + + /// Whether this [_RawMenuAnchorBaseMixin] is the top node of the menu tree. + @protected + bool get isRoot => _parent == null; + + /// The [MenuController] that is used by the [_RawMenuAnchorBaseMixin]. + /// + /// If an overridding widget does not provide a [MenuController], then + /// [_RawMenuAnchorBaseMixin] will create and manage its own. + MenuController get menuController; + + /// Whether this menu layer is open. + @protected + bool get isOpen; + + /// The root of the menu tree that this [RawMenuAnchor] is in. + @protected + _RawMenuAnchorBaseMixin get root { + _RawMenuAnchorBaseMixin anchor = this; + while (anchor._parent != null) { + anchor = anchor._parent!; + } + return anchor; + } + + @override + void initState() { + super.initState(); + menuController._attach(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final _RawMenuAnchorBaseMixin? newParent = MenuController.maybeOf(context)?._anchor; + if (newParent != _parent) { + assert( + newParent != this, + 'A MenuController should only be attached to one anchor at a time.', + ); + _parent?._removeChild(this); + _parent = newParent; + _parent?._addChild(this); + } + + _scrollPosition?.isScrollingNotifier.removeListener(_handleScroll); + _scrollPosition = Scrollable.maybeOf(context)?.position; + _scrollPosition?.isScrollingNotifier.addListener(_handleScroll); + final Size newSize = MediaQuery.sizeOf(context); + if (_viewSize != null && newSize != _viewSize) { + // Close the menus if the view changes size. + root.close(); + } + _viewSize = newSize; + } + + @override + void dispose() { + assert(_debugMenuInfo('Disposing of $this')); + if (isOpen) { + close(inDispose: true); + } + + _parent?._removeChild(this); + _parent = null; + _anchorChildren.clear(); + menuController._detach(this); + super.dispose(); + } + + void _addChild(_RawMenuAnchorBaseMixin child) { + assert(isRoot || _debugMenuInfo('Added root child: $child')); + assert(!_anchorChildren.contains(child)); + _anchorChildren.add(child); + assert(_debugMenuInfo('Added:\n${child.widget.toStringDeep()}')); + assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); + } + + void _removeChild(_RawMenuAnchorBaseMixin child) { + assert(isRoot || _debugMenuInfo('Removed root child: $child')); + assert(_anchorChildren.contains(child)); + assert(_debugMenuInfo('Removing:\n${child.widget.toStringDeep()}')); + _anchorChildren.remove(child); + assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); + } + + void _handleScroll() { + // If an ancestor scrolls, and we're a root anchor, then close the menus. + // Don't just close it on *any* scroll, since we want to be able to scroll + // menus themselves if they're too big for the view. + if (isRoot) { + close(); + } + } + + void _childChangedOpenState() { + _parent?._childChangedOpenState(); + if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + setState(() { + // Mark dirty now, but only if not in a build. + }); + } else { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + setState(() { + // Mark dirty + }); + }); + } + } + + /// Open the menu, optionally at a position relative to the [RawMenuAnchor]. + /// + /// Call this when the menu should be shown to the user. + /// + /// The optional `position` argument should specify the location of the menu in + /// the local coordinates of the [RawMenuAnchor]. + @protected + void open({Offset? position}); + + /// Close the menu. + @protected + void close({bool inDispose = false}); + + @protected + void closeChildren({bool inDispose = false}) { + assert(_debugMenuInfo('Closing children of $this${inDispose ? ' (dispose)' : ''}')); + for (final _RawMenuAnchorBaseMixin child in List<_RawMenuAnchorBaseMixin>.from( + _anchorChildren, + )) { + child.close(inDispose: inDispose); + } + } + + /// Handles taps outside of the menu surface. + /// + /// By default, this closes this submenu's children. + @protected + void handleOutsideTap(PointerDownEvent pointerDownEvent) { + assert(_debugMenuInfo('Tapped Outside $menuController')); + closeChildren(); + } + + // Used to build the anchor widget in subclasses. + @protected + Widget buildAnchor(BuildContext context); + + @override + @nonVirtual + Widget build(BuildContext context) { + return _MenuControllerScope( + isOpen: isOpen, + controller: menuController, + child: Actions( + actions: >{ + // Check if open to allow DismissIntent to bubble when the menu is + // closed. + if (isOpen) DismissIntent: DismissMenuAction(controller: menuController), + }, + child: Builder(builder: buildAnchor), + ), + ); + } + + @override + String toString({DiagnosticLevel? minLevel}) => describeIdentity(this); +} + +class _RawMenuAnchorState extends State with _RawMenuAnchorBaseMixin { + // This is the global key that is used later to determine the bounding rect + // for the anchor's region that the CustomSingleChildLayout's delegate + // uses to determine where to place the menu on the screen and to avoid the + // view's edges. + final GlobalKey _anchorKey = GlobalKey<_RawMenuAnchorState>( + debugLabel: kReleaseMode ? null : 'MenuAnchor', + ); + final OverlayPortalController _overlayController = OverlayPortalController( + debugLabel: kReleaseMode ? null : 'MenuAnchor controller', + ); + + Offset? _menuPosition; + bool get _isRootOverlayAnchor => _parent is! _RawMenuAnchorState; + + // If we are a nested menu, we still want to use the same overlay as the + // root menu. + bool get useRootOverlay { + if (_parent case _RawMenuAnchorState(useRootOverlay: final bool useRoot)) { + return useRoot; + } + + assert(_isRootOverlayAnchor); + return widget.useRootOverlay; + } + + @override + bool get isOpen => _overlayController.isShowing; + + @override + MenuController get menuController => widget.controller; + + @override + void didUpdateWidget(RawMenuAnchor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller._detach(this); + widget.controller._attach(this); + } + } + + @override + void open({Offset? position}) { + assert(menuController._anchor == this); + if (isOpen) { + if (position == _menuPosition) { + assert(_debugMenuInfo("Not opening $this because it's already open")); + // The menu is open and not being moved, so just return. + return; + } + + // The menu is already open, but we need to move to another location, so + // close it first. + close(); + } + + assert(_debugMenuInfo('Opening $this at ${position ?? Offset.zero}')); + + // Close all siblings. + _parent?.closeChildren(); + assert(!_overlayController.isShowing); + + _parent?._childChangedOpenState(); + _menuPosition = position; + _overlayController.show(); + + if (_isRootOverlayAnchor) { + widget.childFocusNode?.requestFocus(); + } + + widget.onOpen?.call(); + if (mounted && SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + setState(() { + // Mark dirty to notify MenuController dependents. + }); + } + } + + // Close the menu. + // + // Call this when the menu should be closed. Has no effect if the menu is + // already closed. + @override + void close({bool inDispose = false}) { + assert(_debugMenuInfo('Closing $this')); + if (!isOpen) { + return; + } + + closeChildren(inDispose: inDispose); + // Don't hide if we're in the middle of a build. + if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + _overlayController.hide(); + } else if (!inDispose) { + SchedulerBinding.instance.addPostFrameCallback((_) { + _overlayController.hide(); + }, debugLabel: 'MenuAnchor.hide'); + } + + if (!inDispose) { + // Notify that _childIsOpen changed state, but only if not + // currently disposing. + _parent?._childChangedOpenState(); + widget.onClose?.call(); + if (mounted && + SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + setState(() { + // Mark dirty, but only if mounted and not in a build. + }); + } + } + } + + Widget _buildOverlay(BuildContext context) { + final BuildContext anchorContext = _anchorKey.currentContext!; + final RenderBox overlay = + Overlay.of(anchorContext, rootOverlay: useRootOverlay).context.findRenderObject()! + as RenderBox; + final RenderBox anchorBox = anchorContext.findRenderObject()! as RenderBox; + final ui.Offset upperLeft = anchorBox.localToGlobal(Offset.zero, ancestor: overlay); + final ui.Offset bottomRight = anchorBox.localToGlobal( + anchorBox.size.bottomRight(Offset.zero), + ancestor: overlay, + ); + + final RawMenuOverlayInfo info = RawMenuOverlayInfo( + anchorRect: Rect.fromPoints(upperLeft, bottomRight), + overlaySize: overlay.size, + position: _menuPosition, + tapRegionGroupId: root.menuController, + ); + + return widget.overlayBuilder(context, info); + } + + @override + Widget buildAnchor(BuildContext context) { + final Widget child = Shortcuts( + includeSemantics: false, + shortcuts: _kMenuTraversalShortcuts, + child: TapRegion( + groupId: root.menuController, + consumeOutsideTaps: root.isOpen && widget.consumeOutsideTaps, + onTapOutside: handleOutsideTap, + child: Builder( + key: _anchorKey, + builder: (BuildContext context) { + return widget.builder?.call(context, menuController, widget.child) ?? + widget.child ?? + const SizedBox(); + }, + ), + ), + ); + + if (useRootOverlay) { + return OverlayPortal.targetsRootOverlay( + controller: _overlayController, + overlayChildBuilder: _buildOverlay, + child: child, + ); + } else { + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildOverlay, + child: child, + ); + } + } + + @override + String toString({DiagnosticLevel? minLevel}) { + return describeIdentity(this); + } +} + +/// Creates a menu anchor that is always visible and is not displayed in an +/// [OverlayPortal]. +/// +/// A [RawMenuAnchorGroup] can be used to create a menu bar that handles +/// external taps and keyboard shortcuts, but defines no default focus or +/// keyboard traversal to enable more flexibility. +/// +/// When a [MenuController] is given to a [RawMenuAnchorGroup], +/// - [MenuController.open] has no effect. +/// - [MenuController.close] closes all child [RawMenuAnchor]s that are open +/// - [MenuController.isOpen] reflects whether any child [RawMenuAnchor] is +/// open. +/// +/// A [child] must be provided. +/// +/// {@tool dartpad} +/// +/// This example uses [RawMenuAnchorGroup] to build a menu bar with four +/// submenus. Hovering over menu items opens their respective submenus. +/// Selecting a menu item will close the menu and update the selected item text. +/// +/// ** See code in examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart ** +/// {@end-tool} +/// +/// See also: +/// * [MenuBar], which wraps this widget with standard layout and semantics and +/// focus management. +/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines. +/// * [RawMenuAnchor], a widget that defines a region attached to a floating +/// submenu. +class RawMenuAnchorGroup extends StatefulWidget { + /// Creates a [RawMenuAnchorGroup]. + const RawMenuAnchorGroup({super.key, required this.child, required this.controller}); + + /// The child displayed by the [RawMenuAnchorGroup]. + /// + /// To access the [MenuController] from the [child], place the child in a + /// builder and call [MenuController.maybeOf]. + final Widget child; + + /// An [MenuController] that allows the closing of the menu from other + /// widgets. + final MenuController controller; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ObjectFlagProperty.has('controller', controller)); + } + + @override + State createState() => _RawMenuAnchorGroupState(); +} + +class _RawMenuAnchorGroupState extends State + with _RawMenuAnchorBaseMixin { + @override + bool get isOpen => _anchorChildren.any((_RawMenuAnchorBaseMixin child) => child.isOpen); + + @override + MenuController get menuController => widget.controller; + + @override + void didUpdateWidget(RawMenuAnchorGroup oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller._detach(this); + widget.controller._attach(this); + } + } + + @override + void close({bool inDispose = false}) { + if (!isOpen) { + return; + } + + closeChildren(inDispose: inDispose); + if (!inDispose) { + if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + setState(() { + // Mark dirty, but only if mounted and not in a build. + }); + } else { + SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) { + if (mounted) { + setState(() { + // Mark dirty. + }); + } + }); + } + } + } + + @override + void open({Offset? position}) { + assert(menuController._anchor == this); + // Menu nodes are always open, so this is a no-op. + return; + } + + @override + Widget buildAnchor(BuildContext context) { + return TapRegion( + groupId: root.menuController, + onTapOutside: handleOutsideTap, + child: widget.child, + ); + } +} + +/// A controller used to manage a menu created by a [RawMenuAnchor], or +/// [RawMenuAnchorGroup]. +/// +/// A [MenuController] is used to control and interrogate a menu after it has +/// been created, with methods such as [open] and [close], and state accessors +/// like [isOpen]. +/// +/// [MenuController.maybeOf] can be used to retrieve a controller from the +/// [BuildContext] of a widget that is a descendant of a [MenuAnchor], +/// [MenuBar], [SubmenuButton], or [RawMenuAnchor]. Doing so will not establish +/// a dependency relationship. +/// +/// [MenuController.maybeIsOpenOf] can be used to interrogate the state of a +/// menu from the [BuildContext] of a widget that is a descendant of a +/// [MenuAnchor]. Unlike [MenuController.maybeOf], this method will establish a +/// dependency relationship, so the calling widget will rebuild when the menu +/// opens and closes, and when the [MenuController] changes. +/// +/// See also: +/// +/// * [MenuAnchor], a menu anchor that follows the Material Design guidelines. +/// * [MenuBar], a widget that creates a menu bar that can take an optional +/// [MenuController]. +/// * [SubmenuButton], a Material widget that has a button that manages a +/// submenu. +/// * [RawMenuAnchor], a generic widget that manages a submenu. +/// * [RawMenuAnchorGroup], a generic widget that wraps a group of submenus. +class MenuController { + /// The anchor that this controller controls. + /// + /// This is set automatically when a [MenuController] is given to the anchor + /// it controls. + _RawMenuAnchorBaseMixin? _anchor; + + /// Whether or not the menu associated with this [MenuController] is open. + bool get isOpen => _anchor?.isOpen ?? false; + + /// Opens the menu that this [MenuController] is associated with. + /// + /// If `position` is given, then the menu will open at the position given, in + /// the coordinate space of the root overlay. + /// + /// If the menu's anchor point is scrolled by an ancestor, or the view changes + /// size, then any open menus will automatically close. + void open({Offset? position}) { + assert(_anchor != null); + _anchor!.open(position: position); + } + + /// Close the menu that this [MenuController] is associated with. + /// + /// Associating with a menu is done by passing a [MenuController] to a + /// [MenuAnchor], [RawMenuAnchor], or [RawMenuAnchorGroup]. + /// + /// If the menu's anchor point is scrolled by an ancestor, or the view changes + /// size, then any open menu will automatically close. + void close() { + _anchor?.close(); + } + + /// Close the children of the menu associated with this [MenuController], + /// without closing the menu itself. + void closeChildren() { + assert(_anchor != null); + _anchor!.closeChildren(); + } + + // ignore: use_setters_to_change_properties + void _attach(_RawMenuAnchorBaseMixin anchor) { + _anchor = anchor; + } + + void _detach(_RawMenuAnchorBaseMixin anchor) { + if (_anchor == anchor) { + _anchor = null; + } + } + + /// Returns the [MenuController] of the ancestor [RawMenuAnchor] or + /// [RawMenuAnchorGroup] nearest to the given `context`, if one exists. + /// Otherwise, returns null. + /// + /// This method will not establish a dependency relationship, so the calling + /// widget will not rebuild when the menu opens and closes, nor when the + /// [MenuController] changes. + static MenuController? maybeOf(BuildContext context) { + return context.getInheritedWidgetOfExactType<_MenuControllerScope>()?.controller; + } + + /// Returns the value of [MenuController.isOpen] of the ancestor + /// [RawMenuAnchor] or [RawMenuAnchorGroup] nearest to the given `context`, if + /// one exists. Otherwise, returns null. + /// + /// This method will establish a dependency relationship, so the calling + /// widget will rebuild when the menu opens and closes. + static bool? maybeIsOpenOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_MenuControllerScope>()?.isOpen; + } + + @override + String toString() => describeIdentity(this); +} + +/// An action that closes all the menus associated with the given +/// [MenuController]. +/// +/// See also: +/// +/// * [MenuAnchor], a material-themed widget that hosts a cascading submenu. +/// * [MenuBar], a widget that defines a menu bar with cascading submenus. +/// * [RawMenuAnchor], a widget that hosts a cascading submenu. +/// * [MenuController], a controller used to manage menus created by a +/// [RawMenuAnchor]. +class DismissMenuAction extends DismissAction { + /// Creates a [DismissMenuAction]. + DismissMenuAction({required this.controller}); + + /// The [MenuController] that manages the menu which should be dismissed upon + /// invocation. + final MenuController controller; + + @override + void invoke(DismissIntent intent) { + controller._anchor!.root.close(); + } + + @override + bool isEnabled(DismissIntent intent) { + return controller._anchor != null; + } +} + +/// A debug print function, which should only be called within an assert, like +/// so: +/// +/// assert(_debugMenuInfo('Debug Message')); +/// +/// so that the call is entirely removed in release builds. +/// +/// Enable debug printing by setting [_kDebugMenus] to true at the top of the +/// file. +bool _debugMenuInfo(String message, [Iterable? details]) { + assert(() { + if (_kDebugMenus) { + debugPrint('MENU: $message'); + if (details != null && details.isNotEmpty) { + for (final String detail in details) { + debugPrint(' $detail'); + } + } + } + return true; + }()); + // Return true so that it can be easily used inside of an assert. + return true; +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 4458e361f5..86f9a8f507 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -104,6 +104,7 @@ export 'src/widgets/pop_scope.dart'; export 'src/widgets/preferred_size.dart'; export 'src/widgets/primary_scroll_controller.dart'; export 'src/widgets/raw_keyboard_listener.dart'; +export 'src/widgets/raw_menu_anchor.dart'; export 'src/widgets/reorderable_list.dart'; export 'src/widgets/restoration.dart'; export 'src/widgets/restoration_properties.dart'; diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index 66d30585c5..6b01b1e247 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -61,6 +61,19 @@ void main() { return find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_MenuPanel'); } + List ancestorRenderTheaters(RenderObject child) { + final List results = []; + RenderObject? node = child; + while (node != null) { + if (node.runtimeType.toString() == '_RenderTheater') { + results.add(node); + } + final RenderObject? parent = node.parent; + node = parent is RenderObject ? parent : null; + } + return results; + } + Finder findMenuBarItemLabels() { return find.byWidgetPredicate( (Widget widget) => widget.runtimeType.toString() == '_MenuItemLabel', @@ -207,6 +220,10 @@ void main() { await tester.tap(find.text(TestMenu.mainMenu1.label)); await tester.pump(); + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)), + ); expect( tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(145.0, 0.0, 655.0, 48.0)), @@ -244,6 +261,10 @@ void main() { await tester.tap(find.text(TestMenu.mainMenu1.label)); await tester.pump(); + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(161.0, 0.0, 639.0, 40.0)), + ); expect( tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(161.0, 0.0, 639.0, 40.0)), @@ -281,6 +302,10 @@ void main() { await tester.tap(find.text(TestMenu.mainMenu1.label)); await tester.pump(); + expect( + tester.getRect(find.byType(MenuBar)), + equals(const Rect.fromLTRB(105.0, 0.0, 695.0, 72.0)), + ); expect( tester.getRect(find.byType(MenuBar)), equals(const Rect.fromLTRB(105.0, 0.0, 695.0, 72.0)), @@ -3429,7 +3454,6 @@ void main() { ), ); await tester.pump(); - await tester.tap(find.text(TestMenu.mainMenu1.label)); await tester.pump(); await tester.tap(find.text(TestMenu.subMenu11.label)); @@ -3870,7 +3894,7 @@ void main() { }); testWidgets( - 'Menu is correctly offsetted when a LayerLink is provided and alignmentOffset is set', + 'Menu is correctly offset when a LayerLink is provided and alignmentOffset is set', (WidgetTester tester) async { final MenuController controller = MenuController(); final UniqueKey contentKey = UniqueKey(); @@ -3910,871 +3934,1018 @@ void main() { ); }, ); - }); - group('LocalizedShortcutLabeler', () { - testWidgets('getShortcutLabel returns the right labels', (WidgetTester tester) async { - String expectedMeta; - String expectedCtrl; - String expectedAlt; - String expectedSeparator; - String expectedShift; - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - expectedCtrl = 'Ctrl'; - expectedMeta = defaultTargetPlatform == TargetPlatform.windows ? 'Win' : 'Meta'; - expectedAlt = 'Alt'; - expectedShift = 'Shift'; - expectedSeparator = '+'; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - expectedCtrl = '⌃'; - expectedMeta = '⌘'; - expectedAlt = '⌥'; - expectedShift = '⇧'; - expectedSeparator = ' '; - } + group('LocalizedShortcutLabeler', () { + testWidgets('getShortcutLabel returns the right labels', (WidgetTester tester) async { + String expectedMeta; + String expectedCtrl; + String expectedAlt; + String expectedSeparator; + String expectedShift; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expectedCtrl = 'Ctrl'; + expectedMeta = defaultTargetPlatform == TargetPlatform.windows ? 'Win' : 'Meta'; + expectedAlt = 'Alt'; + expectedShift = 'Shift'; + expectedSeparator = '+'; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expectedCtrl = '⌃'; + expectedMeta = '⌘'; + expectedAlt = '⌥'; + expectedShift = '⇧'; + expectedSeparator = ' '; + } - const SingleActivator allModifiers = SingleActivator( - LogicalKeyboardKey.keyA, - control: true, - meta: true, - shift: true, - alt: true, + const SingleActivator allModifiers = SingleActivator( + LogicalKeyboardKey.keyA, + control: true, + meta: true, + shift: true, + alt: true, + ); + late String allExpected; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + allExpected = [ + expectedAlt, + expectedCtrl, + expectedMeta, + expectedShift, + 'A', + ].join(expectedSeparator); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + allExpected = [ + expectedCtrl, + expectedAlt, + expectedShift, + expectedMeta, + 'A', + ].join(expectedSeparator); + } + const CharacterActivator charShortcuts = CharacterActivator('ñ'); + const String charExpected = 'ñ'; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + controller: controller, + children: [ + SubmenuButton( + menuChildren: [ + MenuItemButton(shortcut: allModifiers, child: Text(TestMenu.subMenu10.label)), + MenuItemButton( + shortcut: charShortcuts, + child: Text(TestMenu.subMenu11.label), + ), + ], + child: Text(TestMenu.mainMenu0.label), + ), + ], + ), + ), + ), + ); + await tester.tap(find.text(TestMenu.mainMenu0.label)); + await tester.pump(); + + expect(find.text(allExpected), findsOneWidget); + expect(find.text(charExpected), findsOneWidget); + }, variant: TargetPlatformVariant.all()); + }); + + group('CheckboxMenuButton', () { + testWidgets('tapping toggles checkbox', (WidgetTester tester) async { + bool? checkBoxValue; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MenuBar( + children: [ + SubmenuButton( + menuChildren: [ + CheckboxMenuButton( + value: checkBoxValue, + onChanged: (bool? value) { + setState(() { + checkBoxValue = value; + }); + }, + tristate: true, + child: const Text('checkbox'), + ), + ], + child: const Text('submenu'), + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + + expect(tester.widget(find.byType(CheckboxMenuButton)).value, null); + + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, false); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(CheckboxMenuButton)); + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + }); + }); + + group('RadioMenuButton', () { + testWidgets('tapping toggles radio button', (WidgetTester tester) async { + int? radioValue; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MenuBar( + children: [ + SubmenuButton( + menuChildren: [ + RadioMenuButton( + value: 0, + groupValue: radioValue, + onChanged: (int? value) { + setState(() { + radioValue = value; + }); + }, + toggleable: true, + child: const Text('radio 0'), + ), + RadioMenuButton( + value: 1, + groupValue: radioValue, + onChanged: (int? value) { + setState(() { + radioValue = value; + }); + }, + toggleable: true, + child: const Text('radio 1'), + ), + ], + child: const Text('submenu'), + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + + expect( + tester.widget>(find.byType(RadioMenuButton).first).groupValue, + null, + ); + + await tester.tap(find.byType(RadioMenuButton).first); + await tester.pumpAndSettle(); + expect(radioValue, 0); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(RadioMenuButton).first); + await tester.pumpAndSettle(); + expect(radioValue, null); + + await tester.tap(find.byType(SubmenuButton)); + await tester.pump(); + await tester.tap(find.byType(RadioMenuButton).last); + await tester.pumpAndSettle(); + expect(radioValue, 1); + }); + }); + + group('Semantics', () { + testWidgets('MenuItemButton is not a semantic button', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MenuItemButton( + style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ); + + // The flags should not have SemanticsFlag.isButton. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + actions: [SemanticsAction.tap, SemanticsAction.focus], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + transform: Matrix4.translationValues(356.0, 276.0, 0.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('MenuItemButton semantics respects label', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: MenuItemButton( + semanticsLabel: 'TestWidget', + shortcut: const SingleActivator(LogicalKeyboardKey.comma), + style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + onPressed: () {}, + child: const Text('ABC'), + ), + ), + ), + ); + + expect(find.bySemanticsLabel('TestWidget'), findsOneWidget); + semantics.dispose(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets('SubMenuButton is not a semantic button', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SubmenuButton( + onHover: (bool value) {}, + style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + menuChildren: const [], + child: const Text('ABC'), + ), + ), + ), + ); + + // The flags should not have SemanticsFlag.isButton. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasExpandedState, + ], + label: 'ABC', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreTransform: true, + ignoreId: true, + ), + ); + + semantics.dispose(); + }); + + testWidgets('SubmenuButton expanded/collapsed state', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SubmenuButton( + style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + menuChildren: [ + MenuItemButton( + style: MenuItemButton.styleFrom(fixedSize: const Size(120.0, 36.0)), + child: const Text('Item 0'), + onPressed: () {}, + ), + ], + child: const Text('ABC'), + ), + ), + ), + ); + + // Test expanded state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 2, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 3, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + SemanticsFlag.isExpanded, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + ), + TestSemantics( + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 64.0), + children: [ + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + id: 8, + label: 'Item 0', + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ), + ); + + // Test collapsed state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 2, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 3, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.hasExpandedState, + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/131676. + testWidgets('Material3 - Menu uses correct text styles', (WidgetTester tester) async { + const TextStyle menuTextStyle = TextStyle( + fontSize: 18.5, + fontStyle: FontStyle.italic, + wordSpacing: 1.2, + decoration: TextDecoration.lineThrough, ); - late String allExpected; - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - allExpected = [ - expectedAlt, - expectedCtrl, - expectedMeta, - expectedShift, - 'A', - ].join(expectedSeparator); - case TargetPlatform.iOS: - case TargetPlatform.macOS: - allExpected = [ - expectedCtrl, - expectedAlt, - expectedShift, - expectedMeta, - 'A', - ].join(expectedSeparator); - } - const CharacterActivator charShortcuts = CharacterActivator('ñ'); - const String charExpected = 'ñ'; + final ThemeData themeData = ThemeData(textTheme: const TextTheme(labelLarge: menuTextStyle)); await tester.pumpWidget( MaterialApp( + theme: themeData, + home: Material( + child: MenuBar( + controller: controller, + children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), + ), + ), + ), + ); + + // Test menu button text style uses the TextTheme.labelLarge. + Finder buttonMaterial = + find.descendant(of: find.byType(TextButton), matching: find.byType(Material)).first; + Material material = tester.widget(buttonMaterial); + expect(material.textStyle?.fontSize, menuTextStyle.fontSize); + expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle); + expect(material.textStyle?.wordSpacing, menuTextStyle.wordSpacing); + expect(material.textStyle?.decoration, menuTextStyle.decoration); + + // Open the menu. + await tester.tap(find.text(TestMenu.mainMenu1.label)); + await tester.pump(); + + // Test menu item text style uses the TextTheme.labelLarge. + buttonMaterial = + find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), + matching: find.byType(Material), + ) + .first; + material = tester.widget(buttonMaterial); + expect(material.textStyle?.fontSize, menuTextStyle.fontSize); + expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle); + expect(material.textStyle?.wordSpacing, menuTextStyle.wordSpacing); + expect(material.textStyle?.decoration, menuTextStyle.decoration); + }); + + testWidgets('SubmenuButton.onFocusChange is respected', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + int onFocusChangeCalled = 0; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SubmenuButton( + focusNode: focusNode, + onFocusChange: (bool value) { + setState(() { + onFocusChangeCalled += 1; + }); + }, + menuChildren: const [MenuItemButton(child: Text('item 0'))], + child: const Text('Submenu 0'), + ); + }, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasFocus, true); + expect(onFocusChangeCalled, 1); + + focusNode.unfocus(); + await tester.pump(); + expect(focusNode.hasFocus, false); + expect(onFocusChangeCalled, 2); + }); + + testWidgets('Horizontal _MenuPanel wraps children with IntrinsicWidth', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: MenuBar( + children: [MenuItemButton(onPressed: () {}, child: const Text('Menu Item'))], + ), + ), + ), + ); + + // Horizontal _MenuPanel wraps children with IntrinsicWidth to ensure MenuItemButton + // with vertical overflow axis is as wide as the widest child. + final Finder intrinsicWidthFinder = find.ancestor( + of: find.byType(MenuItemButton), + matching: find.byType(IntrinsicWidth), + ); + expect(intrinsicWidthFinder, findsOneWidget); + }); + + testWidgets('SubmenuButton.styleFrom overlayColor overrides default overlay color', ( + WidgetTester tester, + ) async { + const Color overlayColor = Color(0xffff00ff); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SubmenuButton( + style: SubmenuButton.styleFrom(overlayColor: overlayColor), + menuChildren: [ + MenuItemButton(onPressed: () {}, child: const Text('MenuItemButton')), + ], + child: const Text('Submenu'), + ), + ), + ), + ); + + // Hovered. + final Offset center = tester.getCenter(find.byType(SubmenuButton)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pumpAndSettle(); + expect( + getOverlayColor(tester), + paints + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.08)) + ..rect(color: overlayColor.withOpacity(0.1)), + ); + }); + + testWidgets( + 'Garbage collector destroys child _MenuAnchorState after parent is closed', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/149584 + await tester.pumpWidget( + MaterialApp( + home: MenuAnchor( + controller: controller, + menuChildren: const [ + SubmenuButton(menuChildren: [], child: Text('')), + ], + ), + ), + ); + + controller.open(); + await tester.pump(); + + final WeakReference state = WeakReference( + tester.firstState>(find.byType(SubmenuButton)), + ); + expect(state.target, isNotNull); + + controller.close(); + await tester.pump(); + + controller.open(); + await tester.pump(); + + controller.close(); + await tester.pump(); + + // Garbage collect. 1 should be enough, but 3 prevents flaky tests. + await tester.runAsync(() async { + await forceGC(fullGcCycles: 3); + }); + + expect(state.target, isNull); + }, + // Skipped on Web: [intended] ForceGC does not work in web and in release mode. See https://api.flutter.dev/flutter/package-leak_tracker_leak_tracker/forceGC.html + // Skipped for everyone else: forceGC is flaky, see https://github.com/flutter/flutter/issues/154858 + skip: true, + ); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('MenuItemButton.styleFrom can customize the button icon', ( + WidgetTester tester, + ) async { + const Color iconColor = Color(0xFFF000FF); + const double iconSize = 32.0; + const Color disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: MenuItemButton( + style: MenuItemButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + disabledIconColor: disabledIconColor, + ), + onPressed: enabled ? () {} : null, + trailingIcon: const Icon(Icons.add), + child: const Text('Button'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + }); + + // Regression test for https://github.com/flutter/flutter/issues/154798. + testWidgets('SubmenuButton.styleFrom can customize the button icon', ( + WidgetTester tester, + ) async { + const Color iconColor = Color(0xFFF000FF); + const double iconSize = 32.0; + const Color disabledIconColor = Color(0xFFFFF000); + Widget buildButton({bool enabled = true}) { + return MaterialApp( + home: Material( + child: Center( + child: SubmenuButton( + style: SubmenuButton.styleFrom( + iconColor: iconColor, + iconSize: iconSize, + disabledIconColor: disabledIconColor, + ), + trailingIcon: const Icon(Icons.add), + menuChildren: [if (enabled) const Text('Item')], + child: const Text('SubmenuButton'), + ), + ), + ), + ); + } + + // Test enabled button. + await tester.pumpWidget(buildButton()); + expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); + expect(iconStyle(tester, Icons.add).color, iconColor); + + // Test disabled button. + await tester.pumpWidget(buildButton(enabled: false)); + expect(iconStyle(tester, Icons.add).color, disabledIconColor); + }); + + // Regression test for https://github.com/flutter/flutter/issues/155034. + testWidgets('Content is shown in the root overlay when useRootOverlay is true', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + final UniqueKey overlayKey = UniqueKey(); + final UniqueKey menuItemKey = UniqueKey(); + + late final OverlayEntry overlayEntry; + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + Widget boilerplate() { + return MaterialApp( + home: Overlay( + key: overlayKey, + initialEntries: [ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: MenuAnchor( + useRootOverlay: true, + controller: controller, + menuChildren: [ + MenuItemButton( + key: menuItemKey, + onPressed: () {}, + child: const Text('Item 1'), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } + + await tester.pumpWidget(boilerplate()); + expect(find.byKey(menuItemKey), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + expect(find.byKey(menuItemKey), findsOne); + + // Expect two overlays: the root overlay created by MaterialApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Iterable overlays = tester.widgetList(find.byType(Overlay)); + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + final Overlay rootOverlay = overlays.firstWhere( + (Overlay overlay) => overlay != nonRootOverlay, + ); + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the root overlay. + expect( + ancestorRenderTheaters(tester.renderObject(find.byKey(menuItemKey))).single, + tester.renderObject(find.byWidget(rootOverlay)), + ); + }); + + testWidgets('Content is shown in the nearest ancestor overlay when useRootOverlay is false', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + final UniqueKey overlayKey = UniqueKey(); + final UniqueKey menuItemKey = UniqueKey(); + + late final OverlayEntry overlayEntry; + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + Widget boilerplate() { + return MaterialApp( + home: Overlay( + key: overlayKey, + initialEntries: [ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Scaffold( + body: Center( + child: MenuAnchor( + controller: controller, + menuChildren: [ + MenuItemButton( + key: menuItemKey, + onPressed: () {}, + child: const Text('Item 1'), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } + + await tester.pumpWidget(boilerplate()); + expect(find.byKey(menuItemKey), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + expect(find.byKey(menuItemKey), findsOne); + + // Expect two overlays: the root overlay created by MaterialApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the root overlay. + expect( + ancestorRenderTheaters(tester.renderObject(find.byKey(menuItemKey))).first, + tester.renderObject(find.byWidget(nonRootOverlay)), + ); + }); + + // Regression test for https://github.com/flutter/flutter/issues/156572. + testWidgets('Unattached MenuController does not throw when calling close', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + controller.close(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Unattached MenuController returns false when calling isOpen', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + expect(controller.isOpen, false); + }); + + // Regression test for https://github.com/flutter/flutter/issues/157606. + testWidgets('MenuAnchor updates isOpen state correctly', (WidgetTester tester) async { + bool isOpen = false; + int openCount = 0; + int closeCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: MenuAnchor( + menuChildren: const [MenuItemButton(child: Text('menu item'))], + builder: (BuildContext context, MenuController controller, Widget? child) { + isOpen = controller.isOpen; + return FilledButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Text(isOpen ? 'close' : 'open'), + ); + }, + onOpen: () => openCount++, + onClose: () => closeCount++, + ), + ), + ), + ), + ); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 0); + expect(closeCount, 0); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(find.text('close'), findsOneWidget); + expect(isOpen, true); + expect(openCount, 1); + expect(closeCount, 0); + + await tester.tap(find.byType(FilledButton)); + await tester.pump(); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 1); + expect(closeCount, 1); + }); + + testWidgets('SubmenuButton.submenuIcon updates default arrow icon', ( + WidgetTester tester, + ) async { + const IconData disabledIcon = Icons.close; + const IconData hoveredIcon = Icons.bolt; + const IconData focusedIcon = Icons.favorite; + const IconData defaultIcon = Icons.add; + final WidgetStateProperty submenuIcon = WidgetStateProperty.resolveWith(( + Set states, + ) { + if (states.contains(WidgetState.disabled)) { + return const Icon(disabledIcon); + } + if (states.contains(WidgetState.hovered)) { + return const Icon(hoveredIcon); + } + if (states.contains(WidgetState.focused)) { + return const Icon(focusedIcon); + } + return const Icon(defaultIcon); + }); + + Widget buildMenu({WidgetStateProperty? icon, bool enabled = true}) { + return MaterialApp( home: Material( child: MenuBar( controller: controller, children: [ SubmenuButton( menuChildren: [ - MenuItemButton(shortcut: allModifiers, child: Text(TestMenu.subMenu10.label)), - MenuItemButton(shortcut: charShortcuts, child: Text(TestMenu.subMenu11.label)), + SubmenuButton( + submenuIcon: icon, + menuChildren: + enabled + ? [MenuItemButton(child: Text(TestMenu.mainMenu0.label))] + : [], + child: Text(TestMenu.subSubMenu110.label), + ), ], - child: Text(TestMenu.mainMenu0.label), + child: Text(TestMenu.subMenu00.label), ), ], ), ), - ), - ); - await tester.tap(find.text(TestMenu.mainMenu0.label)); + ); + } + + await tester.pumpWidget(buildMenu()); + await tester.tap(find.text(TestMenu.subMenu00.label)); await tester.pump(); - expect(find.text(allExpected), findsOneWidget); - expect(find.text(charExpected), findsOneWidget); - }, variant: TargetPlatformVariant.all()); - }); - - group('CheckboxMenuButton', () { - testWidgets('tapping toggles checkbox', (WidgetTester tester) async { - bool? checkBoxValue; - await tester.pumpWidget( - MaterialApp( - home: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MenuBar( - children: [ - SubmenuButton( - menuChildren: [ - CheckboxMenuButton( - value: checkBoxValue, - onChanged: (bool? value) { - setState(() { - checkBoxValue = value; - }); - }, - tristate: true, - child: const Text('checkbox'), - ), - ], - child: const Text('submenu'), - ), - ], - ); - }, - ), - ), - ); - - await tester.tap(find.byType(SubmenuButton)); - await tester.pump(); - - expect(tester.widget(find.byType(CheckboxMenuButton)).value, null); - - await tester.tap(find.byType(CheckboxMenuButton)); - await tester.pumpAndSettle(); - expect(checkBoxValue, false); - - await tester.tap(find.byType(SubmenuButton)); - await tester.pump(); - await tester.tap(find.byType(CheckboxMenuButton)); - await tester.pumpAndSettle(); - expect(checkBoxValue, true); - - await tester.tap(find.byType(SubmenuButton)); - await tester.pump(); - await tester.tap(find.byType(CheckboxMenuButton)); - await tester.pumpAndSettle(); - expect(checkBoxValue, null); - }); - }); - - group('RadioMenuButton', () { - testWidgets('tapping toggles radio button', (WidgetTester tester) async { - int? radioValue; - await tester.pumpWidget( - MaterialApp( - home: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MenuBar( - children: [ - SubmenuButton( - menuChildren: [ - RadioMenuButton( - value: 0, - groupValue: radioValue, - onChanged: (int? value) { - setState(() { - radioValue = value; - }); - }, - toggleable: true, - child: const Text('radio 0'), - ), - RadioMenuButton( - value: 1, - groupValue: radioValue, - onChanged: (int? value) { - setState(() { - radioValue = value; - }); - }, - toggleable: true, - child: const Text('radio 1'), - ), - ], - child: const Text('submenu'), - ), - ], - ); - }, - ), - ), - ); - - await tester.tap(find.byType(SubmenuButton)); - await tester.pump(); - - expect( - tester.widget>(find.byType(RadioMenuButton).first).groupValue, - null, - ); - - await tester.tap(find.byType(RadioMenuButton).first); - await tester.pumpAndSettle(); - expect(radioValue, 0); - - await tester.tap(find.byType(SubmenuButton)); - await tester.pump(); - await tester.tap(find.byType(RadioMenuButton).first); - await tester.pumpAndSettle(); - expect(radioValue, null); - - await tester.tap(find.byType(SubmenuButton)); - await tester.pump(); - await tester.tap(find.byType(RadioMenuButton).last); - await tester.pumpAndSettle(); - expect(radioValue, 1); - }); - }); - - group('Semantics', () { - testWidgets('MenuItemButton is not a semantic button', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: MenuItemButton( - style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)), - onPressed: () {}, - child: const Text('ABC'), - ), - ), - ), - ); - - // The flags should not have SemanticsFlag.isButton. - expect( - semantics, - hasSemantics( - TestSemantics.root( - children: [ - TestSemantics.rootChild( - actions: [SemanticsAction.tap, SemanticsAction.focus], - label: 'ABC', - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - transform: Matrix4.translationValues(356.0, 276.0, 0.0), - flags: [ - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - ], - textDirection: TextDirection.ltr, - ), - ], - ), - ignoreId: true, - ), - ); - - semantics.dispose(); - }); - - testWidgets('MenuItemButton semantics respects label', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget( - MaterialApp( - home: Center( - child: MenuItemButton( - semanticsLabel: 'TestWidget', - shortcut: const SingleActivator(LogicalKeyboardKey.comma), - style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)), - onPressed: () {}, - child: const Text('ABC'), - ), - ), - ), - ); - - expect(find.bySemanticsLabel('TestWidget'), findsOneWidget); - semantics.dispose(); - }, variant: TargetPlatformVariant.desktop()); - - testWidgets('SubMenuButton is not a semantic button', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: SubmenuButton( - onHover: (bool value) {}, - style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), - menuChildren: const [], - child: const Text('ABC'), - ), - ), - ), - ); - - // The flags should not have SemanticsFlag.isButton. - expect( - semantics, - hasSemantics( - TestSemantics.root( - children: [ - TestSemantics( - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - flags: [ - SemanticsFlag.hasEnabledState, - SemanticsFlag.hasExpandedState, - ], - label: 'ABC', - textDirection: TextDirection.ltr, - ), - ], - ), - ignoreTransform: true, - ignoreId: true, - ), - ); - - semantics.dispose(); - }); - - testWidgets('SubmenuButton expanded/collapsed state', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget( - MaterialApp( - home: Center( - child: SubmenuButton( - style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), - menuChildren: [ - MenuItemButton( - style: MenuItemButton.styleFrom(fixedSize: const Size(120.0, 36.0)), - child: const Text('Item 0'), - onPressed: () {}, - ), - ], - child: const Text('ABC'), - ), - ), - ), - ); - - // Test expanded state. - await tester.tap(find.text('ABC')); - await tester.pumpAndSettle(); - expect( - semantics, - hasSemantics( - TestSemantics.root( - children: [ - TestSemantics( - id: 1, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ - TestSemantics( - id: 2, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ - TestSemantics( - id: 3, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - flags: [SemanticsFlag.scopesRoute], - children: [ - TestSemantics( - id: 4, - flags: [ - SemanticsFlag.isFocused, - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - SemanticsFlag.hasExpandedState, - SemanticsFlag.isExpanded, - ], - actions: [SemanticsAction.tap, SemanticsAction.focus], - label: 'ABC', - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - ), - TestSemantics( - id: 6, - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 64.0), - children: [ - TestSemantics( - id: 7, - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), - flags: [SemanticsFlag.hasImplicitScrolling], - children: [ - TestSemantics( - id: 8, - label: 'Item 0', - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), - flags: [ - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - ], - actions: [ - SemanticsAction.tap, - SemanticsAction.focus, - ], - ), - ], - ), - ], - ), - ], - ), - ], - ), - ], - ), - ], - ), - ignoreTransform: true, - ), - ); - - // Test collapsed state. - await tester.tap(find.text('ABC')); - await tester.pumpAndSettle(); - expect( - semantics, - hasSemantics( - TestSemantics.root( - children: [ - TestSemantics( - id: 1, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ - TestSemantics( - id: 2, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ - TestSemantics( - id: 3, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - flags: [SemanticsFlag.scopesRoute], - children: [ - TestSemantics( - id: 4, - flags: [ - SemanticsFlag.hasExpandedState, - SemanticsFlag.isFocused, - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - ], - actions: [SemanticsAction.tap, SemanticsAction.focus], - label: 'ABC', - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - ), - ], - ), - ], - ), - ], - ), - ], - ), - ignoreTransform: true, - ), - ); - - semantics.dispose(); - }); - }); - - // This is a regression test for https://github.com/flutter/flutter/issues/131676. - testWidgets('Material3 - Menu uses correct text styles', (WidgetTester tester) async { - const TextStyle menuTextStyle = TextStyle( - fontSize: 18.5, - fontStyle: FontStyle.italic, - wordSpacing: 1.2, - decoration: TextDecoration.lineThrough, - ); - final ThemeData themeData = ThemeData(textTheme: const TextTheme(labelLarge: menuTextStyle)); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: Material( - child: MenuBar( - controller: controller, - children: createTestMenus(onPressed: onPressed, onOpen: onOpen, onClose: onClose), - ), - ), - ), - ); - - // Test menu button text style uses the TextTheme.labelLarge. - Finder buttonMaterial = - find.descendant(of: find.byType(TextButton), matching: find.byType(Material)).first; - Material material = tester.widget(buttonMaterial); - expect(material.textStyle?.fontSize, menuTextStyle.fontSize); - expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle); - expect(material.textStyle?.wordSpacing, menuTextStyle.wordSpacing); - expect(material.textStyle?.decoration, menuTextStyle.decoration); - - // Open the menu. - await tester.tap(find.text(TestMenu.mainMenu1.label)); - await tester.pump(); - - // Test menu item text style uses the TextTheme.labelLarge. - buttonMaterial = - find - .descendant( - of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), - matching: find.byType(Material), - ) - .first; - material = tester.widget(buttonMaterial); - expect(material.textStyle?.fontSize, menuTextStyle.fontSize); - expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle); - expect(material.textStyle?.wordSpacing, menuTextStyle.wordSpacing); - expect(material.textStyle?.decoration, menuTextStyle.decoration); - }); - - testWidgets('SubmenuButton.onFocusChange is respected', (WidgetTester tester) async { - final FocusNode focusNode = FocusNode(); - addTearDown(focusNode.dispose); - int onFocusChangeCalled = 0; - await tester.pumpWidget( - MaterialApp( - home: Material( - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return SubmenuButton( - focusNode: focusNode, - onFocusChange: (bool value) { - setState(() { - onFocusChangeCalled += 1; - }); - }, - menuChildren: const [MenuItemButton(child: Text('item 0'))], - child: const Text('Submenu 0'), - ); - }, - ), - ), - ), - ); - - focusNode.requestFocus(); - await tester.pump(); - expect(focusNode.hasFocus, true); - expect(onFocusChangeCalled, 1); - - focusNode.unfocus(); - await tester.pump(); - expect(focusNode.hasFocus, false); - expect(onFocusChangeCalled, 2); - }); - - testWidgets('Horizontal _MenuPanel wraps children with IntrinsicWidth', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - MaterialApp( - home: Material( - child: MenuBar( - children: [MenuItemButton(onPressed: () {}, child: const Text('Menu Item'))], - ), - ), - ), - ); - - // Horizontal _MenuPanel wraps children with IntrinsicWidth to ensure MenuItemButton - // with vertical overflow axis is as wide as the widest child. - final Finder intrinsicWidthFinder = find.ancestor( - of: find.byType(MenuItemButton), - matching: find.byType(IntrinsicWidth), - ); - expect(intrinsicWidthFinder, findsOneWidget); - }); - - testWidgets('SubmenuButton.styleFrom overlayColor overrides default overlay color', ( - WidgetTester tester, - ) async { - const Color overlayColor = Color(0xffff00ff); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SubmenuButton( - style: SubmenuButton.styleFrom(overlayColor: overlayColor), - menuChildren: [ - MenuItemButton(onPressed: () {}, child: const Text('MenuItemButton')), - ], - child: const Text('Submenu'), - ), - ), - ), - ); - - // Hovered. - final Offset center = tester.getCenter(find.byType(SubmenuButton)); - final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(); - await gesture.moveTo(center); - await tester.pumpAndSettle(); - expect(getOverlayColor(tester), paints..rect(color: overlayColor.withOpacity(0.08))); - - // Highlighted (pressed). - await gesture.down(center); - await tester.pumpAndSettle(); - expect( - getOverlayColor(tester), - paints - ..rect(color: overlayColor.withOpacity(0.08)) - ..rect(color: overlayColor.withOpacity(0.08)) - ..rect(color: overlayColor.withOpacity(0.1)), - ); - }); - - testWidgets( - 'Garbage collector destroys child _MenuAnchorState after parent is closed', - (WidgetTester tester) async { - // Regression test for https://github.com/flutter/flutter/issues/149584 - await tester.pumpWidget( - MaterialApp( - home: MenuAnchor( - controller: controller, - menuChildren: const [SubmenuButton(menuChildren: [], child: Text(''))], - ), - ), - ); - - controller.open(); - await tester.pump(); - - final WeakReference state = WeakReference( - tester.firstState>(find.byType(SubmenuButton)), - ); - expect(state.target, isNotNull); + expect(find.byIcon(Icons.arrow_right), findsOneWidget); controller.close(); await tester.pump(); - controller.open(); + await tester.pumpWidget(buildMenu(icon: submenuIcon)); + await tester.tap(find.text(TestMenu.subMenu00.label)); await tester.pump(); + expect(find.byIcon(defaultIcon), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.byIcon(focusedIcon), findsOneWidget); controller.close(); await tester.pump(); - // Garbage collect. 1 should be enough, but 3 prevents flaky tests. - await tester.runAsync(() async { - await forceGC(fullGcCycles: 3); - }); + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + await hoverOver(tester, find.text(TestMenu.subSubMenu110.label)); + await tester.pump(); + expect(find.byIcon(hoveredIcon), findsOneWidget); - expect(state.target, isNull); - }, - // Skipped on Web: [intended] ForceGC does not work in web and in release mode. See https://api.flutter.dev/flutter/package-leak_tracker_leak_tracker/forceGC.html - // Skipped for everyone else: forceGC is flaky, see https://github.com/flutter/flutter/issues/154858 - skip: true, - ); + controller.close(); + await tester.pump(); - // Regression test for https://github.com/flutter/flutter/issues/154798. - testWidgets('MenuItemButton.styleFrom can customize the button icon', ( - WidgetTester tester, - ) async { - const Color iconColor = Color(0xFFF000FF); - const double iconSize = 32.0; - const Color disabledIconColor = Color(0xFFFFF000); - Widget buildButton({bool enabled = true}) { - return MaterialApp( - home: Material( - child: Center( - child: MenuItemButton( - style: MenuItemButton.styleFrom( - iconColor: iconColor, - iconSize: iconSize, - disabledIconColor: disabledIconColor, - ), - onPressed: enabled ? () {} : null, - trailingIcon: const Icon(Icons.add), - child: const Text('Button'), - ), - ), - ), - ); - } - - // Test enabled button. - await tester.pumpWidget(buildButton()); - expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); - expect(iconStyle(tester, Icons.add).color, iconColor); - - // Test disabled button. - await tester.pumpWidget(buildButton(enabled: false)); - expect(iconStyle(tester, Icons.add).color, disabledIconColor); - }); - - // Regression test for https://github.com/flutter/flutter/issues/154798. - testWidgets('SubmenuButton.styleFrom can customize the button icon', (WidgetTester tester) async { - const Color iconColor = Color(0xFFF000FF); - const double iconSize = 32.0; - const Color disabledIconColor = Color(0xFFFFF000); - Widget buildButton({bool enabled = true}) { - return MaterialApp( - home: Material( - child: Center( - child: SubmenuButton( - style: SubmenuButton.styleFrom( - iconColor: iconColor, - iconSize: iconSize, - disabledIconColor: disabledIconColor, - ), - trailingIcon: const Icon(Icons.add), - menuChildren: [if (enabled) const Text('Item')], - child: const Text('SubmenuButton'), - ), - ), - ), - ); - } - - // Test enabled button. - await tester.pumpWidget(buildButton()); - expect(tester.getSize(find.byIcon(Icons.add)), const Size(iconSize, iconSize)); - expect(iconStyle(tester, Icons.add).color, iconColor); - - // Test disabled button. - await tester.pumpWidget(buildButton(enabled: false)); - expect(iconStyle(tester, Icons.add).color, disabledIconColor); - }); - - // Regression test for https://github.com/flutter/flutter/issues/156572. - testWidgets('Unattached MenuController does not throw when calling close', ( - WidgetTester tester, - ) async { - final MenuController controller = MenuController(); - controller.close(); - await tester.pump(); - expect(tester.takeException(), isNull); - }); - - testWidgets('Unattached MenuController returns false when calling isOpen', ( - WidgetTester tester, - ) async { - final MenuController controller = MenuController(); - expect(controller.isOpen, false); - }); - - // Regression test for https://github.com/flutter/flutter/issues/157606. - testWidgets('MenuAnchor updates isOpen state correctly', (WidgetTester tester) async { - bool isOpen = false; - int openCount = 0; - int closeCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: MenuAnchor( - menuChildren: const [MenuItemButton(child: Text('menu item'))], - builder: (BuildContext context, MenuController controller, Widget? child) { - isOpen = controller.isOpen; - return FilledButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: Text(isOpen ? 'close' : 'open'), - ); - }, - onOpen: () => openCount++, - onClose: () => closeCount++, - ), - ), - ), - ), - ); - - expect(find.text('open'), findsOneWidget); - expect(isOpen, false); - expect(openCount, 0); - expect(closeCount, 0); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(find.text('close'), findsOneWidget); - expect(isOpen, true); - expect(openCount, 1); - expect(closeCount, 0); - - await tester.tap(find.byType(FilledButton)); - await tester.pump(); - - expect(find.text('open'), findsOneWidget); - expect(isOpen, false); - expect(openCount, 1); - expect(closeCount, 1); - }); - - testWidgets('SubmenuButton.submenuIcon updates default arrow icon', (WidgetTester tester) async { - const IconData disabledIcon = Icons.close; - const IconData hoveredIcon = Icons.bolt; - const IconData focusedIcon = Icons.favorite; - const IconData defaultIcon = Icons.add; - final WidgetStateProperty submenuIcon = WidgetStateProperty.resolveWith(( - Set states, - ) { - if (states.contains(WidgetState.disabled)) { - return const Icon(disabledIcon); - } - if (states.contains(WidgetState.hovered)) { - return const Icon(hoveredIcon); - } - if (states.contains(WidgetState.focused)) { - return const Icon(focusedIcon); - } - return const Icon(defaultIcon); + await tester.pumpWidget(buildMenu(icon: submenuIcon, enabled: false)); + await tester.tap(find.text(TestMenu.subMenu00.label)); + await tester.pump(); + expect(find.byIcon(disabledIcon), findsOneWidget); }); - - Widget buildMenu({WidgetStateProperty? icon, bool enabled = true}) { - return MaterialApp( - home: Material( - child: MenuBar( - controller: controller, - children: [ - SubmenuButton( - menuChildren: [ - SubmenuButton( - submenuIcon: icon, - menuChildren: - enabled - ? [MenuItemButton(child: Text(TestMenu.mainMenu0.label))] - : [], - child: Text(TestMenu.subSubMenu110.label), - ), - ], - child: Text(TestMenu.subMenu00.label), - ), - ], - ), - ), - ); - } - - await tester.pumpWidget(buildMenu()); - await tester.tap(find.text(TestMenu.subMenu00.label)); - await tester.pump(); - - expect(find.byIcon(Icons.arrow_right), findsOneWidget); - - controller.close(); - await tester.pump(); - - await tester.pumpWidget(buildMenu(icon: submenuIcon)); - await tester.tap(find.text(TestMenu.subMenu00.label)); - await tester.pump(); - expect(find.byIcon(defaultIcon), findsOneWidget); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - expect(find.byIcon(focusedIcon), findsOneWidget); - - controller.close(); - await tester.pump(); - - await tester.tap(find.text(TestMenu.subMenu00.label)); - await tester.pump(); - await hoverOver(tester, find.text(TestMenu.subSubMenu110.label)); - await tester.pump(); - expect(find.byIcon(hoveredIcon), findsOneWidget); - - controller.close(); - await tester.pump(); - - await tester.pumpWidget(buildMenu(icon: submenuIcon, enabled: false)); - await tester.tap(find.text(TestMenu.subMenu00.label)); - await tester.pump(); - expect(find.byIcon(disabledIcon), findsOneWidget); }); } diff --git a/packages/flutter/test/widgets/raw_menu_anchor_test.dart b/packages/flutter/test/widgets/raw_menu_anchor_test.dart new file mode 100644 index 0000000000..bfbf199769 --- /dev/null +++ b/packages/flutter/test/widgets/raw_menu_anchor_test.dart @@ -0,0 +1,2577 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Tests that apply to select constructors have a suffix that indicates which +// constructor the test applies to: +// * [Default]: Applies to [RawMenuAnchor] +// * [Group]: Applies to [RawMenuAnchorGroup]. +// Otherwise, a test applies to all constructors. + +void main() { + late MenuController controller; + String? focusedMenu; + final List selected = []; + final List opened = []; + final List closed = []; + + void onPressed(Tag item) { + selected.add(item); + } + + void onOpen(Tag item) { + opened.add(item); + } + + void onClose(Tag item) { + opened.remove(item); + closed.add(item); + } + + void handleFocusChange() { + focusedMenu = (primaryFocus?.debugLabel ?? primaryFocus).toString(); + } + + setUp(() { + focusedMenu = null; + selected.clear(); + opened.clear(); + closed.clear(); + controller = MenuController(); + focusedMenu = null; + }); + + Future changeSurfaceSize(WidgetTester tester, Size size) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + } + + void listenForFocusChanges() { + FocusManager.instance.addListener(handleFocusChange); + addTearDown(() => FocusManager.instance.removeListener(handleFocusChange)); + } + + T findMenuPanelDescendent(WidgetTester tester) { + return tester.firstWidget(find.descendant(of: find.byType(Panel), matching: find.byType(T))); + } + + List findAncestorRenderTheaters(RenderObject child) { + final List results = []; + RenderObject? node = child; + while (node != null) { + if (node.runtimeType.toString() == '_RenderTheater') { + results.add(node); + } + final RenderObject? parent = node.parent; + node = parent is RenderObject ? parent : null; + } + return results; + } + + testWidgets("[Default] MenuController.isOpen is true when a menu's overlay is shown", ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + Menu( + controller: controller, + menuPanel: Panel(children: [Text(Tag.a.text)]), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(find.text(Tag.a.text), findsOneWidget); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(controller.isOpen, isFalse); + expect(find.text(Tag.a.text), findsNothing); + }); + + testWidgets('[Default] MenuController.open() and .close() toggle overlay visibility', ( + WidgetTester tester, + ) async { + final MenuController nestedController = MenuController(); + await tester.pumpWidget( + App( + Menu( + controller: controller, + menuPanel: Panel( + children: [ + Text(Tag.a.text), + Menu( + controller: nestedController, + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: const AnchorButton(Tag.b), + ), + ], + ), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + // Create the menu. The menu is closed, so no menu items should be found in + // the widget tree. + expect(controller.isOpen, isFalse); + expect(find.text(Tag.anchor.text), findsOne); + expect(find.text(Tag.a.text), findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(nestedController.isOpen, isFalse); + expect(find.text(Tag.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + + // Open the nested menu. + nestedController.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(nestedController.isOpen, isTrue); + expect(find.text(Tag.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsOneWidget); + + // Close the menu from the root controller. + controller.close(); + await tester.pump(); + + // All menus should be closed. + expect(controller.isOpen, isFalse); + expect(find.text(Tag.a.text), findsNothing); + expect(find.text(Tag.b.a.text), findsNothing); + + // Open the nested menu. + controller.open(); + await tester.pump(); + + nestedController.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(nestedController.isOpen, isTrue); + expect(find.text(Tag.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsOneWidget); + + // Close the nested menu, but not the root menu. + nestedController.close(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(nestedController.isOpen, isFalse); + expect(find.text(Tag.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + }); + + testWidgets('[Default] MenuController.closeChildren closes submenu children', ( + WidgetTester tester, + ) async { + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + App( + Menu( + controller: controller, + menuPanel: Panel( + children: [ + Text(Tag.a.text), + Menu( + focusNode: focusNode, + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: AnchorButton(Tag.b, focusNode: focusNode), + ), + ], + ), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + + focusNode.requestFocus(); + await tester.pump(); + + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsOneWidget); + + controller.closeChildren(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + + // Focus should stay on the anchor button. + expect(FocusManager.instance.primaryFocus, focusNode); + }); + + testWidgets('[Default] Can only have one open child anchor', (WidgetTester tester) async { + await tester.pumpWidget( + App( + RawMenuAnchor( + controller: controller, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo position) { + return Column( + children: [ + Menu( + menuPanel: Panel(children: [Text(Tag.a.a.text)]), + child: const AnchorButton(Tag.a), + ), + Menu( + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: const AnchorButton(Tag.b), + ), + ], + ); + }, + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(find.text(Tag.a.text), findsOneWidget); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.a.a.text), findsNothing); + expect(find.text(Tag.b.a.text), findsNothing); + + await tester.tap(find.text(Tag.a.text)); + await tester.pump(); + + expect(find.text(Tag.a.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + + expect(find.text(Tag.a.a.text), findsNothing); + expect(find.text(Tag.b.a.text), findsOneWidget); + }); + + testWidgets('[Default] Context menus can be nested', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Menu( + menuPanel: Panel(children: [Button.tag(Tag.a.a)]), + builder: (BuildContext context, MenuController controller, Widget? child) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AnchorButton(Tag.a), + Menu( + menuPanel: Panel(children: [Button.tag(Tag.b.a)]), + child: const AnchorButton(Tag.b), + ), + ], + ); + }, + ), + ), + ); + + await tester.tap(find.text(Tag.a.text)); + await tester.pump(); + + expect(find.text(Tag.a.a.text), findsOneWidget); + + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + + expect(find.text(Tag.b.a.text), findsOneWidget); + }); + + testWidgets('[Group] MenuController.isOpen is true when a descendent menu is open', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: controller, + child: Row( + children: [ + Menu( + menuPanel: Panel(children: [Text(Tag.a.a.text)]), + child: const AnchorButton(Tag.a), + ), + // Menu should not need to be a direct descendent. + Padding( + padding: const EdgeInsets.all(8.0), + child: Menu( + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: const AnchorButton(Tag.b), + ), + ), + ], + ), + ), + ), + ); + + expect(controller.isOpen, isFalse); + + await tester.tap(find.text(Tag.a.text)); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(find.text(Tag.a.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(find.text(Tag.a.a.text), findsNothing); + expect(find.text(Tag.b.a.text), findsOneWidget); + + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + + expect(controller.isOpen, isFalse); + expect(find.text(Tag.b.a.text), findsNothing); + }); + + testWidgets('[Group] MenuController.open does nothing', (WidgetTester tester) async { + final MenuController nestedController = MenuController(); + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: controller, + child: Column( + children: [ + Menu( + controller: nestedController, + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: const AnchorButton(Tag.b), + ), + ], + ), + ), + ), + ); + + // Create the menu. The menu is closed, so no menu items should be found in + // the widget tree. + expect(controller.isOpen, isFalse); + expect(find.text(Tag.b.text), findsOne); + expect(find.text(Tag.b.a.text), findsNothing); + + // Open the menu (which should do nothing). + controller.open(); + await tester.pump(); + + expect(controller.isOpen, isFalse); + expect(nestedController.isOpen, isFalse); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + }); + + testWidgets('[Group] MenuController.close closes children', (WidgetTester tester) async { + final MenuController nestedController = MenuController(); + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: controller, + child: Column( + children: [ + Menu( + controller: nestedController, + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: const AnchorButton(Tag.b), + ), + ], + ), + ), + ), + ); + + // Open the nested anchor. + nestedController.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(nestedController.isOpen, isTrue); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsOneWidget); + + // Close the root menu panel + controller.close(); + await tester.pump(); + + expect(controller.isOpen, isFalse); + expect(nestedController.isOpen, isFalse); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + }); + + testWidgets('[Group] MenuController.closeChildren closes children', (WidgetTester tester) async { + final MenuController nestedController = MenuController(); + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: controller, + child: Column( + children: [ + Menu( + controller: nestedController, + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: const AnchorButton(Tag.b), + ), + ], + ), + ), + ), + ); + + // Open the nested anchor. + nestedController.open(); + await tester.pump(); + + expect(controller.isOpen, isTrue); + expect(nestedController.isOpen, isTrue); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsOneWidget); + + // Close the root menu panel. + controller.closeChildren(); + await tester.pump(); + + expect(controller.isOpen, isFalse); + expect(nestedController.isOpen, isFalse); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + }); + + testWidgets('[Group] Should only display one open child anchor at a time', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: controller, + child: Row( + children: [ + Menu( + menuPanel: Panel(children: [Text(Tag.a.a.text)]), + child: const AnchorButton(Tag.a), + ), + Menu( + menuPanel: Panel(children: [Text(Tag.b.a.text)]), + child: const AnchorButton(Tag.b), + ), + ], + ), + ), + ), + ); + + expect(find.text(Tag.a.text), findsOneWidget); + expect(find.text(Tag.b.text), findsOneWidget); + expect(find.text(Tag.a.a.text), findsNothing); + expect(find.text(Tag.b.a.text), findsNothing); + + await tester.tap(find.text(Tag.a.text)); + await tester.pump(); + + expect(find.text(Tag.a.a.text), findsOneWidget); + expect(find.text(Tag.b.a.text), findsNothing); + + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + + expect(find.text(Tag.a.a.text), findsNothing); + expect(find.text(Tag.b.a.text), findsOneWidget); + }); + + testWidgets('MenuController.maybeIsOpenOf notifies dependents when isOpen changes', ( + WidgetTester tester, + ) async { + final MenuController groupController = MenuController(); + final MenuController controller = MenuController(); + final MenuController nestedController = MenuController(); + bool? panelIsOpen; + bool? overlayIsOpen; + bool? anchorIsOpen; + int panelBuilds = 0; + int anchorBuilds = 0; + int overlayBuilds = 0; + + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: groupController, + child: Column( + children: [ + // Panel context. + Builder( + builder: (BuildContext context) { + panelIsOpen = MenuController.maybeIsOpenOf(context); + panelBuilds += 1; + return Text(Tag.a.text); + }, + ), + Menu( + controller: controller, + menuPanel: Panel( + children: [ + // Overlay context. + Builder( + builder: (BuildContext context) { + overlayIsOpen = MenuController.maybeIsOpenOf(context); + overlayBuilds += 1; + return Text(Tag.b.a.a.text); + }, + ), + Menu( + controller: nestedController, + menuPanel: Panel(children: [Button.tag(Tag.b.a.b.a)]), + child: Button.tag(Tag.b.a.b), + ), + ], + ), + // Anchor context. + child: Builder( + builder: (BuildContext context) { + anchorIsOpen = MenuController.maybeIsOpenOf(context); + anchorBuilds += 1; + return Text(Tag.b.a.text); + }, + ), + ), + ], + ), + ), + ), + ); + + expect(panelIsOpen, isFalse); + expect(anchorIsOpen, isFalse); + expect(panelBuilds, equals(1)); + expect(anchorBuilds, equals(1)); + expect(overlayBuilds, equals(0)); + + controller.open(); + await tester.pump(); + + expect(panelIsOpen, isTrue); + expect(anchorIsOpen, isTrue); + expect(overlayIsOpen, isTrue); + expect(panelBuilds, equals(2)); + expect(anchorBuilds, equals(2)); + expect(overlayBuilds, equals(1)); + + nestedController.open(); + await tester.pump(); + + // No new builds should have occurred since all controllers are already open. + expect(panelIsOpen, isTrue); + expect(anchorIsOpen, isTrue); + expect(overlayIsOpen, isTrue); + expect(panelBuilds, equals(2)); + expect(anchorBuilds, equals(2)); + expect(overlayBuilds, equals(1)); + + controller.close(); + await tester.pump(); + + expect(panelIsOpen, isFalse); + expect(anchorIsOpen, isFalse); + + // Will be true because builder cannot rebuild when the menu is closed. + expect(overlayIsOpen, isTrue); + expect(panelBuilds, equals(3)); + expect(anchorBuilds, equals(3)); + expect(overlayBuilds, equals(1)); + }); + + testWidgets('MenuController.maybeOf does not notify dependents when MenuController changes', ( + WidgetTester tester, + ) async { + final GlobalKey anchorKey = GlobalKey(); + final MenuController demoControllerOne = MenuController(); + final MenuController demoControllerTwo = MenuController(); + + MenuController? panelController; + MenuController? overlayController; + MenuController? anchorController; + int panelBuilds = 0; + int anchorBuilds = 0; + int overlayBuilds = 0; + + Widget buildAnchor({required MenuController panel, required MenuController overlay}) { + return App( + RawMenuAnchorGroup( + controller: panel, + child: Column( + children: [ + // Panel context. + Builder( + builder: (BuildContext context) { + panelController = MenuController.maybeOf(context); + panelBuilds += 1; + return Text(Tag.a.text); + }, + ), + Menu( + controller: overlay, + menuPanel: Panel( + children: [ + // Overlay context. + Builder( + builder: (BuildContext context) { + overlayController = MenuController.maybeOf(context); + overlayBuilds += 1; + return Text(Tag.b.a.a.text); + }, + ), + ], + ), + // Anchor context. + child: Builder( + key: anchorKey, + builder: (BuildContext context) { + anchorController = MenuController.maybeOf(context); + anchorBuilds += 1; + return Text(Tag.b.a.text); + }, + ), + ), + ], + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor(panel: demoControllerOne, overlay: demoControllerTwo)); + + expect(panelController, isNot(controller)); + expect(anchorController, isNot(controller)); + + await tester.pumpWidget(buildAnchor(panel: controller, overlay: demoControllerTwo)); + + expect(panelController, equals(controller)); + expect(anchorController, isNot(controller)); + expect(panelBuilds, equals(2)); + expect(anchorBuilds, equals(2)); + + MenuController.maybeOf(anchorKey.currentContext!)?.open(); + await tester.pump(); + + expect(panelBuilds, equals(2)); + expect(anchorBuilds, equals(2)); + expect(overlayBuilds, equals(1)); + + await tester.pumpWidget(buildAnchor(panel: demoControllerOne, overlay: controller)); + + expect(panelController, isNot(controller)); + expect(anchorController, equals(controller)); + expect(overlayController, equals(controller)); + expect(panelBuilds, equals(3)); + expect(anchorBuilds, equals(3)); + expect(overlayBuilds, equals(2)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/156572. + testWidgets('Unattached MenuController does not throw when calling close', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + controller.close(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Unattached MenuController returns false when calling isOpen', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + expect(controller.isOpen, false); + }); + + testWidgets('[Default] MenuController is detached on update', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Menu( + controller: controller, + menuPanel: const SizedBox.shrink(), + child: const SizedBox.shrink(), + ), + ), + ); + + // Should not throw because the controller is attached to the menu. + controller.closeChildren(); + + await tester.pumpWidget( + const App(Menu(menuPanel: SizedBox.shrink(), child: SizedBox.shrink())), + ); + + String serializedException = ''; + runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) { + serializedException = exception.toString(); + }); + + expect(serializedException, contains('_anchor != null')); + }); + + testWidgets('[Group] MenuController is detached on update', (WidgetTester tester) async { + await tester.pumpWidget( + App(RawMenuAnchorGroup(controller: controller, child: const SizedBox.shrink())), + ); + + // Should not throw because the controller is attached to the menu. + controller.closeChildren(); + + await tester.pumpWidget( + App(RawMenuAnchorGroup(controller: MenuController(), child: const SizedBox.shrink())), + ); + + String serializedException = ''; + runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) { + serializedException = exception.toString(); + }); + + expect(serializedException, contains('_anchor != null')); + }); + + testWidgets('[Default] MenuController is detached on dispose', (WidgetTester tester) async { + await tester.pumpWidget( + App(Menu(controller: controller, menuPanel: const SizedBox(), child: const SizedBox())), + ); + + // Should not throw because the controller is attached to the menu. + controller.closeChildren(); + + await tester.pumpWidget(const App(SizedBox())); + + String serializedException = ''; + runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) { + serializedException = exception.toString(); + }); + + expect(serializedException, contains('_anchor != null')); + }); + + testWidgets('[Group] MenuController is detached on dispose', (WidgetTester tester) async { + await tester.pumpWidget( + App(RawMenuAnchorGroup(controller: controller, child: const SizedBox())), + ); + + // Should not throw because the controller is attached to the menu. + controller.closeChildren(); + + await tester.pumpWidget(const App(SizedBox())); + + String serializedException = ''; + runZonedGuarded(controller.closeChildren, (Object exception, StackTrace stackTrace) { + serializedException = exception.toString(); + }); + + expect(serializedException, contains('_anchor != null')); + }); + + testWidgets('[Default] MenuOverlayPosition.anchorRect applies transformations to panel', ( + WidgetTester tester, + ) async { + RawMenuOverlayInfo? builderPosition; + final GlobalKey anchorKey = GlobalKey(); + await tester.pumpWidget( + App( + Transform( + transform: Matrix4.translationValues(-50, 50, 0)..scale(1.2), + child: RawMenuAnchor( + controller: controller, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo position) { + builderPosition = position; + return Positioned.fromRect( + rect: position.anchorRect, + child: Container(color: const Color(0xFF0000FF)), + ); + }, + child: AnchorButton(Tag.b, key: anchorKey), + ), + ), + ), + ); + + controller.open(); + await tester.pump(); + + expect(tester.getRect(find.byType(AnchorButton)), equals(builderPosition?.anchorRect)); + }); + + testWidgets('Escape key closes menus', (WidgetTester tester) async { + final FocusNode aFocusNode = FocusNode(); + final FocusNode baaFocusNode = FocusNode(); + final MenuController menuController = MenuController(); + addTearDown(aFocusNode.dispose); + addTearDown(baaFocusNode.dispose); + + await tester.pumpWidget( + App( + RawMenuAnchorGroup( + controller: controller, + child: Row( + children: [ + Button.tag(Tag.a, focusNode: aFocusNode), + Menu( + controller: menuController, + menuPanel: Panel( + children: [ + Menu( + menuPanel: Panel( + children: [Button.tag(Tag.b.a.a, focusNode: baaFocusNode)], + ), + child: AnchorButton(Tag.b.a), + ), + ], + ), + child: const AnchorButton(Tag.b), + ), + ], + ), + ), + ), + ); + + menuController.open(); + await tester.pump(); + + aFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, aFocusNode); + expect(find.text(Tag.b.a.text), findsOneWidget); + + // Test panel child can close siblings with escape key. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(find.text(Tag.b.a.text), findsNothing); + + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + await tester.tap(find.text(Tag.b.a.text)); + await tester.pump(); + baaFocusNode.requestFocus(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus, baaFocusNode); + + // Test ancestors menus are closed with escape key. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(find.text(Tag.b.a.text), findsNothing); + }); + + // Credit to Closure library for the test idea. + testWidgets('Intents are not blocked by closed anchor', (WidgetTester tester) async { + final List invokedIntents = []; + final FocusNode aFocusNode = FocusNode(); + addTearDown(aFocusNode.dispose); + + await tester.pumpWidget( + App( + Actions( + actions: >{ + DirectionalFocusIntent: CallbackAction( + onInvoke: (DirectionalFocusIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + NextFocusIntent: CallbackAction( + onInvoke: (NextFocusIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + PreviousFocusIntent: CallbackAction( + onInvoke: (PreviousFocusIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + DismissIntent: CallbackAction( + onInvoke: (DismissIntent intent) { + invokedIntents.add(intent); + return; + }, + ), + }, + child: RawMenuAnchorGroup( + controller: controller, + child: Row( + children: [ + Menu( + menuPanel: Panel(children: [Text(Tag.a.text)]), + child: AnchorButton(Tag.anchor, focusNode: aFocusNode), + ), + ], + ), + ), + ), + ), + ); + + aFocusNode.requestFocus(); + await tester.pump(); + Actions.invoke(aFocusNode.context!, const DirectionalFocusIntent(TraversalDirection.up)); + Actions.invoke(aFocusNode.context!, const NextFocusIntent()); + Actions.invoke(aFocusNode.context!, const PreviousFocusIntent()); + Actions.invoke(aFocusNode.context!, const DismissIntent()); + await tester.pump(); + + expect( + invokedIntents, + equals(const [ + DirectionalFocusIntent(TraversalDirection.up), + NextFocusIntent(), + PreviousFocusIntent(), + DismissIntent(), + ]), + ); + }); + + testWidgets('[Default] Focus traversal shortcuts are not bound to actions', ( + WidgetTester tester, + ) async { + final FocusNode anchorFocusNode = FocusNode(debugLabel: Tag.anchor.focusNode); + final FocusNode bFocusNode = FocusNode(debugLabel: Tag.b.focusNode); + addTearDown(anchorFocusNode.dispose); + addTearDown(bFocusNode.dispose); + + final Map traversalShortcuts = { + LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const PreviousFocusIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent( + TraversalDirection.left, + ), + }; + + final List invokedIntents = []; + await tester.pumpWidget( + App( + Column( + children: [ + Button.tag(Tag.a), + Actions( + actions: >{ + DirectionalFocusIntent: CallbackAction( + onInvoke: (DirectionalFocusIntent intent) { + invokedIntents.add(intent); + return null; + }, + ), + NextFocusIntent: CallbackAction( + onInvoke: (NextFocusIntent intent) { + invokedIntents.add(intent); + return null; + }, + ), + PreviousFocusIntent: CallbackAction( + onInvoke: (PreviousFocusIntent intent) { + invokedIntents.add(intent); + return null; + }, + ), + }, + child: RawMenuAnchor( + controller: controller, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo position) { + return Column( + children: [ + Button.tag(Tag.a), + Shortcuts( + // Web doesn't automatically handle directional traversal. + shortcuts: traversalShortcuts, + child: Button.tag(Tag.b, focusNode: bFocusNode), + ), + Button.tag(Tag.d), + ], + ); + }, + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + Button.tag(Tag.c), + ], + ), + ), + ); + + listenForFocusChanges(); + + controller.open(); + await tester.pump(); + + anchorFocusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + expect(focusedMenu, equals(Tag.anchor.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + expect(focusedMenu, equals(Tag.anchor.focusNode)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(focusedMenu, equals(Tag.anchor.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.home); + expect(focusedMenu, equals(Tag.anchor.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.end); + expect(focusedMenu, equals(Tag.anchor.focusNode)); + + bFocusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + expect(focusedMenu, equals(Tag.b.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + expect(focusedMenu, equals(Tag.b.focusNode)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(focusedMenu, equals(Tag.b.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.home); + expect(focusedMenu, equals(Tag.b.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.end); + expect(focusedMenu, equals(Tag.b.focusNode)); + + expect( + invokedIntents, + equals(const [ + DirectionalFocusIntent(TraversalDirection.left), + NextFocusIntent(), + PreviousFocusIntent(), + DirectionalFocusIntent(TraversalDirection.left), + NextFocusIntent(), + PreviousFocusIntent(), + ]), + ); + }); + + testWidgets('Actions that wrap Menu are invoked by both anchor and overlay', ( + WidgetTester tester, + ) async { + final FocusNode anchorFocusNode = FocusNode(); + final FocusNode aFocusNode = FocusNode(); + addTearDown(anchorFocusNode.dispose); + addTearDown(aFocusNode.dispose); + bool invokedAnchor = false; + bool invokedOverlay = false; + + await tester.pumpWidget( + App( + Actions( + actions: >{ + VoidCallbackIntent: CallbackAction( + onInvoke: (VoidCallbackIntent intent) { + intent.callback(); + return null; + }, + ), + }, + child: Menu( + focusNode: anchorFocusNode, + menuPanel: Panel(children: [Button.tag(Tag.a, focusNode: aFocusNode)]), + child: AnchorButton(Tag.anchor, focusNode: anchorFocusNode), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + Actions.invoke( + anchorFocusNode.context!, + VoidCallbackIntent(() { + invokedAnchor = true; + }), + ); + Actions.invoke( + aFocusNode.context!, + VoidCallbackIntent(() { + invokedOverlay = true; + }), + ); + + await tester.pump(); + + // DismissIntent should not close the menu. + expect(invokedAnchor, isTrue); + expect(invokedOverlay, isTrue); + }); + + testWidgets('DismissMenuAction closes menus', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + App( + Menu( + menuPanel: Panel( + children: [ + Text(Tag.a.text), + Menu( + menuPanel: Panel( + children: [ + Text(Tag.b.a.text), + Menu( + controller: controller, + menuPanel: Panel(children: [Text(Tag.b.b.a.text)]), + child: AnchorButton(Tag.b.b, focusNode: focusNode), + ), + ], + ), + child: const AnchorButton(Tag.b), + ), + ], + ), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + await tester.tap(find.text(Tag.b.b.text)); + await tester.pump(); + + expect(controller.isOpen, isTrue); + + focusNode.requestFocus(); + await tester.pump(); + + const ActionDispatcher().invokeAction( + DismissMenuAction(controller: controller), + const DismissIntent(), + focusNode.context, + ); + + await tester.pump(); + + expect(find.text(Tag.a.text), findsNothing); + }); + + testWidgets('[Group] Menu panel builder', (WidgetTester tester) async { + await tester.pumpWidget( + App( + alignment: AlignmentDirectional.topStart, + RawMenuAnchorGroup( + controller: controller, + child: Padding( + key: Tag.anchor.key, + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 100, height: 100, color: const ui.Color(0xff0000ff)), + Container(width: 100, height: 100, color: const ui.Color(0xFFFF00D4)), + ], + ), + ), + ), + ), + ); + + expect(find.byKey(Tag.anchor.key), findsOneWidget); + expect(tester.getRect(find.byKey(Tag.anchor.key)), const Rect.fromLTWH(0, 0, 216, 116)); + }); + + testWidgets('[Default] Overlay builder is passed anchor rect', (WidgetTester tester) async { + RawMenuOverlayInfo? overlayPosition; + await tester.pumpWidget( + App( + RawMenuAnchor( + overlayBuilder: (BuildContext context, RawMenuOverlayInfo position) { + overlayPosition = position; + return const SizedBox(); + }, + controller: controller, + child: AnchorButton(Tag.anchor, onPressed: onPressed), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(overlayPosition!.anchorRect, tester.getRect(find.byType(Button))); + }); + + testWidgets('[Default] Overlay contents can be positioned', (WidgetTester tester) async { + await tester.pumpWidget( + App( + RawMenuAnchor( + controller: controller, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo position) { + return Positioned( + top: position.anchorRect.top, + left: position.anchorRect.left, + child: Container( + key: Tag.a.key, + width: 200, + height: 200, + color: const Color(0xFF00FF00), + ), + ); + }, + child: AnchorButton(Tag.anchor, onPressed: onPressed), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + final ui.Offset anchorCorner = tester.getTopLeft(find.byType(Button)); + final ui.Rect contentRect = tester.getRect(find.byKey(Tag.a.key)); + + expect(contentRect, anchorCorner & const Size(200, 200)); + }); + + testWidgets('[Default] TapRegion group ID is passed to overlay', (WidgetTester tester) async { + bool? insideTap; + + await tester.pumpWidget( + App( + RawMenuAnchor( + controller: controller, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo position) { + return Positioned.fromRect( + rect: position.anchorRect.translate(200, 200), + child: TapRegion( + onTapInside: (PointerDownEvent event) { + insideTap = true; + }, + onTapOutside: (PointerDownEvent event) { + insideTap = false; + }, + groupId: insideTap ?? false ? null : position.tapRegionGroupId, + child: Button.tag(Tag.a), + ), + ); + }, + child: AnchorButton(Tag.anchor, onPressed: onPressed), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(find.text(Tag.a.text), findsOneWidget); + + // Start by testing that the tap region has the correct group ID. + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(insideTap, isTrue); + + // The menu should close when the tap region is tapped, so we need to + // reopen. + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(find.text(Tag.a.text), findsOneWidget); + expect(insideTap, isTrue); + + // Now test that setting the tap region group ID to null will cause the + // tap to be considered outside the tap region. + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(insideTap, isFalse); + }); + + testWidgets('Menus close and consume tap when consumesOutsideTap is true', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + Column( + children: [ + Button.tag( + Tag.outside, + onPressed: () { + selected.add(Tag.outside); + }, + ), + RawMenuAnchorGroup( + controller: controller, + child: Column( + children: [ + Menu( + consumeOutsideTaps: true, + onOpen: () => onOpen(Tag.anchor), + onClose: () => onClose(Tag.anchor), + menuPanel: Panel( + children: [ + Menu( + consumeOutsideTaps: true, + onOpen: () => onOpen(Tag.a), + onClose: () => onClose(Tag.a), + menuPanel: Panel(children: [Text(Tag.a.a.text)]), + child: AnchorButton(Tag.a, onPressed: onPressed), + ), + ], + ), + child: AnchorButton(Tag.anchor, onPressed: onPressed), + ), + ], + ), + ), + ], + ), + ), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + // Doesn't consume tap when the menu is closed. + await tester.tap(find.text(Tag.outside.text)); + await tester.pump(); + + expect(selected, equals([Tag.outside])); + selected.clear(); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.tap(find.text(Tag.a.text)); + await tester.pump(); + + expect(opened, equals([Tag.anchor, Tag.a])); + expect(closed, isEmpty); + expect(selected, equals([Tag.anchor, Tag.a])); + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(Tag.outside.text)); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, equals([Tag.a, Tag.anchor])); + expect(selected, isEmpty); + + // When the menu is open, don't expect the outside button to be selected. + expect(selected, isEmpty); + selected.clear(); + opened.clear(); + closed.clear(); + }); + + testWidgets('[Default] Menus close and do not consume tap when consumesOutsideTap is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + App( + Column( + children: [ + Button.tag( + Tag.outside, + onPressed: () { + selected.add(Tag.outside); + }, + ), + RawMenuAnchorGroup( + controller: controller, + child: Column( + children: [ + Menu( + onOpen: () => onOpen(Tag.anchor), + onClose: () => onClose(Tag.anchor), + // ignore: avoid_redundant_argument_values + consumeOutsideTaps: false, + menuPanel: Panel( + children: [ + Menu( + onOpen: () => onOpen(Tag.a), + onClose: () => onClose(Tag.a), + menuPanel: Panel(children: [Text(Tag.a.a.text)]), + child: AnchorButton(Tag.a, onPressed: onPressed), + ), + ], + ), + child: AnchorButton(Tag.anchor, onPressed: onPressed), + ), + ], + ), + ), + ], + ), + ), + ); + + expect(opened, isEmpty); + expect(closed, isEmpty); + + await tester.tap(find.text(Tag.outside.text)); + await tester.pump(); + + // Doesn't consume tap when the menu is closed. + expect(selected, equals([Tag.outside])); + + selected.clear(); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + await tester.tap(find.text(Tag.a.text)); + await tester.pump(); + + expect(opened, equals([Tag.anchor, Tag.a])); + expect(closed, isEmpty); + expect(selected, equals([Tag.anchor, Tag.a])); + + opened.clear(); + closed.clear(); + selected.clear(); + + await tester.tap(find.text(Tag.outside.text)); + await tester.pumpAndSettle(); + + // Because consumesOutsideTap is false, outsideButton is expected to + // receive a tap. + expect(opened, isEmpty); + expect(closed, equals([Tag.a, Tag.anchor])); + expect(selected, equals([Tag.outside])); + + selected.clear(); + opened.clear(); + closed.clear(); + }); + + testWidgets('onOpen is called when the menu is opened', (WidgetTester tester) async { + bool opened = false; + await tester.pumpWidget( + App( + Menu( + controller: controller, + onOpen: () { + opened = true; + }, + menuPanel: const Panel(children: []), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(opened, isTrue); + + opened = false; + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + // onOpen should not be called again. + expect(opened, isFalse); + + controller.open(); + await tester.pump(); + + expect(opened, isTrue); + }); + + testWidgets('onClose is called when the menu is closed', (WidgetTester tester) async { + bool closed = true; + await tester.pumpWidget( + App( + Menu( + controller: controller, + onOpen: () { + closed = false; + }, + onClose: () { + closed = true; + }, + menuPanel: const Panel(children: []), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(closed, isFalse); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(closed, isTrue); + + controller.open(); + await tester.pump(); + + expect(closed, isFalse); + + controller.close(); + await tester.pump(); + + expect(closed, isTrue); + }); + + testWidgets('[Default] diagnostics', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final Widget menuAnchor = RawMenuAnchor( + controller: controller, + childFocusNode: focusNode, + overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) { + return const SizedBox(); + }, + ); + + await tester.pumpWidget(App(menuAnchor)); + controller.open(); + await tester.pump(); + + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + menuAnchor.debugFillProperties(builder); + final List properties = + builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(properties, const ['has focusNode', 'use nearest overlay']); + }); + + testWidgets('[Group] diagnostics', (WidgetTester tester) async { + final Widget menuNode = RawMenuAnchorGroup( + controller: controller, + child: const SizedBox(height: 30, width: 30), + ); + + await tester.pumpWidget(App(menuNode)); + await tester.pump(); + + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + menuNode.debugFillProperties(builder); + final Iterable properties = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()); + expect(properties, equals(const ['has controller'])); + }); + + testWidgets('Surface clip behavior', (WidgetTester tester) async { + await tester.pumpWidget( + App( + Menu( + controller: controller, + menuPanel: const Panel(children: [Text('Button 1')]), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + controller.open(); + await tester.pump(); + + // Test default clip behavior. + expect(findMenuPanelDescendent(tester).clipBehavior, equals(Clip.antiAlias)); + + await tester.pumpWidget( + App( + Menu( + controller: controller, + menuPanel: const Panel(clipBehavior: Clip.hardEdge, children: [Text('Button 1')]), + child: const AnchorButton(Tag.anchor), + ), + ), + ); + + // Test custom clip behavior. + expect(findMenuPanelDescendent(tester).clipBehavior, equals(Clip.hardEdge)); + }); + + // Menu implementations differ as to whether tabbing traverses a closes a + // menu or traverses its items. By default, we let the user choose whether + // to close the menu or traverse its items. + testWidgets('Tab traversal is not handled.', (WidgetTester tester) async { + final FocusNode bFocusNode = FocusNode(debugLabel: Tag.b.focusNode); + final FocusNode bbFocusNode = FocusNode(debugLabel: Tag.b.b.focusNode); + addTearDown(bFocusNode.dispose); + addTearDown(bbFocusNode.dispose); + final List invokedIntents = []; + + await tester.pumpWidget( + App( + Row( + children: [ + Actions( + actions: >{ + NextFocusIntent: CallbackAction( + onInvoke: (NextFocusIntent intent) { + invokedIntents.add(intent); + return null; + }, + ), + PreviousFocusIntent: CallbackAction( + onInvoke: (PreviousFocusIntent intent) { + invokedIntents.add(intent); + return null; + }, + ), + }, + child: RawMenuAnchorGroup( + controller: controller, + child: Column( + children: [ + Button.tag(Tag.a), + Menu( + menuPanel: Panel( + children: [ + Button.tag(Tag.b.a), + Button.tag(Tag.b.b, focusNode: bbFocusNode), + Button.tag(Tag.b.c), + ], + ), + child: AnchorButton(Tag.b, focusNode: bFocusNode), + ), + Button.tag(Tag.c), + ], + ), + ), + ), + ], + ), + ), + ); + + listenForFocusChanges(); + + bFocusNode.requestFocus(); + await tester.pump(); + + expect(focusedMenu, equals(Tag.b.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + expect(focusedMenu, equals(Tag.b.focusNode)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + expect(focusedMenu, equals(Tag.b.focusNode)); + + // Open and move focus to nested menu + await tester.tap(find.text(Tag.b.text)); + await tester.pump(); + bbFocusNode.requestFocus(); + await tester.pump(); + + expect(focusedMenu, equals(Tag.b.b.focusNode)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + expect(focusedMenu, equals(Tag.b.b.focusNode)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + expect(focusedMenu, equals(Tag.b.b.focusNode)); + expect( + invokedIntents, + equals(const [ + NextFocusIntent(), + PreviousFocusIntent(), + NextFocusIntent(), + PreviousFocusIntent(), + ]), + ); + }); + + testWidgets('Menu closes on view size change', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final MediaQueryData mediaQueryData = MediaQueryData.fromView(tester.view); + + bool opened = false; + bool closed = false; + + Widget build(Size size) { + return MediaQuery( + data: mediaQueryData.copyWith(size: size), + child: App( + SingleChildScrollView( + controller: scrollController, + child: Container( + height: 1000, + alignment: Alignment.center, + child: Menu( + onOpen: () { + opened = true; + closed = false; + }, + onClose: () { + opened = false; + closed = true; + }, + controller: controller, + menuPanel: Panel(children: [Text(Tag.a.text)]), + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(build(mediaQueryData.size)); + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(opened, isTrue); + expect(closed, isFalse); + + const Size smallSize = Size(200, 200); + await changeSurfaceSize(tester, smallSize); + await tester.pumpWidget(build(smallSize)); + + expect(opened, isFalse); + expect(closed, isTrue); + }); + + testWidgets('Menu closes on ancestor scroll', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + App( + SingleChildScrollView( + controller: scrollController, + child: Menu( + onOpen: () { + onOpen(Tag.anchor); + }, + onClose: () { + onClose(Tag.anchor); + }, + menuPanel: Panel( + children: [ + Button.tag(Tag.a), + Button.tag(Tag.b), + Button.tag(Tag.c), + Button.tag(Tag.d), + ], + ), + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + expect(opened, isNotEmpty); + expect(closed, isEmpty); + opened.clear(); + + scrollController.jumpTo(1000); + await tester.pump(); + + expect(opened, isEmpty); + expect(closed, isNotEmpty); + }); + + testWidgets('Menus do not close on root menu internal scroll', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/122168. + final ScrollController scrollController = ScrollController(); + addTearDown(scrollController.dispose); + bool rootOpened = false; + const BoxConstraints largeButtonConstraints = BoxConstraints.tightFor(width: 200, height: 300); + + await tester.pumpWidget( + App( + SingleChildScrollView( + controller: scrollController, + child: Container( + height: 700, + alignment: Alignment.topLeft, + child: Menu( + onOpen: () { + rootOpened = true; + }, + onClose: () { + rootOpened = false; + }, + menuPanel: Panel( + children: [ + Menu( + onOpen: () { + onOpen(Tag.a); + }, + onClose: () { + onClose(Tag.a); + }, + menuPanel: Panel( + children: [Button.tag(Tag.a.a, constraints: largeButtonConstraints)], + ), + child: const AnchorButton(Tag.a, constraints: largeButtonConstraints), + ), + Button.tag(Tag.b, constraints: largeButtonConstraints), + Button.tag(Tag.c, constraints: largeButtonConstraints), + Button.tag(Tag.d, constraints: largeButtonConstraints), + ], + ), + child: const AnchorButton(Tag.anchor), + ), + ), + ), + ), + ); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + expect(rootOpened, true); + + // Hover the first submenu anchor. + final TestPointer pointer = TestPointer(1, ui.PointerDeviceKind.mouse); + await tester.tap(find.text(Tag.a.text)); + await tester.sendEventToBinding(pointer.hover(tester.getCenter(find.text(Tag.a.text)))); + await tester.pump(); + expect(opened, equals([Tag.a])); + + // Menus do not close on internal scroll. + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 30.0))); + await tester.pump(); + expect(rootOpened, true); + expect(closed, isEmpty); + + // Menus close on external scroll. + scrollController.jumpTo(700); + await tester.pump(); + expect(rootOpened, false); + expect(closed, equals([Tag.a])); + }); + + // Copied from [MenuAnchor] tests. + // + // Regression test for https://github.com/flutter/flutter/issues/157606. + testWidgets('Menu builder rebuilds when isOpen state changes', (WidgetTester tester) async { + bool isOpen = false; + int openCount = 0; + int closeCount = 0; + + await tester.pumpWidget( + App( + Menu( + menuPanel: Panel(children: [Button.text('Menu Item')]), + builder: (BuildContext context, MenuController controller, Widget? child) { + isOpen = controller.isOpen; + return Button( + Text(isOpen ? 'close' : 'open'), + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + ); + }, + onOpen: () => openCount++, + onClose: () => closeCount++, + ), + ), + ); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 0); + expect(closeCount, 0); + + await tester.tap(find.text('open')); + await tester.pump(); + + expect(find.text('close'), findsOneWidget); + expect(isOpen, true); + expect(openCount, 1); + expect(closeCount, 0); + + await tester.tap(find.text('close')); + await tester.pump(); + + expect(find.text('open'), findsOneWidget); + expect(isOpen, false); + expect(openCount, 1); + expect(closeCount, 1); + }); + + // Copied from [MenuAnchor] tests. + // + // Regression test for https://github.com/flutter/flutter/issues/155034. + testWidgets('Content is shown in the root overlay when useRootOverlay is true', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + final UniqueKey overlayKey = UniqueKey(); + final Finder a = find.text(Tag.a.text); + final Finder aa = find.text(Tag.a.a.text); + + late final OverlayEntry overlayEntry; + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + await tester.pumpWidget( + App( + Overlay( + key: overlayKey, + initialEntries: [ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Menu( + useRootOverlay: true, + controller: controller, + menuPanel: Panel( + children: [ + Menu( + menuPanel: Panel(children: [Button.tag(Tag.a.a)]), + child: const AnchorButton(Tag.a), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ), + ); + + expect(a, findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + await tester.tap(a); + await tester.pump(); + + expect(a, findsOne); + expect(aa, findsOne); + + // Expect two overlays: the root overlay created by WidgetsApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Iterable overlays = tester.widgetList(find.byType(Overlay)); + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + final Overlay rootOverlay = overlays.firstWhere((Overlay overlay) => overlay != nonRootOverlay); + + final RenderObject menuTheater = findAncestorRenderTheaters(tester.renderObject(a)).first; + final RenderObject submenuTheater = findAncestorRenderTheaters(tester.renderObject(aa)).first; + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the root overlay. + expect(menuTheater, tester.renderObject(find.byWidget(rootOverlay))); + expect(menuTheater, submenuTheater); + }); + + testWidgets('Content is shown in the nearest ancestor overlay when useRootOverlay is false', ( + WidgetTester tester, + ) async { + final MenuController controller = MenuController(); + final UniqueKey overlayKey = UniqueKey(); + final Finder a = find.text(Tag.a.text); + final Finder aa = find.text(Tag.a.a.text); + + late final OverlayEntry overlayEntry; + addTearDown(() { + overlayEntry.remove(); + overlayEntry.dispose(); + }); + + await tester.pumpWidget( + App( + Overlay( + key: overlayKey, + initialEntries: [ + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return Center( + child: Menu( + controller: controller, + menuPanel: Panel( + children: [ + // Nested menus should be rendered in the same overlay as + // their parent, so useRootOverlay should have no effect. + Menu( + useRootOverlay: true, + menuPanel: Panel(children: [Button.tag(Tag.a.a)]), + child: const AnchorButton(Tag.a), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ), + ); + + expect(a, findsNothing); + + // Open the menu. + controller.open(); + await tester.pump(); + await tester.tap(a); + await tester.pump(); + + expect(a, findsOne); + expect(aa, findsOne); + + // Expect two overlays: the root overlay created by WidgetsApp and the + // overlay created by the boilerplate code. + expect(find.byType(Overlay), findsNWidgets(2)); + + final Overlay nonRootOverlay = tester.widget(find.byKey(overlayKey)); + final RenderObject menuTheater = findAncestorRenderTheaters(tester.renderObject(a)).first; + final RenderObject submenuTheater = findAncestorRenderTheaters(tester.renderObject(aa)).first; + + // Check that the ancestor _RenderTheater for the menu item is the one + // from the root overlay. + expect(menuTheater, tester.renderObject(find.byWidget(nonRootOverlay))); + expect(menuTheater, submenuTheater); + }); + + testWidgets('Parent updates are not triggered during builds', (WidgetTester tester) async { + // This test ensures that _MenuAnchor._childChangedOpenState does not + // rebuild a child's parent if that parent is currently building. + final MediaQueryData mediaQueryData = MediaQueryData.fromView(tester.view); + + Widget build(Size size) { + return App( + MediaQuery( + data: mediaQueryData.copyWith(size: size), + child: RawMenuAnchorGroup( + controller: controller, + child: const Menu( + menuPanel: Panel(children: []), + child: AnchorButton(Tag.anchor), + ), + ), + ), + ); + } + + await tester.pumpWidget(build(mediaQueryData.size)); + + await tester.tap(find.text(Tag.anchor.text)); + await tester.pump(); + + const Size smallSize = Size(200, 200); + await changeSurfaceSize(tester, smallSize); + + await tester.pumpWidget(build(smallSize)); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} + +// ********* UTILITIES ********* // +/// Allows the creation of arbitrarily-nested tags in tests. +abstract class Tag { + const Tag(); + + static const NestedTag anchor = NestedTag('anchor'); + static const NestedTag outside = NestedTag('outside'); + static const NestedTag a = NestedTag('a'); + static const NestedTag b = NestedTag('b'); + static const NestedTag c = NestedTag('c'); + static const NestedTag d = NestedTag('d'); + + String get text; + String get focusNode; + int get level; + + @override + String toString() { + return 'Tag($text, level: $level)'; + } +} + +class NestedTag extends Tag { + const NestedTag(String name, {Tag? prefix, this.level = 0}) + : assert( + // Limit the nesting level to prevent stack overflow. + level < 9, + 'NestedTag.level must be less than 9 (was $level).', + ), + _name = name, + _prefix = prefix; + + final String _name; + final Tag? _prefix; + + @override + final int level; + + NestedTag get a => NestedTag('a', prefix: this, level: level + 1); + NestedTag get b => NestedTag('b', prefix: this, level: level + 1); + NestedTag get c => NestedTag('c', prefix: this, level: level + 1); + + @override + String get text { + if (level == 0 || _prefix == null) { + return _name; + } + return '${_prefix.text}.$_name'; + } + + @override + String get focusNode { + return 'Focus[$text]'; + } + + Key get key => ValueKey('${text}_Key'); +} + +// A simple, focusable button that calls onPressed when tapped. +// +// The widgets library can't import the material library, so a separate button +// widget has to be created. +class Button extends StatefulWidget { + const Button( + this.child, { + super.key, + this.onPressed, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + String? focusNodeLabel, + BoxConstraints? constraints, + }) : _focusNodeLabel = focusNodeLabel, + constraints = constraints ?? const BoxConstraints.tightFor(width: 225, height: 32); + + factory Button.text( + String text, { + Key? key, + VoidCallback? onPressed, + FocusNode? focusNode, + bool autofocus = false, + BoxConstraints? constraints, + void Function(bool)? onFocusChange, + }) { + return Button( + Text(text), + key: key, + onPressed: onPressed, + focusNode: focusNode, + autofocus: autofocus, + constraints: constraints, + onFocusChange: onFocusChange, + ); + } + + factory Button.tag( + Tag tag, { + Key? key, + VoidCallback? onPressed, + FocusNode? focusNode, + bool autofocus = false, + BoxConstraints? constraints, + void Function(bool)? onFocusChange, + }) { + return Button( + Text(tag.text), + key: key, + onPressed: onPressed, + focusNode: focusNode, + autofocus: autofocus, + constraints: constraints, + onFocusChange: onFocusChange, + focusNodeLabel: tag.focusNode, + ); + } + + final Widget child; + final VoidCallback? onPressed; + final void Function(bool)? onFocusChange; + final FocusNode? focusNode; + final bool autofocus; + final BoxConstraints? constraints; + final String? _focusNodeLabel; + + @override + State