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. ``, `