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 {
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,
......
......@@ -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(
......
......@@ -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<InputDecorator> 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<InputDecorator> 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<InputDecorator> with TickerProviderStat
}
Color borderColor;
if (decoration.enabled) {
if (decoration.enabled || isFocused) {
borderColor = decoration.errorText == null
? _getDefaultBorderColor(themeData)
: themeData.errorColor;
......
......@@ -1108,6 +1108,18 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
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<T> extends State<PopupMenuButton<T>> {
message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip,
child: InkWell(
onTap: widget.enabled ? showButtonMenu : null,
canRequestFocus: widget.enabled,
canRequestFocus: _canRequestFocus,
child: widget.child,
),
);
......
......@@ -891,6 +891,24 @@ class _TextFieldState extends State<TextField> 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<TextField> 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<TextField> 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<TextField> implements TextSelectionGestureDe
Widget child = RepaintBoundary(
child: EditableText(
key: editableTextKey,
readOnly: widget.readOnly,
readOnly: widget.readOnly || !_isEnabled,
toolbarOptions: widget.toolbarOptions,
showCursor: widget.showCursor,
showSelectionHandles: _showSelectionHandles,
......
......@@ -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<FocusableActionDetector> {
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<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
Widget build(BuildContext context) {
Widget child = MouseRegion(
......@@ -1051,7 +1076,7 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
child: Focus(
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: widget.enabled,
canRequestFocus: _canRequestFocus,
onFocusChange: _handleFocusChange,
child: widget.child,
),
......
......@@ -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<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() {
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');
......
......@@ -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');
......
......@@ -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<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 {
// Regression test for https://github.com/flutter/flutter/issues/19305
expect(
......
......@@ -127,18 +127,23 @@ 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(
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: <Widget>[
PopupMenuButton<int>(
key: popupButtonKey,
child: Text('Tap Me', key: popupButtonKey),
enabled: false,
itemBuilder: (BuildContext context) {
itemBuilderCalled = true;
......@@ -155,10 +160,35 @@ void main() {
],
),
),
),
);
}),
);
}
await tester.pumpWidget(buildApp());
// Try to bring up the popup menu and select the first item from it
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);
// 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));
......@@ -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: <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 {
final Key popupButtonKey = UniqueKey();
final GlobalKey childKey = GlobalKey();
......
......@@ -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 {
......
......@@ -317,76 +317,6 @@ void main() {
expect(() => Actions.find<DoNothingIntent>(containerKey.currentContext), throwsAssertionError);
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', () {
......@@ -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', () {
testWidgets('default Intent debugFillProperties', (WidgetTester tester) async {
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