diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart index 302368a366..f56ff368e8 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom_renderer.dart @@ -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', []); + bool get windowHasFocus => + js_util.callMethod(html.document, 'hasFocus', []) ?? false; void _setupHotRestart() { // This persists across hot restarts to clear stale DOM. diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart index 29713f5259..344ff147bd 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart @@ -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(); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index 3eef2af7b8..6eca6cd343 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -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 _roleManagers = {}; + /// 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 _semanticsTree = {}; + final Map _semanticsTree = {}; /// 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? get debugSemanticsTree { - Map? result; + Map? get debugSemanticsTree { + Map? result; assert(() { result = _semanticsTree; return true; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart index 8cae59f157..51f21eaa83 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart @@ -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() { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart index f28e2671f8..d80c4370ef 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -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. ``, `