mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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:
parent
def2376d76
commit
417f47fda5
@ -1 +1 @@
|
||||
55deebbfa3f3b85e63b839fa3aa5a3ad0cf51607
|
||||
8be72094d9b33dae8ff8a7085a3d922e7b3cad47
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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}) {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user