From 8ef291545330f18177d797a3e0ecdc5508a5eefd Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 27 May 2020 15:30:19 -0700 Subject: [PATCH] Add MediaQueryData.navigationMode and allow controls to be focused when disabled. (#54919) This adds a new navigationMode to the MediaQueryData that indicates how focusable controls should behave under different navigation modes, currently with two modes: NavigationMode.traditional, and NavigationMode.directional. It may seem like focusing a disabled control is not desirable, but this is useful for user interfaces that use DPAD navigation because if a control gets disabled, losing focus is disruptive to the user, and it is difficult to control where the focus will end up unless it is done explicitly. --- .../flutter/lib/src/material/icon_button.dart | 8 +- .../flutter/lib/src/material/ink_well.dart | 29 ++- .../lib/src/material/input_decorator.dart | 22 +- .../flutter/lib/src/material/popup_menu.dart | 14 +- .../flutter/lib/src/material/text_field.dart | 27 ++- packages/flutter/lib/src/widgets/actions.dart | 29 ++- .../flutter/lib/src/widgets/media_query.dart | 59 +++++- .../test/material/icon_button_test.dart | 40 ++++ .../flutter/test/material/ink_well_test.dart | 47 +++++ .../test/material/input_decorator_test.dart | 71 +++++++ .../test/material/popup_menu_test.dart | 121 ++++++++--- .../test/material/text_field_test.dart | 80 ++++---- .../flutter/test/widgets/actions_test.dart | 193 +++++++++++------- 13 files changed, 581 insertions(+), 159 deletions(-) diff --git a/packages/flutter/lib/src/material/icon_button.dart b/packages/flutter/lib/src/material/icon_button.dart index 05b05fec4bc..8bec7b37604 100644 --- a/packages/flutter/lib/src/material/icon_button.dart +++ b/packages/flutter/lib/src/material/icon_button.dart @@ -372,10 +372,10 @@ class IconButton extends StatelessWidget { onTap: onPressed, enableFeedback: enableFeedback, child: result, - focusColor: focusColor ?? Theme.of(context).focusColor, - hoverColor: hoverColor ?? Theme.of(context).hoverColor, - highlightColor: highlightColor ?? Theme.of(context).highlightColor, - splashColor: splashColor ?? Theme.of(context).splashColor, + focusColor: focusColor ?? theme.focusColor, + hoverColor: hoverColor ?? theme.hoverColor, + highlightColor: highlightColor ?? theme.highlightColor, + splashColor: splashColor ?? theme.splashColor, radius: splashRadius ?? math.max( Material.defaultSplashRadius, (iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7, diff --git a/packages/flutter/lib/src/material/ink_well.dart b/packages/flutter/lib/src/material/ink_well.dart index 2cac56a0c6c..3be189bb295 100644 --- a/packages/flutter/lib/src/material/ink_well.dart +++ b/packages/flutter/lib/src/material/ink_well.dart @@ -871,6 +871,18 @@ class _InkResponseState extends State<_InnerInkResponse> }); } + bool get _shouldShowFocus { + final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return enabled && _hasFocus; + case NavigationMode.directional: + return _hasFocus; + } + assert(false, 'Navigation mode $mode not handled'); + return null; + } + void _updateFocusHighlights() { bool showFocus; switch (FocusManager.instance.highlightMode) { @@ -878,7 +890,7 @@ class _InkResponseState extends State<_InnerInkResponse> showFocus = false; break; case FocusHighlightMode.traditional: - showFocus = enabled && _hasFocus; + showFocus = _shouldShowFocus; break; } updateHighlight(_HighlightType.focus, value: showFocus); @@ -991,6 +1003,18 @@ class _InkResponseState extends State<_InnerInkResponse> } } + bool get _canRequestFocus { + final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return enabled && widget.canRequestFocus; + case NavigationMode.directional: + return true; + } + assert(false, 'NavigationMode $mode not handled.'); + return null; + } + @override Widget build(BuildContext context) { assert(widget.debugCheckContext(context)); @@ -999,14 +1023,13 @@ class _InkResponseState extends State<_InnerInkResponse> _highlights[type]?.color = getHighlightColorForType(type); } _currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor; - final bool canRequestFocus = enabled && widget.canRequestFocus; return _ParentInkResponseProvider( state: this, child: Actions( actions: _actionMap, child: Focus( focusNode: widget.focusNode, - canRequestFocus: canRequestFocus, + canRequestFocus: _canRequestFocus, onFocusChange: _handleFocusUpdate, autofocus: widget.autofocus, child: MouseRegion( diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index 52f166b4665..9cadc9f7b9b 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -1772,7 +1772,7 @@ class InputDecorator extends StatefulWidget { /// be null. const InputDecorator({ Key key, - this.decoration, + @required this.decoration, this.baseStyle, this.textAlign, this.textAlignVertical, @@ -1781,7 +1781,8 @@ class InputDecorator extends StatefulWidget { this.expands = false, this.isEmpty = false, this.child, - }) : assert(isFocused != null), + }) : assert(decoration != null), + assert(isFocused != null), assert(isHovering != null), assert(expands != null), assert(isEmpty != null), @@ -1789,9 +1790,10 @@ class InputDecorator extends StatefulWidget { /// The text and styles to use when decorating the child. /// - /// If null, `const InputDecoration()` is used. Null [InputDecoration] - /// properties are initialized with the corresponding values from - /// [ThemeData.inputDecorationTheme]. + /// Null [InputDecoration] properties are initialized with the corresponding + /// values from [ThemeData.inputDecorationTheme]. + /// + /// Must not be null. final InputDecoration decoration; /// The style on which to base the label, hint, counter, and error styles @@ -1880,7 +1882,9 @@ class InputDecorator extends StatefulWidget { /// Whether the label needs to get out of the way of the input, either by /// floating or disappearing. - bool get _labelShouldWithdraw => !isEmpty || isFocused; + /// + /// Will withdraw when not empty, or when focused while enabled. + bool get _labelShouldWithdraw => !isEmpty || (isFocused && decoration.enabled); @override _InputDecoratorState createState() => _InputDecoratorState(); @@ -1964,7 +1968,7 @@ class _InputDecoratorState extends State with TickerProviderStat } TextAlign get textAlign => widget.textAlign; - bool get isFocused => widget.isFocused && decoration.enabled; + bool get isFocused => widget.isFocused; bool get isHovering => widget.isHovering && decoration.enabled; bool get isEmpty => widget.isEmpty; bool get _floatingLabelEnabled { @@ -2061,7 +2065,7 @@ class _InputDecoratorState extends State with TickerProviderStat } Color _getDefaultIconColor(ThemeData themeData) { - if (!decoration.enabled) + if (!decoration.enabled && !isFocused) return themeData.disabledColor; switch (themeData.brightness) { @@ -2123,7 +2127,7 @@ class _InputDecoratorState extends State with TickerProviderStat } Color borderColor; - if (decoration.enabled) { + if (decoration.enabled || isFocused) { borderColor = decoration.errorText == null ? _getDefaultBorderColor(themeData) : themeData.errorColor; diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index d29b99a0abc..955e1846dcb 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -1108,6 +1108,18 @@ class PopupMenuButtonState extends State> { return null; } + bool get _canRequestFocus { + final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return widget.enabled; + case NavigationMode.directional: + return true; + } + assert(false, 'Navigation mode $mode not handled'); + return null; + } + @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); @@ -1117,7 +1129,7 @@ class PopupMenuButtonState extends State> { message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, child: InkWell( onTap: widget.enabled ? showButtonMenu : null, - canRequestFocus: widget.enabled, + canRequestFocus: _canRequestFocus, child: widget.child, ), ); diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 3485a5f6a17..f50457c1999 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -891,6 +891,24 @@ class _TextFieldState extends State implements TextSelectionGestureDe _effectiveFocusNode.canRequestFocus = _isEnabled; } + bool get _canRequestFocus { + final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return _isEnabled; + case NavigationMode.directional: + return true; + } + assert(false, 'Navigation mode $mode not handled'); + return null; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _effectiveFocusNode.canRequestFocus = _canRequestFocus; + } + @override void didUpdateWidget(TextField oldWidget) { super.didUpdateWidget(oldWidget); @@ -898,8 +916,8 @@ class _TextFieldState extends State implements TextSelectionGestureDe _controller = TextEditingController.fromValue(oldWidget.controller.value); else if (widget.controller != null && oldWidget.controller == null) _controller = null; - _effectiveFocusNode.canRequestFocus = _isEnabled; - if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly) { + _effectiveFocusNode.canRequestFocus = _canRequestFocus; + if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly && _isEnabled) { if(_effectiveController.selection.isCollapsed) { _showSelectionHandles = !widget.readOnly; } @@ -930,6 +948,9 @@ class _TextFieldState extends State implements TextSelectionGestureDe if (widget.readOnly && _effectiveController.selection.isCollapsed) return false; + if (!_isEnabled) + return false; + if (cause == SelectionChangedCause.longPress) return true; @@ -1034,7 +1055,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe Widget child = RepaintBoundary( child: EditableText( key: editableTextKey, - readOnly: widget.readOnly, + readOnly: widget.readOnly || !_isEnabled, toolbarOptions: widget.toolbarOptions, showCursor: widget.showCursor, showSelectionHandles: _showSelectionHandles, diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart index 87e0ac296ee..d4a52b8d419 100644 --- a/packages/flutter/lib/src/widgets/actions.dart +++ b/packages/flutter/lib/src/widgets/actions.dart @@ -10,6 +10,7 @@ import 'basic.dart'; import 'focus_manager.dart'; import 'focus_scope.dart'; import 'framework.dart'; +import 'media_query.dart'; import 'shortcuts.dart'; // BuildContext/Element doesn't have a parent accessor, but it can be @@ -1012,8 +1013,20 @@ class _FocusableActionDetectorState extends State { return _hovering && target.enabled && _canShowHighlight; } + bool canRequestFocus(FocusableActionDetector target) { + final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return target.enabled; + case NavigationMode.directional: + return true; + } + assert(false, 'Navigation mode $mode not handled'); + return null; + } + bool shouldShowFocusHighlight(FocusableActionDetector target) { - return _focused && target.enabled && _canShowHighlight; + return _focused && _canShowHighlight && canRequestFocus(target); } assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks); @@ -1043,6 +1056,18 @@ class _FocusableActionDetectorState extends State { } } + bool get _canRequestFocus { + final NavigationMode mode = MediaQuery.of(context, nullOk: true)?.navigationMode ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return widget.enabled; + case NavigationMode.directional: + return true; + } + assert(false, 'NavigationMode $mode not handled.'); + return null; + } + @override Widget build(BuildContext context) { Widget child = MouseRegion( @@ -1051,7 +1076,7 @@ class _FocusableActionDetectorState extends State { child: Focus( focusNode: widget.focusNode, autofocus: widget.autofocus, - canRequestFocus: widget.enabled, + canRequestFocus: _canRequestFocus, onFocusChange: _handleFocusChange, child: widget.child, ), diff --git a/packages/flutter/lib/src/widgets/media_query.dart b/packages/flutter/lib/src/widgets/media_query.dart index 383f4318386..eb06f31cdf6 100644 --- a/packages/flutter/lib/src/widgets/media_query.dart +++ b/packages/flutter/lib/src/widgets/media_query.dart @@ -102,6 +102,7 @@ class MediaQueryData { this.highContrast = false, this.disableAnimations = false, this.boldText = false, + this.navigationMode = NavigationMode.traditional, }) : assert(size != null), assert(devicePixelRatio != null), assert(textScaleFactor != null), @@ -116,7 +117,8 @@ class MediaQueryData { assert(invertColors != null), assert(highContrast != null), assert(disableAnimations != null), - assert(boldText != null); + assert(boldText != null), + assert(navigationMode != null); /// Creates data for a media query based on the given window. /// @@ -139,7 +141,8 @@ class MediaQueryData { disableAnimations = window.accessibilityFeatures.disableAnimations, boldText = window.accessibilityFeatures.boldText, highContrast = window.accessibilityFeatures.highContrast, - alwaysUse24HourFormat = window.alwaysUse24HourFormat; + alwaysUse24HourFormat = window.alwaysUse24HourFormat, + navigationMode = NavigationMode.traditional; /// The size of the media in logical pixels (e.g, the size of the screen). /// @@ -355,6 +358,23 @@ class MediaQueryData { /// * [Window.AccessibilityFeatures], where the setting originates. final bool boldText; + /// Describes the navigation mode requested by the platform. + /// + /// Some user interfaces are better navigated using a directional pad (DPAD) + /// or arrow keys, and for those interfaces, some widgets need to handle these + /// directional events differently. In order to know when to do that, these + /// widgets will look for the navigation mode in effect for their context. + /// + /// For instance, in a television interface, [NavigationMode.directional] + /// should be set, so that directional navigation is used to navigate away + /// from a text field using the DPAD. In contrast, on a regular desktop + /// application with the `navigationMode` set to [NavigationMode.traditional], + /// the arrow keys are used to move the cursor instead of navigating away. + /// + /// The [NavigationMode] values indicate the type of navigation to be used in + /// a widget subtree for those widgets sensitive to it. + final NavigationMode navigationMode; + /// The orientation of the media (e.g., whether the device is in landscape or /// portrait mode). Orientation get orientation { @@ -379,6 +399,7 @@ class MediaQueryData { bool invertColors, bool accessibleNavigation, bool boldText, + NavigationMode navigationMode, }) { return MediaQueryData( size: size ?? this.size, @@ -396,6 +417,7 @@ class MediaQueryData { disableAnimations: disableAnimations ?? this.disableAnimations, accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation, boldText: boldText ?? this.boldText, + navigationMode: navigationMode ?? this.navigationMode, ); } @@ -563,7 +585,8 @@ class MediaQueryData { && other.disableAnimations == disableAnimations && other.invertColors == invertColors && other.accessibleNavigation == accessibleNavigation - && other.boldText == boldText; + && other.boldText == boldText + && other.navigationMode == navigationMode; } @override @@ -583,6 +606,7 @@ class MediaQueryData { invertColors, accessibleNavigation, boldText, + navigationMode, ); } @@ -603,6 +627,7 @@ class MediaQueryData { 'disableAnimations: $disableAnimations', 'invertColors: $invertColors', 'boldText: $boldText', + 'navigationMode: ${describeEnum(navigationMode)}', ]; return '${objectRuntimeType(this, 'MediaQueryData')}(${properties.join(', ')})'; } @@ -852,3 +877,31 @@ class MediaQuery extends InheritedWidget { properties.add(DiagnosticsProperty('data', data, showName: false)); } } + +/// Describes the navigation mode to be set by a [MediaQuery] widget +/// +/// The different modes indicate the type of navigation to be used in a widget +/// subtree for those widgets sensitive to it. +/// +/// Use `MediaQuery.of(context).navigationMode` to determine the navigation mode +/// in effect for the given context. Use a [MediaQuery] widget to set the +/// navigation mode for its descendant widgets. +enum NavigationMode { + /// This indicates a traditional keyboard-and-mouse navigation modality. + /// + /// This navigation mode is where the arrow keys can be used for secondary + /// modification operations, like moving sliders or cursors, and disabled + /// controls will lose focus and not be traversable. + traditional, + + /// This indicates a directional-based navigation mode. + /// + /// This navigation mode indicates that arrow keys should be reserved for + /// navigation operations, and secondary modifications operations, like moving + /// sliders or cursors, will use alternative bindings or be disabled. + /// + /// Some behaviors are also affected by this mode. For instance, disabled + /// controls will retain focus when disabled, and will be able to receive + /// focus (although they remain disabled) when traversed. + directional, +} diff --git a/packages/flutter/test/material/icon_button_test.dart b/packages/flutter/test/material/icon_button_test.dart index fd67d949d38..a6994048a30 100644 --- a/packages/flutter/test/material/icon_button_test.dart +++ b/packages/flutter/test/material/icon_button_test.dart @@ -459,6 +459,46 @@ void main() { expect(focusNode.hasPrimaryFocus, isFalse); }); + testWidgets('IconButton keeps focus when disabled in directional navigation mode.', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'IconButton'); + await tester.pumpWidget( + wrap( + child: MediaQuery( + data: const MediaQueryData( + navigationMode: NavigationMode.directional, + ), + child: IconButton( + focusNode: focusNode, + autofocus: true, + onPressed: () {}, + icon: const Icon(Icons.link), + ), + ), + ), + ); + + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.pumpWidget( + wrap( + child: MediaQuery( + data: const MediaQueryData( + navigationMode: NavigationMode.directional, + ), + child: IconButton( + focusNode: focusNode, + autofocus: true, + onPressed: null, + icon: const Icon(Icons.link), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + }); + testWidgets("Disabled IconButton can't be traversed to when disabled.", (WidgetTester tester) async { final FocusNode focusNode1 = FocusNode(debugLabel: 'IconButton 1'); final FocusNode focusNode2 = FocusNode(debugLabel: 'IconButton 2'); diff --git a/packages/flutter/test/material/ink_well_test.dart b/packages/flutter/test/material/ink_well_test.dart index 0c7d56731eb..7ad5d9e6206 100644 --- a/packages/flutter/test/material/ink_well_test.dart +++ b/packages/flutter/test/material/ink_well_test.dart @@ -322,6 +322,7 @@ void main() { semantics.dispose(); }); + testWidgets("ink response doesn't focus when disabled", (WidgetTester tester) async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); @@ -358,6 +359,52 @@ void main() { expect(focusNode.hasPrimaryFocus, isFalse); }); + testWidgets('ink response accepts focus when disabled in directional navigation mode', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); + final GlobalKey childKey = GlobalKey(); + await tester.pumpWidget( + Material( + child: MediaQuery( + data: const MediaQueryData( + navigationMode: NavigationMode.directional, + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: InkWell( + autofocus: true, + onTap: () {}, + onLongPress: () {}, + onHover: (bool hover) {}, + focusNode: focusNode, + child: Container(key: childKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + await tester.pumpWidget( + Material( + child: MediaQuery( + data: const MediaQueryData( + navigationMode: NavigationMode.directional, + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: InkWell( + focusNode: focusNode, + child: Container(key: childKey), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + }); + testWidgets("ink response doesn't hover when disabled", (WidgetTester tester) async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart index 1f4bb823081..cd72e1200fe 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -72,6 +72,17 @@ double getBorderBottom(WidgetTester tester) { return box.size.height; } +Finder findLabel() { + return find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Shaker'), + matching: find.byWidgetPredicate((Widget w) => w is Text), + ); +} + +Rect getLabelRect(WidgetTester tester) { + return tester.getRect(findLabel()); +} + InputBorder getBorder(WidgetTester tester) { if (!tester.any(findBorderPainter())) return null; @@ -3455,6 +3466,66 @@ void main() { expect(getBorderColor(tester), equals(disabledColor)); }); + testWidgets('InputDecorator withdraws label when not empty or focused', (WidgetTester tester) async { + Future pumpDecorator({bool focused, bool enabled = true, bool filled = false, bool empty = true, bool directional = false}) async { + return await tester.pumpWidget( + buildInputDecorator( + isEmpty: empty, + isFocused: focused, + decoration: InputDecoration( + labelText: 'Label', + enabled: enabled, + filled: filled, + focusedBorder: const OutlineInputBorder(), + disabledBorder: const OutlineInputBorder(), + border: const OutlineInputBorder(), + ), + ), + ); + } + + await pumpDecorator(focused: false, empty: true); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + + await pumpDecorator(focused: false, empty: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + + await pumpDecorator(focused: true, empty: true); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + + await pumpDecorator(focused: true, empty: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + + await pumpDecorator(focused: false, empty: true, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + + await pumpDecorator(focused: false, empty: false, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + + // Focused and disabled happens with NavigationMode.directional. + await pumpDecorator(focused: true, empty: true, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, 20))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + + await pumpDecorator(focused: true, empty: false, enabled: false); + await tester.pumpAndSettle(); + expect(getLabelRect(tester).topLeft, equals(const Offset(12, -4))); + expect(getLabelRect(tester).size, equals(const Size(80, 16))); + }); + testWidgets('InputDecorationTheme.toString()', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/19305 expect( diff --git a/packages/flutter/test/material/popup_menu_test.dart b/packages/flutter/test/material/popup_menu_test.dart index 096f6e34f5a..58d1c1d8076 100644 --- a/packages/flutter/test/material/popup_menu_test.dart +++ b/packages/flutter/test/material/popup_menu_test.dart @@ -127,36 +127,45 @@ void main() { }); testWidgets('disabled PopupMenuButton will not call itemBuilder, onSelected or onCanceled', (WidgetTester tester) async { - final Key popupButtonKey = UniqueKey(); + final GlobalKey popupButtonKey = GlobalKey(); bool itemBuilderCalled = false; bool onSelectedCalled = false; bool onCanceledCalled = false; - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Column( - children: [ - PopupMenuButton( - key: popupButtonKey, - enabled: false, - itemBuilder: (BuildContext context) { - itemBuilderCalled = true; - return >[ - const PopupMenuItem( - value: 1, - child: Text('Tap me please!'), - ), - ]; - }, - onSelected: (int selected) => onSelectedCalled = true, - onCanceled: () => onCanceledCalled = true, + Widget buildApp({bool directional = false}) { + return MaterialApp( + home: Builder(builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + navigationMode: NavigationMode.directional, + ), + child: Material( + child: Column( + children: [ + PopupMenuButton( + child: Text('Tap Me', key: popupButtonKey), + enabled: false, + itemBuilder: (BuildContext context) { + itemBuilderCalled = true; + return >[ + const PopupMenuItem( + value: 1, + child: Text('Tap me please!'), + ), + ]; + }, + onSelected: (int selected) => onSelectedCalled = true, + onCanceled: () => onCanceledCalled = true, + ), + ], ), - ], - ), - ), - ), - ); + ), + ); + }), + ); + } + + await tester.pumpWidget(buildApp()); // Try to bring up the popup menu and select the first item from it await tester.tap(find.byKey(popupButtonKey)); @@ -173,6 +182,27 @@ void main() { await tester.pumpAndSettle(); expect(itemBuilderCalled, isFalse); expect(onCanceledCalled, isFalse); + + // Test again, with directional navigation mode and after focusing the button. + await tester.pumpWidget(buildApp(directional: true)); + + // Try to bring up the popup menu and select the first item from it + Focus.of(popupButtonKey.currentContext).requestFocus(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + expect(itemBuilderCalled, isFalse); + expect(onSelectedCalled, isFalse); + + // Try to bring up the popup menu and tap outside it to cancel the menu + await tester.tap(find.byKey(popupButtonKey)); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(0.0, 0.0)); + await tester.pumpAndSettle(); + expect(itemBuilderCalled, isFalse); + expect(onCanceledCalled, isFalse); }); testWidgets('disabled PopupMenuButton is not focusable', (WidgetTester tester) async { @@ -214,6 +244,47 @@ void main() { expect(onSelectedCalled, isFalse); }); + testWidgets('disabled PopupMenuButton is focusable with directional navigation', (WidgetTester tester) async { + final Key popupButtonKey = UniqueKey(); + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Builder(builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + navigationMode: NavigationMode.directional, + ), + child: Material( + child: Column( + children: [ + PopupMenuButton( + key: popupButtonKey, + child: Container(key: childKey), + enabled: false, + itemBuilder: (BuildContext context) { + return >[ + const PopupMenuItem( + value: 1, + child: Text('Tap me please!'), + ), + ]; + }, + onSelected: (int selected) {}, + ), + ], + ), + ), + ); + }), + ), + ); + Focus.of(childKey.currentContext, nullOk: true).requestFocus(); + await tester.pump(); + + expect(Focus.of(childKey.currentContext, nullOk: true).hasPrimaryFocus, isTrue); + }); + testWidgets('PopupMenuItem is only focusable when enabled', (WidgetTester tester) async { final Key popupButtonKey = UniqueKey(); final GlobalKey childKey = GlobalKey(); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index eb53932ad79..0e69b327559 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -3713,45 +3713,6 @@ void main() { semantics.dispose(); }); - testWidgets('TextField loses focus when disabled', (WidgetTester tester) async { - final FocusNode focusNode = FocusNode(debugLabel: 'TextField'); - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Center( - child: TextField( - focusNode: focusNode, - autofocus: true, - maxLength: 10, - enabled: true, - ), - ), - ), - ), - ); - - await tester.pump(); - expect(focusNode.hasPrimaryFocus, isTrue); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Center( - child: TextField( - focusNode: focusNode, - autofocus: true, - maxLength: 10, - enabled: false, - ), - ), - ), - ), - ); - - await tester.pump(); - expect(focusNode.hasPrimaryFocus, isFalse); - }); - testWidgets("Disabled TextField can't be traversed to when disabled.", (WidgetTester tester) async { final FocusNode focusNode1 = FocusNode(debugLabel: 'TextField 1'); final FocusNode focusNode2 = FocusNode(debugLabel: 'TextField 2'); @@ -5147,6 +5108,47 @@ void main() { ), ); expect(focusNode.hasFocus, isFalse); + + await tester.pumpWidget( + boilerplate( + child: Builder(builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + navigationMode: NavigationMode.directional, + ), + child: TextField( + focusNode: focusNode, + autofocus: true, + enabled: true, + ), + ); + }), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + + await tester.pumpWidget( + boilerplate( + child: Builder(builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + navigationMode: NavigationMode.directional, + ), + child: TextField( + focusNode: focusNode, + autofocus: true, + enabled: false, + ), + ); + }), + ), + ); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); }); testWidgets('TextField displays text with text direction', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/actions_test.dart b/packages/flutter/test/widgets/actions_test.dart index 728f6b2c644..82e1a988e9c 100644 --- a/packages/flutter/test/widgets/actions_test.dart +++ b/packages/flutter/test/widgets/actions_test.dart @@ -317,76 +317,6 @@ void main() { expect(() => Actions.find(containerKey.currentContext), throwsAssertionError); expect(Actions.find(containerKey.currentContext, nullOk: true), isNull); }); - testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async { - FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; - final GlobalKey containerKey = GlobalKey(); - bool invoked = false; - const Intent intent = TestIntent(); - final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); - final Action testAction = TestAction( - onInvoke: (Intent intent) { - invoked = true; - return invoked; - }, - ); - bool hovering = false; - bool focusing = false; - - Future buildTest(bool enabled) async { - await tester.pumpWidget( - Center( - child: Actions( - dispatcher: TestDispatcher1(postInvoke: collect), - actions: const >{}, - child: FocusableActionDetector( - enabled: enabled, - focusNode: focusNode, - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.enter): intent, - }, - actions: >{ - TestIntent: testAction, - }, - onShowHoverHighlight: (bool value) => hovering = value, - onShowFocusHighlight: (bool value) => focusing = value, - child: Container(width: 100, height: 100, key: containerKey), - ), - ), - ), - ); - return tester.pump(); - } - - await buildTest(true); - focusNode.requestFocus(); - await tester.pump(); - final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(find.byKey(containerKey))); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - expect(hovering, isTrue); - expect(focusing, isTrue); - expect(invoked, isTrue); - - invoked = false; - await buildTest(false); - expect(hovering, isFalse); - expect(focusing, isFalse); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - expect(invoked, isFalse); - await buildTest(true); - expect(focusing, isFalse); - expect(hovering, isTrue); - await buildTest(false); - expect(focusing, isFalse); - expect(hovering, isFalse); - await gesture.moveTo(Offset.zero); - await buildTest(true); - expect(hovering, isFalse); - expect(focusing, isFalse); - }); }); group('Listening', () { @@ -554,6 +484,129 @@ void main() { }); }); + group(FocusableActionDetector, () { + const Intent intent = TestIntent(); + bool invoked; + bool hovering; + bool focusing; + FocusNode focusNode; + Action testAction; + + Future pumpTest( + WidgetTester tester, { + bool enabled = true, + bool directional = false, + @required Key key, + }) async { + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData( + navigationMode: directional ? NavigationMode.directional : NavigationMode.traditional, + ), + child: Center( + child: Actions( + dispatcher: const TestDispatcher1(), + actions: const >{}, + child: FocusableActionDetector( + enabled: enabled, + focusNode: focusNode, + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.enter): intent, + }, + actions: >{ + TestIntent: testAction, + }, + onShowHoverHighlight: (bool value) => hovering = value, + onShowFocusHighlight: (bool value) => focusing = value, + child: Container(width: 100, height: 100, key: key), + ), + ), + ), + ), + ); + return tester.pump(); + } + + setUp(() async { + invoked = false; + hovering = false; + focusing = false; + + focusNode = FocusNode(debugLabel: 'Test Node'); + testAction = TestAction( + onInvoke: (Intent intent) { + invoked = true; + return invoked; + }, + ); + }); + + testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final GlobalKey containerKey = GlobalKey(); + + await pumpTest(tester, enabled: true, key: containerKey); + focusNode.requestFocus(); + await tester.pump(); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(containerKey))); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + expect(hovering, isTrue); + expect(focusing, isTrue); + expect(invoked, isTrue); + + invoked = false; + await pumpTest(tester, enabled: false, key: containerKey); + expect(hovering, isFalse); + expect(focusing, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(invoked, isFalse); + await pumpTest(tester, enabled: true, key: containerKey); + expect(focusing, isFalse); + expect(hovering, isTrue); + await pumpTest(tester, enabled: false, key: containerKey); + expect(focusing, isFalse); + expect(hovering, isFalse); + await gesture.moveTo(Offset.zero); + await pumpTest(tester, enabled: true, key: containerKey); + expect(hovering, isFalse); + expect(focusing, isFalse); + }); + testWidgets('FocusableActionDetector shows focus highlight appropriately when focused and disabled', (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final GlobalKey containerKey = GlobalKey(); + + await pumpTest(tester, enabled: true, key: containerKey); + await tester.pump(); + expect(focusing, isFalse); + + await pumpTest(tester, enabled: true, key: containerKey); + focusNode.requestFocus(); + await tester.pump(); + expect(focusing, isTrue); + + focusing = false; + await pumpTest(tester, enabled: false, key: containerKey); + focusNode.requestFocus(); + await tester.pump(); + expect(focusing, isFalse); + + await pumpTest(tester, enabled: false, key: containerKey); + focusNode.requestFocus(); + await tester.pump(); + expect(focusing, isFalse); + + // In directional navigation, focus should show, even if disabled. + await pumpTest(tester, enabled: false, key: containerKey, directional: true); + focusNode.requestFocus(); + await tester.pump(); + expect(focusing, isTrue); + }); + }); + group('Diagnostics', () { testWidgets('default Intent debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();