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:
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