Wire up MenuAnchor, MenuBar, MenuItem-related widgets to aria roles (#165596)

This PR is to wire up `MenuAnchor` related widgets to SemanticsRole.
* When use `MenuAnchor` and a menu is opened, the menu has
`SemanticsRole.menu`.
* `MenuBar` has `SemanticsRole.menuBar`
* `MenuItemButton` has `SemanticsRole.menuItem`
* `SubmenuButton` has `SemanticsRole.menuItem` with `aria-haspopup`
attribute setup.
* `CheckboxMenuButton` has `SemanticsRole.menuItemCheckbox`
* `RadioMenuButton` has `SemanticsRole.menuItemRadio`

This PR also includes some changes related to `OverlayPortal` and
`RawMenuAnchor` so the "button" and the "menu" that the button opens has
a "parent-children" relationship. Previously, they are siblings
relationship which will cause some navigation issue in a11y.

## 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.
This commit is contained in:
Qun Cheng 2025-04-24 09:48:20 -07:00 committed by GitHub
parent def2376d76
commit 417f47fda5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 449 additions and 335 deletions

View File

@ -1 +1 @@
55deebbfa3f3b85e63b839fa3aa5a3ad0cf51607
8be72094d9b33dae8ff8a7085a3d922e7b3cad47

View File

@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -126,6 +128,7 @@ class CustomMenu extends StatelessWidget {
child: Semantics(
scopesRoute: true,
explicitChildNodes: true,
role: SemanticsRole.menu,
child: TapRegion(
groupId: info.tapRegionGroupId,
onTapOutside: (PointerDownEvent event) {

View File

@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -91,66 +93,70 @@ class _RawMenuAnchorGroupExampleState extends State<RawMenuAnchorGroupExample> {
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) {
child: Semantics(
role: SemanticsRole.menu,
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();
}
} else if (!value) {
Focus.of(context).unfocus();
}
},
onPressed: () {
if (submenuController.isOpen) {
submenuController.close();
} else {
submenuController.open();
}
},
leadingIcon: item.leading,
child: Text(item.label),
},
leadingIcon: item.leading,
child: Text(item.label),
),
),
),
);
},
),
children: <Widget>[
for (final MenuItem child in menuItems[i].children ?? <MenuItem>[])
MenuItemButton(
onPressed: () {
setState(() {
_selected = child;
});
);
},
),
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),
),
],
),
],
// Close the menu bar after a selection.
controller.close();
},
leadingIcon: child.leading,
child: Text(child.label),
),
],
),
],
),
),
),
),

View File

@ -940,7 +940,9 @@ class _MenuItemButtonState extends State<MenuItemButton> {
child = MouseRegion(onHover: _handlePointerHover, onExit: _handlePointerExit, child: child);
}
return MergeSemantics(child: child);
return MergeSemantics(
child: Semantics(role: SemanticsRole.menuItem, enabled: widget.enabled, child: child),
);
}
void _handleFocusChange() {
@ -1154,44 +1156,54 @@ class CheckboxMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MenuItemButton(
key: key,
onPressed:
onChanged == null
? null
: () {
switch (value) {
case false:
onChanged!(true);
case true:
onChanged!(tristate ? null : false);
case null:
onChanged!(false);
}
},
onHover: onHover,
onFocusChange: onFocusChange,
focusNode: focusNode,
style: style,
shortcut: shortcut,
statesController: statesController,
leadingIcon: ExcludeFocus(
child: IgnorePointer(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: Checkbox.width, maxWidth: Checkbox.width),
child: Checkbox(
tristate: tristate,
value: value,
onChanged: onChanged,
isError: isError,
return MergeSemantics(
child: Semantics(
role: SemanticsRole.menuItemCheckbox,
checked: value ?? false,
mixed: tristate ? value == null : null,
child: MenuItemButton(
key: key,
onPressed:
onChanged == null
? null
: () {
switch (value) {
case false:
onChanged!(true);
case true:
onChanged!(tristate ? null : false);
case null:
onChanged!(false);
}
},
onHover: onHover,
onFocusChange: onFocusChange,
focusNode: focusNode,
style: style,
shortcut: shortcut,
statesController: statesController,
leadingIcon: ExcludeFocus(
child: IgnorePointer(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: Checkbox.width,
maxWidth: Checkbox.width,
),
child: Checkbox(
tristate: tristate,
value: value,
onChanged: onChanged,
isError: isError,
),
),
),
),
clipBehavior: clipBehavior,
trailingIcon: trailingIcon,
closeOnActivate: closeOnActivate,
child: child,
),
),
clipBehavior: clipBehavior,
trailingIcon: trailingIcon,
closeOnActivate: closeOnActivate,
child: child,
);
}
}
@ -1353,40 +1365,49 @@ class RadioMenuButton<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MenuItemButton(
key: key,
onPressed:
onChanged == null
? null
: () {
if (toggleable && groupValue == value) {
return onChanged!(null);
}
onChanged!(value);
},
onHover: onHover,
onFocusChange: onFocusChange,
focusNode: focusNode,
style: style,
shortcut: shortcut,
statesController: statesController,
leadingIcon: ExcludeFocus(
child: IgnorePointer(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: Checkbox.width, maxWidth: Checkbox.width),
child: Radio<T>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
return MergeSemantics(
child: Semantics(
role: SemanticsRole.menuItemRadio,
checked: value == groupValue,
child: MenuItemButton(
key: key,
onPressed:
onChanged == null
? null
: () {
if (toggleable && groupValue == value) {
return onChanged!(null);
}
onChanged!(value);
},
onHover: onHover,
onFocusChange: onFocusChange,
focusNode: focusNode,
style: style,
shortcut: shortcut,
statesController: statesController,
leadingIcon: ExcludeFocus(
child: IgnorePointer(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: Checkbox.width,
maxWidth: Checkbox.width,
),
child: Radio<T>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
),
),
),
),
clipBehavior: clipBehavior,
trailingIcon: trailingIcon,
closeOnActivate: closeOnActivate,
child: child,
),
),
clipBehavior: clipBehavior,
trailingIcon: trailingIcon,
closeOnActivate: closeOnActivate,
child: child,
);
}
}
@ -1825,23 +1846,24 @@ class _SubmenuButtonState extends State<SubmenuButton> {
}
}
child = MergeSemantics(
child: Semantics(
expanded: _enabled && controller.isOpen,
child: TextButton(
style: mergedStyle,
focusNode: _buttonFocusNode,
onFocusChange: _enabled ? widget.onFocusChange : null,
onPressed: _enabled ? toggleShowMenu : null,
isSemanticButton: null,
child: _MenuItemLabel(
leadingIcon: widget.leadingIcon,
trailingIcon: widget.trailingIcon,
hasSubmenu: true,
showDecoration: (_parent?._orientation ?? Axis.horizontal) == Axis.vertical,
submenuIcon: submenuIcon,
child: child,
),
child = Semantics(
container: true,
role: SemanticsRole.menuItem,
expanded: _enabled && controller.isOpen,
enabled: _enabled,
child: TextButton(
style: mergedStyle,
focusNode: _buttonFocusNode,
onFocusChange: _enabled ? widget.onFocusChange : null,
onPressed: _enabled ? toggleShowMenu : null,
isSemanticButton: null,
child: _MenuItemLabel(
leadingIcon: widget.leadingIcon,
trailingIcon: widget.trailingIcon,
hasSubmenu: true,
showDecoration: (_parent?._orientation ?? Axis.horizontal) == Axis.vertical,
submenuIcon: submenuIcon,
child: child,
),
),
);
@ -1872,9 +1894,9 @@ class _SubmenuButtonState extends State<SubmenuButton> {
// After closing the children of this submenu, this submenu button will
// regain focus. Because submenu buttons open on focus, this submenu will
// immediately reopen. To prevent this from happening, we prevent focus on
// SubmenuButtons that do not already have focus using the _openOnFocus
// SubmenuButtons that do not already have focus using the _isOpenOnFocusEnabled
// flag. This flag is reset after one frame.
if (!_buttonFocusNode.hasFocus) {
if (!_buttonFocusNode.hasPrimaryFocus) {
_isOpenOnFocusEnabled = false;
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
FocusManager.instance.applyFocusChangesIfNeeded();
@ -3261,7 +3283,10 @@ class _MenuPanelState extends State<_MenuPanel> {
);
}
return ConstrainedBox(constraints: effectiveConstraints, child: menuPanel);
return Semantics(
role: widget.orientation == Axis.vertical ? SemanticsRole.menu : SemanticsRole.menuBar,
child: ConstrainedBox(constraints: effectiveConstraints, child: menuPanel),
);
}
Widget _intrinsicCrossSize({required Widget child}) {

View File

@ -126,7 +126,7 @@ sealed class _DebugSemanticsRoleChecks {
SemanticsRole.row => _semanticsRow,
SemanticsRole.columnHeader => _semanticsColumnHeader,
SemanticsRole.radioGroup => _semanticsRadioGroup,
SemanticsRole.menu => _semanticsMenu,
SemanticsRole.menu => _noCheckRequired,
SemanticsRole.menuBar => _semanticsMenuBar,
SemanticsRole.menuItem => _semanticsMenuItem,
SemanticsRole.menuItemCheckbox => _semanticsMenuItemCheckbox,
@ -260,14 +260,6 @@ sealed class _DebugSemanticsRoleChecks {
return error;
}
static FlutterError? _semanticsMenu(SemanticsNode node) {
if (node.childrenCount < 1) {
return FlutterError('a menu cannot be empty');
}
return null;
}
static FlutterError? _semanticsMenuBar(SemanticsNode node) {
if (node.childrenCount < 1) {
return FlutterError('a menu bar cannot be empty');

View File

@ -590,6 +590,24 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
@override
Widget buildAnchor(BuildContext context) {
// Only when both `child` and `builder` are not null, can the anchor and its
// children have a parent-child relationship. This is useful for a11y
// traversal in a `MenuBar` composed of a list of `SubmenuButton`s.
final Widget? overlayPortal =
widget.child == null || widget.builder == null
? null
: useRootOverlay
? OverlayPortal.targetsRootOverlay(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: widget.child,
)
: OverlayPortal(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: widget.child,
);
final Widget child = Shortcuts(
includeSemantics: false,
shortcuts: _kMenuTraversalShortcuts,
@ -600,7 +618,7 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
child: Builder(
key: _anchorKey,
builder: (BuildContext context) {
return widget.builder?.call(context, menuController, widget.child) ??
return widget.builder?.call(context, menuController, overlayPortal) ??
widget.child ??
const SizedBox();
},
@ -608,19 +626,22 @@ class _RawMenuAnchorState extends State<RawMenuAnchor> with _RawMenuAnchorBaseMi
),
);
if (useRootOverlay) {
return OverlayPortal.targetsRootOverlay(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: child,
);
} else {
return OverlayPortal(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: child,
);
if (widget.child == null || widget.builder == null) {
if (useRootOverlay) {
return OverlayPortal.targetsRootOverlay(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: child,
);
} else {
return OverlayPortal(
controller: _overlayController,
overlayChildBuilder: _buildOverlay,
child: child,
);
}
}
return child;
}
@override

View File

@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -74,17 +76,11 @@ void main() {
return results;
}
Finder findMenuBarItemLabels() {
return find.byWidgetPredicate(
(Widget widget) => widget.runtimeType.toString() == '_MenuItemLabel',
);
}
// Finds the mnemonic associated with the menu item that has the given label.
Finder findMnemonic(String label) {
return find
.descendant(
of: find.ancestor(of: find.text(label), matching: findMenuBarItemLabels()),
of: find.ancestor(of: find.text(label), matching: find.byType(MenuItemButton)),
matching: find.byType(Text),
)
.last;
@ -220,10 +216,6 @@ 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)),
@ -1361,6 +1353,7 @@ void main() {
),
);
});
testWidgets('menus can be traversed multiple times', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/150334
await tester.pumpWidget(
@ -1368,10 +1361,13 @@ void main() {
home: Material(
child: Column(
children: <Widget>[
MenuItemButton(
autofocus: true,
onPressed: () {},
child: const Text('External Focus'),
Semantics(
role: SemanticsRole.menu,
child: MenuItemButton(
autofocus: true,
onPressed: () {},
child: const Text('External Focus'),
),
),
MenuBar(
controller: controller,
@ -3125,10 +3121,14 @@ void main() {
home: Scaffold(
body: SizedBox(
width: 200,
child: MenuItemButton(
overflowAxis: Axis.vertical,
onPressed: () {},
child: const Text('MenuItem Button does not overflow when child is long'),
// This is added because a menu item must be a child of a menu or menu bar.
child: Semantics(
role: SemanticsRole.menu,
child: MenuItemButton(
overflowAxis: Axis.vertical,
onPressed: () {},
child: const Text('MenuItem Button does not overflow when child is long'),
),
),
),
),
@ -3145,10 +3145,16 @@ void main() {
home: Scaffold(
body: SizedBox(
width: constrainedLayout ? 200 : null,
child: MenuItemButton(
overflowAxis: overflowAxis,
onPressed: () {},
child: const Text('This is a very long text that will wrap to the multiple lines.'),
// This is added because a menu item must be a child of a menu or menu bar.
child: Semantics(
role: SemanticsRole.menu,
child: MenuItemButton(
overflowAxis: overflowAxis,
onPressed: () {},
child: const Text(
'This is a very long text that will wrap to the multiple lines.',
),
),
),
),
),
@ -3186,10 +3192,14 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MenuItemButton(
style: MenuItemButton.styleFrom(overlayColor: overlayColor),
onPressed: () {},
child: const Text('MenuItem'),
// This is added because a menu item must be a child of a menu or menu bar.
body: Semantics(
role: SemanticsRole.menu,
child: MenuItemButton(
style: MenuItemButton.styleFrom(overlayColor: overlayColor),
onPressed: () {},
child: const Text('MenuItem'),
),
),
),
),
@ -3218,7 +3228,14 @@ void main() {
// Regression test for https://github.com/flutter/flutter/issues/147479.
testWidgets('MenuItemButton can build when its child is null', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: SizedBox(width: 200, child: MenuItemButton()))),
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 200,
child: Semantics(role: SemanticsRole.menu, child: const MenuItemButton()),
),
),
),
);
expect(tester.takeException(), isNull);
@ -4152,10 +4169,13 @@ void main() {
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: MenuItemButton(
style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
onPressed: () {},
child: const Text('ABC'),
child: Semantics(
role: SemanticsRole.menu,
child: MenuItemButton(
style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
onPressed: () {},
child: const Text('ABC'),
),
),
),
),
@ -4168,20 +4188,25 @@ void main() {
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
actions: <SemanticsAction>[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>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
role: SemanticsRole.menu,
children: <TestSemantics>[
TestSemantics(
role: SemanticsRole.menuItem,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'ABC',
),
],
textDirection: TextDirection.ltr,
),
],
),
ignoreId: true,
ignoreRect: true,
ignoreTransform: true,
),
);
@ -4193,12 +4218,15 @@ void main() {
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'),
child: Semantics(
role: SemanticsRole.menu,
child: MenuItemButton(
semanticsLabel: 'TestWidget',
shortcut: const SingleActivator(LogicalKeyboardKey.comma),
style: MenuItemButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
onPressed: () {},
child: const Text('ABC'),
),
),
),
),
@ -4214,11 +4242,15 @@ void main() {
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SubmenuButton(
onHover: (bool value) {},
style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
menuChildren: const <Widget>[],
child: const Text('ABC'),
// This is added because a menu item must be a child of a menu or menu bar.
child: Semantics(
role: SemanticsRole.menu,
child: SubmenuButton(
onHover: (bool value) {},
style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
menuChildren: const <Widget>[],
child: const Text('ABC'),
),
),
),
),
@ -4231,18 +4263,29 @@ void main() {
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.hasExpandedState,
role: SemanticsRole.menu,
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.hasExpandedState,
],
role: SemanticsRole.menuItem,
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState],
label: 'ABC',
textDirection: TextDirection.ltr,
),
],
),
],
label: 'ABC',
textDirection: TextDirection.ltr,
),
],
),
ignoreTransform: true,
ignoreId: true,
ignoreRect: true,
),
);
@ -4254,16 +4297,20 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: Center(
child: SubmenuButton(
style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
menuChildren: <Widget>[
MenuItemButton(
style: MenuItemButton.styleFrom(fixedSize: const Size(120.0, 36.0)),
child: const Text('Item 0'),
onPressed: () {},
),
],
child: const Text('ABC'),
// This is added because a menu item must be a child of a menu or menu bar.
child: Semantics(
role: SemanticsRole.menu,
child: SubmenuButton(
style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)),
menuChildren: <Widget>[
MenuItemButton(
style: MenuItemButton.styleFrom(fixedSize: const Size(120.0, 36.0)),
child: const Text('Item 0'),
onPressed: () {},
),
],
child: const Text('ABC'),
),
),
),
),
@ -4292,35 +4339,25 @@ void main() {
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.isFocused,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.hasExpandedState,
SemanticsFlag.isExpanded,
],
actions: <SemanticsAction>[
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),
role: SemanticsRole.menu,
children: <TestSemantics>[
TestSemantics(
id: 7,
rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0),
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],
id: 5,
rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.hasExpandedState,
SemanticsFlag.isExpanded,
],
role: SemanticsRole.menuItem,
children: <TestSemantics>[
TestSemantics(
id: 8,
label: 'Item 0',
rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0),
id: 6,
rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
flags: <SemanticsFlag>[
SemanticsFlag.isFocused,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
@ -4329,6 +4366,41 @@ void main() {
SemanticsAction.tap,
SemanticsAction.focus,
],
label: 'ABC',
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 7,
rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 64.0),
role: SemanticsRole.menu,
children: <TestSemantics>[
TestSemantics(
id: 8,
rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasImplicitScrolling,
],
children: <TestSemantics>[
TestSemantics(
id: 9,
rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.focus,
],
label: 'Item 0',
role: SemanticsRole.menuItem,
),
],
),
],
),
],
),
],
),
@ -4369,19 +4441,38 @@ void main() {
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.hasExpandedState,
SemanticsFlag.isFocused,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.focus,
],
label: 'ABC',
role: SemanticsRole.menu,
rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
children: <TestSemantics>[
TestSemantics(
id: 5,
rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasExpandedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
],
role: SemanticsRole.menuItem,
children: <TestSemantics>[
TestSemantics(
id: 6,
rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0),
flags: <SemanticsFlag>[
SemanticsFlag.isFocused,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.focus,
],
label: 'ABC',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
@ -4457,15 +4548,18 @@ void main() {
home: Material(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return SubmenuButton(
focusNode: focusNode,
onFocusChange: (bool value) {
setState(() {
onFocusChangeCalled += 1;
});
},
menuChildren: const <Widget>[MenuItemButton(child: Text('item 0'))],
child: const Text('Submenu 0'),
return Semantics(
role: SemanticsRole.menu,
child: SubmenuButton(
focusNode: focusNode,
onFocusChange: (bool value) {
setState(() {
onFocusChangeCalled += 1;
});
},
menuChildren: const <Widget>[MenuItemButton(child: Text('item 0'))],
child: const Text('Submenu 0'),
),
);
},
),
@ -4513,12 +4607,15 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SubmenuButton(
style: SubmenuButton.styleFrom(overlayColor: overlayColor),
menuChildren: <Widget>[
MenuItemButton(onPressed: () {}, child: const Text('MenuItemButton')),
],
child: const Text('Submenu'),
body: Semantics(
role: SemanticsRole.menu,
child: SubmenuButton(
style: SubmenuButton.styleFrom(overlayColor: overlayColor),
menuChildren: <Widget>[
MenuItemButton(onPressed: () {}, child: const Text('MenuItemButton')),
],
child: const Text('Submenu'),
),
),
),
),
@ -4599,15 +4696,19 @@ void main() {
return MaterialApp(
home: Material(
child: Center(
child: MenuItemButton(
style: MenuItemButton.styleFrom(
iconColor: iconColor,
iconSize: iconSize,
disabledIconColor: disabledIconColor,
// This is added because a menu item must be a child of a menu or menu bar.
child: Semantics(
role: SemanticsRole.menu,
child: MenuItemButton(
style: MenuItemButton.styleFrom(
iconColor: iconColor,
iconSize: iconSize,
disabledIconColor: disabledIconColor,
),
onPressed: enabled ? () {} : null,
trailingIcon: const Icon(Icons.add),
child: const Text('Button'),
),
onPressed: enabled ? () {} : null,
trailingIcon: const Icon(Icons.add),
child: const Text('Button'),
),
),
),
@ -4635,15 +4736,18 @@ void main() {
return MaterialApp(
home: Material(
child: Center(
child: SubmenuButton(
style: SubmenuButton.styleFrom(
iconColor: iconColor,
iconSize: iconSize,
disabledIconColor: disabledIconColor,
child: Semantics(
role: SemanticsRole.menu,
child: SubmenuButton(
style: SubmenuButton.styleFrom(
iconColor: iconColor,
iconSize: iconSize,
disabledIconColor: disabledIconColor,
),
trailingIcon: const Icon(Icons.add),
menuChildren: <Widget>[if (enabled) const Text('Item')],
child: const Text('SubmenuButton'),
),
trailingIcon: const Icon(Icons.add),
menuChildren: <Widget>[if (enabled) const Text('Item')],
child: const Text('SubmenuButton'),
),
),
),

View File

@ -300,43 +300,6 @@ void main() {
});
});
group('menu', () {
testWidgets('failure case, empty child', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Semantics(
role: SemanticsRole.menu,
child: const ExcludeSemantics(child: Text('something')),
),
),
);
final Object? exception = tester.takeException();
expect(exception, isFlutterError);
final FlutterError error = exception! as FlutterError;
expect(error.message, 'a menu cannot be empty');
});
testWidgets('Success case', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Semantics(
role: SemanticsRole.menu,
explicitChildNodes: true,
child: Semantics(
role: SemanticsRole.menuItem,
selected: false,
onTap: () {},
child: const Text('some child'),
),
),
),
);
expect(tester.takeException(), isNull);
});
});
group('menuBar', () {
testWidgets('failure case, empty child', (WidgetTester tester) async {
await tester.pumpWidget(