Fix DropdownMenu
focus (#156412)
Fixes #143505 I am unable to test sending `TextInputClient.performAction` when the enter key is pressed on desktop platforms. I have created an issue for this: #156414. The current test approach is to check whether the `DropdownMenu` has focus. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Bruno Leroux <bruno.leroux@gmail.com>
This commit is contained in:
parent
eabed2381b
commit
e1e4ee9a01
@ -518,6 +518,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
|||||||
double? leadingPadding;
|
double? leadingPadding;
|
||||||
bool _menuHasEnabledItem = false;
|
bool _menuHasEnabledItem = false;
|
||||||
TextEditingController? _localTextEditingController;
|
TextEditingController? _localTextEditingController;
|
||||||
|
final FocusNode _internalFocudeNode = FocusNode();
|
||||||
|
|
||||||
TextEditingValue get _initialTextEditingValue {
|
TextEditingValue get _initialTextEditingValue {
|
||||||
for (final DropdownMenuEntry<T> entry in filteredEntries) {
|
for (final DropdownMenuEntry<T> entry in filteredEntries) {
|
||||||
@ -554,6 +555,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
|||||||
_localTextEditingController?.dispose();
|
_localTextEditingController?.dispose();
|
||||||
_localTextEditingController = null;
|
_localTextEditingController = null;
|
||||||
}
|
}
|
||||||
|
_internalFocudeNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -832,10 +834,32 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
|||||||
_enableFilter = false;
|
_enableFilter = false;
|
||||||
}
|
}
|
||||||
controller.open();
|
controller.open();
|
||||||
|
_internalFocudeNode.requestFocus();
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleEditingComplete() {
|
||||||
|
if (currentHighlight != null) {
|
||||||
|
final DropdownMenuEntry<T> entry = filteredEntries[currentHighlight!];
|
||||||
|
if (entry.enabled) {
|
||||||
|
_localTextEditingController?.value = TextEditingValue(
|
||||||
|
text: entry.label,
|
||||||
|
selection: TextSelection.collapsed(offset: entry.label.length),
|
||||||
|
);
|
||||||
|
widget.onSelected?.call(entry.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_controller.isOpen) {
|
||||||
|
widget.onSelected?.call(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!widget.enableSearch) {
|
||||||
|
currentHighlight = null;
|
||||||
|
}
|
||||||
|
_controller.close();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final TextDirection textDirection = Directionality.of(context);
|
final TextDirection textDirection = Directionality.of(context);
|
||||||
@ -934,24 +958,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
|||||||
textAlignVertical: TextAlignVertical.center,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
style: effectiveTextStyle,
|
style: effectiveTextStyle,
|
||||||
controller: _localTextEditingController,
|
controller: _localTextEditingController,
|
||||||
onEditingComplete: () {
|
onEditingComplete: _handleEditingComplete,
|
||||||
if (currentHighlight != null) {
|
|
||||||
final DropdownMenuEntry<T> entry = filteredEntries[currentHighlight!];
|
|
||||||
if (entry.enabled) {
|
|
||||||
_localTextEditingController?.value = TextEditingValue(
|
|
||||||
text: entry.label,
|
|
||||||
selection: TextSelection.collapsed(offset: entry.label.length),
|
|
||||||
);
|
|
||||||
widget.onSelected?.call(entry.value);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
widget.onSelected?.call(null);
|
|
||||||
}
|
|
||||||
if (!widget.enableSearch) {
|
|
||||||
currentHighlight = null;
|
|
||||||
}
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
onTap: !widget.enabled ? null : () {
|
onTap: !widget.enabled ? null : () {
|
||||||
handlePressed(controller);
|
handlePressed(controller);
|
||||||
},
|
},
|
||||||
@ -1041,8 +1048,28 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
|
|||||||
_ArrowDownIntent: CallbackAction<_ArrowDownIntent>(
|
_ArrowDownIntent: CallbackAction<_ArrowDownIntent>(
|
||||||
onInvoke: handleDownKeyInvoke,
|
onInvoke: handleDownKeyInvoke,
|
||||||
),
|
),
|
||||||
|
_EnterIntent: CallbackAction<_EnterIntent>(
|
||||||
|
onInvoke: (_) => _handleEditingComplete(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
child: menuAnchor,
|
child: Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
// Handling keyboard navigation when the Textfield has no focus.
|
||||||
|
Shortcuts(
|
||||||
|
shortcuts: const <ShortcutActivator, Intent>{
|
||||||
|
SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(),
|
||||||
|
SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(),
|
||||||
|
SingleActivator(LogicalKeyboardKey.enter): _EnterIntent(),
|
||||||
|
},
|
||||||
|
child: Focus(
|
||||||
|
focusNode: _internalFocudeNode,
|
||||||
|
skipTraversal: true,
|
||||||
|
child: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
menuAnchor,
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1062,6 +1089,10 @@ class _ArrowDownIntent extends Intent {
|
|||||||
const _ArrowDownIntent();
|
const _ArrowDownIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _EnterIntent extends Intent {
|
||||||
|
const _EnterIntent();
|
||||||
|
}
|
||||||
|
|
||||||
class _DropdownMenuBody extends MultiChildRenderObjectWidget {
|
class _DropdownMenuBody extends MultiChildRenderObjectWidget {
|
||||||
const _DropdownMenuBody({
|
const _DropdownMenuBody({
|
||||||
super.children,
|
super.children,
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@ -1874,8 +1875,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(menuAnchor.controller!.isOpen, true);
|
expect(menuAnchor.controller!.isOpen, true);
|
||||||
|
|
||||||
// Simulate `TextInputAction.done` on textfield
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(menuAnchor.controller!.isOpen, false);
|
expect(menuAnchor.controller!.isOpen, false);
|
||||||
});
|
});
|
||||||
@ -1915,31 +1915,29 @@ void main() {
|
|||||||
// Open the menu
|
// Open the menu
|
||||||
await tester.tap(find.byType(DropdownMenu<TestMenu>));
|
await tester.tap(find.byType(DropdownMenu<TestMenu>));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final bool isMobile = switch (themeData.platform) {
|
final bool isMobile = switch (themeData.platform) {
|
||||||
TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => true,
|
TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => true,
|
||||||
TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => false,
|
TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => false,
|
||||||
};
|
};
|
||||||
int expectedCount = isMobile ? 0 : 1;
|
int expectedCount = 1;
|
||||||
|
|
||||||
// Test onSelected on key press
|
// Test onSelected on key press
|
||||||
await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown);
|
await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(selectionCount, expectedCount);
|
expect(selectionCount, expectedCount);
|
||||||
// The desktop platform closed the menu when a completion action is pressed. So we need to reopen it.
|
|
||||||
if (!isMobile) {
|
// Open the menu
|
||||||
await tester.tap(find.byType(DropdownMenu<TestMenu>));
|
await tester.tap(find.byType(DropdownMenu<TestMenu>));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
|
||||||
|
|
||||||
// Disabled item doesn't trigger onSelected callback.
|
// Disabled item doesn't trigger onSelected callback.
|
||||||
final Finder item1 = findMenuItemButton('Item 1');
|
final Finder item1 = findMenuItemButton('Item 1');
|
||||||
await tester.tap(item1);
|
await tester.tap(item1);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(controller.text, isMobile ? '' : 'Item 0');
|
expect(controller.text, 'Item 0');
|
||||||
expect(selectionCount, expectedCount);
|
expect(selectionCount, expectedCount);
|
||||||
|
|
||||||
final Finder item2 = findMenuItemButton('Item 2');
|
final Finder item2 = findMenuItemButton('Item 2');
|
||||||
@ -3576,6 +3574,94 @@ void main() {
|
|||||||
expect(tester.getRect(findMenuMaterial()).top, textFieldBottom);
|
expect(tester.getRect(findMenuMaterial()).top, textFieldBottom);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression test for https://github.com/flutter/flutter/issues/143505.
|
||||||
|
testWidgets('Using keyboard navigation to select', (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
addTearDown(focusNode.dispose);
|
||||||
|
TestMenu? selectedMenu;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Material(
|
||||||
|
child: Center(
|
||||||
|
child: DropdownMenu<TestMenu>(
|
||||||
|
focusNode: focusNode,
|
||||||
|
dropdownMenuEntries: menuChildren,
|
||||||
|
onSelected: (TestMenu? menu) {
|
||||||
|
selectedMenu = menu;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Pressing the tab key 3 times moves the focus to the icon button.
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the focus is on the icon button.
|
||||||
|
final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down));
|
||||||
|
expect(Focus.of(iconButton).hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(selectedMenu, TestMenu.mainMenu0);
|
||||||
|
}, variant: TargetPlatformVariant.all());
|
||||||
|
|
||||||
|
// Regression test for https://github.com/flutter/flutter/issues/143505.
|
||||||
|
testWidgets('Using keyboard navigation to select and without setting the FocusNode parameter',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
TestMenu? selectedMenu;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Material(
|
||||||
|
child: Center(
|
||||||
|
child: DropdownMenu<TestMenu>(
|
||||||
|
dropdownMenuEntries: menuChildren,
|
||||||
|
onSelected: (TestMenu? menu) {
|
||||||
|
selectedMenu = menu;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// If there is no `FocusNode`, by default, `TextField` can receive focus
|
||||||
|
// on desktop platforms, but not on mobile platforms. Therefore, on desktop
|
||||||
|
// platforms, it takes 3 tabs to reach the icon button.
|
||||||
|
final int tabCount = switch (defaultTargetPlatform) {
|
||||||
|
TargetPlatform.iOS || TargetPlatform.android || TargetPlatform.fuchsia => 2,
|
||||||
|
TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 3,
|
||||||
|
};
|
||||||
|
for (int i = 0; i < tabCount; i++) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the focus is on the icon button.
|
||||||
|
final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down));
|
||||||
|
expect(Focus.of(iconButton).hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(selectedMenu, TestMenu.mainMenu0);
|
||||||
|
}, variant: TargetPlatformVariant.all());
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TestMenu {
|
enum TestMenu {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user