diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index 4500c08629cf3..6da1f4d2d0556 100644 --- a/lib/ui/semantics.dart +++ b/lib/ui/semantics.dart @@ -220,6 +220,9 @@ class SemanticsAction { /// must immediately become editable, opening a virtual keyboard, if needed. /// Buttons must respond to tap/click events from the keyboard. /// + /// Widget reaction to this action must be idempotent. It is possible to + /// receive this action more than once, or when the widget is already focused. + /// /// Focus behavior is specific to the platform and to the assistive technology /// used. Typically on desktop operating systems, such as Windows, macOS, and /// Linux, moving accessibility focus will also move the input focus. On diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index 3239c62173a94..3bec9d36e2720 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -2762,6 +2762,30 @@ DomCompositionEvent createDomCompositionEvent(String type, } } +/// This is a pseudo-type for DOM elements that have the boolean `disabled` +/// property. +/// +/// This type cannot be part of the actual type hierarchy because each DOM type +/// defines its `disabled` property ad hoc, without inheriting it from a common +/// type, e.g. [DomHTMLInputElement] and [DomHTMLTextAreaElement]. +/// +/// To use, simply cast any element known to have the `disabled` property to +/// this type using `as DomElementWithDisabledProperty`, then read and write +/// this property as normal. +@JS() +@staticInterop +class DomElementWithDisabledProperty extends DomHTMLElement {} + +extension DomElementWithDisabledPropertyExtension on DomElementWithDisabledProperty { + @JS('disabled') + external JSBoolean? get _disabled; + bool? get disabled => _disabled?.toDart; + + @JS('disabled') + external set _disabled(JSBoolean? value); + set disabled(bool? value) => _disabled = value?.toJS; +} + @JS() @staticInterop class DomHTMLInputElement extends DomHTMLElement {} diff --git a/lib/web_ui/lib/src/engine/semantics/focusable.dart b/lib/web_ui/lib/src/engine/semantics/focusable.dart index 35fff64a50158..921ce2b98361c 100644 --- a/lib/web_ui/lib/src/engine/semantics/focusable.dart +++ b/lib/web_ui/lib/src/engine/semantics/focusable.dart @@ -48,6 +48,7 @@ class Focusable extends RoleManager { /// this role manager did not take the focus. The return value can be used to /// decide whether to stop searching for a node that should take focus. bool focusAsRouteDefault() { + _focusManager._lastEvent = AccessibilityFocusManagerEvent.requestedFocus; owner.element.focus(); return true; } @@ -86,6 +87,21 @@ typedef _FocusTarget = ({ DomEventListener domBlurListener, }); +enum AccessibilityFocusManagerEvent { + /// No event has happend for the target element. + nothing, + + /// The engine requested focus on the DOM element, possibly because the + /// framework requested it. + requestedFocus, + + /// Received the DOM "focus" event. + receivedDomFocus, + + /// Received the DOM "blur" event. + receivedDomBlur, +} + /// Implements accessibility focus management for arbitrary elements. /// /// Unlike [Focusable], which implements focus features on [SemanticsObject]s @@ -102,6 +118,8 @@ class AccessibilityFocusManager { _FocusTarget? _target; + AccessibilityFocusManagerEvent _lastEvent = AccessibilityFocusManagerEvent.nothing; + // The last focus value set by this focus manager, used to prevent requesting // focus on the same element repeatedly. Requesting focus on DOM elements is // not an idempotent operation. If the element is already focused and focus is @@ -148,10 +166,11 @@ class AccessibilityFocusManager { final _FocusTarget newTarget = ( semanticsNodeId: semanticsNodeId, element: element, - domFocusListener: createDomEventListener((_) => _setFocusFromDom(true)), - domBlurListener: createDomEventListener((_) => _setFocusFromDom(false)), + domFocusListener: createDomEventListener((_) => _didReceiveDomFocus()), + domBlurListener: createDomEventListener((_) => _didReceiveDomBlur()), ); _target = newTarget; + _lastEvent = AccessibilityFocusManagerEvent.nothing; element.tabIndex = 0; element.addEventListener('focus', newTarget.domFocusListener); @@ -173,7 +192,7 @@ class AccessibilityFocusManager { target.element.removeEventListener('blur', target.domBlurListener); } - void _setFocusFromDom(bool acquireFocus) { + void _didReceiveDomFocus() { final _FocusTarget? target = _target; if (target == null) { @@ -182,13 +201,23 @@ class AccessibilityFocusManager { return; } - EnginePlatformDispatcher.instance.invokeOnSemanticsAction( - target.semanticsNodeId, - acquireFocus - ? ui.SemanticsAction.didGainAccessibilityFocus - : ui.SemanticsAction.didLoseAccessibilityFocus, - null, - ); + // Do not notify the framework if DOM focus was acquired as a result of + // requesting it programmatically. Only notify the framework if the DOM + // focus was initiated by the browser, e.g. as a result of the screen reader + // shifting focus. + if (_lastEvent != AccessibilityFocusManagerEvent.requestedFocus) { + EnginePlatformDispatcher.instance.invokeOnSemanticsAction( + target.semanticsNodeId, + ui.SemanticsAction.focus, + null, + ); + } + + _lastEvent = AccessibilityFocusManagerEvent.receivedDomFocus; + } + + void _didReceiveDomBlur() { + _lastEvent = AccessibilityFocusManagerEvent.receivedDomBlur; } /// Requests focus or blur on the DOM element. @@ -229,7 +258,7 @@ class AccessibilityFocusManager { // a dialog, and nothing else in the dialog is focused. The Flutter // framework expects that the screen reader will focus on the first (in // traversal order) focusable element inside the dialog and send a - // didGainAccessibilityFocus action. Screen readers on the web do not do + // SemanticsAction.focus action. Screen readers on the web do not do // that, and so the web engine has to implement this behavior directly. So // the dialog will look for a focusable element and request focus on it, // but now there may be a race between this method unsetting the focus and @@ -249,6 +278,7 @@ class AccessibilityFocusManager { return; } + _lastEvent = AccessibilityFocusManagerEvent.requestedFocus; target.element.focus(); }); } diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 28d5de60a9a93..1e9f26157b73c 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -2243,8 +2243,6 @@ class EngineSemantics { 'mousemove', 'mouseleave', 'mouseup', - 'keyup', - 'keydown', ]; if (pointerEventTypes.contains(event.type)) { diff --git a/lib/web_ui/lib/src/engine/semantics/text_field.dart b/lib/web_ui/lib/src/engine/semantics/text_field.dart index bb79ea1df52d9..3618306d37829 100644 --- a/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -2,11 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; import 'package:ui/ui.dart' as ui; -import 'package:ui/ui_web/src/ui_web.dart' as ui_web; -import '../browser_detection.dart' show isIosSafari; import '../dom.dart'; import '../platform_dispatcher.dart'; import '../text_editing/text_editing.dart'; @@ -123,7 +120,10 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { // Android). // Otherwise, the keyboard stays on screen even when the user navigates to // a different screen (e.g. by hitting the "back" button). - domElement?.blur(); + // Keep this consistent with how DefaultTextEditingStrategy does it. As of + // right now, the only difference is that semantic text fields do not + // participate in form autofill. + DefaultTextEditingStrategy.scheduleFocusFlutterView(activeDomElement, activeDomElementView); domElement = null; activeTextField = null; _queuedStyle = null; @@ -162,7 +162,7 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { if (hasAutofillGroup) { placeForm(); } - activeDomElement.focus(); + activeDomElement.focus(preventScroll: true); } @override @@ -207,69 +207,40 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { /// [EngineSemanticsOwner.gestureMode]. However, in Chrome on Android it ignores /// browser gestures when in pointer mode. In Safari on iOS pointer events are /// used to detect text box invocation. This is because Safari issues touch -/// events even when Voiceover is enabled. +/// events even when VoiceOver is enabled. class TextField extends PrimaryRoleManager { TextField(SemanticsObject semanticsObject) : super.blank(PrimaryRole.textField, semanticsObject) { - _setupDomElement(); + _initializeEditableElement(); } - /// The element used for editing, e.g. ``, `