blob: 97360e41357ebd3b9ab13b7e082b104512ed6993 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// @dart = 2.6
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' hide window;
import 'package:test/test.dart';
import 'matchers.dart';
/// The `keyCode` of the "Enter" key.
const int _kReturnKeyCode = 13;
const MethodCodec codec = JSONMethodCodec();
/// Add unit tests for [FirefoxTextEditingStrategy].
/// TODO(nurhan): https://github.com/flutter/flutter/issues/46891
DefaultTextEditingStrategy editingElement;
EditingState lastEditingState;
String lastInputAction;
final InputConfiguration singlelineConfig = InputConfiguration(
inputType: EngineInputType.text,
obscureText: false,
inputAction: 'TextInputAction.done',
autocorrect: true,
);
final Map<String, dynamic> flutterSinglelineConfig =
createFlutterConfig('text');
final InputConfiguration multilineConfig = InputConfiguration(
inputType: EngineInputType.multiline,
obscureText: false,
inputAction: 'TextInputAction.newline',
autocorrect: true,
);
final Map<String, dynamic> flutterMultilineConfig =
createFlutterConfig('multiline');
void trackEditingState(EditingState editingState) {
lastEditingState = editingState;
}
void trackInputAction(String inputAction) {
lastInputAction = inputAction;
}
void main() {
tearDown(() {
lastEditingState = null;
lastInputAction = null;
cleanTextEditingElement();
cleanTestFlags();
clearBackUpDomElementIfExists();
});
group('$GloballyPositionedTextEditingStrategy', () {
HybridTextEditing testTextEditing;
setUp(() {
testTextEditing = HybridTextEditing();
editingElement = GloballyPositionedTextEditingStrategy(testTextEditing);
testTextEditing.useCustomEditableElement(editingElement);
});
test('Creates element when enabled and removes it when disabled', () {
expect(
document.getElementsByTagName('input'),
hasLength(0),
);
// The focus initially is on the body.
expect(document.activeElement, document.body);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(
document.getElementsByTagName('input'),
hasLength(1),
);
final InputElement input = document.getElementsByTagName('input')[0];
// Now the editing element should have focus.
expect(document.activeElement, input);
expect(editingElement.domElement, input);
expect(input.getAttribute('type'), null);
// Input is appended to the glass pane.
expect(domRenderer.glassPaneElement.contains(editingElement.domElement),
isTrue);
editingElement.disable();
expect(
document.getElementsByTagName('input'),
hasLength(0),
);
// The focus is back to the body.
expect(document.activeElement, document.body);
});
test('Knows how to create password fields', () {
final InputConfiguration config = InputConfiguration(
inputType: EngineInputType.text,
inputAction: 'TextInputAction.done',
obscureText: true,
autocorrect: true,
);
editingElement.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
final InputElement input = document.getElementsByTagName('input')[0];
expect(editingElement.domElement, input);
expect(input.getAttribute('type'), 'password');
editingElement.disable();
});
test('Knows to turn autocorrect off', () {
final InputConfiguration config = InputConfiguration(
inputType: EngineInputType.text,
inputAction: 'TextInputAction.done',
obscureText: false,
autocorrect: false,
);
editingElement.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
final InputElement input = document.getElementsByTagName('input')[0];
expect(editingElement.domElement, input);
expect(input.getAttribute('autocorrect'), 'off');
editingElement.disable();
});
test('Knows to turn autocorrect on', () {
final InputConfiguration config = InputConfiguration(
inputType: EngineInputType.text,
inputAction: 'TextInputAction.done',
obscureText: false,
autocorrect: true,
);
editingElement.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
final InputElement input = document.getElementsByTagName('input')[0];
expect(editingElement.domElement, input);
expect(input.getAttribute('autocorrect'), 'on');
editingElement.disable();
});
test('Can read editing state correctly', () {
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
final InputElement input = editingElement.domElement;
input.value = 'foo bar';
input.dispatchEvent(Event.eventType('Event', 'input'));
expect(
lastEditingState,
EditingState(text: 'foo bar', baseOffset: 7, extentOffset: 7),
);
input.setSelectionRange(4, 6);
document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
expect(
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,
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('Multi-line mode also works', () {
// The textarea element is created lazily.
expect(document.getElementsByTagName('textarea'), hasLength(0));
editingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('textarea'), hasLength(1));
final TextAreaElement textarea =
document.getElementsByTagName('textarea')[0];
// Now the textarea should have focus.
expect(document.activeElement, textarea);
expect(editingElement.domElement, textarea);
textarea.value = 'foo\nbar';
textarea.dispatchEvent(Event.eventType('Event', 'input'));
textarea.setSelectionRange(4, 6);
textarea.dispatchEvent(Event.eventType('Event', 'selectionchange'));
// Can read textarea state correctly (and preserves new lines).
expect(
lastEditingState,
EditingState(text: 'foo\nbar', baseOffset: 4, extentOffset: 6),
);
// Can set textarea state correctly (and preserves new lines).
editingElement.setEditingState(
EditingState(text: 'bar\nbaz', baseOffset: 2, extentOffset: 7));
checkTextAreaEditingState(textarea, 'bar\nbaz', 2, 7);
editingElement.disable();
// The textarea should be cleaned up.
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', () {
// Make sure there's nothing in the DOM yet.
expect(document.getElementsByTagName('input'), hasLength(0));
expect(document.getElementsByTagName('textarea'), hasLength(0));
// Use single-line config and expect an `<input>` to be created.
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
expect(document.getElementsByTagName('textarea'), hasLength(0));
// Disable and check that all DOM elements were removed.
editingElement.disable();
expect(document.getElementsByTagName('input'), hasLength(0));
expect(document.getElementsByTagName('textarea'), hasLength(0));
// Use multi-line config and expect an `<textarea>` to be created.
editingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(0));
expect(document.getElementsByTagName('textarea'), hasLength(1));
// Disable again and check that all DOM elements were removed.
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',
autocorrect: true,
);
editingElement.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
// No input action so far.
expect(lastInputAction, isNull);
dispatchKeyboardEvent(
editingElement.domElement,
'keydown',
keyCode: _kReturnKeyCode,
);
expect(lastInputAction, 'TextInputAction.done');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: browserEngine == BrowserEngine.edge);
test('Does not trigger input action in multi-line mode', () {
final InputConfiguration config = InputConfiguration(
inputType: EngineInputType.multiline,
obscureText: false,
inputAction: 'TextInputAction.done',
autocorrect: true,
);
editingElement.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
// No input action so far.
expect(lastInputAction, isNull);
final KeyboardEvent event = dispatchKeyboardEvent(
editingElement.domElement,
'keydown',
keyCode: _kReturnKeyCode,
);
// Still no input action.
expect(lastInputAction, isNull);
// And default behavior of keyboard event shouldn't have been prevented.
expect(event.defaultPrevented, isFalse);
});
test('globally positions and sizes its DOM element', () {
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(editingElement.isEnabled, isTrue);
// No geometry should be set until setEditableSizeAndTransform is called.
expect(editingElement.domElement.style.transform, '');
expect(editingElement.domElement.style.width, '');
expect(editingElement.domElement.style.height, '');
testTextEditing.setEditableSizeAndTransform(EditableTextGeometry(
width: 13,
height: 12,
globalTransform: Matrix4.translationValues(14, 15, 0).storage,
));
// setEditableSizeAndTransform calls placeElement, so expecting geometry to be applied.
expect(editingElement.domElement.style.transform,
'matrix(1, 0, 0, 1, 14, 15)');
expect(editingElement.domElement.style.width, '13px');
expect(editingElement.domElement.style.height, '12px');
});
});
group('$SemanticsTextEditingStrategy', () {
InputElement testInputElement;
HybridTextEditing testTextEditing;
setUp(() {
testInputElement = InputElement();
testTextEditing = HybridTextEditing();
editingElement = GloballyPositionedTextEditingStrategy(testTextEditing);
});
tearDown(() {
testInputElement = null;
});
test('Does not accept dom elements of a wrong type', () {
// A regular <span> shouldn't be accepted.
final HtmlElement span = SpanElement();
expect(
() => SemanticsTextEditingStrategy(HybridTextEditing(), span),
throwsAssertionError,
);
});
test('Does not re-acquire focus', () {
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement);
expect(document.activeElement, document.body);
document.body.append(testInputElement);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.activeElement, testInputElement);
// The input should lose focus now.
editingElement.domElement.blur();
expect(document.activeElement, document.body);
editingElement.disable();
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: (browserEngine == BrowserEngine.webkit ||
browserEngine == BrowserEngine.edge));
test('Does not dispose and recreate dom elements in persistent mode', () {
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement);
// The DOM element should've been eagerly created.
expect(testInputElement, isNotNull);
// But doesn't have focus.
expect(document.activeElement, document.body);
// Can't enable before the input element is inserted into the DOM.
expect(
() => editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
),
throwsAssertionError,
);
document.body.append(testInputElement);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.activeElement, editingElement.domElement);
// It doesn't create a new DOM element.
expect(editingElement.domElement, testInputElement);
editingElement.disable();
// It doesn't remove the DOM element.
expect(editingElement.domElement, testInputElement);
expect(document.body.contains(editingElement.domElement), isTrue);
// But the DOM element loses focus.
expect(document.activeElement, document.body);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: (browserEngine == BrowserEngine.webkit ||
browserEngine == BrowserEngine.edge));
test('Refocuses when setting editing state', () {
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement);
document.body.append(testInputElement);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.activeElement, testInputElement);
editingElement.domElement.blur();
expect(document.activeElement, document.body);
// The input should regain focus now.
editingElement.setEditingState(EditingState(text: 'foo'));
expect(document.activeElement, testInputElement);
editingElement.disable();
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: (browserEngine == BrowserEngine.webkit ||
browserEngine == BrowserEngine.edge));
test('Works in multi-line mode', () {
final TextAreaElement textarea = TextAreaElement();
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), textarea);
expect(editingElement.domElement, textarea);
expect(document.activeElement, document.body);
// Can't enable before the textarea is inserted into the DOM.
expect(
() => editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
),
throwsAssertionError,
);
document.body.append(textarea);
editingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
// Focuses the textarea.
expect(document.activeElement, textarea);
// Doesn't re-acquire focus.
textarea.blur();
expect(document.activeElement, document.body);
// Re-focuses when setting editing state
editingElement.setEditingState(EditingState(text: 'foo'));
expect(document.activeElement, textarea);
editingElement.disable();
// It doesn't remove the textarea from the DOM.
expect(editingElement.domElement, textarea);
expect(document.body.contains(editingElement.domElement), isTrue);
// But the textarea loses focus.
expect(document.activeElement, document.body);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: (browserEngine == BrowserEngine.webkit ||
browserEngine == BrowserEngine.edge));
test('Does not position or size its DOM element', () {
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
testTextEditing.setEditableSizeAndTransform(EditableTextGeometry(
height: 12,
width: 13,
globalTransform: Matrix4.translationValues(14, 15, 0).storage,
));
void checkPlacementIsEmpty() {
expect(editingElement.domElement.style.transform, '');
expect(editingElement.domElement.style.width, '');
expect(editingElement.domElement.style.height, '');
}
checkPlacementIsEmpty();
editingElement.placeElement();
checkPlacementIsEmpty();
});
});
group('$HybridTextEditing', () {
HybridTextEditing textEditing;
final PlatformMessagesSpy spy = PlatformMessagesSpy();
int clientId = 0;
/// Emulates sending of a message by the framework to the engine.
void sendFrameworkMessage(dynamic message) {
textEditing.channel.handleTextInput(message, (ByteData data) {});
}
/// 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, inputAction: inputAction),
],
);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
return clientId;
}
void hideKeyboard() {
const MethodCall hide = MethodCall('TextInput.hide');
sendFrameworkMessage(codec.encodeMethodCall(hide));
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
}
String getEditingInputMode() {
return textEditing.editingElement.domElement.getAttribute('inputmode');
}
setUp(() {
textEditing = HybridTextEditing();
spy.activate();
});
tearDown(() {
spy.deactivate();
if (textEditing.isEditing) {
textEditing.stopEditing();
}
textEditing = null;
});
test('setClient, show, setEditingState, hide', () {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
// Editing shouldn't have started yet.
expect(document.activeElement, document.body);
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(textEditing.editingElement.domElement, '', 0, 0);
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
const MethodCall hide = MethodCall('TextInput.hide');
sendFrameworkMessage(codec.encodeMethodCall(hide));
// Text editing should've stopped.
expect(document.activeElement, document.body);
// Confirm that [HybridTextEditing] didn't send any messages.
expect(spy.messages, isEmpty);
});
test('setClient, setEditingState, show, clearClient', () {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
// Editing shouldn't have started yet.
expect(document.activeElement, document.body);
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
expect(document.activeElement, document.body);
// Confirm that [HybridTextEditing] didn't send any messages.
expect(spy.messages, isEmpty);
});
test('close connection on blur', () async {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
// Editing shouldn't have started yet.
expect(document.activeElement, document.body);
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
// DOM element is blurred.
textEditing.editingElement.domElement.blur();
expect(spy.messages, hasLength(1));
MethodCall call = spy.messages[0];
spy.messages.clear();
expect(call.method, 'TextInputClient.onConnectionClosed');
expect(
call.arguments,
<dynamic>[
123, // Client ID
],
);
// Input element is removed from DOM.
expect(document.getElementsByTagName('input'), hasLength(0));
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: (browserEngine == BrowserEngine.webkit ||
browserEngine == BrowserEngine.edge));
test('setClient, setEditingState, show, setClient', () {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
// Editing shouldn't have started yet.
expect(document.activeElement, document.body);
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
final MethodCall setClient2 = MethodCall(
'TextInput.setClient', <dynamic>[567, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient2));
// Receiving another client via setClient should stop editing, hence
// should remove the previous active element.
expect(document.activeElement, document.body);
// Confirm that [HybridTextEditing] didn't send any messages.
expect(spy.messages, isEmpty);
hideKeyboard();
});
test('setClient, setEditingState, show, setEditingState, clearClient', () {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall setEditingState1 =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState1));
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
const MethodCall setEditingState2 =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'xyz',
'selectionBase': 0,
'selectionExtent': 2,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState2));
// The second [setEditingState] should override the first one.
checkInputEditingState(
textEditing.editingElement.domElement, 'xyz', 0, 2);
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
// Confirm that [HybridTextEditing] didn't send any messages.
expect(spy.messages, isEmpty);
});
test(
'setClient, setEditableSizeAndTransform, setStyle, setEditingState, show, clearClient',
() {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
final MethodCall setStyle =
configureSetStyleMethodCall(12, 'sans-serif', 4, 4, 1);
sendFrameworkMessage(codec.encodeMethodCall(setStyle));
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final HtmlElement domElement = textEditing.editingElement.domElement;
checkInputEditingState(domElement, 'abcd', 2, 3);
// Check if the location and styling is correct.
expect(
domElement.getBoundingClientRect(),
Rectangle<double>.fromPoints(const Point<double>(10.0, 20.0),
const Point<double>(160.0, 70.0)));
expect(domElement.style.transform,
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1)');
expect(textEditing.editingElement.domElement.style.font,
'500 12px sans-serif');
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
// Confirm that [HybridTextEditing] didn't send any messages.
expect(spy.messages, isEmpty);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
skip: browserEngine == BrowserEngine.webkit);
test(
'setClient, show, setEditableSizeAndTransform, setStyle, setEditingState, clearClient',
() {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(
150,
50,
Matrix4.translationValues(
10.0,
20.0,
30.0,
).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
final MethodCall setStyle =
configureSetStyleMethodCall(12, 'sans-serif', 4, 4, 1);
sendFrameworkMessage(codec.encodeMethodCall(setStyle));
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
final HtmlElement domElement = textEditing.editingElement.domElement;
checkInputEditingState(domElement, 'abcd', 2, 3);
// Check if the position is correct.
expect(
domElement.getBoundingClientRect(),
Rectangle<double>.fromPoints(
const Point<double>(10.0, 20.0), const Point<double>(160.0, 70.0)),
);
expect(
domElement.style.transform,
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1)',
);
expect(
textEditing.editingElement.domElement.style.font,
'500 12px sans-serif',
);
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
skip: browserEngine == BrowserEngine.webkit);
test('input font set succesfully with null fontWeightIndex', () {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
final MethodCall setStyle = configureSetStyleMethodCall(
12, 'sans-serif', 4, null /* fontWeightIndex */, 1);
sendFrameworkMessage(codec.encodeMethodCall(setStyle));
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final HtmlElement domElement = textEditing.editingElement.domElement;
checkInputEditingState(domElement, 'abcd', 2, 3);
// Check if the location and styling is correct.
expect(
domElement.getBoundingClientRect(),
Rectangle<double>.fromPoints(const Point<double>(10.0, 20.0),
const Point<double>(160.0, 70.0)));
expect(domElement.style.transform,
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1)');
expect(
textEditing.editingElement.domElement.style.font, '12px sans-serif');
hideKeyboard();
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
skip: browserEngine == BrowserEngine.webkit);
test(
'negative base offset and selection extent values in editing state is handled',
() {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall setEditingState1 =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'xyz',
'selectionBase': 1,
'selectionExtent': 2,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState1));
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
// Check if the selection range is correct.
checkInputEditingState(
textEditing.editingElement.domElement, 'xyz', 1, 2);
const MethodCall setEditingState2 =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'xyz',
'selectionBase': -1,
'selectionExtent': -1,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState2));
// The negative offset values are applied to the dom element as 0.
checkInputEditingState(
textEditing.editingElement.domElement, 'xyz', 0, 0);
hideKeyboard();
});
test('Syncs the editing state back to Flutter', () {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterSinglelineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'abcd',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final InputElement input = textEditing.editingElement.domElement;
input.value = 'something';
input.dispatchEvent(Event.eventType('Event', 'input'));
expect(spy.messages, hasLength(1));
MethodCall call = spy.messages[0];
spy.messages.clear();
expect(call.method, 'TextInputClient.updateEditingState');
expect(
call.arguments,
<dynamic>[
123, // Client ID
<String, dynamic>{
'text': 'something',
'selectionBase': 9,
'selectionExtent': 9
}
],
);
input.setSelectionRange(2, 5);
if (browserEngine == BrowserEngine.firefox) {
Event keyup = KeyboardEvent('keyup');
textEditing.editingElement.domElement.dispatchEvent(keyup);
} else {
document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
}
expect(spy.messages, hasLength(1));
call = spy.messages[0];
spy.messages.clear();
expect(call.method, 'TextInputClient.updateEditingState');
expect(
call.arguments,
<dynamic>[
123, // Client ID
<String, dynamic>{
'text': 'something',
'selectionBase': 2,
'selectionExtent': 5
}
],
);
hideKeyboard();
});
test('Multi-line mode also works', () {
final MethodCall setClient = MethodCall(
'TextInput.setClient', <dynamic>[123, flutterMultilineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
// Editing shouldn't have started yet.
expect(document.activeElement, document.body);
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final TextAreaElement textarea = textEditing.editingElement.domElement;
checkTextAreaEditingState(textarea, '', 0, 0);
// Can set editing state and preserve new lines.
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
'text': 'foo\nbar',
'selectionBase': 2,
'selectionExtent': 3,
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
checkTextAreaEditingState(textarea, 'foo\nbar', 2, 3);
textarea.value = 'something\nelse';
textarea.dispatchEvent(Event.eventType('Event', 'input'));
textarea.setSelectionRange(2, 5);
if (browserEngine == BrowserEngine.firefox) {
textEditing.editingElement.domElement
.dispatchEvent(KeyboardEvent('keyup'));
} else {
document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
}
// Two messages should've been sent. One for the 'input' event and one for
// the 'selectionchange' event.
expect(spy.messages, hasLength(2));
final MethodCall call = spy.messages.last;
spy.messages.clear();
expect(call.method, 'TextInputClient.updateEditingState');
expect(
call.arguments,
<dynamic>[
123, // Client ID
<String, dynamic>{
'text': 'something\nelse',
'selectionBase': 2,
'selectionExtent': 5
}
],
);
const MethodCall hide = MethodCall('TextInput.hide');
sendFrameworkMessage(codec.encodeMethodCall(hide));
// Text editing should've stopped.
expect(document.activeElement, document.body);
// Confirm that [HybridTextEditing] didn't send any more messages.
expect(spy.messages, isEmpty);
});
test('sets correct input type in Android', () {
debugOperatingSystemOverride = OperatingSystem.android;
debugBrowserEngineOverride = BrowserEngine.blink;
/// During initialization [HybridTextEditing] will pick the correct
/// text editing strategy for [OperatingSystem.android].
textEditing = HybridTextEditing();
showKeyboard(inputType: 'text');
expect(getEditingInputMode(), 'text');
showKeyboard(inputType: 'number');
expect(getEditingInputMode(), 'numeric');
showKeyboard(inputType: 'phone');
expect(getEditingInputMode(), 'tel');
showKeyboard(inputType: 'emailAddress');
expect(getEditingInputMode(), 'email');
showKeyboard(inputType: 'url');
expect(getEditingInputMode(), 'url');
hideKeyboard();
});
test('sets correct input type in iOS', () {
debugOperatingSystemOverride = OperatingSystem.iOs;
debugBrowserEngineOverride = BrowserEngine.webkit;
/// During initialization [HybridTextEditing] will pick the correct
/// text editing strategy for [OperatingSystem.iOs].
textEditing = HybridTextEditing();
showKeyboard(inputType: 'text');
expect(getEditingInputMode(), 'text');
showKeyboard(inputType: 'number');
expect(getEditingInputMode(), 'numeric');
showKeyboard(inputType: 'phone');
expect(getEditingInputMode(), 'tel');
showKeyboard(inputType: 'emailAddress');
expect(getEditingInputMode(), 'email');
showKeyboard(inputType: 'url');
expect(getEditingInputMode(), 'url');
hideKeyboard();
});
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: _kReturnKeyCode,
);
expect(spy.messages, hasLength(1));
final MethodCall call = spy.messages.first;
expect(call.method, 'TextInputClient.performAction');
expect(
call.arguments,
<dynamic>[clientId, 'TextInputAction.next'],
);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: browserEngine == BrowserEngine.edge);
test('does not send input action in multi-line mode', () {
showKeyboard(
inputType: 'multiline',
inputAction: 'TextInputAction.next',
);
final KeyboardEvent event = dispatchKeyboardEvent(
textEditing.editingElement.domElement,
'keydown',
keyCode: _kReturnKeyCode,
);
// No input action and no platform message have been sent.
expect(spy.messages, isEmpty);
// And default behavior of keyboard event shouldn't have been prevented.
expect(event.defaultPrevented, isFalse);
});
});
group('EditingState', () {
EditingState _editingState;
setUp(() {
editingElement =
GloballyPositionedTextEditingStrategy(HybridTextEditing());
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
});
test('Configure input element from the editing state', () {
final InputElement input = document.getElementsByTagName('input')[0];
_editingState =
EditingState(text: 'Test', baseOffset: 1, extentOffset: 2);
_editingState.applyToDomElement(input);
expect(input.value, 'Test');
expect(input.selectionStart, 1);
expect(input.selectionEnd, 2);
});
test('Configure text area element from the editing state', () {
cleanTextEditingElement();
editingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
final TextAreaElement textArea =
document.getElementsByTagName('textarea')[0];
_editingState =
EditingState(text: 'Test', baseOffset: 1, extentOffset: 2);
_editingState.applyToDomElement(textArea);
expect(textArea.value, 'Test');
expect(textArea.selectionStart, 1);
expect(textArea.selectionEnd, 2);
});
test('Get Editing State from input element', () {
final InputElement input = document.getElementsByTagName('input')[0];
input.value = 'Test';
input.selectionStart = 1;
input.selectionEnd = 2;
_editingState = EditingState.fromDomElement(input);
expect(_editingState.text, 'Test');
expect(_editingState.baseOffset, 1);
expect(_editingState.extentOffset, 2);
});
test('Get Editing State from text area element', () {
cleanTextEditingElement();
editingElement.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
final TextAreaElement input =
document.getElementsByTagName('textarea')[0];
input.value = 'Test';
input.selectionStart = 1;
input.selectionEnd = 2;
_editingState = EditingState.fromDomElement(input);
expect(_editingState.text, 'Test');
expect(_editingState.baseOffset, 1);
expect(_editingState.extentOffset, 2);
});
test('Compare two editing states', () {
final InputElement input = document.getElementsByTagName('input')[0];
input.value = 'Test';
input.selectionStart = 1;
input.selectionEnd = 2;
EditingState editingState1 = EditingState.fromDomElement(input);
EditingState editingState2 = EditingState.fromDomElement(input);
input.setSelectionRange(1, 3);
EditingState editingState3 = EditingState.fromDomElement(input);
expect(editingState1 == editingState2, true);
expect(editingState1 != editingState3, true);
});
});
}
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>{
'fontSize': fontSize,
'fontFamily': fontFamily,
'textAlignIndex': textAlignIndex,
'fontWeightIndex': fontWeightIndex,
'textDirectionIndex': textDirectionIndex,
});
}
MethodCall configureSetSizeAndTransformMethodCall(
int width, int height, List<double> transform) {
return MethodCall('TextInput.setEditableSizeAndTransform', <String, dynamic>{
'width': width,
'height': height,
'transform': transform
});
}
/// Will disable editing element which will also clean the backup DOM
/// element from the page.
void cleanTextEditingElement() {
if (editingElement.isEnabled) {
// Clean up all the DOM elements and event listeners.
editingElement.disable();
}
}
void cleanTestFlags() {
debugBrowserEngineOverride = null;
debugOperatingSystemOverride = null;
}
void checkInputEditingState(
InputElement input, String text, int start, int end) {
expect(document.activeElement, input);
expect(input.value, text);
expect(input.selectionStart, start);
expect(input.selectionEnd, end);
}
/// In case of an exception backup DOM element(s) can still stay on the DOM.
void clearBackUpDomElementIfExists() {
List<Node> domElementsToRemove = List<Node>();
if (document.getElementsByTagName('input').length > 0) {
domElementsToRemove..addAll(document.getElementsByTagName('input'));
}
if (document.getElementsByTagName('textarea').length > 0) {
domElementsToRemove..addAll(document.getElementsByTagName('textarea'));
}
domElementsToRemove.forEach((Node n) => n.remove());
}
void checkTextAreaEditingState(
TextAreaElement textarea,
String text,
int start,
int end,
) {
expect(document.activeElement, textarea);
expect(textarea.value, text);
expect(textarea.selectionStart, start);
expect(textarea.selectionEnd, end);
}
class PlatformMessagesSpy {
bool _isActive = false;
ui.PlatformMessageCallback _backup;
final List<MethodCall> messages = <MethodCall>[];
void activate() {
assert(!_isActive);
_isActive = true;
_backup = ui.window.onPlatformMessage;
ui.window.onPlatformMessage = (String channel, ByteData data,
ui.PlatformMessageResponseCallback callback) {
messages.add(codec.decodeMethodCall(data));
};
}
void deactivate() {
assert(_isActive);
_isActive = false;
messages.clear();
ui.window.onPlatformMessage = _backup;
}
}
Map<String, dynamic> createFlutterConfig(
String inputType, {
bool obscureText = false,
bool autocorrect = true,
String inputAction,
}) {
return <String, dynamic>{
'inputType': <String, String>{
'name': 'TextInputType.$inputType',
},
'obscureText': obscureText,
'autocorrect': autocorrect,
'inputAction': inputAction ?? 'TextInputAction.done',
};
}