[web] Support input action (#13268)

This commit is contained in:
Mouad Debbar 2019-10-21 15:23:36 -07:00 committed by GitHub
parent 6a3baef78b
commit 4307a9b487
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 247 additions and 56 deletions

View File

@ -7,6 +7,9 @@ part of engine;
/// Make the content editable span visible to facilitate debugging.
const bool _debugVisibleTextEditing = false;
/// The `keyCode` of the "Enter" key.
const int _kReturnKeyCode = 13;
void _emptyCallback(dynamic _) {}
/// These style attributes are constant throughout the life time of an input
@ -171,23 +174,29 @@ class EditingState {
/// This corresponds to Flutter's [TextInputConfiguration].
class InputConfiguration {
InputConfiguration({
this.inputType,
this.obscureText = false,
@required this.inputType,
@required this.inputAction,
@required this.obscureText,
});
InputConfiguration.fromFlutter(Map<String, dynamic> flutterInputConfiguration)
: inputType = EngineInputType.fromName(
flutterInputConfiguration['inputType']['name']),
inputAction = flutterInputConfiguration['inputAction'],
obscureText = flutterInputConfiguration['obscureText'];
/// The type of information being edited in the input control.
final EngineInputType inputType;
/// The default action for the input field.
final String inputAction;
/// Whether to hide the text being edited.
final bool obscureText;
}
typedef _OnChangeCallback = void Function(EditingState editingState);
typedef _OnActionCallback = void Function(String inputAction);
/// Wraps the DOM element used to provide text editing capabilities.
///
@ -219,11 +228,16 @@ class TextEditingElement {
const Duration(milliseconds: 100);
final HybridTextEditing owner;
bool _enabled = false;
@visibleForTesting
bool isEnabled = false;
html.HtmlElement domElement;
InputConfiguration _inputConfiguration;
EditingState _lastEditingState;
_OnChangeCallback _onChange;
_OnActionCallback _onAction;
final List<StreamSubscription<html.Event>> _subscriptions =
<StreamSubscription<html.Event>>[];
@ -261,12 +275,15 @@ class TextEditingElement {
void enable(
InputConfiguration inputConfig, {
@required _OnChangeCallback onChange,
@required _OnActionCallback onAction,
}) {
assert(!_enabled);
assert(!isEnabled);
_initDomElement(inputConfig);
_enabled = true;
isEnabled = true;
_inputConfiguration = inputConfig;
_onChange = onChange;
_onAction = onAction;
// Chrome on Android will hide the onscreen keyboard when you tap outside
// the text box. Instead, we want the framework to tell us to hide the
@ -279,7 +296,7 @@ class TextEditingElement {
if (browserEngine == BrowserEngine.blink ||
browserEngine == BrowserEngine.unknown) {
_subscriptions.add(domElement.onBlur.listen((_) {
if (_enabled) {
if (isEnabled) {
_refocus();
}
}));
@ -297,6 +314,8 @@ class TextEditingElement {
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
/// Detects changes in text selection.
///
/// Currently only used in Firefox.
@ -330,9 +349,9 @@ class TextEditingElement {
///
/// Calling [disable] also removes any registered event listeners.
void disable() {
assert(_enabled);
assert(isEnabled);
_enabled = false;
isEnabled = false;
_lastEditingState = null;
for (int i = 0; i < _subscriptions.length; i++) {
@ -390,7 +409,7 @@ class TextEditingElement {
void setEditingState(EditingState editingState) {
_lastEditingState = editingState;
if (!_enabled || !editingState.isValid) {
if (!isEnabled || !editingState.isValid) {
return;
}
@ -416,6 +435,13 @@ class TextEditingElement {
_onChange(_lastEditingState);
}
}
void _maybeSendAction(html.KeyboardEvent event) {
if (event.keyCode == _kReturnKeyCode) {
event.preventDefault();
_onAction(_inputConfiguration.inputAction);
}
}
}
/// The implementation of a persistent mode for [TextEditingElement].
@ -512,7 +538,7 @@ class HybridTextEditing {
///
/// Use [stopUsingCustomEditableElement] to switch back to default element.
void useCustomEditableElement(TextEditingElement customEditingElement) {
if (_isEditing && customEditingElement != _customEditingElement) {
if (isEditing && customEditingElement != _customEditingElement) {
stopEditing();
}
_customEditingElement = customEditingElement;
@ -529,20 +555,21 @@ class HybridTextEditing {
/// Flag which shows if there is an ongoing editing.
///
/// Also used to define if a keyboard is needed.
bool _isEditing = false;
@visibleForTesting
bool isEditing = false;
/// Indicates whether the input element needs to be positioned.
///
/// See [TextEditingElement._delayBeforePositioning].
bool get inputElementNeedsToBePositioned =>
!inputPositioned && _isEditing && doesKeyboardShiftInput;
!inputPositioned && isEditing && doesKeyboardShiftInput;
/// Flag indicating whether the input element's position is set.
///
/// See [inputElementNeedsToBePositioned].
bool inputPositioned = false;
Map<String, dynamic> _configuration;
InputConfiguration _configuration;
/// All "flutter/textinput" platform messages should be sent to this method.
void handleTextInput(ByteData data) {
@ -551,11 +578,11 @@ class HybridTextEditing {
case 'TextInput.setClient':
final bool clientIdChanged =
_clientId != null && _clientId != call.arguments[0];
if (clientIdChanged && _isEditing) {
if (clientIdChanged && isEditing) {
stopEditing();
}
_clientId = call.arguments[0];
_configuration = call.arguments[1];
_configuration = InputConfiguration.fromFlutter(call.arguments[1]);
break;
case 'TextInput.setEditingState':
@ -564,7 +591,7 @@ class HybridTextEditing {
break;
case 'TextInput.show':
if (!_isEditing) {
if (!isEditing) {
_startEditing();
}
break;
@ -579,7 +606,7 @@ class HybridTextEditing {
case 'TextInput.clearClient':
case 'TextInput.hide':
if (_isEditing) {
if (isEditing) {
stopEditing();
}
break;
@ -587,17 +614,18 @@ class HybridTextEditing {
}
void _startEditing() {
assert(!_isEditing);
_isEditing = true;
assert(!isEditing);
isEditing = true;
editingElement.enable(
InputConfiguration.fromFlutter(_configuration),
_configuration,
onChange: _syncEditingStateToFlutter,
onAction: _sendInputActionToFlutter,
);
}
void stopEditing() {
assert(_isEditing);
_isEditing = false;
assert(isEditing);
isEditing = false;
editingElement.disable();
}
@ -666,6 +694,19 @@ class HybridTextEditing {
);
}
void _sendInputActionToFlutter(String inputAction) {
ui.window.onPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
MethodCall(
'TextInputClient.performAction',
<dynamic>[_clientId, inputAction],
),
),
_emptyCallback,
);
}
/// Positioning of input element is only done if we are not expecting input
/// to be shifted by a virtual keyboard or if the input is already positioned.
///

View File

@ -3,10 +3,11 @@
// found in the LICENSE file.
import 'dart:html';
import 'dart:js_util' as js_util;
import 'dart:typed_data';
import 'package:ui/ui.dart' as ui;
import 'package:ui/src/engine.dart';
import 'package:ui/src/engine.dart' hide window;
import 'package:test/test.dart';
@ -16,14 +17,21 @@ const MethodCodec codec = JSONMethodCodec();
TextEditingElement editingElement;
EditingState lastEditingState;
String lastInputAction;
final InputConfiguration singlelineConfig =
InputConfiguration(inputType: EngineInputType.text);
final InputConfiguration singlelineConfig = InputConfiguration(
inputType: EngineInputType.text,
obscureText: false,
inputAction: 'TextInputAction.done',
);
final Map<String, dynamic> flutterSinglelineConfig =
createFlutterConfig('text');
final InputConfiguration multilineConfig =
InputConfiguration(inputType: EngineInputType.multiline);
final InputConfiguration multilineConfig = InputConfiguration(
inputType: EngineInputType.multiline,
obscureText: false,
inputAction: 'TextInputAction.newline',
);
final Map<String, dynamic> flutterMultilineConfig =
createFlutterConfig('multiline');
@ -31,21 +39,25 @@ void trackEditingState(EditingState editingState) {
lastEditingState = editingState;
}
void trackInputAction(String inputAction) {
lastInputAction = inputAction;
}
void main() {
tearDown(() {
lastEditingState = null;
lastInputAction = null;
});
group('$TextEditingElement', () {
setUp(() {
editingElement = TextEditingElement(HybridTextEditing());
});
tearDown(() {
try {
if (editingElement.isEnabled) {
// Clean up all the DOM elements and event listeners.
editingElement.disable();
} catch (e) {
if (e is AssertionError) {
// This is fine. It just means the test itself disabled the editing element.
} else {
rethrow;
}
}
});
@ -57,7 +69,11 @@ void main() {
// The focus initially is on the body.
expect(document.activeElement, document.body);
editingElement.enable(singlelineConfig, onChange: trackEditingState);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(
document.getElementsByTagName('input'),
hasLength(1),
@ -81,7 +97,11 @@ void main() {
});
test('Can read editing state correctly', () {
editingElement.enable(singlelineConfig, onChange: trackEditingState);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
final InputElement input = editingElement.domElement;
input.value = 'foo bar';
@ -97,29 +117,50 @@ void main() {
lastEditingState,
EditingState(text: 'foo bar', baseOffset: 4, extentOffset: 6),
);
// There should be no input action.
expect(lastInputAction, isNull);
});
test('Can set editing state correctly', () {
editingElement.enable(singlelineConfig, onChange: trackEditingState);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
editingElement.setEditingState(
EditingState(text: 'foo bar baz', baseOffset: 2, extentOffset: 7));
checkInputEditingState(editingElement.domElement, 'foo bar baz', 2, 7);
// There should be no input action.
expect(lastInputAction, isNull);
});
test('Re-acquires focus', () async {
editingElement.enable(singlelineConfig, onChange: trackEditingState);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.activeElement, editingElement.domElement);
editingElement.domElement.blur();
// The focus remains on [editingElement.domElement].
expect(document.activeElement, editingElement.domElement);
// There should be no input action.
expect(lastInputAction, isNull);
});
test('Multi-line mode also works', () {
// The textarea element is created lazily.
expect(document.getElementsByTagName('textarea'), hasLength(0));
editingElement.enable(multilineConfig, onChange: trackEditingState);
editingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('textarea'), hasLength(1));
final TextAreaElement textarea =
@ -152,6 +193,9 @@ void main() {
expect(document.getElementsByTagName('textarea'), hasLength(0));
// The focus is back to the body.
expect(document.activeElement, document.body);
// There should be no input action.
expect(lastInputAction, isNull);
});
test('Same instance can be re-enabled with different config', () {
@ -160,7 +204,11 @@ void main() {
expect(document.getElementsByTagName('textarea'), hasLength(0));
// Use single-line config and expect an `<input>` to be created.
editingElement.enable(singlelineConfig, onChange: trackEditingState);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
expect(document.getElementsByTagName('textarea'), hasLength(0));
@ -170,7 +218,11 @@ void main() {
expect(document.getElementsByTagName('textarea'), hasLength(0));
// Use multi-line config and expect an `<textarea>` to be created.
editingElement.enable(multilineConfig, onChange: trackEditingState);
editingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(0));
expect(document.getElementsByTagName('textarea'), hasLength(1));
@ -178,6 +230,28 @@ void main() {
editingElement.disable();
expect(document.getElementsByTagName('input'), hasLength(0));
expect(document.getElementsByTagName('textarea'), hasLength(0));
// There should be no input action.
expect(lastInputAction, isNull);
});
test('Triggers input action', () {
final InputConfiguration config = InputConfiguration(
inputType: EngineInputType.text,
obscureText: false,
inputAction: 'TextInputAction.done',
);
editingElement.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
// No input action so far.
expect(lastInputAction, isNull);
dispatchKeyboardEvent(editingElement.domElement, 'keydown', keyCode: 13);
expect(lastInputAction, 'TextInputAction.done');
});
group('[persistent mode]', () {
@ -199,8 +273,11 @@ void main() {
expect(document.activeElement, document.body);
document.body.append(input);
persistentEditingElement.enable(singlelineConfig,
onChange: trackEditingState);
persistentEditingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.activeElement, input);
// The input should lose focus now.
@ -222,14 +299,20 @@ void main() {
// Can't enable before the input element is inserted into the DOM.
expect(
() => persistentEditingElement.enable(singlelineConfig,
onChange: trackEditingState),
() => persistentEditingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
),
throwsAssertionError,
);
document.body.append(input);
persistentEditingElement.enable(singlelineConfig,
onChange: trackEditingState);
persistentEditingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.activeElement, persistentEditingElement.domElement);
// It doesn't create a new DOM element.
expect(persistentEditingElement.domElement, input);
@ -249,8 +332,11 @@ void main() {
PersistentTextEditingElement(HybridTextEditing(), input);
document.body.append(input);
persistentEditingElement.enable(singlelineConfig,
onChange: trackEditingState);
persistentEditingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.activeElement, input);
persistentEditingElement.domElement.blur();
@ -273,14 +359,20 @@ void main() {
// Can't enable before the textarea is inserted into the DOM.
expect(
() => persistentEditingElement.enable(singlelineConfig,
onChange: trackEditingState),
() => persistentEditingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
),
throwsAssertionError,
);
document.body.append(textarea);
persistentEditingElement.enable(multilineConfig,
onChange: trackEditingState);
persistentEditingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
// Focuses the textarea.
expect(document.activeElement, textarea);
@ -308,15 +400,25 @@ void main() {
final PlatformMessagesSpy spy = PlatformMessagesSpy();
int clientId = 0;
void showKeyboard({String inputType}) {
/// Sends the necessary platform messages to activate a text field and show
/// the keyboard.
///
/// Returns the `clientId` used in the platform message.
int showKeyboard({String inputType, String inputAction}) {
final MethodCall setClient = MethodCall(
'TextInput.setClient',
<dynamic>[++clientId, createFlutterConfig(inputType)],
<dynamic>[
++clientId,
createFlutterConfig(inputType, inputAction: inputAction),
],
);
textEditing.handleTextInput(codec.encodeMethodCall(setClient));
const MethodCall show = MethodCall('TextInput.show');
textEditing.handleTextInput(codec.encodeMethodCall(show));
return clientId;
}
void hideKeyboard() {
@ -337,8 +439,10 @@ void main() {
});
tearDown(() {
// TODO(mdebbar): clean-up stuff that HybridTextEditing registered on the page
spy.deactivate();
if (textEditing.isEditing) {
textEditing.stopEditing();
}
});
test('setClient, show, setEditingState, hide', () {
@ -766,6 +870,30 @@ void main() {
debugOperatingSystemOverride = null;
});
test('sends the correct input action as a platform message', () {
final int clientId = showKeyboard(
inputType: 'text',
inputAction: 'TextInputAction.next',
);
// There should be no input action yet.
expect(lastInputAction, isNull);
dispatchKeyboardEvent(
textEditing.editingElement.domElement,
'keydown',
keyCode: 13,
);
expect(spy.messages, hasLength(1));
final MethodCall call = spy.messages.first;
expect(call.method, 'TextInputClient.performAction');
expect(
call.arguments,
<dynamic>[clientId, 'TextInputAction.next'],
);
});
});
group('EditingState', () {
@ -842,6 +970,26 @@ void main() {
});
}
KeyboardEvent dispatchKeyboardEvent(
EventTarget target,
String type, {
int keyCode,
}) {
final Function jsKeyboardEvent = js_util.getProperty(window, 'KeyboardEvent');
final List<dynamic> eventArgs = <dynamic>[
type,
<String, dynamic>{
'keyCode': keyCode,
'cancelable': true,
}
];
final KeyboardEvent event =
js_util.callConstructor(jsKeyboardEvent, js_util.jsify(eventArgs));
target.dispatchEvent(event);
return event;
}
MethodCall configureSetStyleMethodCall(int fontSize, String fontFamily,
int textAlignIndex, int fontWeightIndex, int textDirectionIndex) {
return MethodCall('TextInput.setStyle', <String, dynamic>{
@ -909,11 +1057,13 @@ class PlatformMessagesSpy {
Map<String, dynamic> createFlutterConfig(
String inputType, {
bool obscureText = false,
String inputAction,
}) {
return <String, dynamic>{
'inputType': <String, String>{
'name': 'TextInputType.$inputType',
},
'obscureText': obscureText,
'inputAction': inputAction ?? 'TextInputAction.done',
};
}