Web tab selection (#119583)
Correct selection behavior when tabbing into a field on the web.
This commit is contained in:
parent
1c225675c5
commit
0b0450fbff
@ -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),
|
||||
|
Loading…
x
Reference in New Issue
Block a user