Skip to content

Commit

Permalink
[web] fixing text editing for autofill with semantics (flutter#23361)
Browse files Browse the repository at this point in the history
* fixing text editing for autofill with semantics

* fix tests. add tests. unskip tests

* removing new. adding issue to comment
  • Loading branch information
nturgut committed Jan 5, 2021
1 parent a84c4ed commit c01b5ca
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 27 deletions.
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/semantics.dart
Expand Up @@ -927,6 +927,7 @@ class SemanticsObject {
..height = '${rect.height}px';
}
} else {
// TODO: https://github.com/flutter/flutter/issues/73347
if (isDesktop) {
element.style
..removeProperty('transform-origin')
Expand Down
47 changes: 43 additions & 4 deletions lib/web_ui/lib/src/engine/semantics/text_field.dart
Expand Up @@ -15,12 +15,16 @@ part of engine;
/// This class is still responsible for hooking up the DOM element with the
/// [HybridTextEditing] instance so that changes are communicated to Flutter.
class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
/// The semantics object which this text editing element belongs to.
final SemanticsObject semanticsObject;

/// Creates a [SemanticsTextEditingStrategy] that eagerly instantiates
/// [domElement] so the caller can insert it before calling
/// [SemanticsTextEditingStrategy.enable].
SemanticsTextEditingStrategy(
SemanticsTextEditingStrategy(SemanticsObject semanticsObject,
HybridTextEditing owner, html.HtmlElement domElement)
: super(owner) {
: this.semanticsObject = semanticsObject,
super(owner) {
// Make sure the DOM element is of a type that we support for text editing.
// TODO(yjbanov): move into initializer list when https://github.com/dart-lang/sdk/issues/37881 is fixed.
assert((domElement is html.InputElement) ||
Expand All @@ -44,6 +48,20 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
_subscriptions.clear();
_lastEditingState = null;

// If focused element is a part of a form, it needs to stay on the DOM
// until the autofill context of the form is finalized.
// More details on `TextInput.finishAutofillContext` call.
if (_appendedToForm &&
_inputConfiguration.autofillGroup?.formElement != null) {
// We want to save the domElement with the form. However we still
// need to keep the text editing domElement attached to the semantics
// tree. In order to simplify the logic we will create a clone of the
// element.
final html.Node textFieldClone = domElement.clone(false);
domElement = textFieldClone as html.HtmlElement;
_inputConfiguration.autofillGroup?.storeForm();
}

// If the text element still has focus, remove focus from the editable
// element to cause the keyboard to hide.
// Otherwise, the keyboard stays on screen even when the user navigates to
Expand Down Expand Up @@ -87,6 +105,7 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
_inputConfiguration = inputConfig;
_onChange = onChange;
_onAction = onAction;
_applyConfiguration(inputConfig);
}

@override
Expand All @@ -96,6 +115,25 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
// Refocus after setting editing state.
domElement.focus();
}

@override
void placeElement() {
// If this text editing element is a part of an autofill group.
if (hasAutofillGroup) {
placeForm();
}
domElement.focus();
}

@override
void placeForm() {
// Switch domElement's parent from semantics object to form.
domElement.remove();
_inputConfiguration.autofillGroup!.formElement.append(domElement);
semanticsObject.element
.append(_inputConfiguration.autofillGroup!.formElement);
_appendedToForm = true;
}
}

/// Manages semantics objects that represent editable text fields.
Expand All @@ -114,6 +152,7 @@ class TextField extends RoleManager {
? html.TextAreaElement()
: html.InputElement();
textEditingElement = SemanticsTextEditingStrategy(
semanticsObject,
textEditing,
editableDomElement,
);
Expand Down Expand Up @@ -191,8 +230,8 @@ class TextField extends RoleManager {
void _initializeForWebkit() {
// Safari for desktop is also initialized as the other browsers.
if (operatingSystem == OperatingSystem.macOs) {
_initializeForBlink();
return;
_initializeForBlink();
return;
}
num? lastTouchStartOffsetX;
num? lastTouchStartOffsetY;
Expand Down
125 changes: 102 additions & 23 deletions lib/web_ui/test/text_editing_test.dart
Expand Up @@ -365,6 +365,13 @@ void testMain() {
InputElement testInputElement;
HybridTextEditing testTextEditing;

final PlatformMessagesSpy spy = PlatformMessagesSpy();

/// Emulates sending of a message by the framework to the engine.
void sendFrameworkMessage(dynamic message) {
textEditing.channel.handleTextInput(message, (ByteData data) {});
}

setUp(() {
testInputElement = InputElement();
testTextEditing = HybridTextEditing();
Expand All @@ -375,18 +382,77 @@ void testMain() {
testInputElement = null;
});

test('autofill form lifecycle works', () async {
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), testTextEditing, testInputElement);
// Create a configuration with an AutofillGroup of four text fields.
final Map<String, dynamic> flutterMultiAutofillElementConfig =
createFlutterConfig('text',
autofillHint: 'username',
autofillHintsForFields: [
'username',
'email',
'name',
'telephoneNumber'
]);
final MethodCall setClient = MethodCall('TextInput.setClient',
<dynamic>[123, flutterMultiAutofillElementConfig]);
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));

// The transform is changed. For example after a validation error, red
// line appeared under the input field.
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

// Form is added to DOM.
expect(document.getElementsByTagName('form'), isNotEmpty);

const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));

// Confirm that [HybridTextEditing] didn't send any messages.
expect(spy.messages, isEmpty);
// Form stays on the DOM until autofill context is finalized.
expect(document.getElementsByTagName('form'), isNotEmpty);
expect(formsOnTheDom, hasLength(1));

const MethodCall finishAutofillContext =
MethodCall('TextInput.finishAutofillContext', false);
sendFrameworkMessage(codec.encodeMethodCall(finishAutofillContext));

// Form element is removed from DOM.
expect(document.getElementsByTagName('form'), hasLength(0));
expect(formsOnTheDom, hasLength(0));
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: browserEngine == BrowserEngine.edge);

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),
() => SemanticsTextEditingStrategy(
SemanticsObject(5, null), HybridTextEditing(), span),
throwsAssertionError,
);
});

test('Do not re-acquire focus', () {
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement);
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), HybridTextEditing(), testInputElement);

expect(document.activeElement, document.body);

Expand All @@ -408,8 +474,8 @@ void testMain() {
skip: browserEngine == BrowserEngine.edge);

test('Does not dispose and recreate dom elements in persistent mode', () {
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement);
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), HybridTextEditing(), testInputElement);

// The DOM element should've been eagerly created.
expect(testInputElement, isNotNull);
Expand Down Expand Up @@ -454,8 +520,8 @@ void testMain() {
skip: browserEngine == BrowserEngine.edge);

test('Refocuses when setting editing state', () {
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement);
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), HybridTextEditing(), testInputElement);

document.body.append(testInputElement);
editingElement.enable(
Expand All @@ -474,8 +540,8 @@ void testMain() {

test('Works in multi-line mode', () {
final TextAreaElement textarea = TextAreaElement();
editingElement =
SemanticsTextEditingStrategy(HybridTextEditing(), textarea);
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), HybridTextEditing(), textarea);

expect(editingElement.domElement, textarea);
expect(document.activeElement, document.body);
Expand Down Expand Up @@ -731,8 +797,8 @@ void testMain() {
operatingSystem == OperatingSystem.iOs) {
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/textinput');
expect(spy.messages[0].methodName,
'TextInputClient.onConnectionClosed');
expect(
spy.messages[0].methodName, 'TextInputClient.onConnectionClosed');
await Future<void>.delayed(Duration.zero);
// DOM element loses the focus.
expect(document.activeElement, document.body);
Expand Down Expand Up @@ -787,10 +853,8 @@ void testMain() {
// 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));
skip: browserEngine == BrowserEngine.edge);

test('finishAutofillContext removes form from DOM', () async {
// Create a configuration with an AutofillGroup of four text fields.
Expand Down Expand Up @@ -818,6 +882,13 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));

// The transform is changed. For example after a validation error, red
// line appeared under the input field.
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

// Form is added to DOM.
expect(document.getElementsByTagName('form'), isNotEmpty);

Expand All @@ -838,10 +909,8 @@ void testMain() {
expect(document.getElementsByTagName('form'), hasLength(0));
expect(formsOnTheDom, 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));
skip: browserEngine == BrowserEngine.edge);

test('finishAutofillContext with save submits forms', () async {
// Create a configuration with an AutofillGroup of four text fields.
Expand Down Expand Up @@ -869,6 +938,13 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));

// The transform is changed. For example after a validation error, red
// line appeared under the input field.
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

// Form is added to DOM.
expect(document.getElementsByTagName('form'), isNotEmpty);
FormElement formElement = document.getElementsByTagName('form')[0];
Expand All @@ -886,10 +962,8 @@ void testMain() {
// `submit` action is called on form.
await expectLater(await submittedForm.future, true);
},
// 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));
skip: browserEngine == BrowserEngine.edge);

test('forms submits for focused input', () async {
// Create a configuration with an AutofillGroup of four text fields.
Expand Down Expand Up @@ -917,6 +991,13 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));

// The transform is changed. For example after a validation error, red
// line appeared under the input field.
final MethodCall setSizeAndTransform =
configureSetSizeAndTransformMethodCall(150, 50,
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));

// Form is added to DOM.
expect(document.getElementsByTagName('form'), isNotEmpty);
FormElement formElement = document.getElementsByTagName('form')[0];
Expand All @@ -940,10 +1021,8 @@ void testMain() {
expect(document.getElementsByTagName('form'), hasLength(0));
expect(formsOnTheDom, 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));
skip: browserEngine == BrowserEngine.edge);

test('setClient, setEditingState, show, setClient', () {
final MethodCall setClient = MethodCall(
Expand Down

0 comments on commit c01b5ca

Please sign in to comment.