diff --git a/dev/customer_testing/tests.version b/dev/customer_testing/tests.version index ae9a6cd9d65..d0b9c21611e 100644 --- a/dev/customer_testing/tests.version +++ b/dev/customer_testing/tests.version @@ -1 +1 @@ -55deebbfa3f3b85e63b839fa3aa5a3ad0cf51607 +8be72094d9b33dae8ff8a7085a3d922e7b3cad47 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 index f104f7ddbb9..f5e85c835ea 100644 --- 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 @@ -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) { 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 index cbf44c8567d..bef75fca46b 100644 --- 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 @@ -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 { clipBehavior: Clip.hardEdge, child: RawMenuAnchorGroup( controller: controller, - child: Row( - children: [ - 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: [ + 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: [ - for (final MenuItem child in menuItems[i].children ?? []) - MenuItemButton( - onPressed: () { - setState(() { - _selected = child; - }); + ); + }, + ), + children: [ + for (final MenuItem child in menuItems[i].children ?? []) + 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), + ), + ], + ), + ], + ), ), ), ), diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index e64cbb4c521..21c37095d7b 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -940,7 +940,9 @@ class _MenuItemButtonState extends State { 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 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( - 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( + 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 { } } - 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 { // 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}) { diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index f4b5e0fe209..1fa5033d636 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -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'); diff --git a/packages/flutter/lib/src/widgets/raw_menu_anchor.dart b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart index 8a6397f661f..ed268cb19c4 100644 --- a/packages/flutter/lib/src/widgets/raw_menu_anchor.dart +++ b/packages/flutter/lib/src/widgets/raw_menu_anchor.dart @@ -590,6 +590,24 @@ class _RawMenuAnchorState extends State 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 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 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 diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index 6b01b1e2475..d1ad444e650 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -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: [ - 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.rootChild( - actions: [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.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, + role: SemanticsRole.menu, + children: [ + TestSemantics( + role: SemanticsRole.menuItem, + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [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 [], - 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 [], + child: const Text('ABC'), + ), ), ), ), @@ -4231,18 +4263,29 @@ void main() { TestSemantics.root( children: [ TestSemantics( - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - flags: [ - SemanticsFlag.hasEnabledState, - SemanticsFlag.hasExpandedState, + role: SemanticsRole.menu, + children: [ + TestSemantics( + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasExpandedState, + ], + role: SemanticsRole.menuItem, + children: [ + TestSemantics( + flags: [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: [ - 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: [ + 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( id: 4, - flags: [ - SemanticsFlag.isFocused, - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - SemanticsFlag.hasExpandedState, - SemanticsFlag.isExpanded, - ], - actions: [ - 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( - id: 7, - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), - flags: [SemanticsFlag.hasImplicitScrolling], + id: 5, + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.hasExpandedState, + SemanticsFlag.isExpanded, + ], + role: SemanticsRole.menuItem, children: [ 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.isFocused, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isFocusable, @@ -4329,6 +4366,41 @@ void main() { SemanticsAction.tap, SemanticsAction.focus, ], + label: 'ABC', + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 64.0), + role: SemanticsRole.menu, + children: [ + TestSemantics( + id: 8, + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), + flags: [ + SemanticsFlag.hasImplicitScrolling, + ], + children: [ + TestSemantics( + id: 9, + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + label: 'Item 0', + role: SemanticsRole.menuItem, + ), + ], + ), + ], + ), + ], ), ], ), @@ -4369,19 +4441,38 @@ void main() { children: [ TestSemantics( id: 4, - flags: [ - SemanticsFlag.hasExpandedState, - SemanticsFlag.isFocused, - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - ], - actions: [ - SemanticsAction.tap, - SemanticsAction.focus, - ], - label: 'ABC', + role: SemanticsRole.menu, rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + children: [ + TestSemantics( + id: 5, + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + flags: [ + SemanticsFlag.hasExpandedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + role: SemanticsRole.menuItem, + children: [ + TestSemantics( + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + flags: [ + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [ + 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 [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 [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: [ - MenuItemButton(onPressed: () {}, child: const Text('MenuItemButton')), - ], - child: const Text('Submenu'), + body: Semantics( + role: SemanticsRole.menu, + child: SubmenuButton( + style: SubmenuButton.styleFrom(overlayColor: overlayColor), + menuChildren: [ + 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: [if (enabled) const Text('Item')], + child: const Text('SubmenuButton'), ), - trailingIcon: const Icon(Icons.add), - menuChildren: [if (enabled) const Text('Item')], - child: const Text('SubmenuButton'), ), ), ), diff --git a/packages/flutter/test/widgets/semantics_role_checks_test.dart b/packages/flutter/test/widgets/semantics_role_checks_test.dart index aea1c8567d5..2ff71613258 100644 --- a/packages/flutter/test/widgets/semantics_role_checks_test.dart +++ b/packages/flutter/test/widgets/semantics_role_checks_test.dart @@ -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(