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.
<s>Documentation examples have been added, and can be viewed at
https://menu-anchor.web.app/</s>
<s>https://github.com/user-attachments/assets/25d35f23-2aad-4d07-9172-5c3fd65d53cf</s>
@dkwingsmt
List which issues are fixed by this PR.
https://github.com/flutter/flutter/pull/143712
Some issues that need to be addressed:
Semantics:
<img width="1027" alt="image"
src="https://github.com/user-attachments/assets/d69661c9-8435-4d9c-b200-474968cb57eb">
I'm basing the menu semantics off of the comment
[here](ef3ca70db2/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
<s>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.</s>
*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].
<!-- Links -->
[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 <bruno.leroux@gmail.com>
Co-authored-by: chunhtai <47866232+chunhtai@users.noreply.github.com>
This commit is contained in:
182
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart
Normal file
182
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart
Normal file
@@ -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<RawMenuAnchorExample> createState() => _RawMenuAnchorExampleState();
|
||||
}
|
||||
|
||||
class _RawMenuAnchorExampleState extends State<RawMenuAnchorExample> {
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
Expanded(flex: 3, child: Text(_selectedAnimal?.label ?? 'Select One')),
|
||||
const Flexible(child: Icon(Icons.arrow_drop_down, size: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: <Widget>[
|
||||
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<Widget> children;
|
||||
final Widget anchor;
|
||||
final MenuController controller;
|
||||
final FocusNode focusNode;
|
||||
|
||||
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
|
||||
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>(Color.fromARGB(55, 139, 195, 255)),
|
||||
iconSize: WidgetStatePropertyAll<double>(17),
|
||||
padding: WidgetStatePropertyAll<EdgeInsets>(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())),
|
||||
);
|
||||
}
|
||||
}
|
||||
262
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart
Normal file
262
examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart
Normal file
@@ -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<MenuItem>? children;
|
||||
}
|
||||
|
||||
const List<MenuItem> menuItems = <MenuItem>[
|
||||
MenuItem(
|
||||
'File',
|
||||
children: <MenuItem>[
|
||||
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>[
|
||||
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>[
|
||||
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>[
|
||||
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<RawMenuAnchorGroupExample> createState() => _RawMenuAnchorGroupExampleState();
|
||||
}
|
||||
|
||||
class _RawMenuAnchorGroupExampleState extends State<RawMenuAnchorGroupExample> {
|
||||
final MenuController controller = MenuController();
|
||||
MenuItem? _selected;
|
||||
List<FocusNode> focusNodes = List<FocusNode>.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: <Widget>[
|
||||
if (_selected != null) Text('Selected: ${_selected!.label}', style: titleStyle),
|
||||
UnconstrainedBox(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: RawMenuAnchorGroup(
|
||||
controller: controller,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
for (final MenuItem child in menuItems[i].children ?? <MenuItem>[])
|
||||
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<Widget> children;
|
||||
final Widget anchor;
|
||||
final FocusNode focusNode;
|
||||
|
||||
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
|
||||
SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
|
||||
};
|
||||
|
||||
@override
|
||||
State<CustomSubmenu> createState() => _CustomSubmenuState();
|
||||
}
|
||||
|
||||
class _CustomSubmenuState extends State<CustomSubmenu> {
|
||||
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<double>(17),
|
||||
overlayColor: WidgetStatePropertyAll<Color>(Color(0x0D1A1A1A)),
|
||||
padding: WidgetStatePropertyAll<EdgeInsets>(EdgeInsets.symmetric(horizontal: 12)),
|
||||
textStyle: WidgetStatePropertyAll<TextStyle>(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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<TestGesture> 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"))'),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user