From b16c47d57a08cbbceca8f58f11d55e33a88bac63 Mon Sep 17 00:00:00 2001 From: nturgut Date: Thu, 9 Jul 2020 20:25:51 -0700 Subject: [PATCH] using text capitalization value in web (#19564) * using text capitalization value in web engine * update editing state * add capitalization support to autofill fields * add autocapitalize attribute for mobile browsers which effects on screen keyboards * removing changes on the input value. only keeping onscreen keyboard changes * update unit tests. tests are added for ios-safari. android chrome is still not supported * changing license files this time don't update LICENSES file * Update licenses_flutter * addresing reviewer comments --- ci/licenses_golden/licenses_flutter | 1 + lib/web_ui/lib/src/engine.dart | 1 + .../text_editing/text_capitalization.dart | 91 +++++++++++ .../src/engine/text_editing/text_editing.dart | 101 ++++++++---- lib/web_ui/test/text_editing_test.dart | 147 ++++++++++++++---- 5 files changed, 281 insertions(+), 60 deletions(-) create mode 100644 lib/web_ui/lib/src/engine/text_editing/text_capitalization.dart diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 8433b2263d88..31d3192ccdab 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -521,6 +521,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_break_properties.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text/word_breaker.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/autofill_hint.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/text_capitalization.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/util.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/validators.dart diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index 64114b5455c2..f4ca2d230c2a 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -129,6 +129,7 @@ part 'engine/text/word_break_properties.dart'; part 'engine/text/word_breaker.dart'; part 'engine/text_editing/autofill_hint.dart'; part 'engine/text_editing/input_type.dart'; +part 'engine/text_editing/text_capitalization.dart'; part 'engine/text_editing/text_editing.dart'; part 'engine/util.dart'; part 'engine/validators.dart'; diff --git a/lib/web_ui/lib/src/engine/text_editing/text_capitalization.dart b/lib/web_ui/lib/src/engine/text_editing/text_capitalization.dart new file mode 100644 index 000000000000..ab5dea1943a8 --- /dev/null +++ b/lib/web_ui/lib/src/engine/text_editing/text_capitalization.dart @@ -0,0 +1,91 @@ +// 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. + +part of engine; + +/// Controls the capitalization of the text. +/// +/// This corresponds to Flutter's [TextCapitalization]. +/// +/// Uses `text-transform` css property. +/// See: https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform +enum TextCapitalization { + /// Uppercase for the first letter of each word. + words, + + /// Currently not implemented on Flutter Web. Uppercase for the first letter + /// of each sentence. + sentences, + + /// Uppercase for each letter. + characters, + + /// Lowercase for each letter. + none, +} + +/// Helper class for text capitalization. +/// +/// Uses `autocapitalize` attribute on input element. +/// See: https://developers.google.com/web/updates/2015/04/autocapitalize +/// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autocapitalize +class TextCapitalizationConfig { + final TextCapitalization textCapitalization; + + const TextCapitalizationConfig.defaultCapitalization() + : textCapitalization = TextCapitalization.none; + + TextCapitalizationConfig.fromInputConfiguration(String inputConfiguration) + : this.textCapitalization = + inputConfiguration == 'TextCapitalization.words' + ? TextCapitalization.words + : inputConfiguration == 'TextCapitalization.characters' + ? TextCapitalization.characters + : inputConfiguration == 'TextCapitalization.sentences' + ? TextCapitalization.sentences + : TextCapitalization.none; + + /// Sets `autocapitalize` attribute on input elements. + /// + /// This attribute is only available for mobile browsers. + /// + /// Note that in mobile browsers the onscreen keyboards provide sentence + /// level capitalization as default as apposed to no capitalization on desktop + /// browser. + /// + /// See: https://developers.google.com/web/updates/2015/04/autocapitalize + /// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autocapitalize + void setAutocapitalizeAttribute(html.HtmlElement domElement) { + String autocapitalize = ''; + switch (textCapitalization) { + case TextCapitalization.words: + // TODO: There is a bug for `words` level capitalization in IOS now. + // For now go back to default. Remove the check after bug is resolved. + // https://bugs.webkit.org/show_bug.cgi?id=148504 + if (browserEngine == BrowserEngine.webkit) { + autocapitalize = 'sentences'; + } else { + autocapitalize = 'words'; + } + break; + case TextCapitalization.characters: + autocapitalize = 'characters'; + break; + case TextCapitalization.sentences: + autocapitalize = 'sentences'; + break; + case TextCapitalization.none: + default: + autocapitalize = 'off'; + break; + } + if (domElement is html.InputElement) { + html.InputElement element = domElement; + element.setAttribute('autocapitalize', autocapitalize); + } else if (domElement is html.TextAreaElement) { + html.TextAreaElement element = domElement; + element.setAttribute('autocapitalize', autocapitalize); + } + } +} diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index 44474731d966..0289429eebad 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. - part of engine; /// Make the content editable span visible to facilitate debugging. @@ -115,8 +114,10 @@ class EngineAutofillForm { if (fields != null) { for (Map field in fields.cast>()) { final Map autofillInfo = field['autofill']; - final AutofillInfo autofill = - AutofillInfo.fromFrameworkMessage(autofillInfo); + final AutofillInfo autofill = AutofillInfo.fromFrameworkMessage( + autofillInfo, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + field['textCapitalization'])); // The focused text editing element will not be created here. final AutofillInfo focusedElement = @@ -170,16 +171,24 @@ class EngineAutofillForm { keys.forEach((String key) { final html.Element element = elements![key]!; subscriptions.add(element.onInput.listen((html.Event e) { - _handleChange(element, key); + if (items![key] == null) { + throw StateError( + 'Autofill would not work withuot Autofill value set'); + } else { + final AutofillInfo autofillInfo = items![key] as AutofillInfo; + _handleChange(element, autofillInfo); + } })); }); return subscriptions; } - void _handleChange(html.Element domElement, String? tag) { - EditingState newEditingState = EditingState.fromDomElement(domElement as html.HtmlElement?); + void _handleChange(html.Element domElement, AutofillInfo autofillInfo) { + EditingState newEditingState = EditingState.fromDomElement( + domElement as html.HtmlElement?, + textCapitalization: autofillInfo.textCapitalization); - _sendAutofillEditingState(tag, newEditingState); + _sendAutofillEditingState(autofillInfo.uniqueIdentifier, newEditingState); } /// Sends the 'TextInputClient.updateEditingStateWithTag' message to the framework. @@ -207,7 +216,11 @@ class EngineAutofillForm { /// These values are to be used when a text field have autofill enabled. @visibleForTesting class AutofillInfo { - AutofillInfo({required this.editingState, required this.uniqueIdentifier, required this.hint}); + AutofillInfo( + {required this.editingState, + required this.uniqueIdentifier, + required this.hint, + required this.textCapitalization}); /// The current text and selection state of a text field. final EditingState editingState; @@ -217,6 +230,19 @@ class AutofillInfo { /// Used as id of the text field. final String uniqueIdentifier; + /// Information on how should autofilled text capitalized. + /// + /// For example for [TextCapitalization.characters] each letter is converted + /// to upper case. + /// + /// This value is not necessary for autofilling the focused element since + /// [DefaultTextEditingStrategy._inputConfiguration] already has this + /// information. + /// + /// On the other hand for the multi element forms, for the input elements + /// other the focused field, we need to use this information. + final TextCapitalizationConfig textCapitalization; + /// Attribute used for autofill. /// /// Used as a guidance to the browser as to the type of information expected @@ -224,7 +250,9 @@ class AutofillInfo { /// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete final String hint; - factory AutofillInfo.fromFrameworkMessage(Map autofill) { + factory AutofillInfo.fromFrameworkMessage(Map autofill, + {TextCapitalizationConfig textCapitalization = + const TextCapitalizationConfig.defaultCapitalization()}) { assert(autofill != null); // ignore: unnecessary_null_comparison final String uniqueIdentifier = autofill['uniqueIdentifier']!; final List hintsList = autofill['hints']; @@ -233,7 +261,8 @@ class AutofillInfo { return AutofillInfo( uniqueIdentifier: uniqueIdentifier, hint: BrowserAutofillHints.instance.flutterToEngine(hintsList[0]), - editingState: editingState); + editingState: editingState, + textCapitalization: textCapitalization); } void applyToDomElement(html.HtmlElement domElement, @@ -302,7 +331,9 @@ class EditingState { /// /// [domElement] can be a [InputElement] or a [TextAreaElement] depending on /// the [InputType] of the text field. - factory EditingState.fromDomElement(html.HtmlElement? domElement) { + factory EditingState.fromDomElement(html.HtmlElement? domElement, + {TextCapitalizationConfig textCapitalization = + const TextCapitalizationConfig.defaultCapitalization()}) { if (domElement is html.InputElement) { html.InputElement element = domElement; return EditingState( @@ -352,10 +383,10 @@ class EditingState { if (runtimeType != other.runtimeType) { return false; } - return other is EditingState - && other.text == text - && other.baseOffset == baseOffset - && other.extentOffset == extentOffset; + return other is EditingState && + other.text == text && + other.baseOffset == baseOffset && + other.extentOffset == extentOffset; } @override @@ -396,6 +427,7 @@ class InputConfiguration { required this.inputAction, required this.obscureText, required this.autocorrect, + required this.textCapitalization, this.autofill, this.autofillGroup, }); @@ -407,9 +439,12 @@ class InputConfiguration { inputAction = flutterInputConfiguration['inputAction'], obscureText = flutterInputConfiguration['obscureText'], autocorrect = flutterInputConfiguration['autocorrect'], + textCapitalization = TextCapitalizationConfig.fromInputConfiguration( + flutterInputConfiguration['textCapitalization']), autofill = flutterInputConfiguration.containsKey('autofill') - ? AutofillInfo.fromFrameworkMessage(flutterInputConfiguration['autofill']) - : null, + ? AutofillInfo.fromFrameworkMessage( + flutterInputConfiguration['autofill']) + : null, autofillGroup = EngineAutofillForm.fromFrameworkMessage( flutterInputConfiguration['autofill'], flutterInputConfiguration['fields']); @@ -435,6 +470,8 @@ class InputConfiguration { final AutofillInfo? autofill; final EngineAutofillForm? autofillGroup; + + final TextCapitalizationConfig textCapitalization; } typedef _OnChangeCallback = void Function(EditingState? editingState); @@ -500,18 +537,18 @@ class GloballyPositionedTextEditingStrategy extends DefaultTextEditingStrategy { void placeElement() { super.placeElement(); if (hasAutofillGroup) { - _geometry?.applyToDomElement(focusedFormElement!); - placeForm(); - // On Chrome, when a form is focused, it opens an autofill menu - // immeddiately. - // Flutter framework sends `setEditableSizeAndTransform` for informing - // the engine about the location of the text field. This call will - // arrive after `show` call. - // Therefore on Chrome we place the element when - // `setEditableSizeAndTransform` method is called and focus on the form - // only after placing it to the correct position. Hence autofill menu - // does not appear on top-left of the page. - focusedFormElement!.focus(); + _geometry?.applyToDomElement(focusedFormElement!); + placeForm(); + // On Chrome, when a form is focused, it opens an autofill menu + // immeddiately. + // Flutter framework sends `setEditableSizeAndTransform` for informing + // the engine about the location of the text field. This call will + // arrive after `show` call. + // Therefore on Chrome we place the element when + // `setEditableSizeAndTransform` method is called and focus on the form + // only after placing it to the correct position. Hence autofill menu + // does not appear on top-left of the page. + focusedFormElement!.focus(); } else { _geometry?.applyToDomElement(domElement); } @@ -551,6 +588,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { set domElement(html.HtmlElement element) { _domElement = element; } + html.HtmlElement? _domElement; late InputConfiguration _inputConfiguration; @@ -694,7 +732,8 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { void _handleChange(html.Event event) { assert(isEnabled); - EditingState newEditingState = EditingState.fromDomElement(domElement); + EditingState newEditingState = EditingState.fromDomElement(domElement, + textCapitalization: _inputConfiguration.textCapitalization); if (newEditingState != _lastEditingState) { _lastEditingState = newEditingState; @@ -818,6 +857,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { } else { domRenderer.glassPaneElement!.append(domElement); } + inputConfig.textCapitalization.setAutocapitalizeAttribute(domElement); } @override @@ -948,6 +988,7 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy { } else { domRenderer.glassPaneElement!.append(domElement); } + inputConfig.textCapitalization.setAutocapitalizeAttribute(domElement); } @override diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index 341fc68994e1..a45fa836dfbe 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -31,16 +31,19 @@ final InputConfiguration singlelineConfig = InputConfiguration( obscureText: false, inputAction: 'TextInputAction.done', autocorrect: true, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + 'TextCapitalization.none'), ); final Map flutterSinglelineConfig = createFlutterConfig('text'); final InputConfiguration multilineConfig = InputConfiguration( - inputType: EngineInputType.multiline, - obscureText: false, - inputAction: 'TextInputAction.newline', - autocorrect: true, -); + inputType: EngineInputType.multiline, + obscureText: false, + inputAction: 'TextInputAction.newline', + autocorrect: true, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + 'TextCapitalization.none')); final Map flutterMultilineConfig = createFlutterConfig('multiline'); @@ -108,11 +111,12 @@ void main() { test('Knows how to create password fields', () { final InputConfiguration config = InputConfiguration( - inputType: EngineInputType.text, - inputAction: 'TextInputAction.done', - obscureText: true, - autocorrect: true, - ); + inputType: EngineInputType.text, + inputAction: 'TextInputAction.done', + obscureText: true, + autocorrect: true, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + 'TextCapitalization.none')); editingElement.enable( config, onChange: trackEditingState, @@ -128,11 +132,12 @@ void main() { test('Knows to turn autocorrect off', () { final InputConfiguration config = InputConfiguration( - inputType: EngineInputType.text, - inputAction: 'TextInputAction.done', - obscureText: false, - autocorrect: false, - ); + inputType: EngineInputType.text, + inputAction: 'TextInputAction.done', + obscureText: false, + autocorrect: false, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + 'TextCapitalization.none')); editingElement.enable( config, onChange: trackEditingState, @@ -148,11 +153,12 @@ void main() { test('Knows to turn autocorrect on', () { final InputConfiguration config = InputConfiguration( - inputType: EngineInputType.text, - inputAction: 'TextInputAction.done', - obscureText: false, - autocorrect: true, - ); + inputType: EngineInputType.text, + inputAction: 'TextInputAction.done', + obscureText: false, + autocorrect: true, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + 'TextCapitalization.none')); editingElement.enable( config, onChange: trackEditingState, @@ -287,11 +293,12 @@ void main() { test('Triggers input action', () { final InputConfiguration config = InputConfiguration( - inputType: EngineInputType.text, - obscureText: false, - inputAction: 'TextInputAction.done', - autocorrect: true, - ); + inputType: EngineInputType.text, + obscureText: false, + inputAction: 'TextInputAction.done', + autocorrect: true, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + 'TextCapitalization.none')); editingElement.enable( config, onChange: trackEditingState, @@ -313,11 +320,12 @@ void main() { test('Does not trigger input action in multi-line mode', () { final InputConfiguration config = InputConfiguration( - inputType: EngineInputType.multiline, - obscureText: false, - inputAction: 'TextInputAction.done', - autocorrect: true, - ); + inputType: EngineInputType.multiline, + obscureText: false, + inputAction: 'TextInputAction.done', + autocorrect: true, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + 'TextCapitalization.none')); editingElement.enable( config, onChange: trackEditingState, @@ -859,6 +867,82 @@ void main() { expect(document.getElementsByTagName('form'), isEmpty); }); + test( + 'No capitilization: setClient, setEditingState, show', () { + // Create a configuration with an AutofillGroup of four text fields. + final Map capitilizeWordsConfig = createFlutterConfig( + 'text', + textCapitalization: 'TextCapitalization.none'); + final MethodCall setClient = MethodCall( + 'TextInput.setClient', [123, capitilizeWordsConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState1 = + MethodCall('TextInput.setEditingState', { + 'text': '', + 'selectionBase': 0, + 'selectionExtent': 0, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + spy.messages.clear(); + + // Test for mobile Safari. `sentences` is the default attribute for + // mobile browsers. Check if `off` is added to the input element. + if (browserEngine == BrowserEngine.webkit && + operatingSystem == OperatingSystem.iOs) { + expect( + textEditing.editingElement.domElement + .getAttribute('autocapitalize'), + 'off'); + } else { + expect( + textEditing.editingElement.domElement + .getAttribute('autocapitalize'), + isNull); + } + + spy.messages.clear(); + hideKeyboard(); + }); + + test( + 'All characters capitilization: setClient, setEditingState, show', () { + // Create a configuration with an AutofillGroup of four text fields. + final Map capitilizeWordsConfig = createFlutterConfig( + 'text', + textCapitalization: 'TextCapitalization.characters'); + final MethodCall setClient = MethodCall( + 'TextInput.setClient', [123, capitilizeWordsConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState1 = + MethodCall('TextInput.setEditingState', { + 'text': '', + 'selectionBase': 0, + 'selectionExtent': 0, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + spy.messages.clear(); + + // Test for mobile Safari. + if (browserEngine == BrowserEngine.webkit && + operatingSystem == OperatingSystem.iOs) { + expect( + textEditing.editingElement.domElement + .getAttribute('autocapitalize'), + 'characters'); + } + + spy.messages.clear(); + hideKeyboard(); + }); + test( 'setClient, setEditableSizeAndTransform, setStyle, setEditingState, show, clearClient', () { @@ -1710,6 +1794,7 @@ Map createFlutterConfig( String inputType, { bool obscureText = false, bool autocorrect = true, + String textCapitalization = 'TextCapitalization.none', String inputAction, String autofillHint, List autofillHintsForFields, @@ -1721,6 +1806,7 @@ Map createFlutterConfig( 'obscureText': obscureText, 'autocorrect': autocorrect, 'inputAction': inputAction ?? 'TextInputAction.done', + 'textCapitalization': textCapitalization, if (autofillHint != null) 'autofill': createAutofillInfo(autofillHint, autofillHint), if (autofillHintsForFields != null) @@ -1763,5 +1849,6 @@ Map createOneFieldValue(String hint, String uniqueId) => 'signed': null, 'decimal': null }, + 'textCapitalization': 'TextCapitalization.none', 'autofill': createAutofillInfo(hint, uniqueId) };