Unverified Commit 8ef29154 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

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.
parent bd06749e
...@@ -372,10 +372,10 @@ class IconButton extends StatelessWidget { ...@@ -372,10 +372,10 @@ class IconButton extends StatelessWidget {
onTap: onPressed, onTap: onPressed,
enableFeedback: enableFeedback, enableFeedback: enableFeedback,
child: result, child: result,
focusColor: focusColor ?? Theme.of(context).focusColor, focusColor: focusColor ?? theme.focusColor,
hoverColor: hoverColor ?? Theme.of(context).hoverColor, hoverColor: hoverColor ?? theme.hoverColor,
highlightColor: highlightColor ?? Theme.of(context).highlightColor, highlightColor: highlightColor ?? theme.highlightColor,
splashColor: splashColor ?? Theme.of(context).splashColor, splashColor: splashColor ?? theme.splashColor,
radius: splashRadius ?? math.max( radius: splashRadius ?? math.max(
Material.defaultSplashRadius, Material.defaultSplashRadius,
(iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7, (iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7,
......
...@@ -871,6 +871,18 @@ class _InkResponseState extends State<_InnerInkResponse> ...@@ -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() { void _updateFocusHighlights() {
bool showFocus; bool showFocus;
switch (FocusManager.instance.highlightMode) { switch (FocusManager.instance.highlightMode) {
...@@ -878,7 +890,7 @@ class _InkResponseState extends State<_InnerInkResponse> ...@@ -878,7 +890,7 @@ class _InkResponseState extends State<_InnerInkResponse>
showFocus = false; showFocus = false;
break; break;
case FocusHighlightMode.traditional: case FocusHighlightMode.traditional:
showFocus = enabled && _hasFocus; showFocus = _shouldShowFocus;
break; break;
} }
updateHighlight(_HighlightType.focus, value: showFocus); updateHighlight(_HighlightType.focus, value: showFocus);
...@@ -991,6 +1003,18 @@ class _InkResponseState extends State<_InnerInkResponse> ...@@ -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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(widget.debugCheckContext(context)); assert(widget.debugCheckContext(context));
...@@ -999,14 +1023,13 @@ class _InkResponseState extends State<_InnerInkResponse> ...@@ -999,14 +1023,13 @@ class _InkResponseState extends State<_InnerInkResponse>
_highlights[type]?.color = getHighlightColorForType(type); _highlights[type]?.color = getHighlightColorForType(type);
} }
_currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor; _currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor;
final bool canRequestFocus = enabled && widget.canRequestFocus;
return _ParentInkResponseProvider( return _ParentInkResponseProvider(
state: this, state: this,
child: Actions( child: Actions(
actions: _actionMap, actions: _actionMap,
child: Focus( child: Focus(
focusNode: widget.focusNode, focusNode: widget.focusNode,
canRequestFocus: canRequestFocus, canRequestFocus: _canRequestFocus,
onFocusChange: _handleFocusUpdate, onFocusChange: _handleFocusUpdate,
autofocus: widget.autofocus, autofocus: widget.autofocus,
child: MouseRegion( child: MouseRegion(
......
...@@ -1772,7 +1772,7 @@ class InputDecorator extends StatefulWidget { ...@@ -1772,7 +1772,7 @@ class InputDecorator extends StatefulWidget {
/// be null. /// be null.
const InputDecorator({ const InputDecorator({
Key key, Key key,
this.decoration, @required this.decoration,
this.baseStyle, this.baseStyle,
this.textAlign, this.textAlign,
this.textAlignVertical, this.textAlignVertical,
...@@ -1781,7 +1781,8 @@ class InputDecorator extends StatefulWidget { ...@@ -1781,7 +1781,8 @@ class InputDecorator extends StatefulWidget {
this.expands = false, this.expands = false,
this.isEmpty = false, this.isEmpty = false,
this.child, this.child,
}) : assert(isFocused != null), }) : assert(decoration != null),
assert(isFocused != null),
assert(isHovering != null), assert(isHovering != null),
assert(expands != null), assert(expands != null),
assert(isEmpty != null), assert(isEmpty != null),
...@@ -1789,9 +1790,10 @@ class InputDecorator extends StatefulWidget { ...@@ -1789,9 +1790,10 @@ class InputDecorator extends StatefulWidget {
/// The text and styles to use when decorating the child. /// The text and styles to use when decorating the child.
/// ///
/// If null, `const InputDecoration()` is used. Null [InputDecoration] /// Null [InputDecoration] properties are initialized with the corresponding
/// properties are initialized with the corresponding values from /// values from [ThemeData.inputDecorationTheme].
/// [ThemeData.inputDecorationTheme]. ///
/// Must not be null.
final InputDecoration decoration; final InputDecoration decoration;
/// The style on which to base the label, hint, counter, and error styles /// The style on which to base the label, hint, counter, and error styles
...@@ -1880,7 +1882,9 @@ class InputDecorator extends StatefulWidget { ...@@ -1880,7 +1882,9 @@ class InputDecorator extends StatefulWidget {
/// Whether the label needs to get out of the way of the input, either by /// Whether the label needs to get out of the way of the input, either by
/// floating or disappearing. /// 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 @override
_InputDecoratorState createState() => _InputDecoratorState(); _InputDecoratorState createState() => _InputDecoratorState();
...@@ -1964,7 +1968,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat ...@@ -1964,7 +1968,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
} }
TextAlign get textAlign => widget.textAlign; 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 isHovering => widget.isHovering && decoration.enabled;
bool get isEmpty => widget.isEmpty; bool get isEmpty => widget.isEmpty;
bool get _floatingLabelEnabled { bool get _floatingLabelEnabled {
...@@ -2061,7 +2065,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat ...@@ -2061,7 +2065,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
} }
Color _getDefaultIconColor(ThemeData themeData) { Color _getDefaultIconColor(ThemeData themeData) {
if (!decoration.enabled) if (!decoration.enabled && !isFocused)
return themeData.disabledColor; return themeData.disabledColor;
switch (themeData.brightness) { switch (themeData.brightness) {
...@@ -2123,7 +2127,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat ...@@ -2123,7 +2127,7 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
} }
Color borderColor; Color borderColor;
if (decoration.enabled) { if (decoration.enabled || isFocused) {
borderColor = decoration.errorText == null borderColor = decoration.errorText == null
? _getDefaultBorderColor(themeData) ? _getDefaultBorderColor(themeData)
: themeData.errorColor; : themeData.errorColor;
......
...@@ -1108,6 +1108,18 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> { ...@@ -1108,6 +1108,18 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
return null; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context)); assert(debugCheckHasMaterialLocalizations(context));
...@@ -1117,7 +1129,7 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> { ...@@ -1117,7 +1129,7 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
child: InkWell( child: InkWell(
onTap: widget.enabled ? showButtonMenu : null, onTap: widget.enabled ? showButtonMenu : null,
canRequestFocus: widget.enabled, canRequestFocus: _canRequestFocus,
child: widget.child, child: widget.child,
), ),
); );
......
...@@ -891,6 +891,24 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe ...@@ -891,6 +891,24 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
_effectiveFocusNode.canRequestFocus = _isEnabled; _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 @override
void didUpdateWidget(TextField oldWidget) { void didUpdateWidget(TextField oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
...@@ -898,8 +916,8 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe ...@@ -898,8 +916,8 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
_controller = TextEditingController.fromValue(oldWidget.controller.value); _controller = TextEditingController.fromValue(oldWidget.controller.value);
else if (widget.controller != null && oldWidget.controller == null) else if (widget.controller != null && oldWidget.controller == null)
_controller = null; _controller = null;
_effectiveFocusNode.canRequestFocus = _isEnabled; _effectiveFocusNode.canRequestFocus = _canRequestFocus;
if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly) { if (_effectiveFocusNode.hasFocus && widget.readOnly != oldWidget.readOnly && _isEnabled) {
if(_effectiveController.selection.isCollapsed) { if(_effectiveController.selection.isCollapsed) {
_showSelectionHandles = !widget.readOnly; _showSelectionHandles = !widget.readOnly;
} }
...@@ -930,6 +948,9 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe ...@@ -930,6 +948,9 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
if (widget.readOnly && _effectiveController.selection.isCollapsed) if (widget.readOnly && _effectiveController.selection.isCollapsed)
return false; return false;
if (!_isEnabled)
return false;
if (cause == SelectionChangedCause.longPress) if (cause == SelectionChangedCause.longPress)
return true; return true;
...@@ -1034,7 +1055,7 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe ...@@ -1034,7 +1055,7 @@ class _TextFieldState extends State<TextField> implements TextSelectionGestureDe
Widget child = RepaintBoundary( Widget child = RepaintBoundary(
child: EditableText( child: EditableText(
key: editableTextKey, key: editableTextKey,
readOnly: widget.readOnly, readOnly: widget.readOnly || !_isEnabled,
toolbarOptions: widget.toolbarOptions, toolbarOptions: widget.toolbarOptions,
showCursor: widget.showCursor, showCursor: widget.showCursor,
showSelectionHandles: _showSelectionHandles, showSelectionHandles: _showSelectionHandles,
......
...@@ -10,6 +10,7 @@ import 'basic.dart'; ...@@ -10,6 +10,7 @@ import 'basic.dart';
import 'focus_manager.dart'; import 'focus_manager.dart';
import 'focus_scope.dart'; import 'focus_scope.dart';
import 'framework.dart'; import 'framework.dart';
import 'media_query.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
// BuildContext/Element doesn't have a parent accessor, but it can be // BuildContext/Element doesn't have a parent accessor, but it can be
...@@ -1012,8 +1013,20 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -1012,8 +1013,20 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
return _hovering && target.enabled && _canShowHighlight; 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) { bool shouldShowFocusHighlight(FocusableActionDetector target) {
return _focused && target.enabled && _canShowHighlight; return _focused && _canShowHighlight && canRequestFocus(target);
} }
assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks); assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
...@@ -1043,6 +1056,18 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -1043,6 +1056,18 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
} }
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = MouseRegion( Widget child = MouseRegion(
...@@ -1051,7 +1076,7 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -1051,7 +1076,7 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
child: Focus( child: Focus(
focusNode: widget.focusNode, focusNode: widget.focusNode,
autofocus: widget.autofocus, autofocus: widget.autofocus,
canRequestFocus: widget.enabled, canRequestFocus: _canRequestFocus,
onFocusChange: _handleFocusChange, onFocusChange: _handleFocusChange,
child: widget.child, child: widget.child,
), ),
......
...@@ -102,6 +102,7 @@ class MediaQueryData { ...@@ -102,6 +102,7 @@ class MediaQueryData {
this.highContrast = false, this.highContrast = false,
this.disableAnimations = false, this.disableAnimations = false,
this.boldText = false, this.boldText = false,
this.navigationMode = NavigationMode.traditional,
}) : assert(size != null), }) : assert(size != null),
assert(devicePixelRatio != null), assert(devicePixelRatio != null),
assert(textScaleFactor != null), assert(textScaleFactor != null),
...@@ -116,7 +117,8 @@ class MediaQueryData { ...@@ -116,7 +117,8 @@ class MediaQueryData {
assert(invertColors != null), assert(invertColors != null),
assert(highContrast != null), assert(highContrast != null),
assert(disableAnimations != null), assert(disableAnimations != null),
assert(boldText != null); assert(boldText != null),
assert(navigationMode != null);
/// Creates data for a media query based on the given window. /// Creates data for a media query based on the given window.
/// ///
...@@ -139,7 +141,8 @@ class MediaQueryData { ...@@ -139,7 +141,8 @@ class MediaQueryData {
disableAnimations = window.accessibilityFeatures.disableAnimations, disableAnimations = window.accessibilityFeatures.disableAnimations,
boldText = window.accessibilityFeatures.boldText, boldText = window.accessibilityFeatures.boldText,
highContrast = window.accessibilityFeatures.highContrast, 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). /// The size of the media in logical pixels (e.g, the size of the screen).
/// ///
...@@ -355,6 +358,23 @@ class MediaQueryData { ...@@ -355,6 +358,23 @@ class MediaQueryData {
/// * [Window.AccessibilityFeatures], where the setting originates. /// * [Window.AccessibilityFeatures], where the setting originates.
final bool boldText; 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 /// The orientation of the media (e.g., whether the device is in landscape or
/// portrait mode). /// portrait mode).
Orientation get orientation { Orientation get orientation {
...@@ -379,6 +399,7 @@ class MediaQueryData { ...@@ -379,6 +399,7 @@ class MediaQueryData {
bool invertColors, bool invertColors,
bool accessibleNavigation, bool accessibleNavigation,
bool boldText, bool boldText,
NavigationMode navigationMode,
}) { }) {
return MediaQueryData( return MediaQueryData(
size: size ?? this.size, size: size ?? this.size,
...@@ -396,6 +417,7 @@ class MediaQueryData { ...@@ -396,6 +417,7 @@ class MediaQueryData {
disableAnimations: disableAnimations ?? this.disableAnimations, disableAnimations: disableAnimations ?? this.disableAnimations,
accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation, accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation,
boldText: boldText ?? this.boldText, boldText: boldText ?? this.boldText,
navigationMode: navigationMode ?? this.navigationMode,
); );
} }
...@@ -563,7 +585,8 @@ class MediaQueryData { ...@@ -563,7 +585,8 @@ class MediaQueryData {
&& other.disableAnimations == disableAnimations && other.disableAnimations == disableAnimations
&& other.invertColors == invertColors && other.invertColors == invertColors
&& other.accessibleNavigation == accessibleNavigation && other.accessibleNavigation == accessibleNavigation
&& other.boldText == boldText; && other.boldText == boldText
&& other.navigationMode == navigationMode;
} }
@override @override
...@@ -583,6 +606,7 @@ class MediaQueryData { ...@@ -583,6 +606,7 @@ class MediaQueryData {
invertColors, invertColors,
accessibleNavigation, accessibleNavigation,
boldText, boldText,
navigationMode,
); );
} }
...@@ -603,6 +627,7 @@ class MediaQueryData { ...@@ -603,6 +627,7 @@ class MediaQueryData {
'disableAnimations: $disableAnimations', 'disableAnimations: $disableAnimations',
'invertColors: $invertColors', 'invertColors: $invertColors',
'boldText: $boldText', 'boldText: $boldText',
'navigationMode: ${describeEnum(navigationMode)}',
]; ];
return '${objectRuntimeType(this, 'MediaQueryData')}(${properties.join(', ')})'; return '${objectRuntimeType(this, 'MediaQueryData')}(${properties.join(', ')})';
} }
...@@ -852,3 +877,31 @@ class MediaQuery extends InheritedWidget { ...@@ -852,3 +877,31 @@ class MediaQuery extends InheritedWidget {
properties.add(DiagnosticsProperty<MediaQueryData>('data', data, showName: false)); properties.add(DiagnosticsProperty<MediaQueryData>('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,
}
...@@ -459,6 +459,46 @@ void main() { ...@@ -459,6 +459,46 @@ void main() {
expect(focusNode.hasPrimaryFocus, isFalse); 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 { testWidgets("Disabled IconButton can't be traversed to when disabled.", (WidgetTester tester) async {
final FocusNode focusNode1 = FocusNode(debugLabel: 'IconButton 1'); final FocusNode focusNode1 = FocusNode(debugLabel: 'IconButton 1');
final FocusNode focusNode2 = FocusNode(debugLabel: 'IconButton 2'); final FocusNode focusNode2 = FocusNode(debugLabel: 'IconButton 2');
......
...@@ -322,6 +322,7 @@ void main() { ...@@ -322,6 +322,7 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets("ink response doesn't focus when disabled", (WidgetTester tester) async { testWidgets("ink response doesn't focus when disabled", (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
...@@ -358,6 +359,52 @@ void main() { ...@@ -358,6 +359,52 @@ void main() {
expect(focusNode.hasPrimaryFocus, isFalse); 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 { testWidgets("ink response doesn't hover when disabled", (WidgetTester tester) async {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
......
...@@ -72,6 +72,17 @@ double getBorderBottom(WidgetTester tester) { ...@@ -72,6 +72,17 @@ double getBorderBottom(WidgetTester tester) {
return box.size.height; 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) { InputBorder getBorder(WidgetTester tester) {
if (!tester.any(findBorderPainter())) if (!tester.any(findBorderPainter()))
return null; return null;
...@@ -3455,6 +3466,66 @@ void main() { ...@@ -3455,6 +3466,66 @@ void main() {
expect(getBorderColor(tester), equals(disabledColor)); expect(getBorderColor(tester), equals(disabledColor));
}); });
testWidgets('InputDecorator withdraws label when not empty or focused', (WidgetTester tester) async {
Future<void> 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 { testWidgets('InputDecorationTheme.toString()', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/19305 // Regression test for https://github.com/flutter/flutter/issues/19305
expect( expect(
......
...@@ -127,36 +127,45 @@ void main() { ...@@ -127,36 +127,45 @@ void main() {
}); });
testWidgets('disabled PopupMenuButton will not call itemBuilder, onSelected or onCanceled', (WidgetTester tester) async { 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 itemBuilderCalled = false;
bool onSelectedCalled = false; bool onSelectedCalled = false;
bool onCanceledCalled = false; bool onCanceledCalled = false;
await tester.pumpWidget( Widget buildApp({bool directional = false}) {
MaterialApp( return MaterialApp(
home: Material( home: Builder(builder: (BuildContext context) {
child: Column( return MediaQuery(
children: <Widget>[ data: MediaQuery.of(context).copyWith(
PopupMenuButton<int>( navigationMode: NavigationMode.directional,
key: popupButtonKey, ),
enabled: false, child: Material(
itemBuilder: (BuildContext context) { child: Column(
itemBuilderCalled = true; children: <Widget>[
return <PopupMenuEntry<int>>[ PopupMenuButton<int>(
const PopupMenuItem<int>( child: Text('Tap Me', key: popupButtonKey),
value: 1, enabled: false,
child: Text('Tap me please!'), itemBuilder: (BuildContext context) {
), itemBuilderCalled = true;
]; return <PopupMenuEntry<int>>[
}, const PopupMenuItem<int>(
onSelected: (int selected) => onSelectedCalled = true, value: 1,
onCanceled: () => onCanceledCalled = true, 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 // Try to bring up the popup menu and select the first item from it
await tester.tap(find.byKey(popupButtonKey)); await tester.tap(find.byKey(popupButtonKey));
...@@ -173,6 +182,27 @@ void main() { ...@@ -173,6 +182,27 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(itemBuilderCalled, isFalse); expect(itemBuilderCalled, isFalse);
expect(onCanceledCalled, 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 { testWidgets('disabled PopupMenuButton is not focusable', (WidgetTester tester) async {
...@@ -214,6 +244,47 @@ void main() { ...@@ -214,6 +244,47 @@ void main() {
expect(onSelectedCalled, isFalse); 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: <Widget>[
PopupMenuButton<int>(
key: popupButtonKey,
child: Container(key: childKey),
enabled: false,
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<int>>[
const PopupMenuItem<int>(
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 { testWidgets('PopupMenuItem is only focusable when enabled', (WidgetTester tester) async {
final Key popupButtonKey = UniqueKey(); final Key popupButtonKey = UniqueKey();
final GlobalKey childKey = GlobalKey(); final GlobalKey childKey = GlobalKey();
......
...@@ -3713,45 +3713,6 @@ void main() { ...@@ -3713,45 +3713,6 @@ void main() {
semantics.dispose(); 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 { testWidgets("Disabled TextField can't be traversed to when disabled.", (WidgetTester tester) async {
final FocusNode focusNode1 = FocusNode(debugLabel: 'TextField 1'); final FocusNode focusNode1 = FocusNode(debugLabel: 'TextField 1');
final FocusNode focusNode2 = FocusNode(debugLabel: 'TextField 2'); final FocusNode focusNode2 = FocusNode(debugLabel: 'TextField 2');
...@@ -5147,6 +5108,47 @@ void main() { ...@@ -5147,6 +5108,47 @@ void main() {
), ),
); );
expect(focusNode.hasFocus, isFalse); 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 { testWidgets('TextField displays text with text direction', (WidgetTester tester) async {
......
...@@ -317,76 +317,6 @@ void main() { ...@@ -317,76 +317,6 @@ void main() {
expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext), throwsAssertionError); expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext), throwsAssertionError);
expect(Actions.find<DoNothingIntent>(containerKey.currentContext, nullOk: true), isNull); expect(Actions.find<DoNothingIntent>(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<Intent> testAction = TestAction(
onInvoke: (Intent intent) {
invoked = true;
return invoked;
},
);
bool hovering = false;
bool focusing = false;
Future<void> buildTest(bool enabled) async {
await tester.pumpWidget(
Center(
child: Actions(
dispatcher: TestDispatcher1(postInvoke: collect),
actions: const <Type, Action<Intent>>{},
child: FocusableActionDetector(
enabled: enabled,
focusNode: focusNode,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): intent,
},
actions: <Type, Action<Intent>>{
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', () { group('Listening', () {
...@@ -554,6 +484,129 @@ void main() { ...@@ -554,6 +484,129 @@ void main() {
}); });
}); });
group(FocusableActionDetector, () {
const Intent intent = TestIntent();
bool invoked;
bool hovering;
bool focusing;
FocusNode focusNode;
Action<Intent> testAction;
Future<void> 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 <Type, Action<Intent>>{},
child: FocusableActionDetector(
enabled: enabled,
focusNode: focusNode,
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): intent,
},
actions: <Type, Action<Intent>>{
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', () { group('Diagnostics', () {
testWidgets('default Intent debugFillProperties', (WidgetTester tester) async { testWidgets('default Intent debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment