Skip to content

Commit b29f8f7

Browse files
davidhicks980blerouxchunhtai
authored
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. flutter/flutter#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](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 <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 <[email protected]> Co-authored-by: chunhtai <[email protected]>
1 parent 3315ad2 commit b29f8f7

File tree

9 files changed

+5310
-1260
lines changed

9 files changed

+5310
-1260
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/services.dart';
7+
8+
/// Flutter code sample for a [RawMenuAnchor] that demonstrates
9+
/// how to create a simple menu.
10+
void main() {
11+
runApp(const RawMenuAnchorApp());
12+
}
13+
14+
enum Animal {
15+
cat('Cat', leading: Text('🦁')),
16+
kitten('Kitten', leading: Text('🐱')),
17+
felisCatus('Felis catus', leading: Text('🐈')),
18+
dog('Dog', leading: Text('🐕'));
19+
20+
const Animal(this.label, {this.leading});
21+
final String label;
22+
final Widget? leading;
23+
}
24+
25+
class RawMenuAnchorExample extends StatefulWidget {
26+
const RawMenuAnchorExample({super.key});
27+
28+
@override
29+
State<RawMenuAnchorExample> createState() => _RawMenuAnchorExampleState();
30+
}
31+
32+
class _RawMenuAnchorExampleState extends State<RawMenuAnchorExample> {
33+
final FocusNode focusNode = FocusNode();
34+
final MenuController controller = MenuController();
35+
Animal? _selectedAnimal;
36+
37+
@override
38+
void dispose() {
39+
focusNode.dispose();
40+
super.dispose();
41+
}
42+
43+
@override
44+
Widget build(BuildContext context) {
45+
final ThemeData theme = Theme.of(context);
46+
return UnconstrainedBox(
47+
clipBehavior: Clip.hardEdge,
48+
child: Row(
49+
mainAxisSize: MainAxisSize.min,
50+
children: <Widget>[
51+
Text('Favorite Animal:', style: theme.textTheme.titleMedium),
52+
const SizedBox(width: 8),
53+
CustomMenu(
54+
controller: controller,
55+
focusNode: focusNode,
56+
anchor: FilledButton(
57+
focusNode: focusNode,
58+
style: FilledButton.styleFrom(fixedSize: const Size(172, 36)),
59+
onPressed: () {
60+
if (controller.isOpen) {
61+
controller.close();
62+
} else {
63+
controller.open();
64+
}
65+
},
66+
child: Row(
67+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
68+
children: <Widget>[
69+
Expanded(flex: 3, child: Text(_selectedAnimal?.label ?? 'Select One')),
70+
const Flexible(child: Icon(Icons.arrow_drop_down, size: 16)),
71+
],
72+
),
73+
),
74+
children: <Widget>[
75+
for (final Animal animal in Animal.values)
76+
MenuItemButton(
77+
autofocus: _selectedAnimal == animal,
78+
onPressed: () {
79+
setState(() {
80+
_selectedAnimal = animal;
81+
});
82+
controller.close();
83+
},
84+
leadingIcon: SizedBox(width: 24, child: Center(child: animal.leading)),
85+
trailingIcon:
86+
_selectedAnimal == animal ? const Icon(Icons.check, size: 20) : null,
87+
child: Text(animal.label),
88+
),
89+
],
90+
),
91+
],
92+
),
93+
);
94+
}
95+
}
96+
97+
class CustomMenu extends StatelessWidget {
98+
const CustomMenu({
99+
super.key,
100+
required this.children,
101+
required this.anchor,
102+
required this.controller,
103+
required this.focusNode,
104+
});
105+
106+
final List<Widget> children;
107+
final Widget anchor;
108+
final MenuController controller;
109+
final FocusNode focusNode;
110+
111+
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
112+
SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(),
113+
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
114+
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
115+
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
116+
};
117+
118+
@override
119+
Widget build(BuildContext context) {
120+
return RawMenuAnchor(
121+
controller: controller,
122+
childFocusNode: focusNode,
123+
overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
124+
return Positioned(
125+
top: info.anchorRect.bottom + 4,
126+
left: info.anchorRect.left,
127+
// The overlay will be treated as a dialog.
128+
child: Semantics(
129+
scopesRoute: true,
130+
explicitChildNodes: true,
131+
child: TapRegion(
132+
groupId: info.tapRegionGroupId,
133+
onTapOutside: (PointerDownEvent event) {
134+
MenuController.maybeOf(context)?.close();
135+
},
136+
child: FocusScope(
137+
child: IntrinsicWidth(
138+
child: Container(
139+
clipBehavior: Clip.antiAlias,
140+
constraints: const BoxConstraints(minWidth: 168),
141+
padding: const EdgeInsets.symmetric(vertical: 6),
142+
decoration: BoxDecoration(
143+
color: Theme.of(context).colorScheme.surface,
144+
borderRadius: BorderRadius.circular(6),
145+
boxShadow: kElevationToShadow[4],
146+
),
147+
child: Shortcuts(shortcuts: _shortcuts, child: Column(children: children)),
148+
),
149+
),
150+
),
151+
),
152+
),
153+
);
154+
},
155+
child: anchor,
156+
);
157+
}
158+
}
159+
160+
class RawMenuAnchorApp extends StatelessWidget {
161+
const RawMenuAnchorApp({super.key});
162+
163+
static const ButtonStyle menuButtonStyle = ButtonStyle(
164+
overlayColor: WidgetStatePropertyAll<Color>(Color.fromARGB(55, 139, 195, 255)),
165+
iconSize: WidgetStatePropertyAll<double>(17),
166+
padding: WidgetStatePropertyAll<EdgeInsets>(EdgeInsets.symmetric(horizontal: 12)),
167+
);
168+
169+
@override
170+
Widget build(BuildContext context) {
171+
return MaterialApp(
172+
theme: ThemeData.from(
173+
useMaterial3: true,
174+
colorScheme: ColorScheme.fromSeed(
175+
seedColor: Colors.blue,
176+
dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
177+
),
178+
).copyWith(menuButtonTheme: const MenuButtonThemeData(style: menuButtonStyle)),
179+
home: const Scaffold(body: Center(child: RawMenuAnchorExample())),
180+
);
181+
}
182+
}

0 commit comments

Comments
 (0)