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
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
|
||||||
import 'system_channels.dart';
|
import 'system_channels.dart';
|
||||||
|
|
||||||
/// Controls specific aspects of the system navigation stack.
|
/// Controls specific aspects of the system navigation stack.
|
||||||
|
@ -2592,6 +2592,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
_didAutoFocus = true;
|
_didAutoFocus = true;
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && renderEditable.hasSize) {
|
if (mounted && renderEditable.hasSize) {
|
||||||
|
_flagInternalFocus();
|
||||||
FocusScope.of(context).autofocus(widget.focusNode);
|
FocusScope.of(context).autofocus(widget.focusNode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -2714,6 +2715,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
clipboardStatus.removeListener(_onChangedClipboardStatus);
|
clipboardStatus.removeListener(_onChangedClipboardStatus);
|
||||||
clipboardStatus.dispose();
|
clipboardStatus.dispose();
|
||||||
_cursorVisibilityNotifier.dispose();
|
_cursorVisibilityNotifier.dispose();
|
||||||
|
FocusManager.instance.removeListener(_unflagInternalFocus);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
|
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.
|
/// Express interest in interacting with the keyboard.
|
||||||
///
|
///
|
||||||
/// If this control is already attached to the keyboard, this function will
|
/// 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) {
|
if (_hasFocus) {
|
||||||
_openInputConnection();
|
_openInputConnection();
|
||||||
} else {
|
} else {
|
||||||
|
_flagInternalFocus();
|
||||||
widget.focusNode.requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged.
|
widget.focusNode.requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3677,7 +3697,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
if (!widget.readOnly) {
|
if (!widget.readOnly) {
|
||||||
_scheduleShowCaretOnScreen(withAnimation: true);
|
_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.
|
// Place cursor at the end if the selection is invalid when we receive focus.
|
||||||
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), null);
|
_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.
|
// unfocused field that previously had a selection in the same spot.
|
||||||
if (value == textEditingValue) {
|
if (value == textEditingValue) {
|
||||||
if (!widget.focusNode.hasFocus) {
|
if (!widget.focusNode.hasFocus) {
|
||||||
|
_flagInternalFocus();
|
||||||
widget.focusNode.requestFocus();
|
widget.focusNode.requestFocus();
|
||||||
_selectionOverlay = _createSelectionOverlay();
|
_selectionOverlay = _createSelectionOverlay();
|
||||||
}
|
}
|
||||||
|
@ -553,7 +553,14 @@ void main() {
|
|||||||
focusNode.requestFocus();
|
focusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
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(scrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
|
||||||
expect(editableScrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
|
expect(editableScrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
|
||||||
});
|
});
|
||||||
|
@ -780,13 +780,28 @@ void main() {
|
|||||||
focusNode.requestFocus();
|
focusNode.requestFocus();
|
||||||
await tester.pump();
|
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);
|
expect(focusNode.hasFocus, isTrue);
|
||||||
|
|
||||||
focusNode.unfocus();
|
focusNode.unfocus();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(controller.value, value);
|
if (kIsWeb) {
|
||||||
|
expect(controller.value, webValue);
|
||||||
|
} else {
|
||||||
|
expect(controller.value, value);
|
||||||
|
}
|
||||||
expect(focusNode.hasFocus, isFalse);
|
expect(focusNode.hasFocus, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -4349,7 +4364,10 @@ void main() {
|
|||||||
],
|
],
|
||||||
value: expectedValue,
|
value: expectedValue,
|
||||||
textDirection: TextDirection.ltr,
|
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;
|
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 {
|
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.
|
/// On web, the context menu (aka toolbar) is provided by the browser.
|
||||||
const bool isContextMenuProvidedByPlatform = isBrowser;
|
const bool isContextMenuProvidedByPlatform = isBrowser;
|
||||||
|
|
||||||
// Returns the first RenderEditable.
|
// Returns the RenderEditable at the given index, or the first if not given.
|
||||||
RenderEditable findRenderEditable(WidgetTester tester) {
|
RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) {
|
||||||
final RenderObject root = tester.renderObject(find.byType(EditableText));
|
final RenderObject root = tester.renderObject(find.byType(EditableText).at(index));
|
||||||
expect(root, isNotNull);
|
expect(root, isNotNull);
|
||||||
|
|
||||||
late RenderEditable renderEditable;
|
late RenderEditable renderEditable;
|
||||||
@ -37,8 +37,8 @@ List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBo
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Offset textOffsetToPosition(WidgetTester tester, int offset) {
|
Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) {
|
||||||
final RenderEditable renderEditable = findRenderEditable(tester);
|
final RenderEditable renderEditable = findRenderEditable(tester, index: index);
|
||||||
final List<TextSelectionPoint> endpoints = globalize(
|
final List<TextSelectionPoint> endpoints = globalize(
|
||||||
renderEditable.getEndpointsForSelection(
|
renderEditable.getEndpointsForSelection(
|
||||||
TextSelection.collapsed(offset: offset),
|
TextSelection.collapsed(offset: offset),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user