[material/menu_anchor.dart] MenuAnchor focus refactoring for RawMenuAnchor (#150950)
This PR is aimed at (1) reducing the private API surface of _MenuAnchorState to make migration into RawMenuAnchor simpler, and (2) fixing focus-related bugs. Directional focus handling was moved from MenuAnchor (_MenuDirectionalFocusAction, _MenuNextFocusAction, and _MenuPreviousFocusAction) into SubmenuButton (_SubmenuDirectionalFocusAction). MenuAnchor now behaves similarly to a flat FocusScope, which makes it easier to customize. A future PR will ideally expose or remove the remaining internals (_lastItemFocusNode, _firstItemFocusNode, _isRoot, etc). All previous framework tests are passing, and additional tests were added for fixes (MenuAnchor tab traversal, reopened menus not being focusable), and to test MenuAnchor focus behavior separately from MenuBar. However, [one example test](https://github.com/flutter/flutter/pull/150950/files#diff-a33fa01b59d280784e7c8ed6b704bd005cde95b7d3b649dc82fd58530061a09d) had to be changed. I'm not sure why the previous example test was working to begin with, as submenu buttons are supposed to open on focus, but this behavior was not observed in the original test. Fixes https://github.com/flutter/flutter/issues/144381, https://github.com/flutter/flutter/issues/150334. One added feature is the ability to move between top-level horizontal submenus if a horizontal movement is made on a vertical menu item that has no children in the movement direction. This behavior was observed on Google Docs, MacOS, and various other menu systems I encountered. https://github.com/flutter/flutter/assets/59215665/04a42b8a-cc9e-4a50-9d0c-6f2d784cfc78
This commit is contained in:
@@ -31,13 +31,16 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Background Color'), findsOneWidget);
|
||||
expect(find.text(example.MenuEntry.colorRed.label), findsOneWidget);
|
||||
expect(find.text(example.MenuEntry.colorGreen.label), findsOneWidget);
|
||||
expect(find.text(example.MenuEntry.colorBlue.label), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Background Color'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(example.MenuEntry.colorRed.label), findsOneWidget);
|
||||
expect(find.text(example.MenuEntry.colorGreen.label), findsOneWidget);
|
||||
expect(find.text(example.MenuEntry.colorBlue.label), findsOneWidget);
|
||||
expect(find.text(example.MenuEntry.colorRed.label), findsNothing);
|
||||
expect(find.text(example.MenuEntry.colorGreen.label), findsNothing);
|
||||
expect(find.text(example.MenuEntry.colorBlue.label), findsNothing);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
|
||||
@@ -320,7 +320,6 @@ class _MenuAnchorState extends State<MenuAnchor> {
|
||||
Axis get _orientation => Axis.vertical;
|
||||
bool get _isOpen => _overlayController.isShowing;
|
||||
bool get _isRoot => _parent == null;
|
||||
bool get _isTopLevel => _parent?._isRoot ?? false;
|
||||
MenuController get _menuController => widget.controller ?? _internalMenuController!;
|
||||
|
||||
@override
|
||||
@@ -427,9 +426,6 @@ class _MenuAnchorState extends State<MenuAnchor> {
|
||||
Widget _buildContents(BuildContext context) {
|
||||
return Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
DirectionalFocusIntent: _MenuDirectionalFocusAction(),
|
||||
PreviousFocusIntent: _MenuPreviousFocusAction(),
|
||||
NextFocusIntent: _MenuNextFocusAction(),
|
||||
DismissIntent: DismissMenuAction(controller: _menuController),
|
||||
},
|
||||
child: Builder(
|
||||
@@ -453,6 +449,15 @@ class _MenuAnchorState extends State<MenuAnchor> {
|
||||
return policy.findFirstFocus(_menuScopeNode, ignoreCurrentFocus: true);
|
||||
}
|
||||
|
||||
FocusNode? get _lastItemFocusNode {
|
||||
if (_menuScopeNode.context == null) {
|
||||
return null;
|
||||
}
|
||||
final FocusTraversalPolicy policy =
|
||||
FocusTraversalGroup.maybeOf(_menuScopeNode.context!) ?? ReadingOrderTraversalPolicy();
|
||||
return policy.findLastFocus(_menuScopeNode, ignoreCurrentFocus: true);
|
||||
}
|
||||
|
||||
void _addChild(_MenuAnchorState child) {
|
||||
assert(_isRoot || _debugMenuInfo('Added root child: $child'));
|
||||
assert(!_anchorChildren.contains(child));
|
||||
@@ -467,31 +472,6 @@ class _MenuAnchorState extends State<MenuAnchor> {
|
||||
assert(_debugMenuInfo('Removing:\n${child.widget.toStringDeep()}'));
|
||||
_anchorChildren.remove(child);
|
||||
assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}'));
|
||||
}
|
||||
|
||||
List<_MenuAnchorState> _getFocusableChildren() {
|
||||
if (_parent == null) {
|
||||
return <_MenuAnchorState>[];
|
||||
}
|
||||
return _parent!._anchorChildren.where((_MenuAnchorState menu) {
|
||||
return menu.widget.childFocusNode?.canRequestFocus ?? false;
|
||||
},).toList();
|
||||
}
|
||||
|
||||
_MenuAnchorState? get _nextFocusableSibling {
|
||||
final List<_MenuAnchorState> focusable = _getFocusableChildren();
|
||||
if (focusable.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return focusable[(focusable.indexOf(this) + 1) % focusable.length];
|
||||
}
|
||||
|
||||
_MenuAnchorState? get _previousFocusableSibling {
|
||||
final List<_MenuAnchorState> focusable = _getFocusableChildren();
|
||||
if (focusable.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return focusable[(focusable.indexOf(this) - 1 + focusable.length) % focusable.length];
|
||||
}
|
||||
|
||||
_MenuAnchorState get _root {
|
||||
@@ -502,14 +482,6 @@ _MenuAnchorState? get _previousFocusableSibling {
|
||||
return anchor;
|
||||
}
|
||||
|
||||
_MenuAnchorState get _topLevel {
|
||||
_MenuAnchorState handle = this;
|
||||
while (handle._parent != null && !handle._parent!._isTopLevel) {
|
||||
handle = handle._parent!;
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
void _childChangedOpenState() {
|
||||
_parent?._childChangedOpenState();
|
||||
assert(mounted);
|
||||
@@ -572,6 +544,10 @@ _MenuAnchorState? get _previousFocusableSibling {
|
||||
_menuPosition = position;
|
||||
_overlayController.show();
|
||||
|
||||
if (_isRoot) {
|
||||
_focusButton();
|
||||
}
|
||||
|
||||
widget.onOpen?.call();
|
||||
}
|
||||
|
||||
@@ -1852,11 +1828,15 @@ class SubmenuButton extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SubmenuButtonState extends State<SubmenuButton> {
|
||||
FocusNode? _internalFocusNode;
|
||||
late final Map<Type, Action<Intent>> actions = <Type, Action<Intent>>{
|
||||
DirectionalFocusIntent: _SubmenuDirectionalFocusAction(submenu: this)
|
||||
};
|
||||
bool _waitingToFocusMenu = false;
|
||||
bool _isOpenOnFocusEnabled = true;
|
||||
MenuController? _internalMenuController;
|
||||
MenuController get _menuController => widget.controller ?? _internalMenuController!;
|
||||
_MenuAnchorState? get _anchor => _MenuAnchorState._maybeOf(context);
|
||||
_MenuAnchorState? get _parent => _MenuAnchorState._maybeOf(context);
|
||||
FocusNode? _internalFocusNode;
|
||||
FocusNode get _buttonFocusNode => widget.focusNode ?? _internalFocusNode!;
|
||||
bool get _enabled => widget.menuChildren.isNotEmpty;
|
||||
|
||||
@@ -1913,7 +1893,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
||||
Widget build(BuildContext context) {
|
||||
Offset menuPaddingOffset = widget.alignmentOffset ?? Offset.zero;
|
||||
final EdgeInsets menuPadding = _computeMenuPadding(context);
|
||||
final Axis orientation = _anchor?._orientation ?? Axis.vertical;
|
||||
final Axis orientation = _parent?._orientation ?? Axis.vertical;
|
||||
// Move the submenu over by the size of the menu padding, so that
|
||||
// the first menu item aligns with the submenu button that opens it.
|
||||
menuPaddingOffset += switch ((orientation, Directionality.of(context))) {
|
||||
@@ -1923,29 +1903,22 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
||||
(Axis.vertical, TextDirection.ltr) => Offset(0, -menuPadding.top),
|
||||
};
|
||||
|
||||
return MenuAnchor(
|
||||
controller: _menuController,
|
||||
childFocusNode: _buttonFocusNode,
|
||||
alignmentOffset: menuPaddingOffset,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
onClose: widget.onClose,
|
||||
onOpen: () {
|
||||
if (!_waitingToFocusMenu) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_menuController._anchor?._focusButton();
|
||||
_waitingToFocusMenu = false;
|
||||
}, debugLabel: 'MenuAnchor.focus');
|
||||
_waitingToFocusMenu = true;
|
||||
}
|
||||
setState(() { /* Rebuild with updated controller.isOpen value */ });
|
||||
widget.onOpen?.call();
|
||||
},
|
||||
style: widget.menuStyle,
|
||||
builder: (BuildContext context, MenuController controller, Widget? child) {
|
||||
// Since we don't want to use the theme style or default style from the
|
||||
// TextButton, we merge the styles, merging them in the right order when
|
||||
// each type of style exists. Each "*StyleOf" function is only called
|
||||
// once.
|
||||
return Actions(
|
||||
actions: actions,
|
||||
child: MenuAnchor(
|
||||
controller: _menuController,
|
||||
childFocusNode: _buttonFocusNode,
|
||||
alignmentOffset: menuPaddingOffset,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
onClose: _onClose,
|
||||
onOpen: _onOpen,
|
||||
style: widget.menuStyle,
|
||||
builder:
|
||||
(BuildContext context, MenuController controller, Widget? child) {
|
||||
// Since we don't want to use the theme style or default style from the
|
||||
// TextButton, we merge the styles, merging them in the right order when
|
||||
// each type of style exists. Each "*StyleOf" function is only called
|
||||
// once.
|
||||
ButtonStyle mergedStyle = widget.themeStyleOf(context)?.merge(widget.defaultStyleOf(context))
|
||||
?? widget.defaultStyleOf(context);
|
||||
mergedStyle = widget.style?.merge(mergedStyle) ?? mergedStyle;
|
||||
@@ -2009,9 +1982,38 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
||||
},
|
||||
menuChildren: widget.menuChildren,
|
||||
child: widget.child,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
void _onClose() {
|
||||
// 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
|
||||
// flag. This flag is reset after one frame.
|
||||
if (!_buttonFocusNode.hasFocus) {
|
||||
_isOpenOnFocusEnabled = false;
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
|
||||
FocusManager.instance.applyFocusChangesIfNeeded();
|
||||
_isOpenOnFocusEnabled = true;
|
||||
}, debugLabel: 'MenuAnchor.preventOpenOnFocus');
|
||||
}
|
||||
widget.onClose?.call();
|
||||
}
|
||||
|
||||
void _onOpen() {
|
||||
if (!_waitingToFocusMenu) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_menuController._anchor?._focusButton();
|
||||
_waitingToFocusMenu = false;
|
||||
}, debugLabel: 'MenuAnchor.focus');
|
||||
_waitingToFocusMenu = true;
|
||||
}
|
||||
setState(() {/* Rebuild with updated controller.isOpen value */});
|
||||
widget.onOpen?.call();
|
||||
}
|
||||
|
||||
EdgeInsets _computeMenuPadding(BuildContext context) {
|
||||
final MaterialStateProperty<EdgeInsetsGeometry?> insets =
|
||||
widget.menuStyle?.padding ??
|
||||
@@ -2024,7 +2026,7 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (_buttonFocusNode.hasPrimaryFocus) {
|
||||
if (!_menuController.isOpen) {
|
||||
if (!_menuController.isOpen && _isOpenOnFocusEnabled) {
|
||||
_menuController.open();
|
||||
}
|
||||
} else {
|
||||
@@ -2035,6 +2037,120 @@ class _SubmenuButtonState extends State<SubmenuButton> {
|
||||
}
|
||||
}
|
||||
|
||||
class _SubmenuDirectionalFocusAction extends DirectionalFocusAction {
|
||||
_SubmenuDirectionalFocusAction({
|
||||
required this.submenu,
|
||||
});
|
||||
final _SubmenuButtonState submenu;
|
||||
_MenuAnchorState get _anchor => submenu._menuController._anchor!;
|
||||
FocusNode get _buttonFocusNode => submenu._buttonFocusNode;
|
||||
_MenuAnchorState? get _parent => _anchor._parent;
|
||||
bool get _isParentRoot => _parent?._isRoot ?? false;
|
||||
|
||||
/// The orientation of the menu that contains this submenu button.
|
||||
Axis? get _orientation => _parent?._orientation;
|
||||
|
||||
/// Whether the anchor that intercepted this DirectionalFocusAction is a submenu.
|
||||
bool get isSubmenu => submenu._buttonFocusNode.hasPrimaryFocus;
|
||||
|
||||
@override
|
||||
void invoke(DirectionalFocusIntent intent) {
|
||||
assert(_debugMenuInfo('${intent.direction}: Invoking directional focus intent.'));
|
||||
final TextDirection directionality = Directionality.of(submenu.context);
|
||||
switch ((_orientation, directionality, intent.direction)) {
|
||||
case (Axis.horizontal, TextDirection.ltr, TraversalDirection.left):
|
||||
case (Axis.horizontal, TextDirection.rtl, TraversalDirection.right):
|
||||
assert(_debugMenuInfo('Moving to previous $MenuBar item'));
|
||||
// Focus this MenuBar SubmenuButton, then move focus to the previous focusable
|
||||
// MenuBar item.
|
||||
_buttonFocusNode
|
||||
..requestFocus()
|
||||
..previousFocus();
|
||||
return;
|
||||
case (Axis.horizontal, TextDirection.ltr, TraversalDirection.right):
|
||||
case (Axis.horizontal, TextDirection.rtl, TraversalDirection.left):
|
||||
assert(_debugMenuInfo('Moving to next $MenuBar item'));
|
||||
// Focus this MenuBar SubmenuButton, then move focus to the next focusable
|
||||
// MenuBar item.
|
||||
_buttonFocusNode
|
||||
..requestFocus()
|
||||
..nextFocus();
|
||||
return;
|
||||
case (Axis.horizontal, _, TraversalDirection.down):
|
||||
if (isSubmenu) {
|
||||
// If this is a top-level (horizontal) button in a menubar, focus the
|
||||
// first item in this button's submenu.
|
||||
final FocusNode? firstItem = _anchor._firstItemFocusNode;
|
||||
if (firstItem?.canRequestFocus ?? false) {
|
||||
firstItem!.requestFocus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case (Axis.horizontal, _, TraversalDirection.up):
|
||||
if (isSubmenu) {
|
||||
// If this is a top-level (horizontal) button in a menubar, focus the
|
||||
// last item in this button's submenu. This makes navigating into
|
||||
// upward-oriented submenus more intuitive.
|
||||
final FocusNode? lastItem = _anchor._lastItemFocusNode;
|
||||
if (lastItem?.canRequestFocus ?? false) {
|
||||
lastItem!.requestFocus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case (Axis.vertical, TextDirection.ltr, TraversalDirection.left):
|
||||
case (Axis.vertical, TextDirection.rtl, TraversalDirection.right):
|
||||
if (_parent?._parent?._orientation == Axis.horizontal) {
|
||||
if (isSubmenu) {
|
||||
_parent!.widget.childFocusNode
|
||||
?..requestFocus()
|
||||
..previousFocus();
|
||||
} else {
|
||||
assert(_debugMenuInfo('Exiting submenu'));
|
||||
// MenuBar SubmenuButton => SubmenuButton => child
|
||||
// Focus the parent SubmenuButton anchor attached to this child.
|
||||
_buttonFocusNode.requestFocus();
|
||||
}
|
||||
} else {
|
||||
if (isSubmenu) {
|
||||
if (_isParentRoot) {
|
||||
// Moving in the closing direction while focused on a
|
||||
// SubmenuButton within a root MenuAnchor menu should not close
|
||||
// the menu.
|
||||
return;
|
||||
}
|
||||
_parent
|
||||
?.._focusButton()
|
||||
.._close();
|
||||
} else {
|
||||
// If focus is not on a submenu button, closing the anchor this item
|
||||
// presides in will close the menu and focus the anchor button.
|
||||
_anchor._close();
|
||||
}
|
||||
assert(_debugMenuInfo('Exiting submenu'));
|
||||
}
|
||||
return;
|
||||
case (Axis.vertical, TextDirection.ltr, TraversalDirection.right) when isSubmenu:
|
||||
case (Axis.vertical, TextDirection.rtl, TraversalDirection.left) when isSubmenu:
|
||||
assert(_debugMenuInfo('Entering submenu'));
|
||||
if (_anchor._isOpen) {
|
||||
_anchor._firstItemFocusNode?.requestFocus();
|
||||
} else {
|
||||
_anchor._open();
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
|
||||
if (_anchor._isOpen) {
|
||||
_anchor._firstItemFocusNode?.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Actions.maybeInvoke(submenu.context, intent);
|
||||
}
|
||||
}
|
||||
|
||||
/// An action that closes all the menus associated with the given
|
||||
/// [MenuController].
|
||||
///
|
||||
@@ -2352,6 +2468,10 @@ class _MenuBarAnchor extends MenuAnchor {
|
||||
}
|
||||
|
||||
class _MenuBarAnchorState extends _MenuAnchorState {
|
||||
late final Map<Type, Action<Intent>> actions = <Type, Action<Intent>>{
|
||||
DismissIntent: DismissMenuAction(controller: _menuController),
|
||||
};
|
||||
|
||||
@override
|
||||
bool get _isOpen {
|
||||
// If it's a bar, then it's "open" if any of its children are open.
|
||||
@@ -2373,25 +2493,19 @@ class _MenuBarAnchorState extends _MenuAnchorState {
|
||||
node: _menuScopeNode,
|
||||
skipTraversal: !isOpen,
|
||||
canRequestFocus: isOpen,
|
||||
descendantsAreFocusable: true,
|
||||
child: ExcludeFocus(
|
||||
excluding: !isOpen,
|
||||
child: Shortcuts(
|
||||
shortcuts: _kMenuTraversalShortcuts,
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
DirectionalFocusIntent: _MenuDirectionalFocusAction(),
|
||||
PreviousFocusIntent: _MenuPreviousFocusAction(),
|
||||
NextFocusIntent: _MenuNextFocusAction(),
|
||||
DismissIntent: DismissMenuAction(controller: _menuController),
|
||||
},
|
||||
child: Builder(builder: (BuildContext context) {
|
||||
return _MenuPanel(
|
||||
menuStyle: widget.style,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
orientation: Axis.horizontal,
|
||||
children: widget.menuChildren,
|
||||
);
|
||||
}),
|
||||
actions: actions,
|
||||
child: _MenuPanel(
|
||||
menuStyle: widget.style,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
orientation: Axis.horizontal,
|
||||
children: widget.menuChildren,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2406,168 +2520,6 @@ class _MenuBarAnchorState extends _MenuAnchorState {
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuPreviousFocusAction extends PreviousFocusAction {
|
||||
@override
|
||||
bool invoke(PreviousFocusIntent intent) {
|
||||
assert(_debugMenuInfo('_MenuNextFocusAction invoked with $intent'));
|
||||
final BuildContext? context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) {
|
||||
return super.invoke(intent);
|
||||
}
|
||||
final _MenuAnchorState? anchor = _MenuAnchorState._maybeOf(context);
|
||||
if (anchor == null || !anchor._root._isOpen) {
|
||||
return super.invoke(intent);
|
||||
}
|
||||
|
||||
return _moveToPreviousFocusable(anchor);
|
||||
}
|
||||
|
||||
static bool _moveToPreviousFocusable(_MenuAnchorState currentMenu) {
|
||||
final _MenuAnchorState? sibling = currentMenu._previousFocusableSibling;
|
||||
sibling?._focusButton();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuNextFocusAction extends NextFocusAction {
|
||||
@override
|
||||
bool invoke(NextFocusIntent intent) {
|
||||
assert(_debugMenuInfo('_MenuNextFocusAction invoked with $intent'));
|
||||
final BuildContext? context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) {
|
||||
return super.invoke(intent);
|
||||
}
|
||||
final _MenuAnchorState? anchor = _MenuAnchorState._maybeOf(context);
|
||||
if (anchor == null || !anchor._root._isOpen) {
|
||||
return super.invoke(intent);
|
||||
}
|
||||
|
||||
return _moveToNextFocusable(anchor);
|
||||
}
|
||||
|
||||
static bool _moveToNextFocusable(_MenuAnchorState currentMenu) {
|
||||
final _MenuAnchorState? sibling = currentMenu._nextFocusableSibling;
|
||||
sibling?._focusButton();
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _MenuDirectionalFocusAction extends DirectionalFocusAction {
|
||||
/// Creates a [DirectionalFocusAction].
|
||||
_MenuDirectionalFocusAction();
|
||||
|
||||
@override
|
||||
void invoke(DirectionalFocusIntent intent) {
|
||||
assert(_debugMenuInfo('_MenuDirectionalFocusAction invoked with $intent'));
|
||||
final BuildContext? context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) {
|
||||
super.invoke(intent);
|
||||
return;
|
||||
}
|
||||
final _MenuAnchorState? anchor = _MenuAnchorState._maybeOf(context);
|
||||
if (anchor == null || !anchor._root._isOpen) {
|
||||
super.invoke(intent);
|
||||
return;
|
||||
}
|
||||
final bool buttonIsFocused = anchor.widget.childFocusNode?.hasPrimaryFocus ?? false;
|
||||
final Axis? parentOrientation = anchor._parent?._orientation;
|
||||
final Axis orientation = (buttonIsFocused ? parentOrientation : null) ?? anchor._orientation;
|
||||
final bool differentParent = orientation != parentOrientation;
|
||||
final bool firstItemIsFocused = anchor._firstItemFocusNode?.hasPrimaryFocus ?? false;
|
||||
final bool rtl = switch (Directionality.of(context)) {
|
||||
TextDirection.rtl => true,
|
||||
TextDirection.ltr => false,
|
||||
};
|
||||
|
||||
assert(_debugMenuInfo('In _MenuDirectionalFocusAction, current node is ${anchor.widget.childFocusNode?.debugLabel}, '
|
||||
'button is${buttonIsFocused ? '' : ' not'} focused. Assuming ${orientation.name} orientation.'));
|
||||
|
||||
final bool Function(_MenuAnchorState) traversal = switch ((intent.direction, orientation)) {
|
||||
(TraversalDirection.up, Axis.horizontal) => _moveToParent,
|
||||
(TraversalDirection.up, Axis.vertical) => firstItemIsFocused ? _moveToParent: _moveToPrevious,
|
||||
(TraversalDirection.down, Axis.horizontal) => _moveToSubmenu,
|
||||
(TraversalDirection.down, Axis.vertical) => _moveToNext,
|
||||
(TraversalDirection.left, Axis.horizontal) => rtl ? _moveToNext : _moveToPrevious,
|
||||
(TraversalDirection.right, Axis.horizontal) => rtl ? _moveToPrevious : _moveToNext,
|
||||
(TraversalDirection.left, Axis.vertical) when rtl => buttonIsFocused ? _moveToSubmenu : _moveToNextFocusableTopLevel,
|
||||
(TraversalDirection.left, Axis.vertical) when differentParent => _moveToPreviousFocusableTopLevel,
|
||||
(TraversalDirection.left, Axis.vertical) => buttonIsFocused ? _moveToPreviousFocusableTopLevel : _moveToParent,
|
||||
(TraversalDirection.right, Axis.vertical) when !rtl => buttonIsFocused ? _moveToSubmenu : _moveToNextFocusableTopLevel,
|
||||
(TraversalDirection.right, Axis.vertical) when differentParent => _moveToPreviousFocusableTopLevel,
|
||||
(TraversalDirection.right, Axis.vertical) => buttonIsFocused ? _moveToPreviousFocusableTopLevel : _moveToParent,
|
||||
};
|
||||
if (!traversal(anchor)) {
|
||||
super.invoke(intent);
|
||||
}
|
||||
}
|
||||
|
||||
bool _moveToNext(_MenuAnchorState currentMenu) {
|
||||
assert(_debugMenuInfo('Moving focus to next item in menu'));
|
||||
// Need to invalidate the scope data because we're switching scopes, and
|
||||
// otherwise the anti-hysteresis code will interfere with moving to the
|
||||
// correct node.
|
||||
if (currentMenu.widget.childFocusNode != null) {
|
||||
if (currentMenu.widget.childFocusNode!.nearestScope != null) {
|
||||
final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!);
|
||||
policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _moveToNextFocusableTopLevel(_MenuAnchorState currentMenu) {
|
||||
final _MenuAnchorState? sibling = currentMenu._topLevel._nextFocusableSibling;
|
||||
sibling?._focusButton();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _moveToParent(_MenuAnchorState currentMenu) {
|
||||
assert(_debugMenuInfo('Moving focus to parent menu button'));
|
||||
if (!(currentMenu.widget.childFocusNode?.hasPrimaryFocus ?? true)) {
|
||||
currentMenu._focusButton();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _moveToPrevious(_MenuAnchorState currentMenu) {
|
||||
assert(_debugMenuInfo('Moving focus to previous item in menu'));
|
||||
// Need to invalidate the scope data because we're switching scopes, and
|
||||
// otherwise the anti-hysteresis code will interfere with moving to the
|
||||
// correct node.
|
||||
if (currentMenu.widget.childFocusNode != null) {
|
||||
if (currentMenu.widget.childFocusNode!.nearestScope != null) {
|
||||
final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!);
|
||||
policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _moveToPreviousFocusableTopLevel(_MenuAnchorState currentMenu) {
|
||||
final _MenuAnchorState? sibling = currentMenu._topLevel._previousFocusableSibling;
|
||||
sibling?._focusButton();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _moveToSubmenu(_MenuAnchorState currentMenu) {
|
||||
assert(_debugMenuInfo('Opening submenu'));
|
||||
if (!currentMenu._isOpen) {
|
||||
// If no submenu is open, then an arrow opens the submenu.
|
||||
currentMenu._open();
|
||||
return true;
|
||||
} else {
|
||||
final FocusNode? firstNode = currentMenu._firstItemFocusNode;
|
||||
if (firstNode != null && firstNode.nearestScope != firstNode) {
|
||||
// Don't request focus if the "first" found node is a focus scope, since
|
||||
// that means that nothing else in the submenu is focusable.
|
||||
firstNode.requestFocus();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An [InheritedWidget] that provides a descendant [MenuAcceleratorLabel] with
|
||||
/// the function to invoke when the accelerator is pressed.
|
||||
///
|
||||
@@ -3589,7 +3541,6 @@ class _Submenu extends StatelessWidget {
|
||||
skipTraversal: true,
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
DirectionalFocusIntent: _MenuDirectionalFocusAction(),
|
||||
DismissIntent: DismissMenuAction(controller: anchor._menuController),
|
||||
},
|
||||
child: Shortcuts(
|
||||
|
||||
@@ -1302,6 +1302,59 @@ void main() {
|
||||
'clipBehavior: Clip.none'),
|
||||
);
|
||||
});
|
||||
testWidgets('menus can be traversed multiple times', (WidgetTester tester) async {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/150334
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
MenuItemButton(
|
||||
autofocus: true,
|
||||
onPressed: () {},
|
||||
child: const Text('External Focus'),
|
||||
),
|
||||
MenuBar(
|
||||
controller: controller,
|
||||
children: createTestMenus(
|
||||
onPressed: onPressed,
|
||||
onOpen: onOpen,
|
||||
onClose: onClose,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
listenForFocusChanges();
|
||||
|
||||
// Have to open a menu initially to start things going.
|
||||
await tester.tap(find.text(TestMenu.mainMenu1.label));
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("External Focus"))'));
|
||||
|
||||
await tester.tap(find.text(TestMenu.mainMenu1.label));
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 10"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
|
||||
});
|
||||
|
||||
testWidgets('keyboard tab traversal works', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -1440,6 +1493,40 @@ void main() {
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))'));
|
||||
|
||||
// Since this is a leaf off of a vertical menu, moving left should
|
||||
// return to this menu's parent button.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
|
||||
|
||||
// Moving left while in a first-level submenu should focus the
|
||||
// previous top-level menubar anchor.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
|
||||
|
||||
// Pressing arrowup from a top-level menubar anchor should focus the last
|
||||
// item in that anchor's submenu.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
|
||||
await tester.pump();
|
||||
|
||||
// Enter the submenu.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))'));
|
||||
|
||||
// Move to next top-level menu button.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))'));
|
||||
});
|
||||
|
||||
testWidgets('keyboard directional traversal works in RTL mode', (WidgetTester tester) async {
|
||||
@@ -1526,6 +1613,356 @@ void main() {
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 113"))'));
|
||||
|
||||
// Since this is a leaf off of a vertical menu, moving right should
|
||||
// return to this menu's parent button.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
|
||||
|
||||
// Moving left while in a first-level submenu should focus the
|
||||
// previous top-level menubar anchor.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
|
||||
|
||||
// Pressing arrowup from a top-level menubar anchor should focus the last
|
||||
// item in that anchor's submenu.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 12"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Sub Menu 11"))'));
|
||||
|
||||
// Enter the submenu.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Sub Menu 110"))'));
|
||||
|
||||
// Move to next top-level menu button.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))'));
|
||||
});
|
||||
|
||||
testWidgets('MenuAnchor tab traversal works', (WidgetTester tester) async {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/144381
|
||||
final FocusNode buttonFocusNode = FocusNode(debugLabel: TestMenu.anchorButton.label);
|
||||
addTearDown(buttonFocusNode.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
MenuAnchor(
|
||||
childFocusNode: buttonFocusNode,
|
||||
menuChildren: <Widget>[
|
||||
MenuItemButton(onPressed: () {}, child: const Text('start')),
|
||||
...createTestMenus(
|
||||
onPressed: onPressed,
|
||||
onOpen: onOpen,
|
||||
onClose: onClose,
|
||||
),
|
||||
],
|
||||
builder: (
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
Widget? child,
|
||||
) {
|
||||
return TextButton(
|
||||
focusNode: buttonFocusNode,
|
||||
onPressed: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: Text(TestMenu.anchorButton.label),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
listenForFocusChanges();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
|
||||
// Directional traversal doesn't work until a menu item is focused.
|
||||
// To start focusing, hover over the first menu item.
|
||||
await hoverOver(tester, find.text('start'));
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("start"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("start"))'));
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 2"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 1"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
opened.clear();
|
||||
closed.clear();
|
||||
|
||||
// Test closing a menu with enter.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(opened, isEmpty);
|
||||
expect(closed, <TestMenu>[TestMenu.mainMenu0]);
|
||||
});
|
||||
|
||||
testWidgets('MenuAnchor LTR directional traversal works', (WidgetTester tester) async {
|
||||
final FocusNode buttonFocusNode = FocusNode(debugLabel: TestMenu.anchorButton.label);
|
||||
addTearDown(buttonFocusNode.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
MenuAnchor(
|
||||
childFocusNode: buttonFocusNode,
|
||||
menuChildren: <Widget>[
|
||||
MenuItemButton(onPressed: () {}, child: const Text('start')),
|
||||
...createTestMenus(
|
||||
onPressed: onPressed,
|
||||
onOpen: onOpen,
|
||||
onClose: onClose,
|
||||
),
|
||||
],
|
||||
builder: (
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
Widget? child,
|
||||
) {
|
||||
return TextButton(
|
||||
focusNode: buttonFocusNode,
|
||||
onPressed: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: const Text('Open'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
listenForFocusChanges();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
expect(find.text('start'), findsOneWidget);
|
||||
|
||||
// Directional traversal doesn't work until a menu item is focused.
|
||||
// To start focusing, hover over the first menu item.
|
||||
await hoverOver(tester, find.text('start'));
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("start"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsOne);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 00"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 01"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))'));
|
||||
|
||||
// We're at the deepest menu on a LTR menu, so arrow right should not change focus.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))'));
|
||||
|
||||
// Arrow left should move focus to the parent anchor.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsNothing);
|
||||
|
||||
// We're at the root menu, so arrow left should not change focus and
|
||||
// should not open the submenu.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsNothing);
|
||||
|
||||
// Open the submenu again
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsOne);
|
||||
|
||||
// Close all menus
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
expect(find.byType(MenuItemButton), findsNothing);
|
||||
});
|
||||
|
||||
|
||||
testWidgets('MenuAnchor RTL directional traversal works', (WidgetTester tester) async {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/119532
|
||||
final FocusNode buttonFocusNode = FocusNode(debugLabel: TestMenu.anchorButton.label);
|
||||
addTearDown(buttonFocusNode.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
MenuAnchor(
|
||||
childFocusNode: buttonFocusNode,
|
||||
menuChildren: <Widget>[
|
||||
MenuItemButton(onPressed: () {}, child: const Text('start')),
|
||||
...createTestMenus(
|
||||
onPressed: onPressed,
|
||||
onOpen: onOpen,
|
||||
onClose: onClose,
|
||||
),
|
||||
],
|
||||
builder: (
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
Widget? child,
|
||||
) {
|
||||
return TextButton(
|
||||
focusNode: buttonFocusNode,
|
||||
onPressed: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: const Text('Open'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
listenForFocusChanges();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
expect(find.text('start'), findsOneWidget);
|
||||
|
||||
// Directional traversal doesn't work until a menu item is focused.
|
||||
// To start focusing, hover over the first menu item.
|
||||
await hoverOver(tester, find.text('start'));
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("start"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsOne);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 00"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 01"))'));
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))'));
|
||||
|
||||
// We're at the deepest menu on a RTL menu, so arrow left should not change focus.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
expect(focusedMenu, equals('MenuItemButton(Text("Sub Menu 02"))'));
|
||||
|
||||
// Arrow right should move focus to the parent anchor.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsNothing);
|
||||
|
||||
// We're at the root menu, so arrow right should not change focus and
|
||||
// should not open the submenu.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsNothing);
|
||||
|
||||
// Open the submenu again
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals('SubmenuButton(Text("Menu 0"))'));
|
||||
expect(find.text('Sub Menu 00'), findsOne);
|
||||
|
||||
// Close all menus
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(focusedMenu, equals(TestMenu.anchorButton.label));
|
||||
expect(find.byType(MenuItemButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hover traversal works', (WidgetTester tester) async {
|
||||
|
||||
Reference in New Issue
Block a user