Unverified Commit 0b0450fb authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Web tab selection (#119583)

Correct selection behavior when tabbing into a field on the web.
parent 1c225675
......@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'system_channels.dart';
/// Controls specific aspects of the system navigation stack.
......
......@@ -2592,6 +2592,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_didAutoFocus = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted && renderEditable.hasSize) {
_flagInternalFocus();
FocusScope.of(context).autofocus(widget.focusNode);
}
});
......@@ -2714,6 +2715,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
clipboardStatus.removeListener(_onChangedClipboardStatus);
clipboardStatus.dispose();
_cursorVisibilityNotifier.dispose();
FocusManager.instance.removeListener(_unflagInternalFocus);
super.dispose();
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
}
......@@ -3236,6 +3238,23 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
}
// Indicates that a call to _handleFocusChanged originated within
// EditableText, allowing it to distinguish between internal and external
// focus changes.
bool _nextFocusChangeIsInternal = false;
// Sets _nextFocusChangeIsInternal to true only until any subsequent focus
// change happens.
void _flagInternalFocus() {
_nextFocusChangeIsInternal = true;
FocusManager.instance.addListener(_unflagInternalFocus);
}
void _unflagInternalFocus() {
_nextFocusChangeIsInternal = false;
FocusManager.instance.removeListener(_unflagInternalFocus);
}
/// Express interest in interacting with the keyboard.
///
/// If this control is already attached to the keyboard, this function will
......@@ -3247,6 +3266,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (_hasFocus) {
_openInputConnection();
} else {
_flagInternalFocus();
widget.focusNode.requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged.
}
}
......@@ -3677,7 +3697,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (!widget.readOnly) {
_scheduleShowCaretOnScreen(withAnimation: true);
}
if (!_value.selection.isValid) {
final bool shouldSelectAll = widget.selectionEnabled && kIsWeb
&& !_isMultiline && !_nextFocusChangeIsInternal;
if (shouldSelectAll) {
// On native web, single line <input> tags select all when receiving
// focus.
_handleSelectionChanged(
TextSelection(
baseOffset: 0,
extentOffset: _value.text.length,
),
null,
);
} else if (!_value.selection.isValid) {
// Place cursor at the end if the selection is invalid when we receive focus.
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null);
}
......@@ -3834,6 +3866,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// unfocused field that previously had a selection in the same spot.
if (value == textEditingValue) {
if (!widget.focusNode.hasFocus) {
_flagInternalFocus();
widget.focusNode.requestFocus();
_selectionOverlay = _createSelectionOverlay();
}
......
......@@ -553,7 +553,14 @@ void main() {
focusNode.requestFocus();
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), !readOnly);
if (kIsWeb) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
}
// On web, the entire field is selected, and only part of that selection
// is visible on the screen.
expect(isCaretOnScreen(tester), !readOnly && !kIsWeb);
expect(scrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
expect(editableScrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
});
......
......@@ -780,13 +780,28 @@ void main() {
focusNode.requestFocus();
await tester.pump();
expect(controller.value, value);
// On web, focusing a single-line input selects the entire field.
final TextEditingValue webValue = value.copyWith(
selection: TextSelection(
baseOffset: 0,
extentOffset: controller.value.text.length,
),
);
if (kIsWeb) {
expect(controller.value, webValue);
} else {
expect(controller.value, value);
}
expect(focusNode.hasFocus, isTrue);
focusNode.unfocus();
await tester.pump();
expect(controller.value, value);
if (kIsWeb) {
expect(controller.value, webValue);
} else {
expect(controller.value, value);
}
expect(focusNode.hasFocus, isFalse);
});
......@@ -4349,7 +4364,10 @@ void main() {
],
value: expectedValue,
textDirection: TextDirection.ltr,
textSelection: const TextSelection.collapsed(offset: 24),
// Focusing a single-line field on web selects it.
textSelection: kIsWeb
? const TextSelection(baseOffset: 0, extentOffset: 24)
: const TextSelection.collapsed(offset: 24),
),
],
),
......@@ -15062,7 +15080,7 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
),
),
);
}
},
),
),
);
......@@ -15088,6 +15106,217 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
EditableText.debugDeterministicCursor = false;
});
group('selection behavior when receiving focus', () {
testWidgets('tabbing between fields', (WidgetTester tester) async {
final TextEditingController controller1 = TextEditingController();
final TextEditingController controller2 = TextEditingController();
controller1.text = 'Text1';
controller2.text = 'Text2\nLine2';
final FocusNode focusNode1 = FocusNode();
final FocusNode focusNode2 = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
EditableText(
key: ValueKey<String>(controller1.text),
controller: controller1,
focusNode: focusNode1,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
const SizedBox(height: 200.0),
EditableText(
key: ValueKey<String>(controller2.text),
controller: controller2,
focusNode: focusNode2,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
minLines: 10,
maxLines: 20,
),
const SizedBox(height: 100.0),
],
),
),
);
expect(focusNode1.hasFocus, isFalse);
expect(focusNode2.hasFocus, isFalse);
expect(
controller1.selection,
const TextSelection.collapsed(offset: -1),
);
expect(
controller2.selection,
const TextSelection.collapsed(offset: -1),
);
// Tab to the first field (single line).
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(focusNode1.hasFocus, isTrue);
expect(focusNode2.hasFocus, isFalse);
expect(
controller1.selection,
kIsWeb
? TextSelection(
baseOffset: 0,
extentOffset: controller1.text.length,
)
: TextSelection.collapsed(
offset: controller1.text.length,
),
);
// Move the cursor to another position in the first field.
await tester.tapAt(textOffsetToPosition(tester, controller1.text.length - 1));
await tester.pumpAndSettle();
expect(
controller1.selection,
TextSelection.collapsed(
offset: controller1.text.length - 1,
),
);
// Tab to the second field (multiline).
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(focusNode1.hasFocus, isFalse);
expect(focusNode2.hasFocus, isTrue);
expect(
controller2.selection,
TextSelection.collapsed(
offset: controller2.text.length,
),
);
// Move the cursor to another position in the second field.
await tester.tapAt(textOffsetToPosition(tester, controller2.text.length - 1, index: 1));
await tester.pumpAndSettle();
expect(
controller2.selection,
TextSelection.collapsed(
offset: controller2.text.length - 1,
),
);
// On web, the document root is also focusable.
if (kIsWeb) {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(focusNode1.hasFocus, isFalse);
expect(focusNode2.hasFocus, isFalse);
}
// Tabbing again goes back to the first field and reselects the field.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(focusNode1.hasFocus, isTrue);
expect(focusNode2.hasFocus, isFalse);
expect(
controller1.selection,
kIsWeb
? TextSelection(
baseOffset: 0,
extentOffset: controller1.text.length,
)
: TextSelection.collapsed(
offset: controller1.text.length - 1,
),
);
// Tabbing to the second field again retains the moved selection.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(focusNode1.hasFocus, isFalse);
expect(focusNode2.hasFocus, isTrue);
expect(
controller2.selection,
TextSelection.collapsed(
offset: controller2.text.length - 1,
),
);
});
testWidgets('when having focus stolen between frames on web', (WidgetTester tester) async {
final TextEditingController controller1 = TextEditingController();
controller1.text = 'Text1';
final FocusNode focusNode1 = FocusNode();
final FocusNode focusNode2 = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
EditableText(
key: ValueKey<String>(controller1.text),
controller: controller1,
focusNode: focusNode1,
style: Typography.material2018().black.titleMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
const SizedBox(height: 200.0),
Focus(
focusNode: focusNode2,
child: const SizedBox.shrink(),
),
const SizedBox(height: 100.0),
],
),
),
);
expect(focusNode1.hasFocus, isFalse);
expect(focusNode2.hasFocus, isFalse);
expect(
controller1.selection,
const TextSelection.collapsed(offset: -1),
);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText).first);
// Set the text editing value in order to trigger an internal call to
// requestFocus.
state.userUpdateTextEditingValue(
controller1.value,
SelectionChangedCause.keyboard,
);
// Focus takes a frame to update, so it hasn't changed yet.
expect(focusNode1.hasFocus, isFalse);
expect(focusNode2.hasFocus, isFalse);
// Before EditableText's listener on widget.focusNode can be called, change
// the focus again
focusNode2.requestFocus();
await tester.pump();
expect(focusNode1.hasFocus, isFalse);
expect(focusNode2.hasFocus, isTrue);
// Focus the EditableText again, which should cause the field to be selected
// on web.
focusNode1.requestFocus();
await tester.pumpAndSettle();
expect(focusNode1.hasFocus, isTrue);
expect(focusNode2.hasFocus, isFalse);
expect(
controller1.selection,
TextSelection(
baseOffset: 0,
extentOffset: controller1.text.length,
),
);
},
skip: !kIsWeb, // [intended]
);
});
}
class UnsettableController extends TextEditingController {
......
......@@ -10,9 +10,9 @@ import 'package:flutter_test/flutter_test.dart';
/// On web, the context menu (aka toolbar) is provided by the browser.
const bool isContextMenuProvidedByPlatform = isBrowser;
// Returns the first RenderEditable.
RenderEditable findRenderEditable(WidgetTester tester) {
final RenderObject root = tester.renderObject(find.byType(EditableText));
// Returns the RenderEditable at the given index, or the first if not given.
RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) {
final RenderObject root = tester.renderObject(find.byType(EditableText).at(index));
expect(root, isNotNull);
late RenderEditable renderEditable;
......@@ -37,8 +37,8 @@ List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBo
}).toList();
}
Offset textOffsetToPosition(WidgetTester tester, int offset) {
final RenderEditable renderEditable = findRenderEditable(tester);
Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) {
final RenderEditable renderEditable = findRenderEditable(tester, index: index);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(
TextSelection.collapsed(offset: offset),
......
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