Fix a11y tab traversal (flutter/engine#25797)

* fix race with framework when using tab to traverse in a11y mode
This commit is contained in:
Yegor
2021-05-10 19:25:42 -07:00
committed by GitHub
parent 6c4bd58b2c
commit 848c588bf0
10 changed files with 1508 additions and 736 deletions

View File

@@ -92,8 +92,8 @@ class DomRenderer {
/// This getter calls the `hasFocus` method of the `Document` interface.
/// See for more details:
/// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus
bool? get windowHasFocus =>
js_util.callMethod(html.document, 'hasFocus', <dynamic>[]);
bool get windowHasFocus =>
js_util.callMethod(html.document, 'hasFocus', <dynamic>[]) ?? false;
void _setupHotRestart() {
// This persists across hot restarts to clear stale DOM.

View File

@@ -49,12 +49,9 @@ class LabelAndValue extends RoleManager {
final bool hasValue = semanticsObject.hasValue;
final bool hasLabel = semanticsObject.hasLabel;
// If the node is incrementable or a text field the value is reported to the
// browser via the respective role managers. We do not need to also render
// it again here.
final bool shouldDisplayValue = hasValue &&
!semanticsObject.isIncrementable &&
!semanticsObject.isTextField;
// If the node is incrementable the value is reported to the browser via
// the respective role manager. We do not need to also render it again here.
final bool shouldDisplayValue = hasValue && !semanticsObject.isIncrementable;
if (!hasLabel && !shouldDisplayValue) {
_cleanUpDom();

View File

@@ -269,8 +269,8 @@ class SemanticsObject {
}
/// See [ui.SemanticsUpdateBuilder.updateNode].
int? get flags => _flags;
int? _flags;
int get flags => _flags;
int _flags = 0;
/// Whether the [flags] field has been updated but has not been applied to the
/// DOM yet.
@@ -583,7 +583,7 @@ class SemanticsObject {
SemanticsObject? _parent;
/// Whether this node currently has a given [SemanticsFlag].
bool hasFlag(ui.SemanticsFlag flag) => _flags! & flag.index != 0;
bool hasFlag(ui.SemanticsFlag flag) => _flags & flag.index != 0;
/// Whether [actions] contains the given action.
bool hasAction(ui.SemanticsAction action) => (_actions! & action.index) != 0;
@@ -784,15 +784,24 @@ class SemanticsObject {
/// > A map literal is ordered: iterating over the keys and/or values of the maps always happens in the order the keys appeared in the source code.
final Map<Role, RoleManager?> _roleManagers = <Role, RoleManager?>{};
/// Returns the role manager for the given [role].
///
/// If a role manager does not exist for the given role, returns null.
RoleManager? debugRoleManagerFor(Role role) => _roleManagers[role];
/// Detects the roles that this semantics object corresponds to and manages
/// the lifecycles of [SemanticsObjectRole] objects.
void _updateRoles() {
_updateRole(Role.labelAndValue, (hasLabel || hasValue) && !isVisualOnly);
_updateRole(Role.labelAndValue, (hasLabel || hasValue) && !isTextField && !isVisualOnly);
_updateRole(Role.textField, isTextField);
_updateRole(
Role.tappable,
hasAction(ui.SemanticsAction.tap) ||
hasFlag(ui.SemanticsFlag.isButton));
bool shouldUseTappableRole =
(hasAction(ui.SemanticsAction.tap) || hasFlag(ui.SemanticsFlag.isButton)) &&
// Text fields manage their own focus/tap interactions. We don't need the
// tappable role manager. It only confuses AT.
!isTextField;
_updateRole(Role.tappable, shouldUseTappableRole);
_updateRole(Role.incrementable, isIncrementable);
_updateRole(Role.scrollable,
isVerticalScrollContainer || isHorizontalScrollContainer);
@@ -1144,7 +1153,7 @@ class EngineSemanticsOwner {
_instance = null;
}
final Map<int?, SemanticsObject?> _semanticsTree = <int?, SemanticsObject?>{};
final Map<int, SemanticsObject> _semanticsTree = <int, SemanticsObject>{};
/// Map [SemanticsObject.id] to parent [SemanticsObject] it was attached to
/// this frame.
@@ -1221,8 +1230,8 @@ class EngineSemanticsOwner {
/// Returns the entire semantics tree for testing.
///
/// Works only in debug mode.
Map<int?, SemanticsObject?>? get debugSemanticsTree {
Map<int?, SemanticsObject?>? result;
Map<int, SemanticsObject>? get debugSemanticsTree {
Map<int, SemanticsObject>? result;
assert(() {
result = _semanticsTree;
return true;

View File

@@ -20,6 +20,10 @@ class Tappable extends RoleManager {
void update() {
final html.Element element = semanticsObject.element;
// "tab-index=0" is used to allow keyboard traversal of non-form elements.
// See also: https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets
element.tabIndex = 0;
semanticsObject.setAriaRole(
'button', semanticsObject.hasFlag(ui.SemanticsFlag.isButton));
@@ -48,6 +52,11 @@ class Tappable extends RoleManager {
_stopListening();
}
}
// Request focus so that the AT shifts a11y focus to this node.
if (semanticsObject.isFlagsDirty && semanticsObject.hasFocus) {
element.focus();
}
}
void _stopListening() {

View File

@@ -14,28 +14,94 @@ 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;
/// Initializes the [SemanticsTextEditingStrategy] singleton.
///
/// This method must be called prior to accessing [instance].
static SemanticsTextEditingStrategy ensureInitialized(HybridTextEditing owner) {
if (_instance != null && instance.owner == owner) {
return instance;
}
return _instance = SemanticsTextEditingStrategy(owner);
}
/// The [SemanticsTextEditingStrategy] singleton.
static SemanticsTextEditingStrategy get instance => _instance!;
static SemanticsTextEditingStrategy? _instance;
/// Creates a [SemanticsTextEditingStrategy] that eagerly instantiates
/// [domElement] so the caller can insert it before calling
/// [SemanticsTextEditingStrategy.enable].
SemanticsTextEditingStrategy(SemanticsObject semanticsObject,
HybridTextEditing owner, html.HtmlElement domElement)
: 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) ||
(domElement is html.TextAreaElement));
super.domElement = domElement;
SemanticsTextEditingStrategy(HybridTextEditing owner)
: super(owner);
/// The text field whose DOM element is currently used for editing.
///
/// If this field is null, no editing takes place.
TextField? activeTextField;
/// Current input configuration supplied by the "flutter/textinput" channel.
InputConfiguration? inputConfig;
_OnChangeCallback? onChange;
_OnActionCallback? onAction;
/// The semantics implementation does not operate on DOM nodes, but only
/// remembers the config and callbacks. This is because the DOM nodes are
/// supplied in the semantics update and enabled by [activate].
@override
void enable(
InputConfiguration inputConfig, {
required _OnChangeCallback onChange,
required _OnActionCallback onAction,
}) {
this.inputConfig = inputConfig;
this.onChange = onChange;
this.onAction = onAction;
}
/// Attaches the DOM element owned by [textField] to the text editing
/// strategy.
///
/// This method must be called after [enable] to name sure that [inputConfig],
/// [onChange], and [onAction] are not null.
void activate(TextField textField) {
assert(
inputConfig != null && onChange != null && onAction != null,
'"enable" should be called before "enableFromSemantics" and initialize input configuration',
);
if (activeTextField == textField) {
// The specified field is already active. Skip.
return;
} else if (activeTextField != null) {
// Another text field is currently active. Deactivate it before switching.
disable();
}
activeTextField = textField;
domElement = textField.editableElement;
_syncStyle();
super.enable(inputConfig!, onChange: onChange!, onAction: onAction!);
}
/// Detaches the DOM element owned by [textField] from this text editing
/// strategy.
///
/// Typically at this point the element loses focus (blurs) and stops being
/// used for editing.
void deactivate(TextField textField) {
if (activeTextField == textField) {
disable();
}
}
@override
void disable() {
// We don't want to remove the DOM element because the caller is responsible
// for that. However we still want to stop editing, cleanup the handlers.
assert(isEnabled);
if (!isEnabled) {
return;
}
isEnabled = false;
_style = null;
@@ -47,28 +113,15 @@ 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.
// element to cause the on-screen keyboard, if any, to hide (e.g. on iOS,
// Android).
// Otherwise, the keyboard stays on screen even when the user navigates to
// a different screen (e.g. by hitting the "back" button).
if (operatingSystem == OperatingSystem.android ||
operatingSystem == OperatingSystem.iOs) {
domElement.blur();
}
domElement?.blur();
domElement = null;
activeTextField = null;
_queuedStyle = null;
}
@override
@@ -79,27 +132,15 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(activeDomElement.onInput.listen(_handleChange));
_subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
preventDefaultForMouseEvents();
}
@override
void initializeElementPlacement() {
// Element placement is done by [TextField].
}
@override
void initializeTextEditing(InputConfiguration inputConfig,
{_OnChangeCallback? onChange, _OnActionCallback? onAction}) {
// In accesibilty mode, the user of this class is supposed to insert the
// [domElement] on their own. Let's make sure they did.
assert(html.document.body!.contains(domElement));
isEnabled = true;
_inputConfiguration = inputConfig;
_onChange = onChange;
@@ -107,31 +148,47 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
_applyConfiguration(inputConfig);
}
@override
void setEditingState(EditingState? editingState) {
super.setEditingState(editingState);
// 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();
activeDomElement.focus();
}
@override
void initializeElementPlacement() {
// Element placement is done by [TextField].
}
@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;
}
@override
void updateElementPlacement(EditableTextGeometry geometry) {
// Element placement is done by [TextField].
}
EditableTextStyle? _queuedStyle;
@override
void updateElementStyle(EditableTextStyle style) {
_queuedStyle = style;
_syncStyle();
}
/// Apply style to the element, if both style and element are available.
///
/// Because style is supplied by the "flutter/textinput" channel and the DOM
/// element is supplied by the semantics tree, the existence of both at the
/// same time is not guaranteed.
void _syncStyle() {
if (_queuedStyle == null || domElement == null) {
return;
}
super.updateElementStyle(_queuedStyle!);
}
}
@@ -146,20 +203,15 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
class TextField extends RoleManager {
TextField(SemanticsObject semanticsObject)
: super(Role.textField, semanticsObject) {
final html.HtmlElement editableDomElement =
editableElement =
semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
? html.TextAreaElement()
: html.InputElement();
textEditingElement = SemanticsTextEditingStrategy(
semanticsObject,
textEditing,
editableDomElement,
);
_setupDomElement();
}
SemanticsTextEditingStrategy? textEditingElement;
html.HtmlElement get _textFieldElement => textEditingElement!.domElement;
/// The element used for editing, e.g. `<input>`, `<textarea>`.
late final html.HtmlElement editableElement;
void _setupDomElement() {
// On iOS, even though the semantic text field is transparent, the cursor
@@ -167,13 +219,13 @@ class TextField extends RoleManager {
// are made invisible by CSS in [DomRenderer.reset].
// But there's one more case where iOS highlights text. That's when there's
// and autocorrect suggestion. To disable that, we have to do the following:
_textFieldElement
editableElement
..spellcheck = false
..setAttribute('autocorrect', 'off')
..setAttribute('autocomplete', 'off')
..setAttribute('data-semantics-role', 'text-field');
_textFieldElement.style
editableElement.style
..position = 'absolute'
// `top` and `left` are intentionally set to zero here.
//
@@ -188,7 +240,7 @@ class TextField extends RoleManager {
..left = '0'
..width = '${semanticsObject.rect!.width}px'
..height = '${semanticsObject.rect!.height}px';
semanticsObject.element.append(_textFieldElement);
semanticsObject.element.append(editableElement);
switch (browserEngine) {
case BrowserEngine.blink:
@@ -211,12 +263,11 @@ class TextField extends RoleManager {
/// When in browser gesture mode, the focus is forwarded to the framework as
/// a tap to initialize editing.
void _initializeForBlink() {
_textFieldElement.addEventListener('focus', (html.Event event) {
editableElement.addEventListener('focus', (html.Event event) {
if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
return;
}
textEditing.useCustomEditableElement(textEditingElement);
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsObject.id, ui.SemanticsAction.tap, null);
});
@@ -236,14 +287,13 @@ class TextField extends RoleManager {
num? lastTouchStartOffsetX;
num? lastTouchStartOffsetY;
_textFieldElement.addEventListener('touchstart', (html.Event event) {
textEditing.useCustomEditableElement(textEditingElement);
editableElement.addEventListener('touchstart', (html.Event event) {
final html.TouchEvent touchEvent = event as html.TouchEvent;
lastTouchStartOffsetX = touchEvent.changedTouches!.last.client.x;
lastTouchStartOffsetY = touchEvent.changedTouches!.last.client.y;
}, true);
_textFieldElement.addEventListener('touchend', (html.Event event) {
editableElement.addEventListener('touchend', (html.Event event) {
final html.TouchEvent touchEvent = event as html.TouchEvent;
if (lastTouchStartOffsetX != null) {
@@ -251,7 +301,7 @@ class TextField extends RoleManager {
final num offsetX = touchEvent.changedTouches!.last.client.x;
final num offsetY = touchEvent.changedTouches!.last.client.y;
// This should match the similar constant define in:
// This should match the similar constant defined in:
//
// lib/src/gestures/constants.dart
//
@@ -272,15 +322,75 @@ class TextField extends RoleManager {
}, true);
}
bool _hasFocused = false;
@override
void update() {
// The user is editing the semantic text field directly, so there's no need
// to do any update here.
if (semanticsObject.hasLabel) {
editableElement.setAttribute(
'aria-label',
semanticsObject.label!,
);
} else {
editableElement.removeAttribute('aria-label');
}
editableElement.style
..width = '${semanticsObject.rect!.width}px'
..height = '${semanticsObject.rect!.height}px';
// Whether we should request that the browser shift focus to the editable
// element, so that both the framework and the browser agree on what's
// currently focused.
bool needsDomFocusRequest = false;
final EditingState editingState = EditingState(
text: semanticsObject.value,
baseOffset: semanticsObject.textSelectionBase,
extentOffset: semanticsObject.textSelectionExtent,
);
if (semanticsObject.hasFocus) {
if (!_hasFocused) {
_hasFocused = true;
SemanticsTextEditingStrategy.instance.activate(this);
needsDomFocusRequest = true;
}
if (html.document.activeElement != editableElement) {
needsDomFocusRequest = true;
}
// Focused elements should have full text editing state applied.
SemanticsTextEditingStrategy.instance.setEditingState(editingState);
} else if (_hasFocused) {
SemanticsTextEditingStrategy.instance.deactivate(this);
// Only apply text, because this node is not focused.
editingState.applyTextToDomElement(editableElement);
if (_hasFocused && html.document.activeElement == editableElement) {
// Unlike `editableElement.focus()` we don't need to schedule `blur`
// post-update because `document.activeElement` implies that the
// element is already attached to the DOM. If it's not, it can't
// possibly be focused and therefore there's no need to blur.
editableElement.blur();
}
_hasFocused = false;
}
if (needsDomFocusRequest) {
// Schedule focus post-update to make sure the element is attached to
// the document. Otherwise focus() has no effect.
semanticsObject.owner.addOneTimePostUpdateCallback(() {
if (html.document.activeElement != editableElement) {
editableElement.focus();
}
});
}
}
@override
void dispose() {
_textFieldElement.remove();
textEditing.stopUsingCustomEditableElement();
editableElement.remove();
SemanticsTextEditingStrategy.instance.deactivate(this);
}
}

View File

@@ -7,6 +7,9 @@ part of engine;
/// Make the content editable span visible to facilitate debugging.
bool _debugVisibleTextEditing = false;
/// Set this to `true` to print when text input commands are scheduled and run.
bool _debugPrintTextInputCommands = false;
/// The `keyCode` of the "Enter" key.
const int _kReturnKeyCode = 13;
@@ -485,6 +488,14 @@ class EditingState {
///
/// [domElement] can be a [InputElement] or a [TextAreaElement] depending on
/// the [InputType] of the text field.
///
/// This should only be used by focused elements only, because only focused
/// elements can have their text selection range set. Attempting to set
/// selection range on a non-focused element will cause it to request focus.
///
/// See also:
///
/// * [applyTextToDomElement], which is used for non-focused elements.
void applyToDomElement(html.HtmlElement? domElement) {
if (domElement is html.InputElement) {
html.InputElement element = domElement;
@@ -494,6 +505,25 @@ class EditingState {
html.TextAreaElement element = domElement;
element.value = text;
element.setSelectionRange(baseOffset!, extentOffset!);
} else {
throw UnsupportedError('Unsupported DOM element type: <${domElement?.tagName}> (${domElement.runtimeType})');
}
}
/// Applies the [text] to the [domElement].
///
/// This is used by non-focused elements.
///
/// See also:
///
/// * [applyToDomElement], which is used for focused elements.
void applyTextToDomElement(html.HtmlElement? domElement) {
if (domElement is html.InputElement) {
html.InputElement element = domElement;
element.value = text;
} else if (domElement is html.TextAreaElement) {
html.TextAreaElement element = domElement;
element.value = text;
} else {
throw UnsupportedError('Unsupported DOM element type');
}
@@ -652,9 +682,9 @@ class GloballyPositionedTextEditingStrategy extends DefaultTextEditingStrategy {
// does not appear on top-left of the page.
// Refocus on the elements after applying the geometry.
focusedFormElement!.focus();
domElement.focus();
activeDomElement.focus();
} else {
_geometry?.applyToDomElement(domElement);
_geometry?.applyToDomElement(activeDomElement);
}
}
}
@@ -685,7 +715,7 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy {
/// Making an extra `focus` request causes flickering in Safari.
@override
void placeElement() {
_geometry?.applyToDomElement(domElement);
_geometry?.applyToDomElement(activeDomElement);
if (hasAutofillGroup) {
placeForm();
// On Safari Desktop, when a form is focused, it opens an autofill menu
@@ -702,16 +732,16 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy {
// users ongoing work to continue uninterrupted when there is an update to
// the transform.
// If domElement is not focused cursor location will not be correct.
domElement.focus();
activeDomElement.focus();
if (_lastEditingState != null) {
_lastEditingState!.applyToDomElement(domElement);
_lastEditingState!.applyToDomElement(activeDomElement);
}
}
}
@override
void initializeElementPlacement() {
domElement.focus();
activeDomElement.focus();
}
}
@@ -744,12 +774,20 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
@visibleForTesting
bool isEnabled = false;
html.HtmlElement get domElement => _domElement!;
set domElement(html.HtmlElement element) {
_domElement = element;
}
/// The DOM element used for editing, if any.
html.HtmlElement? domElement;
html.HtmlElement? _domElement;
/// Same as [domElement] but null-checked.
///
/// This must only be called in places that know for sure that a DOM element
/// is currently available for editing.
html.HtmlElement get activeDomElement {
assert(
domElement != null,
'The DOM element of this text editing strategy is not currently active.',
);
return domElement!;
}
late InputConfiguration _inputConfiguration;
EditingState? _lastEditingState;
@@ -783,18 +821,18 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
}) {
assert(!isEnabled);
_domElement = inputConfig.inputType.createDomElement();
domElement = inputConfig.inputType.createDomElement();
_applyConfiguration(inputConfig);
_setStaticStyleAttributes(domElement);
_style?.applyToDomElement(domElement);
_setStaticStyleAttributes(activeDomElement);
_style?.applyToDomElement(activeDomElement);
if (!hasAutofillGroup) {
// If there is an Autofill Group the `FormElement`, it will be appended to the
// DOM later, when the first location information arrived.
// Otherwise, on Blink based Desktop browsers, the autofill menu appears
// on top left of the screen.
domRenderer.glassPaneElement!.append(domElement);
domRenderer.glassPaneElement!.append(activeDomElement);
_appendedToForm = false;
}
@@ -809,19 +847,19 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
_inputConfiguration = config;
if (config.readOnly) {
domElement.setAttribute('readonly', 'readonly');
activeDomElement.setAttribute('readonly', 'readonly');
} else {
domElement.removeAttribute('readonly');
activeDomElement.removeAttribute('readonly');
}
if (config.obscureText) {
domElement.setAttribute('type', 'password');
activeDomElement.setAttribute('type', 'password');
}
config.autofill?.applyToDomElement(domElement, focusedElement: true);
config.autofill?.applyToDomElement(activeDomElement, focusedElement: true);
final String autocorrectValue = config.autocorrect ? 'on' : 'off';
domElement.setAttribute('autocorrect', autocorrectValue);
activeDomElement.setAttribute('autocorrect', autocorrectValue);
}
@override
@@ -837,16 +875,16 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(activeDomElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
// Refocus on the domElement after blur, so that user can keep editing the
// Refocus on the activeDomElement after blur, so that user can keep editing the
// text field.
_subscriptions.add(domElement.onBlur.listen((_) {
domElement.focus();
_subscriptions.add(activeDomElement.onBlur.listen((_) {
activeDomElement.focus();
}));
preventDefaultForMouseEvents();
@@ -860,12 +898,11 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
}
}
@mustCallSuper
@override
void updateElementStyle(EditableTextStyle style) {
_style = style;
if (isEnabled) {
_style!.applyToDomElement(domElement);
_style!.applyToDomElement(activeDomElement);
}
}
@@ -888,16 +925,15 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
if (_appendedToForm &&
_inputConfiguration.autofillGroup?.formElement != null) {
// Subscriptions are removed, listeners won't be triggered.
domElement.blur();
_hideAutofillElements(domElement, isOffScreen: true);
activeDomElement.blur();
_hideAutofillElements(activeDomElement, isOffScreen: true);
_inputConfiguration.autofillGroup?.storeForm();
} else {
domElement.remove();
activeDomElement.remove();
}
_domElement = null;
domElement = null;
}
@mustCallSuper
@override
void setEditingState(EditingState? editingState) {
_lastEditingState = editingState;
@@ -908,18 +944,18 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
}
void placeElement() {
domElement.focus();
activeDomElement.focus();
}
void placeForm() {
_inputConfiguration.autofillGroup!.placeForm(domElement);
_inputConfiguration.autofillGroup!.placeForm(activeDomElement);
_appendedToForm = true;
}
void _handleChange(html.Event event) {
assert(isEnabled);
EditingState newEditingState = EditingState.fromDomElement(domElement,
EditingState newEditingState = EditingState.fromDomElement(activeDomElement,
textCapitalization: _inputConfiguration.textCapitalization);
if (newEditingState != _lastEditingState) {
@@ -962,7 +998,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
}
// Re-focuses after setting editing state.
domElement.focus();
activeDomElement.focus();
}
/// Prevent default behavior for mouse down, up and move.
@@ -971,15 +1007,15 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy {
/// selection conflicts with selection sent from the framework, which creates
/// flickering during selection by mouse.
void preventDefaultForMouseEvents() {
_subscriptions.add(domElement.onMouseDown.listen((_) {
_subscriptions.add(activeDomElement.onMouseDown.listen((_) {
_.preventDefault();
}));
_subscriptions.add(domElement.onMouseUp.listen((_) {
_subscriptions.add(activeDomElement.onMouseUp.listen((_) {
_.preventDefault();
}));
_subscriptions.add(domElement.onMouseMove.listen((_) {
_subscriptions.add(activeDomElement.onMouseMove.listen((_) {
_.preventDefault();
}));
}
@@ -1038,11 +1074,11 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
}) {
super.initializeTextEditing(inputConfig,
onChange: onChange, onAction: onAction);
inputConfig.inputType.configureInputMode(domElement);
inputConfig.inputType.configureInputMode(activeDomElement);
if (hasAutofillGroup) {
placeForm();
}
inputConfig.textCapitalization.setAutocapitalizeAttribute(domElement);
inputConfig.textCapitalization.setAutocapitalizeAttribute(activeDomElement);
}
@override
@@ -1050,7 +1086,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
/// Position the element outside of the page before focusing on it. This is
/// useful for not triggering a scroll when iOS virtual keyboard is
/// coming up.
domElement.style.transform = 'translate(-9999px, -9999px)';
activeDomElement.style.transform = 'translate(-9999px, -9999px)';
_canPosition = false;
}
@@ -1063,14 +1099,14 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(activeDomElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
// Position the DOM element after it is focused.
_subscriptions.add(domElement.onFocus.listen((_) {
_subscriptions.add(activeDomElement.onFocus.listen((_) {
// Cancel previous timer if exists.
_schedulePlacement();
}));
@@ -1082,7 +1118,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
//
// Since in all these cases, the connection needs to be closed,
// [domRenderer.windowHasFocus] is not checked in [IOSTextEditingStrategy].
_subscriptions.add(domElement.onBlur.listen((_) {
_subscriptions.add(activeDomElement.onBlur.listen((_) {
owner.sendTextConnectionClosedToFrameworkIfAny();
}));
}
@@ -1120,7 +1156,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
/// [_positionInputElementTimer] timer is restarted. The element will be
/// placed to its correct position after [_delayBeforePlacement].
void _addTapListener() {
_subscriptions.add(domElement.onClick.listen((_) {
_subscriptions.add(activeDomElement.onClick.listen((_) {
// Check if the element is already positioned. If not this does not fall
// under `The user was using the long press, now they want to enter text
// via keyboard` journey.
@@ -1144,8 +1180,8 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
@override
void placeElement() {
domElement.focus();
_geometry?.applyToDomElement(domElement);
activeDomElement.focus();
_geometry?.applyToDomElement(activeDomElement);
}
}
@@ -1167,13 +1203,13 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
}) {
super.initializeTextEditing(inputConfig,
onChange: onChange, onAction: onAction);
inputConfig.inputType.configureInputMode(domElement);
inputConfig.inputType.configureInputMode(activeDomElement);
if (hasAutofillGroup) {
placeForm();
} else {
domRenderer.glassPaneElement!.append(domElement);
domRenderer.glassPaneElement!.append(activeDomElement);
}
inputConfig.textCapitalization.setAutocapitalizeAttribute(domElement);
inputConfig.textCapitalization.setAutocapitalizeAttribute(activeDomElement);
}
@override
@@ -1184,19 +1220,19 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(activeDomElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
_subscriptions.add(domElement.onBlur.listen((_) {
if (domRenderer.windowHasFocus!) {
_subscriptions.add(activeDomElement.onBlur.listen((_) {
if (domRenderer.windowHasFocus) {
// 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
// keyboard via `TextInput.clearClient` or `TextInput.hide`. Therefore
// refocus as long as [domRenderer.windowHasFocus] is true.
domElement.focus();
activeDomElement.focus();
} else {
owner.sendTextConnectionClosedToFrameworkIfAny();
}
@@ -1205,8 +1241,8 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
@override
void placeElement() {
domElement.focus();
_geometry?.applyToDomElement(domElement);
activeDomElement.focus();
_geometry?.applyToDomElement(activeDomElement);
}
}
@@ -1238,9 +1274,9 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(activeDomElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
// Detects changes in text selection.
//
@@ -1255,18 +1291,18 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
//
// After each keyup, the start/end values of the selection is compared to
// the previously saved editing state.
_subscriptions.add(domElement.onKeyUp.listen((event) {
_subscriptions.add(activeDomElement.onKeyUp.listen((event) {
_handleChange(event);
}));
// In Firefox the context menu item "Select All" does not work without
// listening to onSelect. On the other browsers onSelectionChange is
// enough for covering "Select All" functionality.
_subscriptions.add(domElement.onSelect.listen(_handleChange));
_subscriptions.add(activeDomElement.onSelect.listen(_handleChange));
// Refocus on the domElement after blur, so that user can keep editing the
// Refocus on the activeDomElement after blur, so that user can keep editing the
// text field.
_subscriptions.add(domElement.onBlur.listen((_) {
_subscriptions.add(activeDomElement.onBlur.listen((_) {
_postponeFocus();
}));
@@ -1279,23 +1315,214 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
// Calling focus inside a Timer for `0` milliseconds guarantee that it is
// called after blur event propagation is completed.
Timer(const Duration(milliseconds: 0), () {
domElement.focus();
activeDomElement.focus();
});
}
@override
void placeElement() {
domElement.focus();
_geometry?.applyToDomElement(domElement);
activeDomElement.focus();
_geometry?.applyToDomElement(activeDomElement);
// Set the last editing state if it exists, this is critical for a
// users ongoing work to continue uninterrupted when there is an update to
// the transform.
if (_lastEditingState != null) {
_lastEditingState!.applyToDomElement(domElement);
_lastEditingState!.applyToDomElement(activeDomElement);
}
}
}
/// Base class for all `TextInput` commands sent through the `flutter/textinput`
/// channel.
@immutable
abstract class TextInputCommand {
const TextInputCommand();
/// Executes the logic for this command.
void run(HybridTextEditing textEditing);
}
/// Responds to the 'TextInput.setClient' message.
class TextInputSetClient extends TextInputCommand {
TextInputSetClient({
required this.clientId,
required this.configuration,
});
final int clientId;
final InputConfiguration configuration;
void run(HybridTextEditing textEditing) {
final bool clientIdChanged = textEditing._clientId != null && textEditing._clientId != clientId;
if (clientIdChanged && textEditing.isEditing) {
// We're connecting a new client. Any pending command for the previous client
// are irrelevant at this point.
textEditing.stopEditing();
}
textEditing._clientId = clientId;
textEditing.configuration = configuration;
}
}
/// Creates the text editing strategy used in non-a11y mode.
DefaultTextEditingStrategy createDefaultTextEditingStrategy(HybridTextEditing textEditing) {
DefaultTextEditingStrategy strategy;
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.iOs) {
strategy = IOSTextEditingStrategy(textEditing);
} else if (browserEngine == BrowserEngine.webkit) {
strategy = SafariDesktopTextEditingStrategy(textEditing);
} else if (browserEngine == BrowserEngine.blink &&
operatingSystem == OperatingSystem.android) {
strategy = AndroidTextEditingStrategy(textEditing);
} else if (browserEngine == BrowserEngine.firefox) {
strategy = FirefoxTextEditingStrategy(textEditing);
} else {
strategy = GloballyPositionedTextEditingStrategy(textEditing);
}
return strategy;
}
/// Responds to the 'TextInput.updateConfig' message.
class TextInputUpdateConfig extends TextInputCommand {
TextInputUpdateConfig();
void run(HybridTextEditing textEditing) {
textEditing.strategy._applyConfiguration(textEditing.configuration!);
}
}
/// Responds to the 'TextInput.setEditingState' message.
class TextInputSetEditingState extends TextInputCommand {
TextInputSetEditingState({
required this.state,
});
final EditingState state;
void run(HybridTextEditing textEditing) {
textEditing.strategy.setEditingState(state);
}
}
/// Responds to the 'TextInput.show' message.
class TextInputShow extends TextInputCommand {
const TextInputShow();
void run(HybridTextEditing textEditing) {
if (!textEditing.isEditing) {
textEditing._startEditing();
}
}
}
/// Responds to the 'TextInput.setEditableSizeAndTransform' message.
class TextInputSetEditableSizeAndTransform extends TextInputCommand {
TextInputSetEditableSizeAndTransform({
required this.geometry,
});
final EditableTextGeometry geometry;
void run(HybridTextEditing textEditing) {
textEditing.strategy.updateElementPlacement(geometry);
}
}
/// Responds to the 'TextInput.setStyle' message.
class TextInputSetStyle extends TextInputCommand {
TextInputSetStyle({
required this.style,
});
final EditableTextStyle style;
void run(HybridTextEditing textEditing) {
textEditing.strategy.updateElementStyle(style);
}
}
/// Responds to the 'TextInput.clearClient' message.
class TextInputClearClient extends TextInputCommand {
const TextInputClearClient();
void run(HybridTextEditing textEditing) {
if (textEditing.isEditing) {
textEditing.stopEditing();
}
}
}
/// Responds to the 'TextInput.hide' message.
class TextInputHide extends TextInputCommand {
const TextInputHide();
void run(HybridTextEditing textEditing) {
if (textEditing.isEditing) {
textEditing.stopEditing();
}
}
}
class TextInputSetMarkedTextRect extends TextInputCommand {
const TextInputSetMarkedTextRect();
void run(HybridTextEditing textEditing) {
// No-op: this message is currently only used on iOS to implement
// UITextInput.firstRecForRange.
}
}
class TextInputSetCaretRect extends TextInputCommand {
const TextInputSetCaretRect();
void run(HybridTextEditing textEditing) {
// No-op: not supported on this platform.
}
}
class TextInputFinishAutofillContext extends TextInputCommand {
TextInputFinishAutofillContext({
required this.saveForm,
});
final bool saveForm;
void run(HybridTextEditing textEditing) {
// Close the text editing connection. Form is finalizing.
textEditing.sendTextConnectionClosedToFrameworkIfAny();
if (saveForm) {
saveForms();
}
// Clean the forms from DOM after submitting them.
cleanForms();
}
}
/// Submits the forms currently attached to the DOM.
///
/// Browser will save the information entered to the form.
///
/// Called when the form is finalized with save option `true`.
/// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277
void saveForms() {
formsOnTheDom.forEach((String identifier, html.FormElement form) {
final html.InputElement submitBtn =
form.getElementsByClassName('submitBtn').first as html.InputElement;
submitBtn.click();
});
}
/// Removes the forms from the DOM.
///
/// Called when the form is finalized.
void cleanForms() {
for (html.FormElement form in formsOnTheDom.values) {
form.remove();
}
formsOnTheDom.clear();
}
/// Translates the message-based communication between the framework and the
/// engine [implementation].
///
@@ -1311,103 +1538,84 @@ class TextEditingChannel {
ByteData? data, ui.PlatformMessageResponseCallback? callback) {
const JSONMethodCodec codec = JSONMethodCodec();
final MethodCall call = codec.decodeMethodCall(data);
late final TextInputCommand command;
switch (call.method) {
case 'TextInput.setClient':
implementation.setClient(
call.arguments[0],
InputConfiguration.fromFrameworkMessage(call.arguments[1]),
command = TextInputSetClient(
clientId: call.arguments[0],
configuration: InputConfiguration.fromFrameworkMessage(call.arguments[1]),
);
break;
case 'TextInput.updateConfig':
final config = InputConfiguration.fromFrameworkMessage(call.arguments);
implementation.updateConfig(config);
// Set configuration eagerly because it contains data about the text
// field used to flush the command queue. However, delaye applying the
// configuration because the strategy may not be available yet.
implementation.configuration = InputConfiguration.fromFrameworkMessage(call.arguments);
command = TextInputUpdateConfig();
break;
case 'TextInput.setEditingState':
implementation
.setEditingState(EditingState.fromFrameworkMessage(call.arguments));
command = TextInputSetEditingState(
state: EditingState.fromFrameworkMessage(call.arguments),
);
break;
case 'TextInput.show':
implementation.show();
command = const TextInputShow();
break;
case 'TextInput.setEditableSizeAndTransform':
implementation.setEditableSizeAndTransform(
EditableTextGeometry.fromFrameworkMessage(call.arguments));
command = TextInputSetEditableSizeAndTransform(
geometry: EditableTextGeometry.fromFrameworkMessage(call.arguments),
);
break;
case 'TextInput.setStyle':
implementation
.setStyle(EditableTextStyle.fromFrameworkMessage(call.arguments));
command = TextInputSetStyle(
style: EditableTextStyle.fromFrameworkMessage(call.arguments),
);
break;
case 'TextInput.clearClient':
implementation.clearClient();
command = const TextInputClearClient();
break;
case 'TextInput.hide':
implementation.hide();
command = const TextInputHide();
break;
case 'TextInput.requestAutofill':
// No-op: This message is sent by the framework to requests the platform autofill UI to appear.
// Since autofill UI is a part of the browser, web engine does not need to utilize this method.
// There's no API to request autofill on the web. Instead we let the
// browser show autofill options automatically, if available. We
// therefore simply ignore this message.
break;
case 'TextInput.finishAutofillContext':
final bool saveForm = call.arguments as bool;
// Close the text editing connection. Form is finalizing.
implementation.sendTextConnectionClosedToFrameworkIfAny();
if (saveForm) {
saveForms();
}
// Clean the forms from DOM after submitting them.
cleanForms();
command = TextInputFinishAutofillContext(
saveForm: call.arguments as bool,
);
break;
case 'TextInput.setMarkedTextRect':
// No-op: this message is currently only used on iOS to implement
// UITextInput.firstRecForRange.
command = const TextInputSetMarkedTextRect();
break;
case 'TextInput.setCaretRect':
// No-op: not supported on this platform.
command = const TextInputSetCaretRect();
break;
default:
EnginePlatformDispatcher.instance._replyToPlatformMessage(callback, null);
return;
}
EnginePlatformDispatcher.instance
._replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true));
}
/// Used for submitting the forms attached on the DOM.
///
/// Browser will save the information entered to the form.
///
/// Called when the form is finalized with save option `true`.
/// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277
void saveForms() {
formsOnTheDom.forEach((String identifier, html.FormElement form) {
final html.InputElement submitBtn =
form.getElementsByClassName('submitBtn').first as html.InputElement;
submitBtn.click();
implementation.acceptCommand(command, () {
EnginePlatformDispatcher.instance
._replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true));
});
}
/// Used for removing the forms on the DOM.
///
/// Called when the form is finalized.
void cleanForms() {
for (html.FormElement form in formsOnTheDom.values) {
form.remove();
}
formsOnTheDom.clear();
}
/// Sends the 'TextInputClient.updateEditingState' message to the framework.
void updateEditingState(int? clientId, EditingState? editingState) {
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
@@ -1477,118 +1685,15 @@ class HybridTextEditing {
/// The constructor also decides which text editing strategy to use depending
/// on the operating system and browser engine.
HybridTextEditing() {
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.iOs) {
this._defaultEditingElement = IOSTextEditingStrategy(this);
} else if (browserEngine == BrowserEngine.webkit) {
this._defaultEditingElement = SafariDesktopTextEditingStrategy(this);
} else if ((browserEngine == BrowserEngine.blink ||
browserEngine == BrowserEngine.samsung) &&
operatingSystem == OperatingSystem.android) {
this._defaultEditingElement = AndroidTextEditingStrategy(this);
} else if (browserEngine == BrowserEngine.firefox) {
this._defaultEditingElement = FirefoxTextEditingStrategy(this);
} else {
this._defaultEditingElement = GloballyPositionedTextEditingStrategy(this);
}
channel = TextEditingChannel(this);
}
late TextEditingChannel channel;
/// The text editing stategy used. It can change depending on the
/// formfactor/browser.
///
/// It uses an HTML element to manage editing state when a custom element is
/// not provided via [useCustomEditableElement]
late final DefaultTextEditingStrategy _defaultEditingElement;
/// The HTML element used to manage editing state.
///
/// This field is populated using [useCustomEditableElement]. If `null` the
/// [_defaultEditingElement] is used instead.
DefaultTextEditingStrategy? _customEditingElement;
DefaultTextEditingStrategy get editingElement {
return _customEditingElement ?? _defaultEditingElement;
}
/// Responds to the 'TextInput.setClient' message.
void setClient(int? clientId, InputConfiguration configuration) {
final bool clientIdChanged = _clientId != null && _clientId != clientId;
if (clientIdChanged && isEditing) {
stopEditing();
}
_clientId = clientId;
_configuration = configuration;
}
void updateConfig(InputConfiguration configuration) {
_configuration = configuration;
editingElement._applyConfiguration(_configuration);
}
/// Responds to the 'TextInput.setEditingState' message.
void setEditingState(EditingState state) {
editingElement.setEditingState(state);
}
/// Responds to the 'TextInput.show' message.
void show() {
if (!isEditing) {
_startEditing();
}
}
/// Responds to the 'TextInput.setEditableSizeAndTransform' message.
void setEditableSizeAndTransform(EditableTextGeometry geometry) {
editingElement.updateElementPlacement(geometry);
}
/// Responds to the 'TextInput.setStyle' message.
void setStyle(EditableTextStyle style) {
editingElement.updateElementStyle(style);
}
/// Responds to the 'TextInput.clearClient' message.
void clearClient() {
// We do not distinguish between "clearClient" and "hide" on the Web.
hide();
}
/// Responds to the 'TextInput.hide' message.
void hide() {
if (isEditing) {
stopEditing();
}
}
/// A CSS class name used to identify all elements used for text editing.
@visibleForTesting
static const String textEditingClass = 'flt-text-editing';
static bool isEditingElement(html.Element element) {
return element.classes.contains(textEditingClass);
}
/// Requests that [customEditingElement] is used for managing text editing state
/// instead of the hidden default element.
///
/// Use [stopUsingCustomEditableElement] to switch back to default element.
void useCustomEditableElement(
DefaultTextEditingStrategy? customEditingElement) {
if (isEditing && customEditingElement != _customEditingElement) {
stopEditing();
}
_customEditingElement = customEditingElement;
}
/// Switches back to using the built-in default element for managing text
/// editing state.
void stopUsingCustomEditableElement() {
useCustomEditableElement(null);
}
int? _clientId;
/// Flag which shows if there is an ongoing editing.
@@ -1597,13 +1702,30 @@ class HybridTextEditing {
@visibleForTesting
bool isEditing = false;
late InputConfiguration _configuration;
InputConfiguration? configuration;
DefaultTextEditingStrategy? debugTextEditingStrategyOverride;
/// Supplies the DOM element used for editing.
late final DefaultTextEditingStrategy strategy =
debugTextEditingStrategyOverride ??
(EngineSemanticsOwner.instance.semanticsEnabled
? SemanticsTextEditingStrategy.ensureInitialized(this)
: createDefaultTextEditingStrategy(this));
void acceptCommand(TextInputCommand command, ui.VoidCallback callback) {
if (_debugPrintTextInputCommands) {
print('flutter/textinput channel command: ${command.runtimeType}');
}
command.run(this);
callback();
}
void _startEditing() {
assert(!isEditing);
isEditing = true;
editingElement.enable(
_configuration,
strategy.enable(
configuration!,
onChange: (EditingState? editingState) {
channel.updateEditingState(_clientId, editingState);
},
@@ -1616,7 +1738,7 @@ class HybridTextEditing {
void stopEditing() {
assert(isEditing);
isEditing = false;
editingElement.disable();
strategy.disable();
}
void sendTextConnectionClosedToFrameworkIfAny() {

View File

@@ -17,7 +17,7 @@ import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import '../../matchers.dart';
import 'semantics_tester.dart';
DateTime _testTime = DateTime(2018, 12, 17);
@@ -27,13 +27,8 @@ void main() {
internalBootstrapBrowserTest(() => testMain);
}
String rootSemanticStyle = '';
void testMain() {
setUp(() {
rootSemanticStyle = browserEngine != BrowserEngine.edge
? 'filter: opacity(0%); color: rgba(0, 0, 0, 0)' :
'color: rgba(0, 0, 0, 0); filter: opacity(0%)';
EngineSemanticsOwner.debugResetSemantics();
});
@@ -1198,24 +1193,23 @@ void _testTappable() {
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
actions: 0 | ui.SemanticsAction.tap.index,
flags: 0 |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.isButton.index,
transform: Matrix4.identity().toFloat64(),
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem role="button" style="$rootSemanticStyle"></sem>
''');
expect(tester.getSemanticsObject(0).element.tabIndex, 0);
semantics().semanticsEnabled = false;
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50754
@@ -1417,53 +1411,9 @@ void _testLiveRegion() {
});
}
void expectSemanticsTree(String semanticsHtml) {
expect(
canonicalizeHtml(html.document.querySelector('flt-semantics').outerHtml),
canonicalizeHtml(semanticsHtml),
);
}
html.Element findScrollable() {
return html.document.querySelectorAll('flt-semantics').firstWhere(
(html.Element element) =>
element.style.overflow == 'hidden' ||
element.style.overflowY == 'scroll' ||
element.style.overflowX == 'scroll',
orElse: () => null,
);
}
class SemanticsActionLogger {
StreamController<int> idLogController;
StreamController<ui.SemanticsAction> actionLogController;
Stream<int> idLog;
Stream<ui.SemanticsAction> actionLog;
SemanticsActionLogger() {
idLogController = StreamController<int>();
actionLogController = StreamController<ui.SemanticsAction>();
idLog = idLogController.stream.asBroadcastStream();
actionLog = actionLogController.stream.asBroadcastStream();
// The browser kicks us out of the test zone when the browser event happens.
// We memorize the test zone so we can call expect when the callback is
// fired.
final Zone testZone = Zone.current;
ui.window.onSemanticsAction =
(int id, ui.SemanticsAction action, ByteData args) {
idLogController.add(id);
actionLogController.add(action);
testZone.run(() {
expect(args, null);
});
};
}
}
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
// TODO(yjbanov): move this to TestSemanticsBuilder
void updateNode(
ui.SemanticsUpdateBuilder builder, {
int id = 0,

View File

@@ -0,0 +1,388 @@
// 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.
import 'dart:async';
import 'dart:html' as html;
import 'dart:typed_data';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import '../../matchers.dart';
/// CSS style applied to the root of the semantics tree.
// TODO(yjbanov): this should be handled internally by [expectSemanticsTree].
// No need for every test to inject it.
final String rootSemanticStyle = browserEngine != BrowserEngine.edge
? 'filter: opacity(0%); color: rgba(0, 0, 0, 0)'
: 'color: rgba(0, 0, 0, 0); filter: opacity(0%)';
/// A convenience wrapper of the semantics API for building and inspecting the
/// semantics tree in unit tests.
class SemanticsTester {
SemanticsTester(this.owner);
final EngineSemanticsOwner owner;
final List<SemanticsNodeUpdate> _nodeUpdates = <SemanticsNodeUpdate>[];
/// Updates one semantics node.
///
/// Provides reasonable defaults for the missing attributes, and conveniences
/// for specifying flags, such as [isTextField].
SemanticsNodeUpdate updateNode({
required int id,
// Flags
int flags = 0,
bool? hasCheckedState,
bool? isChecked,
bool? isSelected,
bool? isButton,
bool? isLink,
bool? isTextField,
bool? isReadOnly,
bool? isFocusable,
bool? isFocused,
bool? hasEnabledState,
bool? isEnabled,
bool? isInMutuallyExclusiveGroup,
bool? isHeader,
bool? isObscured,
bool? scopesRoute,
bool? namesRoute,
bool? isHidden,
bool? isImage,
bool? isLiveRegion,
bool? hasToggledState,
bool? isToggled,
bool? hasImplicitScrolling,
bool? isMultiline,
bool? isSlider,
bool? isKeyboardKey,
// Actions
int actions = 0,
bool? hasTap,
bool? hasLongPress,
bool? hasScrollLeft,
bool? hasScrollRight,
bool? hasScrollUp,
bool? hasScrollDown,
bool? hasIncrease,
bool? hasDecrease,
bool? hasShowOnScreen,
bool? hasMoveCursorForwardByCharacter,
bool? hasMoveCursorBackwardByCharacter,
bool? hasSetSelection,
bool? hasCopy,
bool? hasCut,
bool? hasPaste,
bool? hasDidGainAccessibilityFocus,
bool? hasDidLoseAccessibilityFocus,
bool? hasCustomAction,
bool? hasDismiss,
bool? hasMoveCursorForwardByWord,
bool? hasMoveCursorBackwardByWord,
bool? hasSetText,
// Other attributes
int? maxValueLength,
int? currentValueLength,
int? textSelectionBase,
int? textSelectionExtent,
int? platformViewId,
int? scrollChildren,
int? scrollIndex,
double? scrollPosition,
double? scrollExtentMax,
double? scrollExtentMin,
double? elevation,
double? thickness,
ui.Rect? rect,
String? label,
String? hint,
String? value,
String? increasedValue,
String? decreasedValue,
ui.TextDirection? textDirection,
Float64List? transform,
Int32List? additionalActions,
List<SemanticsNodeUpdate>? children,
}) {
// Flags
if (hasCheckedState == true) {
flags |= ui.SemanticsFlag.hasCheckedState.index;
}
if (isChecked == true) {
flags |= ui.SemanticsFlag.isChecked.index;
}
if (isSelected == true) {
flags |= ui.SemanticsFlag.isSelected.index;
}
if (isButton == true) {
flags |= ui.SemanticsFlag.isButton.index;
}
if (isLink == true) {
flags |= ui.SemanticsFlag.isLink.index;
}
if (isTextField == true) {
flags |= ui.SemanticsFlag.isTextField.index;
}
if (isReadOnly == true) {
flags |= ui.SemanticsFlag.isReadOnly.index;
}
if (isFocusable == true) {
flags |= ui.SemanticsFlag.isFocusable.index;
}
if (isFocused == true) {
flags |= ui.SemanticsFlag.isFocused.index;
}
if (hasEnabledState == true) {
flags |= ui.SemanticsFlag.hasEnabledState.index;
}
if (isEnabled == true) {
flags |= ui.SemanticsFlag.isEnabled.index;
}
if (isInMutuallyExclusiveGroup == true) {
flags |= ui.SemanticsFlag.isInMutuallyExclusiveGroup.index;
}
if (isHeader == true) {
flags |= ui.SemanticsFlag.isHeader.index;
}
if (isObscured == true) {
flags |= ui.SemanticsFlag.isObscured.index;
}
if (scopesRoute == true) {
flags |= ui.SemanticsFlag.scopesRoute.index;
}
if (namesRoute == true) {
flags |= ui.SemanticsFlag.namesRoute.index;
}
if (isHidden == true) {
flags |= ui.SemanticsFlag.isHidden.index;
}
if (isImage == true) {
flags |= ui.SemanticsFlag.isImage.index;
}
if (isLiveRegion == true) {
flags |= ui.SemanticsFlag.isLiveRegion.index;
}
if (hasToggledState == true) {
flags |= ui.SemanticsFlag.hasToggledState.index;
}
if (isToggled == true) {
flags |= ui.SemanticsFlag.isToggled.index;
}
if (hasImplicitScrolling == true) {
flags |= ui.SemanticsFlag.hasImplicitScrolling.index;
}
if (isMultiline == true) {
flags |= ui.SemanticsFlag.isMultiline.index;
}
if (isSlider == true) {
flags |= ui.SemanticsFlag.isSlider.index;
}
if (isKeyboardKey == true) {
flags |= ui.SemanticsFlag.isKeyboardKey.index;
}
// Actions
if (hasTap == true) {
actions != ui.SemanticsAction.tap.index;
}
if (hasLongPress == true) {
actions != ui.SemanticsAction.longPress.index;
}
if (hasScrollLeft == true) {
actions != ui.SemanticsAction.scrollLeft.index;
}
if (hasScrollRight == true) {
actions != ui.SemanticsAction.scrollRight.index;
}
if (hasScrollUp == true) {
actions != ui.SemanticsAction.scrollUp.index;
}
if (hasScrollDown == true) {
actions != ui.SemanticsAction.scrollDown.index;
}
if (hasIncrease == true) {
actions != ui.SemanticsAction.increase.index;
}
if (hasDecrease == true) {
actions != ui.SemanticsAction.decrease.index;
}
if (hasShowOnScreen == true) {
actions != ui.SemanticsAction.showOnScreen.index;
}
if (hasMoveCursorForwardByCharacter == true) {
actions != ui.SemanticsAction.moveCursorForwardByCharacter.index;
}
if (hasMoveCursorBackwardByCharacter == true) {
actions != ui.SemanticsAction.moveCursorBackwardByCharacter.index;
}
if (hasSetSelection == true) {
actions != ui.SemanticsAction.setSelection.index;
}
if (hasCopy == true) {
actions != ui.SemanticsAction.copy.index;
}
if (hasCut == true) {
actions != ui.SemanticsAction.cut.index;
}
if (hasPaste == true) {
actions != ui.SemanticsAction.paste.index;
}
if (hasDidGainAccessibilityFocus == true) {
actions != ui.SemanticsAction.didGainAccessibilityFocus.index;
}
if (hasDidLoseAccessibilityFocus == true) {
actions != ui.SemanticsAction.didLoseAccessibilityFocus.index;
}
if (hasCustomAction == true) {
actions != ui.SemanticsAction.customAction.index;
}
if (hasDismiss == true) {
actions != ui.SemanticsAction.dismiss.index;
}
if (hasMoveCursorForwardByWord == true) {
actions != ui.SemanticsAction.moveCursorForwardByWord.index;
}
if (hasMoveCursorBackwardByWord == true) {
actions != ui.SemanticsAction.moveCursorBackwardByWord.index;
}
if (hasSetText == true) {
actions != ui.SemanticsAction.setText.index;
}
// Other attributes
ui.Rect childRect(SemanticsNodeUpdate child) {
return transformRect(Matrix4.fromFloat32List(child.transform), child.rect);
}
// If a rect is not provided, generate one than covers all children.
ui.Rect effectiveRect = rect ?? ui.Rect.zero;
if (children != null && children.isNotEmpty) {
effectiveRect = childRect(children.first);
for (SemanticsNodeUpdate child in children.skip(1)) {
effectiveRect = effectiveRect.expandToInclude(childRect(child));
}
}
final Int32List childIds = Int32List(children?.length ?? 0);
if (children != null) {
for (int i = 0; i < children.length; i++) {
childIds[i] = children[i].id;
}
}
final SemanticsNodeUpdate update = SemanticsNodeUpdate(
id: id,
flags: flags,
actions: actions,
maxValueLength: maxValueLength ?? 0,
currentValueLength: currentValueLength ?? 0,
textSelectionBase: textSelectionBase ?? 0,
textSelectionExtent: textSelectionExtent ?? 0,
platformViewId: platformViewId ?? 0,
scrollChildren: scrollChildren ?? 0,
scrollIndex: scrollIndex ?? 0,
scrollPosition: scrollPosition ?? 0,
scrollExtentMax: scrollExtentMax ?? 0,
scrollExtentMin: scrollExtentMin ?? 0,
rect: effectiveRect,
label: label ?? '',
hint: hint ?? '',
value: value ?? '',
increasedValue: increasedValue ?? '',
decreasedValue: decreasedValue ?? '',
transform: transform != null ? toMatrix32(transform) : Matrix4.identity().storage,
elevation: elevation ?? 0,
thickness: thickness ?? 0,
childrenInTraversalOrder: childIds,
childrenInHitTestOrder: childIds,
additionalActions: additionalActions ?? Int32List(0),
);
_nodeUpdates.add(update);
return update;
}
/// Updates the HTML tree from semantics updates accumulated by this builder.
///
/// This builder forgets previous updates and may be reused in future updates.
Map<int, SemanticsObject> apply() {
owner.updateSemantics(SemanticsUpdate(nodeUpdates: _nodeUpdates));
_nodeUpdates.clear();
return owner.debugSemanticsTree!;
}
/// Locates the semantics object with the given [id].
SemanticsObject getSemanticsObject(int id) {
return owner.debugSemanticsTree![id]!;
}
/// Locates the role manager of the semantics object with the give [id].
RoleManager? getRoleManager(int id, Role role) {
return getSemanticsObject(id).debugRoleManagerFor(role);
}
/// Locates the [TextField] role manager of the semantics object with the give [id].
TextField getTextField(int id) {
return getRoleManager(id, Role.textField) as TextField;
}
}
/// Verifies the HTML structure of the current semantics tree.
void expectSemanticsTree(String semanticsHtml) {
expect(
canonicalizeHtml(html.document.querySelector('flt-semantics')!.outerHtml!),
canonicalizeHtml(semanticsHtml),
);
}
/// Finds the first HTML element in the semantics tree used for scrolling.
html.Element? findScrollable() {
return html.document.querySelectorAll('flt-semantics').cast<html.Element?>().firstWhere(
(html.Element? element) =>
element!.style.overflow == 'hidden' ||
element.style.overflowY == 'scroll' ||
element.style.overflowX == 'scroll',
orElse: () => null,
);
}
/// Logs semantics actions dispatched to [ui.window].
class SemanticsActionLogger {
late StreamController<int> _idLogController;
late StreamController<ui.SemanticsAction> _actionLogController;
/// Semantics object ids that dispatched the actions.
Stream<int> get idLog => _idLog;
late Stream<int> _idLog;
/// The actions that were dispatched to [ui.window].
Stream<ui.SemanticsAction> get actionLog => _actionLog;
late Stream<ui.SemanticsAction> _actionLog;
SemanticsActionLogger() {
_idLogController = StreamController<int>();
_actionLogController = StreamController<ui.SemanticsAction>();
_idLog = _idLogController.stream.asBroadcastStream();
_actionLog = _actionLogController.stream.asBroadcastStream();
// The browser kicks us out of the test zone when the browser event happens.
// We memorize the test zone so we can call expect when the callback is
// fired.
final Zone testZone = Zone.current;
ui.window.onSemanticsAction =
(int id, ui.SemanticsAction action, ByteData? args) {
_idLogController.add(id);
_actionLogController.add(action);
testZone.run(() {
expect(args, null);
});
};
}
}

View File

@@ -0,0 +1,416 @@
// 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.
@TestOn('chrome || safari || firefox')
import 'dart:html' as html;
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart' hide window;
import 'package:ui/ui.dart' as ui;
import 'semantics_tester.dart';
final InputConfiguration singlelineConfig = InputConfiguration(
inputType: EngineInputType.text,
);
final InputConfiguration multilineConfig = InputConfiguration(
inputType: EngineInputType.multiline,
inputAction: 'TextInputAction.newline',
);
EngineSemanticsOwner semantics() => EngineSemanticsOwner.instance;
const MethodCodec codec = JSONMethodCodec();
DateTime _testTime = DateTime(2021, 4, 16);
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
setUp(() {
EngineSemanticsOwner.debugResetSemantics();
});
group('$SemanticsTextEditingStrategy', () {
late HybridTextEditing testTextEditing;
late SemanticsTextEditingStrategy strategy;
setUp(() {
testTextEditing = HybridTextEditing();
SemanticsTextEditingStrategy.ensureInitialized(testTextEditing);
strategy = SemanticsTextEditingStrategy.instance;
testTextEditing.debugTextEditingStrategyOverride = strategy;
testTextEditing.configuration = singlelineConfig;
});
test('renders a text field', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
createTextFieldSemantics(value: 'hello');
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<input value="hello" />
</sem>''');
semantics().semanticsEnabled = false;
});
// TODO(yjbanov): this test will need to be adjusted for Safari when we add
// Safari testing.
test('sends a tap action when browser requests focus', () async {
final SemanticsActionLogger logger = SemanticsActionLogger();
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
createTextFieldSemantics(value: 'hello');
final html.Element textField = html.document
.querySelectorAll('input[data-semantics-role="text-field"]')
.single;
expect(html.document.activeElement, isNot(textField));
textField.focus();
expect(html.document.activeElement, textField);
expect(await logger.idLog.first, 0);
expect(await logger.actionLog.first, ui.SemanticsAction.tap);
semantics().semanticsEnabled = false;
}, // TODO(nurhan): https://github.com/flutter/flutter/issues/46638
// TODO(nurhan): https://github.com/flutter/flutter/issues/50590
// TODO(nurhan): https://github.com/flutter/flutter/issues/50754
skip: (browserEngine != BrowserEngine.blink));
test('Syncs editing state from framework', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
expect(html.document.activeElement, html.document.body);
int changeCount = 0;
int actionCount = 0;
strategy.enable(
singlelineConfig,
onChange: (_) {
changeCount++;
},
onAction: (_) {
actionCount++;
},
);
// Create
SemanticsObject textFieldSemantics = createTextFieldSemantics(
value: 'hello',
label: 'greeting',
isFocused: true,
rect: ui.Rect.fromLTWH(0, 0, 10, 15),
);
TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField;
expect(textField.editableElement, strategy.domElement);
expect(html.document.activeElement, strategy.domElement);
expect((textField.editableElement as dynamic).value, 'hello');
expect(textField.editableElement.getAttribute('aria-label'), 'greeting');
expect(textField.editableElement.style.width, '10px');
expect(textField.editableElement.style.height, '15px');
// Update
createTextFieldSemantics(
value: 'bye',
label: 'farewell',
isFocused: false,
rect: ui.Rect.fromLTWH(0, 0, 12, 17),
);
expect(html.document.activeElement, html.document.body);
expect(strategy.domElement, null);
expect((textField.editableElement as dynamic).value, 'bye');
expect(textField.editableElement.getAttribute('aria-label'), 'farewell');
expect(textField.editableElement.style.width, '12px');
expect(textField.editableElement.style.height, '17px');
strategy.disable();
semantics().semanticsEnabled = false;
// There was no user interaction with the <input> element,
// so we should expect no engine-to-framework feedback.
expect(changeCount, 0);
expect(actionCount, 0);
});
test('Gives up focus after DOM blur', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
expect(html.document.activeElement, html.document.body);
strategy.enable(
singlelineConfig,
onChange: (_) {},
onAction: (_) {},
);
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
value: 'hello',
isFocused: true,
);
final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField;
expect(textField.editableElement, strategy.domElement);
expect(html.document.activeElement, strategy.domElement);
// The input should not refocus after blur.
textField.editableElement.blur();
expect(html.document.activeElement, html.document.body);
strategy.disable();
semantics().semanticsEnabled = false;
});
test('Does not dispose and recreate dom elements in persistent mode', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
strategy.enable(
singlelineConfig,
onChange: (_) {},
onAction: (_) {},
);
// It doesn't create a new DOM element.
expect(strategy.domElement, isNull);
// During the semantics update the DOM element is created and is focused on.
final SemanticsObject textFieldSemantics = createTextFieldSemantics(
value: 'hello',
isFocused: true,
);
expect(strategy.domElement, isNotNull);
expect(html.document.activeElement, strategy.domElement);
strategy.disable();
expect(strategy.domElement, isNull);
// It doesn't remove the DOM element.
final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField;
expect(html.document.body!.contains(textField.editableElement), isTrue);
// Editing element is not enabled.
expect(strategy.isEnabled, isFalse);
expect(html.document.activeElement, html.document.body);
semantics().semanticsEnabled = false;
});
test('Refocuses when setting editing state', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
strategy.enable(
singlelineConfig,
onChange: (_) {},
onAction: (_) {},
);
createTextFieldSemantics(
value: 'hello',
isFocused: true,
);
expect(strategy.domElement, isNotNull);
expect(html.document.activeElement, strategy.domElement);
// Blur the element without telling the framework.
strategy.activeDomElement.blur();
expect(html.document.activeElement, html.document.body);
// The input will have focus after editing state is set and semantics updated.
strategy.setEditingState(EditingState(text: 'foo'));
// NOTE: at this point some browsers, e.g. some versions of Safari will
// have set the focus on the editing element as a result of setting
// the test selection range. Other browsers require an explicit call
// to `element.focus()` for the element to acquire focus. So far,
// this discrepancy hasn't caused issues, so we're not checking for
// any particular focus state between setEditingState and
// createTextFieldSemantics. However, this is something for us to
// keep in mind in case this causes issues in the future.
createTextFieldSemantics(
value: 'hello',
isFocused: true,
);
expect(html.document.activeElement, strategy.domElement);
strategy.disable();
semantics().semanticsEnabled = false;
});
test('Works in multi-line mode', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
strategy.enable(
multilineConfig,
onChange: (_) {},
onAction: (_) {},
);
createTextFieldSemantics(
value: 'hello',
isFocused: true,
isMultiline: true,
);
final html.TextAreaElement textArea = strategy.domElement as html.TextAreaElement;
expect(html.document.activeElement, textArea);
strategy.enable(
singlelineConfig,
onChange: (_) {},
onAction: (_) {},
);
textArea.blur();
expect(html.document.activeElement, html.document.body);
strategy.disable();
// It doesn't remove the textarea from the DOM.
expect(html.document.body!.contains(textArea), isTrue);
// Editing element is not enabled.
expect(strategy.isEnabled, isFalse);
semantics().semanticsEnabled = false;
});
test('Does not position or size its DOM element', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
strategy.enable(
singlelineConfig,
onChange: (_) {},
onAction: (_) {},
);
// Send width and height that are different from semantics values on
// purpose.
final EditableTextGeometry geometry = EditableTextGeometry(
height: 12,
width: 13,
globalTransform: Matrix4.translationValues(14, 15, 0).storage,
);
final ui.Rect semanticsRect = ui.Rect.fromLTRB(0, 0, 100, 50);
testTextEditing.acceptCommand(
TextInputSetEditableSizeAndTransform(geometry: geometry),
() {},
);
createTextFieldSemantics(
value: 'hello',
isFocused: true,
rect: semanticsRect,
);
// Checks that the placement attributes come from semantics and not from
// EditableTextGeometry.
void checkPlacementIsSetBySemantics() {
expect(strategy.activeDomElement.style.transform, '');
expect(strategy.activeDomElement.style.width, '${semanticsRect.width}px');
expect(strategy.activeDomElement.style.height, '${semanticsRect.height}px');
}
checkPlacementIsSetBySemantics();
strategy.placeElement();
checkPlacementIsSetBySemantics();
semantics().semanticsEnabled = false;
});
Map<int, SemanticsObject> createTwoFieldSemantics(SemanticsTester builder, { int? focusFieldId }) {
builder.updateNode(
id: 0,
children: <SemanticsNodeUpdate>[
builder.updateNode(
id: 1,
isTextField: true,
value: 'Hello',
isFocused: focusFieldId == 1,
rect: ui.Rect.fromLTRB(0, 0, 50, 10),
),
builder.updateNode(
id: 2,
isTextField: true,
value: 'World',
isFocused: focusFieldId == 2,
rect: ui.Rect.fromLTRB(0, 20, 50, 10),
),
],
);
return builder.apply();
}
test('Changes focus from one text field to another through a semantics update', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
strategy.enable(
singlelineConfig,
onChange: (_) {},
onAction: (_) {},
);
// Switch between the two fields a few times.
for (int i = 0; i < 5; i++) {
final SemanticsTester tester = SemanticsTester(semantics());
createTwoFieldSemantics(tester, focusFieldId: 1);
expect(tester.apply().length, 3);
expect(html.document.activeElement, tester.getTextField(1).editableElement);
expect(strategy.domElement, tester.getTextField(1).editableElement);
createTwoFieldSemantics(tester, focusFieldId: 2);
expect(tester.apply().length, 3);
expect(html.document.activeElement, tester.getTextField(2).editableElement);
expect(strategy.domElement, tester.getTextField(2).editableElement);
}
semantics().semanticsEnabled = false;
});
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: browserEngine == BrowserEngine.edge);
}
SemanticsObject createTextFieldSemantics({
required String value,
String label = '',
bool isFocused = false,
bool isMultiline = false,
ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50),
}) {
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
label: label,
value: value,
isTextField: true,
isFocused: isFocused,
isMultiline: isMultiline,
hasTap: true,
rect: rect,
textDirection: ui.TextDirection.ltr,
);
tester.apply();
return tester.getSemanticsObject(0);
}

View File

@@ -13,7 +13,6 @@ import 'package:test/test.dart';
import 'package:ui/src/engine.dart' hide window;
import 'matchers.dart';
import 'spy.dart';
/// The `keyCode` of the "Enter" key.
@@ -24,7 +23,7 @@ const MethodCodec codec = JSONMethodCodec();
/// Add unit tests for [FirefoxTextEditingStrategy].
/// TODO(nurhan): https://github.com/flutter/flutter/issues/46891
DefaultTextEditingStrategy editingElement;
DefaultTextEditingStrategy editingStrategy;
EditingState lastEditingState;
String lastInputAction;
@@ -57,7 +56,7 @@ void testMain() {
tearDown(() {
lastEditingState = null;
lastInputAction = null;
cleanTextEditingElement();
cleanTextEditingStrategy();
cleanTestFlags();
clearBackUpDomElementIfExists();
});
@@ -67,8 +66,9 @@ void testMain() {
setUp(() {
testTextEditing = HybridTextEditing();
editingElement = GloballyPositionedTextEditingStrategy(testTextEditing);
testTextEditing.useCustomEditableElement(editingElement);
editingStrategy = GloballyPositionedTextEditingStrategy(testTextEditing);
testTextEditing.debugTextEditingStrategyOverride = editingStrategy;
testTextEditing.configuration = singlelineConfig;
});
test('Creates element when enabled and removes it when disabled', () {
@@ -79,7 +79,7 @@ void testMain() {
// The focus initially is on the body.
expect(document.activeElement, document.body);
editingElement.enable(
editingStrategy.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -91,14 +91,14 @@ void testMain() {
final InputElement input = document.getElementsByTagName('input')[0];
// Now the editing element should have focus.
expect(document.activeElement, input);
expect(editingElement.domElement, input);
expect(editingStrategy.domElement, input);
expect(input.getAttribute('type'), null);
// Input is appended to the glass pane.
expect(domRenderer.glassPaneElement.contains(editingElement.domElement),
expect(domRenderer.glassPaneElement.contains(editingStrategy.domElement),
isTrue);
editingElement.disable();
editingStrategy.disable();
expect(
document.getElementsByTagName('input'),
hasLength(0),
@@ -108,73 +108,81 @@ void testMain() {
});
test('Respects read-only config', () {
final InputConfiguration config = InputConfiguration(readOnly: true);
editingElement.enable(
final InputConfiguration config = InputConfiguration(
readOnly: true,
);
editingStrategy.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
final InputElement input = document.getElementsByTagName('input')[0];
expect(editingElement.domElement, input);
expect(editingStrategy.domElement, input);
expect(input.getAttribute('readonly'), 'readonly');
editingElement.disable();
editingStrategy.disable();
});
test('Knows how to create password fields', () {
final InputConfiguration config = InputConfiguration(obscureText: true);
editingElement.enable(
final InputConfiguration config = InputConfiguration(
obscureText: true,
);
editingStrategy.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
final InputElement input = document.getElementsByTagName('input')[0];
expect(editingElement.domElement, input);
expect(editingStrategy.domElement, input);
expect(input.getAttribute('type'), 'password');
editingElement.disable();
editingStrategy.disable();
});
test('Knows to turn autocorrect off', () {
final InputConfiguration config = InputConfiguration(autocorrect: false);
editingElement.enable(
final InputConfiguration config = InputConfiguration(
autocorrect: false,
);
editingStrategy.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
final InputElement input = document.getElementsByTagName('input')[0];
expect(editingElement.domElement, input);
expect(editingStrategy.domElement, input);
expect(input.getAttribute('autocorrect'), 'off');
editingElement.disable();
editingStrategy.disable();
});
test('Knows to turn autocorrect on', () {
final InputConfiguration config = InputConfiguration(autocorrect: true);
editingElement.enable(
final InputConfiguration config = InputConfiguration(
autocorrect: true,
);
editingStrategy.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(document.getElementsByTagName('input'), hasLength(1));
final InputElement input = document.getElementsByTagName('input')[0];
expect(editingElement.domElement, input);
expect(editingStrategy.domElement, input);
expect(input.getAttribute('autocorrect'), 'on');
editingElement.disable();
editingStrategy.disable();
});
test('Can read editing state correctly', () {
editingElement.enable(
editingStrategy.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
final InputElement input = editingElement.domElement;
final InputElement input = editingStrategy.domElement;
input.value = 'foo bar';
input.dispatchEvent(Event.eventType('Event', 'input'));
expect(
@@ -194,15 +202,15 @@ void testMain() {
});
test('Can set editing state correctly', () {
editingElement.enable(
editingStrategy.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
editingElement.setEditingState(
editingStrategy.setEditingState(
EditingState(text: 'foo bar baz', baseOffset: 2, extentOffset: 7));
checkInputEditingState(editingElement.domElement, 'foo bar baz', 2, 7);
checkInputEditingState(editingStrategy.domElement, 'foo bar baz', 2, 7);
// There should be no input action.
expect(lastInputAction, isNull);
@@ -211,7 +219,7 @@ void testMain() {
test('Multi-line mode also works', () {
// The textarea element is created lazily.
expect(document.getElementsByTagName('textarea'), hasLength(0));
editingElement.enable(
editingStrategy.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -222,7 +230,7 @@ void testMain() {
document.getElementsByTagName('textarea')[0];
// Now the textarea should have focus.
expect(document.activeElement, textarea);
expect(editingElement.domElement, textarea);
expect(editingStrategy.domElement, textarea);
textarea.value = 'foo\nbar';
textarea.dispatchEvent(Event.eventType('Event', 'input'));
@@ -235,11 +243,11 @@ void testMain() {
);
// Can set textarea state correctly (and preserves new lines).
editingElement.setEditingState(
editingStrategy.setEditingState(
EditingState(text: 'bar\nbaz', baseOffset: 2, extentOffset: 7));
checkTextAreaEditingState(textarea, 'bar\nbaz', 2, 7);
editingElement.disable();
editingStrategy.disable();
// The textarea should be cleaned up.
expect(document.getElementsByTagName('textarea'), hasLength(0));
// The focus is back to the body.
@@ -255,7 +263,7 @@ void testMain() {
expect(document.getElementsByTagName('textarea'), hasLength(0));
// Use single-line config and expect an `<input>` to be created.
editingElement.enable(
editingStrategy.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -264,12 +272,12 @@ void testMain() {
expect(document.getElementsByTagName('textarea'), hasLength(0));
// Disable and check that all DOM elements were removed.
editingElement.disable();
editingStrategy.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(
editingStrategy.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -278,7 +286,7 @@ void testMain() {
expect(document.getElementsByTagName('textarea'), hasLength(1));
// Disable again and check that all DOM elements were removed.
editingElement.disable();
editingStrategy.disable();
expect(document.getElementsByTagName('input'), hasLength(0));
expect(document.getElementsByTagName('textarea'), hasLength(0));
@@ -287,9 +295,10 @@ void testMain() {
});
test('Triggers input action', () {
final InputConfiguration config =
InputConfiguration(inputAction: 'TextInputAction.done');
editingElement.enable(
final InputConfiguration config = InputConfiguration(
inputAction: 'TextInputAction.done',
);
editingStrategy.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -299,7 +308,7 @@ void testMain() {
expect(lastInputAction, isNull);
dispatchKeyboardEvent(
editingElement.domElement,
editingStrategy.domElement,
'keydown',
keyCode: _kReturnKeyCode,
);
@@ -313,7 +322,7 @@ void testMain() {
inputType: EngineInputType.multiline,
inputAction: 'TextInputAction.done',
);
editingElement.enable(
editingStrategy.enable(
config,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -323,7 +332,7 @@ void testMain() {
expect(lastInputAction, isNull);
final KeyboardEvent event = dispatchKeyboardEvent(
editingElement.domElement,
editingStrategy.domElement,
'keydown',
keyCode: _kReturnKeyCode,
);
@@ -335,270 +344,29 @@ void testMain() {
});
test('globally positions and sizes its DOM element', () {
editingElement.enable(
editingStrategy.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
expect(editingElement.isEnabled, isTrue);
expect(editingStrategy.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, '');
expect(editingStrategy.domElement.style.transform, '');
expect(editingStrategy.domElement.style.width, '');
expect(editingStrategy.domElement.style.height, '');
testTextEditing.setEditableSizeAndTransform(EditableTextGeometry(
testTextEditing.acceptCommand(TextInputSetEditableSizeAndTransform(geometry: 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,
expect(editingStrategy.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;
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();
editingElement = GloballyPositionedTextEditingStrategy(testTextEditing);
});
tearDown(() {
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(
SemanticsObject(5, null), HybridTextEditing(), span),
throwsAssertionError,
);
});
test('Do not re-acquire focus', () {
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), 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 not refocus after blur.
editingElement.domElement.blur();
expect(document.activeElement, document.body);
editingElement.disable();
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: browserEngine == BrowserEngine.edge);
test('Does not dispose and recreate dom elements in persistent mode', () {
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), 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);
// Editing element is not enabled.
expect(editingElement.isEnabled, isFalse);
// For mobile browsers `blur` is called to close the onscreen keyboard.
if (operatingSystem == OperatingSystem.iOs &&
browserEngine == BrowserEngine.webkit) {
expect(document.activeElement, document.body);
} else {
expect(document.activeElement, editingElement.domElement);
}
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: browserEngine == BrowserEngine.edge);
test('Refocuses when setting editing state', () {
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), HybridTextEditing(), testInputElement);
document.body.append(testInputElement);
editingElement.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
);
// The input will have focus after editing state is set.
editingElement.setEditingState(EditingState(text: 'foo'));
expect(document.activeElement, testInputElement);
editingElement.disable();
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: browserEngine == BrowserEngine.edge);
test('Works in multi-line mode', () {
final TextAreaElement textarea = TextAreaElement();
editingElement = SemanticsTextEditingStrategy(
SemanticsObject(5, null), 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);
textarea.blur();
// The textArea loses focus.
expect(document.activeElement, document.body);
editingElement.disable();
// It doesn't remove the textarea from the DOM.
expect(document.body.contains(editingElement.domElement), isTrue);
// Editing element is not enabled.
expect(editingElement.isEnabled, isFalse);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: 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();
expect(editingStrategy.domElement.style.width, '13px');
expect(editingStrategy.domElement.style.height, '12px');
});
});
@@ -644,7 +412,7 @@ void testMain() {
}
String getEditingInputMode() {
return textEditing.editingElement.domElement.getAttribute('inputmode');
return textEditing.strategy.domElement.getAttribute('inputmode');
}
setUp(() {
@@ -671,7 +439,7 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(textEditing.editingElement.domElement, '', 0, 0);
checkInputEditingState(textEditing.strategy.domElement, '', 0, 0);
const MethodCall setEditingState =
MethodCall('TextInput.setEditingState', <String, dynamic>{
@@ -682,7 +450,7 @@ void testMain() {
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
const MethodCall hide = MethodCall('TextInput.hide');
sendFrameworkMessage(codec.encodeMethodCall(hide));
@@ -714,7 +482,7 @@ void testMain() {
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
@@ -745,7 +513,7 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final HtmlElement element = textEditing.editingElement.domElement;
final HtmlElement element = textEditing.strategy.domElement;
expect(element.getAttribute('readonly'), 'readonly');
// Update the read-only config.
@@ -787,10 +555,11 @@ void testMain() {
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
expect(textEditing.isEditing, isTrue);
// DOM element is blurred.
textEditing.editingElement.domElement.blur();
textEditing.strategy.domElement.blur();
// For ios-safari the connection is closed.
if (browserEngine == BrowserEngine.webkit &&
@@ -807,11 +576,11 @@ void testMain() {
expect(spy.messages, hasLength(0));
await Future<void>.delayed(Duration.zero);
// DOM element still keeps the focus.
expect(document.activeElement, textEditing.editingElement.domElement);
expect(document.activeElement, textEditing.strategy.domElement);
}
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50769
skip: (browserEngine == BrowserEngine.edge));
skip: browserEngine == BrowserEngine.edge);
test('finishAutofillContext closes connection no autofill element',
() async {
@@ -834,7 +603,7 @@ void testMain() {
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
const MethodCall finishAutofillContext =
MethodCall('TextInput.finishAutofillContext', false);
@@ -859,14 +628,16 @@ void testMain() {
test('finishAutofillContext removes form from DOM', () async {
// Create a configuration with an AutofillGroup of four text fields.
final Map<String, dynamic> flutterMultiAutofillElementConfig =
createFlutterConfig('text',
autofillHint: 'username',
autofillHintsForFields: [
'username',
'email',
'name',
'telephoneNumber'
]);
createFlutterConfig(
'text',
autofillHint: 'username',
autofillHintsForFields: [
'username',
'email',
'name',
'telephoneNumber'
],
);
final MethodCall setClient = MethodCall('TextInput.setClient',
<dynamic>[123, flutterMultiAutofillElementConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
@@ -1044,7 +815,7 @@ void testMain() {
sendFrameworkMessage(codec.encodeMethodCall(show));
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
final MethodCall setClient2 = MethodCall(
'TextInput.setClient', <dynamic>[567, flutterSinglelineConfig]);
@@ -1086,7 +857,7 @@ void testMain() {
// The second [setEditingState] should override the first one.
checkInputEditingState(
textEditing.editingElement.domElement, 'xyz', 0, 2);
textEditing.strategy.domElement, 'xyz', 0, 2);
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
@@ -1123,7 +894,7 @@ void testMain() {
// The second [setEditingState] should override the first one.
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
final FormElement formElement = document.getElementsByTagName('form')[0];
// The form has one input element and one submit button.
@@ -1161,7 +932,7 @@ void testMain() {
sendFrameworkMessage(codec.encodeMethodCall(show));
final InputElement inputElement =
textEditing.editingElement.domElement as InputElement;
textEditing.strategy.domElement as InputElement;
expect(inputElement.value, 'abcd');
if (!(browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.macOs)) {
@@ -1181,11 +952,11 @@ void testMain() {
sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
// Check the element still has focus. User can keep editing.
expect(document.activeElement, textEditing.editingElement.domElement);
expect(document.activeElement, textEditing.strategy.domElement);
// Check the cursor location is the same.
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
const MethodCall clearClient = MethodCall('TextInput.clearClient');
sendFrameworkMessage(codec.encodeMethodCall(clearClient));
@@ -1232,7 +1003,7 @@ void testMain() {
// The second [setEditingState] should override the first one.
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
final FormElement formElement = document.getElementsByTagName('form')[0];
// The form has 4 input elements and one submit button.
@@ -1274,12 +1045,12 @@ void testMain() {
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.iOs) {
expect(
textEditing.editingElement.domElement
textEditing.strategy.domElement
.getAttribute('autocapitalize'),
'off');
} else {
expect(
textEditing.editingElement.domElement
textEditing.strategy.domElement
.getAttribute('autocapitalize'),
isNull);
}
@@ -1313,7 +1084,7 @@ void testMain() {
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.iOs) {
expect(
textEditing.editingElement.domElement
textEditing.strategy.domElement
.getAttribute('autocapitalize'),
'characters');
}
@@ -1349,7 +1120,7 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final HtmlElement domElement = textEditing.editingElement.domElement;
final HtmlElement domElement = textEditing.strategy.domElement;
checkInputEditingState(domElement, 'abcd', 2, 3);
@@ -1360,7 +1131,7 @@ void testMain() {
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,
expect(textEditing.strategy.domElement.style.font,
'500 12px sans-serif');
const MethodCall clearClient = MethodCall('TextInput.clearClient');
@@ -1405,7 +1176,7 @@ void testMain() {
});
sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
final HtmlElement domElement = textEditing.editingElement.domElement;
final HtmlElement domElement = textEditing.strategy.domElement;
checkInputEditingState(domElement, 'abcd', 2, 3);
@@ -1420,7 +1191,7 @@ void testMain() {
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1)',
);
expect(
textEditing.editingElement.domElement.style.font,
textEditing.strategy.domElement.style.font,
'500 12px sans-serif',
);
@@ -1428,11 +1199,11 @@ void testMain() {
if (browserEngine == BrowserEngine.blink ||
browserEngine == BrowserEngine.samsung ||
browserEngine == BrowserEngine.webkit) {
expect(textEditing.editingElement.domElement.classes,
expect(textEditing.strategy.domElement.classes,
contains('transparentTextEditing'));
} else {
expect(
textEditing.editingElement.domElement.classes.any(
textEditing.strategy.domElement.classes.any(
(element) => element.toString() == 'transparentTextEditing'),
isFalse);
}
@@ -1468,7 +1239,7 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final HtmlElement domElement = textEditing.editingElement.domElement;
final HtmlElement domElement = textEditing.strategy.domElement;
checkInputEditingState(domElement, 'abcd', 2, 3);
@@ -1480,7 +1251,7 @@ void testMain() {
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');
textEditing.strategy.domElement.style.font, '12px sans-serif');
hideKeyboard();
},
@@ -1490,7 +1261,7 @@ void testMain() {
test('Canonicalizes font family', () {
showKeyboard();
final HtmlElement input = textEditing.editingElement.domElement;
final HtmlElement input = textEditing.strategy.domElement;
MethodCall setStyle;
@@ -1529,7 +1300,7 @@ void testMain() {
// Check if the selection range is correct.
checkInputEditingState(
textEditing.editingElement.domElement, 'xyz', 1, 2);
textEditing.strategy.domElement, 'xyz', 1, 2);
const MethodCall setEditingState2 =
MethodCall('TextInput.setEditingState', <String, dynamic>{
@@ -1541,7 +1312,7 @@ void testMain() {
// The negative offset values are applied to the dom element as 0.
checkInputEditingState(
textEditing.editingElement.domElement, 'xyz', 0, 0);
textEditing.strategy.domElement, 'xyz', 0, 0);
hideKeyboard();
});
@@ -1562,7 +1333,7 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final InputElement input = textEditing.editingElement.domElement;
final InputElement input = textEditing.strategy.domElement;
input.value = 'something';
input.dispatchEvent(Event.eventType('Event', 'input'));
@@ -1586,7 +1357,7 @@ void testMain() {
input.setSelectionRange(2, 5);
if (browserEngine == BrowserEngine.firefox) {
Event keyup = KeyboardEvent('keyup');
textEditing.editingElement.domElement.dispatchEvent(keyup);
textEditing.strategy.domElement.dispatchEvent(keyup);
} else {
document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
}
@@ -1644,7 +1415,7 @@ void testMain() {
// The second [setEditingState] should override the first one.
checkInputEditingState(
textEditing.editingElement.domElement, 'abcd', 2, 3);
textEditing.strategy.domElement, 'abcd', 2, 3);
final FormElement formElement = document.getElementsByTagName('form')[0];
// The form has 4 input elements and one submit button.
@@ -1695,7 +1466,7 @@ void testMain() {
const MethodCall show = MethodCall('TextInput.show');
sendFrameworkMessage(codec.encodeMethodCall(show));
final TextAreaElement textarea = textEditing.editingElement.domElement;
final TextAreaElement textarea = textEditing.strategy.domElement;
checkTextAreaEditingState(textarea, '', 0, 0);
// Can set editing state and preserve new lines.
@@ -1713,7 +1484,7 @@ void testMain() {
textarea.dispatchEvent(Event.eventType('Event', 'input'));
textarea.setSelectionRange(2, 5);
if (browserEngine == BrowserEngine.firefox) {
textEditing.editingElement.domElement
textEditing.strategy.domElement
.dispatchEvent(KeyboardEvent('keyup'));
} else {
document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
@@ -1837,7 +1608,7 @@ void testMain() {
expect(lastInputAction, isNull);
dispatchKeyboardEvent(
textEditing.editingElement.domElement,
textEditing.strategy.domElement,
'keydown',
keyCode: _kReturnKeyCode,
);
@@ -1860,7 +1631,7 @@ void testMain() {
);
final KeyboardEvent event = dispatchKeyboardEvent(
textEditing.editingElement.domElement,
textEditing.strategy.domElement,
'keydown',
keyCode: _kReturnKeyCode,
);
@@ -2088,9 +1859,9 @@ void testMain() {
EditingState _editingState;
setUp(() {
editingElement =
editingStrategy =
GloballyPositionedTextEditingStrategy(HybridTextEditing());
editingElement.enable(
editingStrategy.enable(
singlelineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -2128,8 +1899,8 @@ void testMain() {
});
test('Configure text area element from the editing state', () {
cleanTextEditingElement();
editingElement.enable(
cleanTextEditingStrategy();
editingStrategy.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -2161,8 +1932,8 @@ void testMain() {
});
test('Get Editing State from text area element', () {
cleanTextEditingElement();
editingElement.enable(
cleanTextEditingStrategy();
editingStrategy.enable(
multilineConfig,
onChange: trackEditingState,
onAction: trackInputAction,
@@ -2242,10 +2013,10 @@ MethodCall configureSetSizeAndTransformMethodCall(
/// Will disable editing element which will also clean the backup DOM
/// element from the page.
void cleanTextEditingElement() {
if (editingElement != null && editingElement.isEnabled) {
void cleanTextEditingStrategy() {
if (editingStrategy != null && editingStrategy.isEnabled) {
// Clean up all the DOM elements and event listeners.
editingElement.disable();
editingStrategy.disable();
}
}