Skip to content

Commit 0b0450f

Browse files
authored
Web tab selection (#119583)
Correct selection behavior when tabbing into a field on the web.
1 parent 1c22567 commit 0b0450f

File tree

5 files changed

+280
-12
lines changed

5 files changed

+280
-12
lines changed

packages/flutter/lib/src/services/system_navigator.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
65
import 'system_channels.dart';
76

87
/// Controls specific aspects of the system navigation stack.

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2592,6 +2592,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
25922592
_didAutoFocus = true;
25932593
SchedulerBinding.instance.addPostFrameCallback((_) {
25942594
if (mounted && renderEditable.hasSize) {
2595+
_flagInternalFocus();
25952596
FocusScope.of(context).autofocus(widget.focusNode);
25962597
}
25972598
});
@@ -2714,6 +2715,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
27142715
clipboardStatus.removeListener(_onChangedClipboardStatus);
27152716
clipboardStatus.dispose();
27162717
_cursorVisibilityNotifier.dispose();
2718+
FocusManager.instance.removeListener(_unflagInternalFocus);
27172719
super.dispose();
27182720
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
27192721
}
@@ -3236,6 +3238,23 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
32363238
}
32373239
}
32383240

3241+
// Indicates that a call to _handleFocusChanged originated within
3242+
// EditableText, allowing it to distinguish between internal and external
3243+
// focus changes.
3244+
bool _nextFocusChangeIsInternal = false;
3245+
3246+
// Sets _nextFocusChangeIsInternal to true only until any subsequent focus
3247+
// change happens.
3248+
void _flagInternalFocus() {
3249+
_nextFocusChangeIsInternal = true;
3250+
FocusManager.instance.addListener(_unflagInternalFocus);
3251+
}
3252+
3253+
void _unflagInternalFocus() {
3254+
_nextFocusChangeIsInternal = false;
3255+
FocusManager.instance.removeListener(_unflagInternalFocus);
3256+
}
3257+
32393258
/// Express interest in interacting with the keyboard.
32403259
///
32413260
/// If this control is already attached to the keyboard, this function will
@@ -3247,6 +3266,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
32473266
if (_hasFocus) {
32483267
_openInputConnection();
32493268
} else {
3269+
_flagInternalFocus();
32503270
widget.focusNode.requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged.
32513271
}
32523272
}
@@ -3677,7 +3697,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
36773697
if (!widget.readOnly) {
36783698
_scheduleShowCaretOnScreen(withAnimation: true);
36793699
}
3680-
if (!_value.selection.isValid) {
3700+
final bool shouldSelectAll = widget.selectionEnabled && kIsWeb
3701+
&& !_isMultiline && !_nextFocusChangeIsInternal;
3702+
if (shouldSelectAll) {
3703+
// On native web, single line <input> tags select all when receiving
3704+
// focus.
3705+
_handleSelectionChanged(
3706+
TextSelection(
3707+
baseOffset: 0,
3708+
extentOffset: _value.text.length,
3709+
),
3710+
null,
3711+
);
3712+
} else if (!_value.selection.isValid) {
36813713
// Place cursor at the end if the selection is invalid when we receive focus.
36823714
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null);
36833715
}
@@ -3834,6 +3866,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
38343866
// unfocused field that previously had a selection in the same spot.
38353867
if (value == textEditingValue) {
38363868
if (!widget.focusNode.hasFocus) {
3869+
_flagInternalFocus();
38373870
widget.focusNode.requestFocus();
38383871
_selectionOverlay = _createSelectionOverlay();
38393872
}

packages/flutter/test/widgets/editable_text_show_on_screen_test.dart

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,14 @@ void main() {
553553
focusNode.requestFocus();
554554
await tester.pumpAndSettle();
555555

556-
expect(isCaretOnScreen(tester), !readOnly);
556+
if (kIsWeb) {
557+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
558+
await tester.pump();
559+
}
560+
561+
// On web, the entire field is selected, and only part of that selection
562+
// is visible on the screen.
563+
expect(isCaretOnScreen(tester), !readOnly && !kIsWeb);
557564
expect(scrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
558565
expect(editableScrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
559566
});

packages/flutter/test/widgets/editable_text_test.dart

Lines changed: 233 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -780,13 +780,28 @@ void main() {
780780
focusNode.requestFocus();
781781
await tester.pump();
782782

783-
expect(controller.value, value);
783+
// On web, focusing a single-line input selects the entire field.
784+
final TextEditingValue webValue = value.copyWith(
785+
selection: TextSelection(
786+
baseOffset: 0,
787+
extentOffset: controller.value.text.length,
788+
),
789+
);
790+
if (kIsWeb) {
791+
expect(controller.value, webValue);
792+
} else {
793+
expect(controller.value, value);
794+
}
784795
expect(focusNode.hasFocus, isTrue);
785796

786797
focusNode.unfocus();
787798
await tester.pump();
788799

789-
expect(controller.value, value);
800+
if (kIsWeb) {
801+
expect(controller.value, webValue);
802+
} else {
803+
expect(controller.value, value);
804+
}
790805
expect(focusNode.hasFocus, isFalse);
791806
});
792807

@@ -4349,7 +4364,10 @@ void main() {
43494364
],
43504365
value: expectedValue,
43514366
textDirection: TextDirection.ltr,
4352-
textSelection: const TextSelection.collapsed(offset: 24),
4367+
// Focusing a single-line field on web selects it.
4368+
textSelection: kIsWeb
4369+
? const TextSelection(baseOffset: 0, extentOffset: 24)
4370+
: const TextSelection.collapsed(offset: 24),
43534371
),
43544372
],
43554373
),
@@ -15062,7 +15080,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
1506215080
),
1506315081
),
1506415082
);
15065-
}
15083+
},
1506615084
),
1506715085
),
1506815086
);
@@ -15088,6 +15106,217 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
1508815106

1508915107
EditableText.debugDeterministicCursor = false;
1509015108
});
15109+
15110+
group('selection behavior when receiving focus', () {
15111+
testWidgets('tabbing between fields', (WidgetTester tester) async {
15112+
final TextEditingController controller1 = TextEditingController();
15113+
final TextEditingController controller2 = TextEditingController();
15114+
controller1.text = 'Text1';
15115+
controller2.text = 'Text2\nLine2';
15116+
final FocusNode focusNode1 = FocusNode();
15117+
final FocusNode focusNode2 = FocusNode();
15118+
15119+
await tester.pumpWidget(
15120+
MaterialApp(
15121+
home: Column(
15122+
crossAxisAlignment: CrossAxisAlignment.start,
15123+
children: <Widget>[
15124+
EditableText(
15125+
key: ValueKey<String>(controller1.text),
15126+
controller: controller1,
15127+
focusNode: focusNode1,
15128+
style: Typography.material2018().black.titleMedium!,
15129+
cursorColor: Colors.blue,
15130+
backgroundCursorColor: Colors.grey,
15131+
),
15132+
const SizedBox(height: 200.0),
15133+
EditableText(
15134+
key: ValueKey<String>(controller2.text),
15135+
controller: controller2,
15136+
focusNode: focusNode2,
15137+
style: Typography.material2018().black.titleMedium!,
15138+
cursorColor: Colors.blue,
15139+
backgroundCursorColor: Colors.grey,
15140+
minLines: 10,
15141+
maxLines: 20,
15142+
),
15143+
const SizedBox(height: 100.0),
15144+
],
15145+
),
15146+
),
15147+
);
15148+
15149+
expect(focusNode1.hasFocus, isFalse);
15150+
expect(focusNode2.hasFocus, isFalse);
15151+
expect(
15152+
controller1.selection,
15153+
const TextSelection.collapsed(offset: -1),
15154+
);
15155+
expect(
15156+
controller2.selection,
15157+
const TextSelection.collapsed(offset: -1),
15158+
);
15159+
15160+
// Tab to the first field (single line).
15161+
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
15162+
await tester.pumpAndSettle();
15163+
expect(focusNode1.hasFocus, isTrue);
15164+
expect(focusNode2.hasFocus, isFalse);
15165+
expect(
15166+
controller1.selection,
15167+
kIsWeb
15168+
? TextSelection(
15169+
baseOffset: 0,
15170+
extentOffset: controller1.text.length,
15171+
)
15172+
: TextSelection.collapsed(
15173+
offset: controller1.text.length,
15174+
),
15175+
);
15176+
15177+
// Move the cursor to another position in the first field.
15178+
await tester.tapAt(textOffsetToPosition(tester, controller1.text.length - 1));
15179+
await tester.pumpAndSettle();
15180+
expect(
15181+
controller1.selection,
15182+
TextSelection.collapsed(
15183+
offset: controller1.text.length - 1,
15184+
),
15185+
);
15186+
15187+
// Tab to the second field (multiline).
15188+
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
15189+
await tester.pumpAndSettle();
15190+
expect(focusNode1.hasFocus, isFalse);
15191+
expect(focusNode2.hasFocus, isTrue);
15192+
expect(
15193+
controller2.selection,
15194+
TextSelection.collapsed(
15195+
offset: controller2.text.length,
15196+
),
15197+
);
15198+
15199+
// Move the cursor to another position in the second field.
15200+
await tester.tapAt(textOffsetToPosition(tester, controller2.text.length - 1, index: 1));
15201+
await tester.pumpAndSettle();
15202+
expect(
15203+
controller2.selection,
15204+
TextSelection.collapsed(
15205+
offset: controller2.text.length - 1,
15206+
),
15207+
);
15208+
15209+
// On web, the document root is also focusable.
15210+
if (kIsWeb) {
15211+
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
15212+
await tester.pumpAndSettle();
15213+
expect(focusNode1.hasFocus, isFalse);
15214+
expect(focusNode2.hasFocus, isFalse);
15215+
}
15216+
15217+
// Tabbing again goes back to the first field and reselects the field.
15218+
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
15219+
await tester.pumpAndSettle();
15220+
expect(focusNode1.hasFocus, isTrue);
15221+
expect(focusNode2.hasFocus, isFalse);
15222+
expect(
15223+
controller1.selection,
15224+
kIsWeb
15225+
? TextSelection(
15226+
baseOffset: 0,
15227+
extentOffset: controller1.text.length,
15228+
)
15229+
: TextSelection.collapsed(
15230+
offset: controller1.text.length - 1,
15231+
),
15232+
);
15233+
15234+
// Tabbing to the second field again retains the moved selection.
15235+
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
15236+
await tester.pumpAndSettle();
15237+
expect(focusNode1.hasFocus, isFalse);
15238+
expect(focusNode2.hasFocus, isTrue);
15239+
expect(
15240+
controller2.selection,
15241+
TextSelection.collapsed(
15242+
offset: controller2.text.length - 1,
15243+
),
15244+
);
15245+
});
15246+
15247+
testWidgets('when having focus stolen between frames on web', (WidgetTester tester) async {
15248+
final TextEditingController controller1 = TextEditingController();
15249+
controller1.text = 'Text1';
15250+
final FocusNode focusNode1 = FocusNode();
15251+
final FocusNode focusNode2 = FocusNode();
15252+
15253+
await tester.pumpWidget(
15254+
MaterialApp(
15255+
home: Column(
15256+
crossAxisAlignment: CrossAxisAlignment.start,
15257+
children: <Widget>[
15258+
EditableText(
15259+
key: ValueKey<String>(controller1.text),
15260+
controller: controller1,
15261+
focusNode: focusNode1,
15262+
style: Typography.material2018().black.titleMedium!,
15263+
cursorColor: Colors.blue,
15264+
backgroundCursorColor: Colors.grey,
15265+
),
15266+
const SizedBox(height: 200.0),
15267+
Focus(
15268+
focusNode: focusNode2,
15269+
child: const SizedBox.shrink(),
15270+
),
15271+
const SizedBox(height: 100.0),
15272+
],
15273+
),
15274+
),
15275+
);
15276+
15277+
expect(focusNode1.hasFocus, isFalse);
15278+
expect(focusNode2.hasFocus, isFalse);
15279+
expect(
15280+
controller1.selection,
15281+
const TextSelection.collapsed(offset: -1),
15282+
);
15283+
15284+
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText).first);
15285+
15286+
// Set the text editing value in order to trigger an internal call to
15287+
// requestFocus.
15288+
state.userUpdateTextEditingValue(
15289+
controller1.value,
15290+
SelectionChangedCause.keyboard,
15291+
);
15292+
// Focus takes a frame to update, so it hasn't changed yet.
15293+
expect(focusNode1.hasFocus, isFalse);
15294+
expect(focusNode2.hasFocus, isFalse);
15295+
15296+
// Before EditableText's listener on widget.focusNode can be called, change
15297+
// the focus again
15298+
focusNode2.requestFocus();
15299+
await tester.pump();
15300+
expect(focusNode1.hasFocus, isFalse);
15301+
expect(focusNode2.hasFocus, isTrue);
15302+
15303+
// Focus the EditableText again, which should cause the field to be selected
15304+
// on web.
15305+
focusNode1.requestFocus();
15306+
await tester.pumpAndSettle();
15307+
expect(focusNode1.hasFocus, isTrue);
15308+
expect(focusNode2.hasFocus, isFalse);
15309+
expect(
15310+
controller1.selection,
15311+
TextSelection(
15312+
baseOffset: 0,
15313+
extentOffset: controller1.text.length,
15314+
),
15315+
);
15316+
},
15317+
skip: !kIsWeb, // [intended]
15318+
);
15319+
});
1509115320
}
1509215321

1509315322
class UnsettableController extends TextEditingController {

packages/flutter/test/widgets/editable_text_utils.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import 'package:flutter_test/flutter_test.dart';
1010
/// On web, the context menu (aka toolbar) is provided by the browser.
1111
const bool isContextMenuProvidedByPlatform = isBrowser;
1212

13-
// Returns the first RenderEditable.
14-
RenderEditable findRenderEditable(WidgetTester tester) {
15-
final RenderObject root = tester.renderObject(find.byType(EditableText));
13+
// Returns the RenderEditable at the given index, or the first if not given.
14+
RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) {
15+
final RenderObject root = tester.renderObject(find.byType(EditableText).at(index));
1616
expect(root, isNotNull);
1717

1818
late RenderEditable renderEditable;
@@ -37,8 +37,8 @@ List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBo
3737
}).toList();
3838
}
3939

40-
Offset textOffsetToPosition(WidgetTester tester, int offset) {
41-
final RenderEditable renderEditable = findRenderEditable(tester);
40+
Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) {
41+
final RenderEditable renderEditable = findRenderEditable(tester, index: index);
4242
final List<TextSelectionPoint> endpoints = globalize(
4343
renderEditable.getEndpointsForSelection(
4444
TextSelection.collapsed(offset: offset),

0 commit comments

Comments
 (0)