diff --git a/DEPS b/DEPS index 2861c466cfb5a..6ad1ee90985b9 100644 --- a/DEPS +++ b/DEPS @@ -66,7 +66,7 @@ vars = { # Dart is: https://github.com/dart-lang/sdk/blob/main/DEPS # You can use //tools/dart/create_updated_flutter_deps.py to produce # updated revision list of existing dependencies. - 'dart_revision': 'bcf68d22f0faf8c58e7af5bcd5a948d7ecb9e0ba', + 'dart_revision': 'fe94d9b88531fdbcd015a5d43d777c2244321672', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py diff --git a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart index 9289184310ba5..43f847caa9474 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart @@ -443,7 +443,8 @@ class HtmlViewEmbedder { sceneHost.insertBefore(platformViewRoot, elementToInsertBefore); final RenderCanvas? overlay = _overlays[viewId]; if (overlay != null) { - sceneHost.insertBefore(overlay.htmlElement, elementToInsertBefore); + sceneHost.insertBefore( + overlay.htmlElement, elementToInsertBefore); } } else { final DomElement platformViewRoot = _viewClipChains[viewId]!.root; @@ -653,8 +654,6 @@ class HtmlViewEmbedder { } } _svgClipDefs.clear(); - _svgPathDefs?.remove(); - _svgPathDefs = null; } static void removeElement(DomElement element) { diff --git a/lib/web_ui/lib/src/engine/canvaskit/renderer.dart b/lib/web_ui/lib/src/engine/canvaskit/renderer.dart index df9ae2fa20b54..deb63b4b159bf 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/renderer.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/renderer.dart @@ -85,7 +85,6 @@ class CanvasKitRenderer implements Renderer { viewManager.onViewDisposed.listen(_onViewDisposed); _instance = this; }(); - registerHotRestartListener(dispose); return _initialized; } @@ -452,7 +451,6 @@ class CanvasKitRenderer implements Renderer { rasterizer.dispose(); } _rasterizers.clear(); - clearFragmentProgramCache(); } @override diff --git a/lib/web_ui/lib/src/engine/html/renderer.dart b/lib/web_ui/lib/src/engine/html/renderer.dart index afada4fca1896..0cf11314f8d74 100644 --- a/lib/web_ui/lib/src/engine/html/renderer.dart +++ b/lib/web_ui/lib/src/engine/html/renderer.dart @@ -31,7 +31,6 @@ class HtmlRenderer implements Renderer { // to make the unpacking happen while we are waiting for network requests. lineLookup; }); - registerHotRestartListener(clearFragmentProgramCache); _instance = this; } diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 7ca80db0fc439..1453da94e0c3d 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -775,7 +775,11 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { // view hasn't been rendered already in this scope. final bool shouldRender = _viewsRenderedInCurrentFrame?.add(viewToRender) ?? false; - if (shouldRender) { + // TODO(harryterkelsen): HTML renderer needs to violate the render rule in + // order to perform golden tests in Flutter framework because on the HTML + // renderer, golden tests render to DOM and then take a browser screenshot, + // https://github.com/flutter/flutter/issues/137073. + if (shouldRender || renderer.rendererTag == 'html') { await renderer.renderScene(scene, viewToRender); } } diff --git a/lib/web_ui/lib/src/engine/semantics/checkable.dart b/lib/web_ui/lib/src/engine/semantics/checkable.dart index d50f51c512c64..1840acc4aa6e1 100644 --- a/lib/web_ui/lib/src/engine/semantics/checkable.dart +++ b/lib/web_ui/lib/src/engine/semantics/checkable.dart @@ -102,4 +102,7 @@ class Checkable extends PrimaryRoleManager { removeAttribute('aria-disabled'); removeAttribute('disabled'); } + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; } diff --git a/lib/web_ui/lib/src/engine/semantics/dialog.dart b/lib/web_ui/lib/src/engine/semantics/dialog.dart index 9f64e42e7acff..0eab7758a1d93 100644 --- a/lib/web_ui/lib/src/engine/semantics/dialog.dart +++ b/lib/web_ui/lib/src/engine/semantics/dialog.dart @@ -16,6 +16,39 @@ class Dialog extends PrimaryRoleManager { // names its own route an `aria-label` is used instead of `aria-describedby`. addFocusManagement(); addLiveRegion(); + + // When a route/dialog shows up it is expected that the screen reader will + // focus on something inside it. There could be two possibilities: + // + // 1. The framework explicitly marked a node inside the dialog as focused + // via the `isFocusable` and `isFocused` flags. In this case, the node + // will request focus directly and there's nothing to do on top of that. + // 2. No node inside the route takes focus explicitly. In this case, the + // expectation is to look through all nodes in traversal order and focus + // on the first one. + semanticsObject.owner.addOneTimePostUpdateCallback(() { + if (semanticsObject.owner.hasNodeRequestingFocus) { + // Case 1: a node requested explicit focus. Nothing extra to do. + return; + } + + // Case 2: nothing requested explicit focus. Focus on the first descendant. + _setDefaultFocus(); + }); + } + + void _setDefaultFocus() { + semanticsObject.visitDepthFirstInTraversalOrder((SemanticsObject node) { + final PrimaryRoleManager? roleManager = node.primaryRole; + if (roleManager == null) { + return true; + } + + // If the node does not take focus (e.g. focusing on it does not make + // sense at all). Despair not. Keep looking. + final bool didTakeFocus = roleManager.focusAsRouteDefault(); + return !didTakeFocus; + }); } @override @@ -57,6 +90,13 @@ class Dialog extends PrimaryRoleManager { routeName.semanticsObject.element.id, ); } + + @override + bool focusAsRouteDefault() { + // Dialogs are the ones that look inside themselves to find elements to + // focus on. It doesn't make sense to focus on the dialog itself. + return false; + } } /// Supplies a description for the nearest ancestor [Dialog]. diff --git a/lib/web_ui/lib/src/engine/semantics/focusable.dart b/lib/web_ui/lib/src/engine/semantics/focusable.dart index 4caf56f3f3eac..35fff64a50158 100644 --- a/lib/web_ui/lib/src/engine/semantics/focusable.dart +++ b/lib/web_ui/lib/src/engine/semantics/focusable.dart @@ -34,6 +34,24 @@ class Focusable extends RoleManager { final AccessibilityFocusManager _focusManager; + /// Requests focus as a result of a route (e.g. dialog) deciding that the node + /// managed by this class should be focused by default when nothing requests + /// focus explicitly. + /// + /// This method of taking focus is different from the regular method of using + /// the [SemanticsObject.hasFocus] flag, as in this case the framework did not + /// explicitly request focus. Instead, the DOM element is being focus directly + /// programmatically, simulating the screen reader choosing a default element + /// to focus on. + /// + /// Returns `true` if the role manager took the focus. Returns `false` if + /// 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() { + owner.element.focus(); + return true; + } + @override void update() { if (semanticsObject.isFocusable) { @@ -84,6 +102,14 @@ class AccessibilityFocusManager { _FocusTarget? _target; + // 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 + // requested the browser will scroll to that element. However, scrolling is + // not this class' concern and so this class should avoid doing anything that + // would affect scrolling. + bool? _lastSetValue; + /// Whether this focus manager is managing a focusable target. bool get isManaging => _target != null; @@ -136,6 +162,7 @@ class AccessibilityFocusManager { void stopManaging() { final _FocusTarget? target = _target; _target = null; + _lastSetValue = null; if (target == null) { /// Nothing is being managed. Just return. @@ -144,11 +171,6 @@ class AccessibilityFocusManager { target.element.removeEventListener('focus', target.domFocusListener); target.element.removeEventListener('blur', target.domBlurListener); - - // Blur the element after removing listeners. If this method is being called - // it indicates that the framework already knows that this node should not - // have focus, and there's no need to notify it. - target.element.blur(); } void _setFocusFromDom(bool acquireFocus) { @@ -174,6 +196,10 @@ class AccessibilityFocusManager { final _FocusTarget? target = _target; if (target == null) { + // If this branch is being executed, there's a bug somewhere already, but + // it doesn't hurt to clean up old values anyway. + _lastSetValue = null; + // Nothing is being managed right now. assert(() { printWarning( @@ -185,6 +211,32 @@ class AccessibilityFocusManager { return; } + if (value == _lastSetValue) { + // The focus is being changed to a value that's already been requested in + // the past. Do nothing. + return; + } + _lastSetValue = value; + + if (value) { + _owner.willRequestFocus(); + } else { + // Do not blur elements. Instead let the element be blurred by requesting + // focus elsewhere. Blurring elements is a very error-prone thing to do, + // as it is subject to non-local effects. Let's say the framework decides + // that a semantics node is currently not focused. That would lead to + // changeFocus(false) to be called. However, what if this node is inside + // 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 + // 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 + // the dialog requesting focus on the same element. + return; + } + // Delay the focus request until the final DOM structure is established // because the element may not yet be attached to the DOM, or it may be // reparented and lose focus again. @@ -197,11 +249,7 @@ class AccessibilityFocusManager { return; } - if (value) { - target.element.focus(); - } else { - target.element.blur(); - } + target.element.focus(); }); } } diff --git a/lib/web_ui/lib/src/engine/semantics/image.dart b/lib/web_ui/lib/src/engine/semantics/image.dart index efe1d7cdb414b..da36ad2a87b5d 100644 --- a/lib/web_ui/lib/src/engine/semantics/image.dart +++ b/lib/web_ui/lib/src/engine/semantics/image.dart @@ -23,6 +23,9 @@ class ImageRoleManager extends PrimaryRoleManager { addTappable(); } + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; + /// The element with role="img" and aria-label could block access to all /// children elements, therefore create an auxiliary element and describe the /// image in that if the semantic object have child nodes. diff --git a/lib/web_ui/lib/src/engine/semantics/incrementable.dart b/lib/web_ui/lib/src/engine/semantics/incrementable.dart index 5dceb9582c77c..6a8c846d0915c 100644 --- a/lib/web_ui/lib/src/engine/semantics/incrementable.dart +++ b/lib/web_ui/lib/src/engine/semantics/incrementable.dart @@ -59,6 +59,12 @@ class Incrementable extends PrimaryRoleManager { _focusManager.manage(semanticsObject.id, _element); } + @override + bool focusAsRouteDefault() { + _element.focus(); + return true; + } + /// The HTML element used to render semantics to the browser. final DomHTMLInputElement _element = createDomHTMLInputElement(); diff --git a/lib/web_ui/lib/src/engine/semantics/link.dart b/lib/web_ui/lib/src/engine/semantics/link.dart index 00dcdfcad54c5..168c93322c430 100644 --- a/lib/web_ui/lib/src/engine/semantics/link.dart +++ b/lib/web_ui/lib/src/engine/semantics/link.dart @@ -18,4 +18,7 @@ class Link extends PrimaryRoleManager { element.style.display = 'block'; return element; } + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; } diff --git a/lib/web_ui/lib/src/engine/semantics/platform_view.dart b/lib/web_ui/lib/src/engine/semantics/platform_view.dart index 7502694390dc3..9426e524d04ea 100644 --- a/lib/web_ui/lib/src/engine/semantics/platform_view.dart +++ b/lib/web_ui/lib/src/engine/semantics/platform_view.dart @@ -38,4 +38,13 @@ class PlatformViewRoleManager extends PrimaryRoleManager { removeAttribute('aria-owns'); } } + + @override + bool focusAsRouteDefault() { + // It's unclear how it's possible to auto-focus on something inside a + // platform view without knowing what's in it. If the framework adds API for + // focusing on platform view internals, this method will be able to do more, + // but for now there's nothing to focus on. + return false; + } } diff --git a/lib/web_ui/lib/src/engine/semantics/scrollable.dart b/lib/web_ui/lib/src/engine/semantics/scrollable.dart index 61cb17f6ae611..3c0eb2b6392c3 100644 --- a/lib/web_ui/lib/src/engine/semantics/scrollable.dart +++ b/lib/web_ui/lib/src/engine/semantics/scrollable.dart @@ -239,4 +239,7 @@ class Scrollable extends PrimaryRoleManager { _gestureModeListener = null; } } + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; } diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index c88543b2d0331..b65f4484b7a74 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -519,9 +519,13 @@ abstract class PrimaryRoleManager { void removeEventListener(String type, DomEventListener? listener, [bool? useCapture]) => element.removeEventListener(type, listener, useCapture); + /// Convenience getter for the [Focusable] role manager, if any. + Focusable? get focusable => _focusable; + Focusable? _focusable; + /// Adds generic focus management features. void addFocusManagement() { - addSecondaryRole(Focusable(semanticsObject, this)); + addSecondaryRole(_focusable = Focusable(semanticsObject, this)); } /// Adds generic live region features. @@ -594,6 +598,22 @@ abstract class PrimaryRoleManager { removeAttribute('role'); _isDisposed = true; } + + /// Transfers the accessibility focus to the [element] managed by this role + /// manager as a result of this node taking focus by default. + /// + /// For example, when a dialog pops up it is expected that one of its child + /// nodes takes accessibility focus. + /// + /// Transferring accessibility focus is different from transferring input + /// focus. Not all elements that can take accessibility focus can also take + /// input focus. For example, a plain text node cannot take input focus, but + /// it can take accessibility focus. + /// + /// Returns `true` if the role manager took the focus. Returns `false` if + /// 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(); } /// A role used when a more specific role couldn't be assigned to the node. @@ -639,6 +659,38 @@ final class GenericRole extends PrimaryRoleManager { setAriaRole('text'); } } + + @override + bool focusAsRouteDefault() { + // Case 1: current node has input focus. Let the input focus system decide + // default focusability. + if (semanticsObject.isFocusable) { + final Focusable? focusable = this.focusable; + if (focusable != null) { + return focusable.focusAsRouteDefault(); + } + } + + // Case 2: current node is not focusable, but just a container of other + // nodes or lacks a label. Do not focus on it and let the search continue. + if (semanticsObject.hasChildren || !semanticsObject.hasLabel) { + return false; + } + + // Case 3: current node is visual/informational. Move just the + // accessibility focus. + + // Plain text nodes should not be focusable via keyboard or mouse. They are + // only focusable for the purposes of focusing the screen reader. To achieve + // this the -1 value is used. + // + // See also: + // + // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex + element.tabIndex = -1; + element.focus(); + return true; + } } /// Provides a piece of functionality to a [SemanticsObject]. @@ -1719,16 +1771,64 @@ class SemanticsObject { } } - /// Recursively visits the tree rooted at `this` node in depth-first fashion. + /// Recursively visits the tree rooted at `this` node in depth-first fashion + /// in the order nodes were rendered into the DOM. + /// + /// Useful for debugging only. /// /// Calls the [callback] for `this` node, then for all of its descendants. - void visitDepthFirst(void Function(SemanticsObject) callback) { + /// + /// Unlike [visitDepthFirstInTraversalOrder] this method can traverse + /// partially updated, incomplete, or inconsistent tree. + void _debugVisitRenderedSemanticNodesDepthFirst(void Function(SemanticsObject) callback) { callback(this); _currentChildrenInRenderOrder?.forEach((SemanticsObject child) { - child.visitDepthFirst(callback); + child._debugVisitRenderedSemanticNodesDepthFirst(callback); }); } + /// Recursively visits the tree rooted at `this` node in depth-first fashion + /// in traversal order. + /// + /// Calls the [callback] for `this` node, then for all of its descendants. If + /// the callback returns true, continues visiting descendants. Otherwise, + /// stops immediately after visiting the node that caused the callback to + /// return false. + void visitDepthFirstInTraversalOrder(bool Function(SemanticsObject) callback) { + _visitDepthFirstInTraversalOrder(callback); + } + + bool _visitDepthFirstInTraversalOrder(bool Function(SemanticsObject) callback) { + final bool shouldContinueVisiting = callback(this); + + if (!shouldContinueVisiting) { + return false; + } + + final Int32List? childrenInTraversalOrder = _childrenInTraversalOrder; + + if (childrenInTraversalOrder == null) { + return true; + } + + for (final int childId in childrenInTraversalOrder) { + final SemanticsObject? child = owner._semanticsTree[childId]; + + assert( + child != null, + 'visitDepthFirstInTraversalOrder must only be called after the node ' + 'tree has been established. However, child #$childId does not have its ' + 'SemanticsNode created at the time this method was called.', + ); + + if (!child!._visitDepthFirstInTraversalOrder(callback)) { + return false; + } + } + + return true; + } + @override String toString() { String result = super.toString(); @@ -2170,7 +2270,7 @@ class EngineSemanticsOwner { // A detached node may or may not have some of its descendants reattached // elsewhere. Walk the descendant tree and find all descendants that were // reattached to a parent. Those descendants need to be removed. - detachmentRoot.visitDepthFirst((SemanticsObject node) { + detachmentRoot.visitDepthFirstInTraversalOrder((SemanticsObject node) { final SemanticsObject? parent = _attachments[node.id]; if (parent == null) { // Was not reparented and is removed permanently from the tree. @@ -2179,8 +2279,8 @@ class EngineSemanticsOwner { assert(node._parent == parent); assert(node.element.parentNode == parent._childContainerElement); } + return true; }); - } for (final SemanticsObject removal in removals) { @@ -2202,6 +2302,7 @@ class EngineSemanticsOwner { } finally { _phase = SemanticsUpdatePhase.idle; } + _hasNodeRequestingFocus = false; } /// Returns the entire semantics tree for testing. @@ -2235,7 +2336,7 @@ class EngineSemanticsOwner { final SemanticsObject? root = _semanticsTree[0]; if (root != null) { - root.visitDepthFirst((SemanticsObject child) { + root._debugVisitRenderedSemanticNodesDepthFirst((SemanticsObject child) { liveIds[child.id] = child._childrenInTraversalOrder?.toList() ?? const []; }); } @@ -2379,6 +2480,27 @@ AFTER: $description _phase = SemanticsUpdatePhase.idle; _oneTimePostUpdateCallbacks.clear(); } + + /// True, if any semantics node requested focus explicitly during the latest + /// semantics update. + /// + /// The default value is `false`, and it is reset back to `false` after the + /// semantics update at the end of [updateSemantics]. + /// + /// Since focus can only be taken by no more than one element, the engine + /// should not request focus for multiple elements. This flag helps resolve + /// that. + bool get hasNodeRequestingFocus => _hasNodeRequestingFocus; + bool _hasNodeRequestingFocus = false; + + /// Declares that a semantics node will explicitly request focus. + /// + /// This prevents others, [Dialog] in particular, from requesting autofocus, + /// as focus can only be taken by one element. Explicit focus has higher + /// precedence than autofocus. + void willRequestFocus() { + _hasNodeRequestingFocus = true; + } } /// Computes the [longest increasing subsequence](http://en.wikipedia.org/wiki/Longest_increasing_subsequence). diff --git a/lib/web_ui/lib/src/engine/semantics/tappable.dart b/lib/web_ui/lib/src/engine/semantics/tappable.dart index e0cdfbb2ebf37..e1abbc2f52a0c 100644 --- a/lib/web_ui/lib/src/engine/semantics/tappable.dart +++ b/lib/web_ui/lib/src/engine/semantics/tappable.dart @@ -11,6 +11,9 @@ class Button extends PrimaryRoleManager { setAriaRole('button'); } + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; + @override void update() { super.update(); 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 ce9d61433bcb7..889a410cd550d 100644 --- a/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -224,6 +224,16 @@ class TextField extends PrimaryRoleManager { return editableElement!; } + @override + bool focusAsRouteDefault() { + final DomHTMLElement? editableElement = this.editableElement; + if (editableElement == null) { + return false; + } + editableElement.focus(); + return true; + } + /// Timer that times when to set the location of the input text. /// /// This is only used for iOS. In iOS, virtual keyboard shifts the screen. diff --git a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/renderer.dart b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/renderer.dart index df4cf213a33a3..292994ec18122 100644 --- a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/renderer.dart +++ b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/renderer.dart @@ -349,7 +349,6 @@ class SkwasmRenderer implements Renderer { FutureOr initialize() { surface = SkwasmSurface(); sceneView = EngineSceneView(SkwasmPictureRenderer(surface)); - registerHotRestartListener(clearFragmentProgramCache); } @override diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 50937070ed961..716dc70e04760 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -9,7 +9,7 @@ import 'package:meta/meta.dart'; import 'package:ui/ui.dart' as ui; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; -import '../engine.dart' show DimensionsProvider, registerHotRestartListener; +import '../engine.dart' show DimensionsProvider, registerHotRestartListener, renderer; import 'browser_detection.dart'; import 'display.dart'; import 'dom.dart'; @@ -58,8 +58,7 @@ base class EngineFlutterView implements ui.FlutterView { // by the public `EngineFlutterView` constructor). DomElement? hostElement, ) : embeddingStrategy = EmbeddingStrategy.create(hostElement: hostElement), - dimensionsProvider = - DimensionsProvider.create(hostElement: hostElement) { + dimensionsProvider = DimensionsProvider.create(hostElement: hostElement) { // The embeddingStrategy will take care of cleaning up the rootElement on // hot restart. embeddingStrategy.attachViewRoot(dom.rootElement); @@ -71,8 +70,7 @@ base class EngineFlutterView implements ui.FlutterView { static EngineFlutterWindow implicit( EnginePlatformDispatcher platformDispatcher, DomElement? hostElement, - ) => - EngineFlutterWindow._(platformDispatcher, hostElement); + ) => EngineFlutterWindow._(platformDispatcher, hostElement); @override final int viewId; @@ -102,6 +100,8 @@ base class EngineFlutterView implements ui.FlutterView { dimensionsProvider.close(); pointerBinding.dispose(); dom.rootElement.remove(); + // TODO(harryterkelsen): What should we do about this in multi-view? + renderer.clearFragmentProgramCache(); semantics.reset(); } @@ -114,8 +114,7 @@ base class EngineFlutterView implements ui.FlutterView { @override void updateSemantics(ui.SemanticsUpdate update) { - assert(!isDisposed, - 'Trying to update semantics on a disposed EngineFlutterView.'); + assert(!isDisposed, 'Trying to update semantics on a disposed EngineFlutterView.'); semantics.updateSemantics(update); } @@ -128,18 +127,15 @@ base class EngineFlutterView implements ui.FlutterView { late final ContextMenu contextMenu = ContextMenu(dom.rootElement); - late final DomManager dom = - DomManager(viewId: viewId, devicePixelRatio: devicePixelRatio); + late final DomManager dom = DomManager(viewId: viewId, devicePixelRatio: devicePixelRatio); late final PointerBinding pointerBinding; // TODO(goderbauer): Provide API to configure constraints. See also TODO in "render". @override - ViewConstraints get physicalConstraints => - ViewConstraints.tight(physicalSize); + ViewConstraints get physicalConstraints => ViewConstraints.tight(physicalSize); - late final EngineSemanticsOwner semantics = - EngineSemanticsOwner(dom.semanticsHost); + late final EngineSemanticsOwner semantics = EngineSemanticsOwner(dom.semanticsHost); @override ui.Size get physicalSize { @@ -188,8 +184,7 @@ base class EngineFlutterView implements ui.FlutterView { ui.GestureSettings get gestureSettings => _viewConfiguration.gestureSettings; @override - List get displayFeatures => - _viewConfiguration.displayFeatures; + List get displayFeatures => _viewConfiguration.displayFeatures; @override EngineFlutterDisplay get display => EngineFlutterDisplay.instance; @@ -245,14 +240,11 @@ base class EngineFlutterView implements ui.FlutterView { // Return false if the previous dimensions are not set. if (_physicalSize != null) { // First confirm both height and width are effected. - if (_physicalSize!.height != newPhysicalSize.height && - _physicalSize!.width != newPhysicalSize.width) { + if (_physicalSize!.height != newPhysicalSize.height && _physicalSize!.width != newPhysicalSize.width) { // If prior to rotation height is bigger than width it should be the // opposite after the rotation and vice versa. - if ((_physicalSize!.height > _physicalSize!.width && - newPhysicalSize.height < newPhysicalSize.width) || - (_physicalSize!.width > _physicalSize!.height && - newPhysicalSize.width < newPhysicalSize.height)) { + if ((_physicalSize!.height > _physicalSize!.width && newPhysicalSize.height < newPhysicalSize.width) || + (_physicalSize!.width > _physicalSize!.height && newPhysicalSize.width < newPhysicalSize.height)) { // Rotation detected return true; } @@ -277,8 +269,7 @@ final class _EngineFlutterViewImpl extends EngineFlutterView { } /// The Web implementation of [ui.SingletonFlutterWindow]. -final class EngineFlutterWindow extends EngineFlutterView - implements ui.SingletonFlutterWindow { +final class EngineFlutterWindow extends EngineFlutterView implements ui.SingletonFlutterWindow { EngineFlutterWindow._( EnginePlatformDispatcher platformDispatcher, DomElement? hostElement, @@ -325,8 +316,7 @@ final class EngineFlutterWindow extends EngineFlutterView double get textScaleFactor => platformDispatcher.textScaleFactor; @override - bool get nativeSpellCheckServiceDefined => - platformDispatcher.nativeSpellCheckServiceDefined; + bool get nativeSpellCheckServiceDefined => platformDispatcher.nativeSpellCheckServiceDefined; @override bool get brieflyShowPassword => platformDispatcher.brieflyShowPassword; @@ -335,8 +325,7 @@ final class EngineFlutterWindow extends EngineFlutterView bool get alwaysUse24HourFormat => platformDispatcher.alwaysUse24HourFormat; @override - ui.VoidCallback? get onTextScaleFactorChanged => - platformDispatcher.onTextScaleFactorChanged; + ui.VoidCallback? get onTextScaleFactorChanged => platformDispatcher.onTextScaleFactorChanged; @override set onTextScaleFactorChanged(ui.VoidCallback? callback) { platformDispatcher.onTextScaleFactorChanged = callback; @@ -346,8 +335,7 @@ final class EngineFlutterWindow extends EngineFlutterView ui.Brightness get platformBrightness => platformDispatcher.platformBrightness; @override - ui.VoidCallback? get onPlatformBrightnessChanged => - platformDispatcher.onPlatformBrightnessChanged; + ui.VoidCallback? get onPlatformBrightnessChanged => platformDispatcher.onPlatformBrightnessChanged; @override set onPlatformBrightnessChanged(ui.VoidCallback? callback) { platformDispatcher.onPlatformBrightnessChanged = callback; @@ -357,8 +345,7 @@ final class EngineFlutterWindow extends EngineFlutterView String? get systemFontFamily => platformDispatcher.systemFontFamily; @override - ui.VoidCallback? get onSystemFontFamilyChanged => - platformDispatcher.onSystemFontFamilyChanged; + ui.VoidCallback? get onSystemFontFamilyChanged => platformDispatcher.onSystemFontFamilyChanged; @override set onSystemFontFamilyChanged(ui.VoidCallback? callback) { platformDispatcher.onSystemFontFamilyChanged = callback; @@ -386,8 +373,7 @@ final class EngineFlutterWindow extends EngineFlutterView } @override - ui.PointerDataPacketCallback? get onPointerDataPacket => - platformDispatcher.onPointerDataPacket; + ui.PointerDataPacketCallback? get onPointerDataPacket => platformDispatcher.onPointerDataPacket; @override set onPointerDataPacket(ui.PointerDataPacketCallback? callback) { platformDispatcher.onPointerDataPacket = callback; @@ -410,8 +396,7 @@ final class EngineFlutterWindow extends EngineFlutterView bool get semanticsEnabled => platformDispatcher.semanticsEnabled; @override - ui.VoidCallback? get onSemanticsEnabledChanged => - platformDispatcher.onSemanticsEnabledChanged; + ui.VoidCallback? get onSemanticsEnabledChanged => platformDispatcher.onSemanticsEnabledChanged; @override set onSemanticsEnabledChanged(ui.VoidCallback? callback) { platformDispatcher.onSemanticsEnabledChanged = callback; @@ -426,8 +411,7 @@ final class EngineFlutterWindow extends EngineFlutterView set onFrameDataChanged(ui.VoidCallback? callback) {} @override - ui.AccessibilityFeatures get accessibilityFeatures => - platformDispatcher.accessibilityFeatures; + ui.AccessibilityFeatures get accessibilityFeatures => platformDispatcher.accessibilityFeatures; @override ui.VoidCallback? get onAccessibilityFeaturesChanged => @@ -447,16 +431,14 @@ final class EngineFlutterWindow extends EngineFlutterView } @override - ui.PlatformMessageCallback? get onPlatformMessage => - platformDispatcher.onPlatformMessage; + ui.PlatformMessageCallback? get onPlatformMessage => platformDispatcher.onPlatformMessage; @override set onPlatformMessage(ui.PlatformMessageCallback? callback) { platformDispatcher.onPlatformMessage = callback; } @override - void setIsolateDebugName(String name) => - ui.PlatformDispatcher.instance.setIsolateDebugName(name); + void setIsolateDebugName(String name) => ui.PlatformDispatcher.instance.setIsolateDebugName(name); /// Handles the browser history integration to allow users to use the back /// button, etc. @@ -562,8 +544,7 @@ final class EngineFlutterWindow extends EngineFlutterView Future handleNavigationMessage(ByteData? data) async { return _waitInTheLine(() async { final MethodCall decoded = const JSONMethodCodec().decodeMethodCall(data); - final Map? arguments = - decoded.arguments as Map?; + final Map? arguments = decoded.arguments as Map?; switch (decoded.method) { case 'selectMultiEntryHistory': await _useMultiEntryBrowserHistory(); @@ -587,9 +568,7 @@ final class EngineFlutterWindow extends EngineFlutterView path = Uri.decodeComponent( Uri( path: uri.path.isEmpty ? '/' : uri.path, - queryParameters: uri.queryParametersAll.isEmpty - ? null - : uri.queryParametersAll, + queryParameters: uri.queryParametersAll.isEmpty ? null : uri.queryParametersAll, fragment: uri.fragment.isEmpty ? null : uri.fragment, ).toString(), ); @@ -665,7 +644,6 @@ EngineFlutterWindow get window { ); return _window!; } - EngineFlutterWindow? _window; /// Initializes the [window] (aka the implicit view), if it's not already @@ -711,10 +689,10 @@ class ViewConstraints implements ui.ViewConstraints { }); ViewConstraints.tight(ui.Size size) - : minWidth = size.width, - maxWidth = size.width, - minHeight = size.height, - maxHeight = size.height; + : minWidth = size.width, + maxWidth = size.width, + minHeight = size.height, + maxHeight = size.height; @override final double minWidth; @@ -727,17 +705,15 @@ class ViewConstraints implements ui.ViewConstraints { @override bool isSatisfiedBy(ui.Size size) { - return (minWidth <= size.width) && - (size.width <= maxWidth) && - (minHeight <= size.height) && - (size.height <= maxHeight); + return (minWidth <= size.width) && (size.width <= maxWidth) && + (minHeight <= size.height) && (size.height <= maxHeight); } @override bool get isTight => minWidth >= maxWidth && minHeight >= maxHeight; @override - ViewConstraints operator /(double factor) { + ViewConstraints operator/(double factor) { return ViewConstraints( minWidth: minWidth / factor, maxWidth: maxWidth / factor, @@ -754,11 +730,11 @@ class ViewConstraints implements ui.ViewConstraints { if (other.runtimeType != runtimeType) { return false; } - return other is ViewConstraints && - other.minWidth == minWidth && - other.maxWidth == maxWidth && - other.minHeight == minHeight && - other.maxHeight == maxHeight; + return other is ViewConstraints + && other.minWidth == minWidth + && other.maxWidth == maxWidth + && other.minHeight == minHeight + && other.maxHeight == maxHeight; } @override @@ -769,10 +745,8 @@ class ViewConstraints implements ui.ViewConstraints { if (minWidth == double.infinity && minHeight == double.infinity) { return 'ViewConstraints(biggest)'; } - if (minWidth == 0 && - maxWidth == double.infinity && - minHeight == 0 && - maxHeight == double.infinity) { + if (minWidth == 0 && maxWidth == double.infinity && + minHeight == 0 && maxHeight == double.infinity) { return 'ViewConstraints(unconstrained)'; } String describe(double min, double max, String dim) { @@ -781,7 +755,6 @@ class ViewConstraints implements ui.ViewConstraints { } return '${min.toStringAsFixed(1)}<=$dim<=${max.toStringAsFixed(1)}'; } - final String width = describe(minWidth, maxWidth, 'w'); final String height = describe(minHeight, maxHeight, 'h'); return 'ViewConstraints($width, $height)'; diff --git a/lib/web_ui/test/canvaskit/embedded_views_test.dart b/lib/web_ui/test/canvaskit/embedded_views_test.dart index c90a9e00b318b..57ef4a8d74c31 100644 --- a/lib/web_ui/test/canvaskit/embedded_views_test.dart +++ b/lib/web_ui/test/canvaskit/embedded_views_test.dart @@ -731,15 +731,12 @@ void testMain() { await renderScene(sb.build()); } - await renderTestScene(); + final DomNode skPathDefs = sceneHost.querySelector('#sk_path_defs')!; - final DomElement? skPathDefs = sceneHost.querySelector('#sk_path_defs'); - expect( - skPathDefs, - isNotNull, - reason: 'Should have created SVG paths after rendering the scene', - ); - expect(skPathDefs!.childNodes, hasLength(1)); + expect(skPathDefs.childNodes, hasLength(0)); + + await renderTestScene(); + expect(skPathDefs.childNodes, hasLength(1)); await renderTestScene(); expect(skPathDefs.childNodes, hasLength(1)); diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index d461713fd6f97..d751eb589f075 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -579,6 +579,11 @@ class MockRoleManager extends PrimaryRoleManager { super.update(); _log('update'); } + + @override + bool focusAsRouteDefault() { + throw UnimplementedError(); + } } class MockSemanticsEnabler implements SemanticsEnabler { @@ -1539,16 +1544,24 @@ void _testIncrementables() { }; pumpSemantics(isFocused: false); + final DomElement element = owner().debugSemanticsTree![0]!.element.querySelector('input')!; expect(capturedActions, isEmpty); pumpSemantics(isFocused: true); expect(capturedActions, [ (0, ui.SemanticsAction.didGainAccessibilityFocus, null), ]); + capturedActions.clear(); pumpSemantics(isFocused: false); + expect( + reason: 'The engine never calls blur() explicitly.', + capturedActions, + isEmpty, + ); + + element.blur(); expect(capturedActions, [ - (0, ui.SemanticsAction.didGainAccessibilityFocus, null), (0, ui.SemanticsAction.didLoseAccessibilityFocus, null), ]); @@ -1899,16 +1912,28 @@ void _testCheckables() { }; pumpSemantics(isFocused: false); + final DomElement element = owner().debugSemanticsTree![0]!.element; expect(capturedActions, isEmpty); pumpSemantics(isFocused: true); expect(capturedActions, [ (0, ui.SemanticsAction.didGainAccessibilityFocus, null), ]); + capturedActions.clear(); + // The framework removes focus from the widget (i.e. "blurs" it). Since the + // blurring is initiated by the framework, there's no need to send any + // notifications back to the framework about it. pumpSemantics(isFocused: false); + expect(capturedActions, isEmpty); + + // If the element is blurred by the browser, then we do want to notify the + // framework. This is because screen reader can be focused on something + // other than what the framework is focused on, and notifying the framework + // about the loss of focus on a node is information that the framework did + // not have before. + element.blur(); expect(capturedActions, [ - (0, ui.SemanticsAction.didGainAccessibilityFocus, null), (0, ui.SemanticsAction.didLoseAccessibilityFocus, null), ]); @@ -2071,16 +2096,20 @@ void _testTappable() { }; pumpSemantics(isFocused: false); + final DomElement element = owner().debugSemanticsTree![0]!.element; expect(capturedActions, isEmpty); pumpSemantics(isFocused: true); expect(capturedActions, [ (0, ui.SemanticsAction.didGainAccessibilityFocus, null), ]); + capturedActions.clear(); pumpSemantics(isFocused: false); + expect(capturedActions, isEmpty); + + element.blur(); expect(capturedActions, [ - (0, ui.SemanticsAction.didGainAccessibilityFocus, null), (0, ui.SemanticsAction.didLoseAccessibilityFocus, null), ]); @@ -2858,6 +2887,212 @@ void _testDialog() { semantics().semanticsEnabled = false; }); + + // Test the simple scenario of a dialog coming up and containing focusable + // descendants that are not initially focused. The expectation is that the + // first descendant will be auto-focused. + test('focuses on the first unfocused Focusable', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final List capturedActions = []; + EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) { + capturedActions.add((event.nodeId, event.type, event.arguments)); + }; + + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + scopesRoute: true, + transform: Matrix4.identity().toFloat64(), + children: [ + tester.updateNode( + id: 1, + // None of the children should have isFocused set to `true` to make + // sure that the auto-focus logic kicks in. + children: [ + tester.updateNode( + id: 2, + label: 'Button 1', + hasTap: true, + hasEnabledState: true, + isEnabled: true, + isButton: true, + isFocusable: true, + isFocused: false, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ), + tester.updateNode( + id: 3, + label: 'Button 2', + hasTap: true, + hasEnabledState: true, + isEnabled: true, + isButton: true, + isFocusable: true, + isFocused: false, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ), + ], + ), + ], + ); + tester.apply(); + + expect( + capturedActions, + [ + (2, ui.SemanticsAction.didGainAccessibilityFocus, null), + ], + ); + + semantics().semanticsEnabled = false; + }); + + // Test the scenario of a dialog coming up and containing focusable + // descendants with one of them explicitly requesting focus. The expectation + // is that the dialog will not attempt to auto-focus on anything and let the + // respective descendant take focus. + test('does nothing if a descendant asks for focus explicitly', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final List capturedActions = []; + EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) { + capturedActions.add((event.nodeId, event.type, event.arguments)); + }; + + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + scopesRoute: true, + transform: Matrix4.identity().toFloat64(), + children: [ + tester.updateNode( + id: 1, + children: [ + tester.updateNode( + id: 2, + label: 'Button 1', + hasTap: true, + hasEnabledState: true, + isEnabled: true, + isButton: true, + isFocusable: true, + isFocused: false, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ), + tester.updateNode( + id: 3, + label: 'Button 2', + hasTap: true, + hasEnabledState: true, + isEnabled: true, + isButton: true, + isFocusable: true, + // Asked for focus explicitly. + isFocused: true, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ), + ], + ), + ], + ); + tester.apply(); + + expect( + capturedActions, + [ + (3, ui.SemanticsAction.didGainAccessibilityFocus, null), + ], + ); + + semantics().semanticsEnabled = false; + }); + + // Test the scenario of a dialog coming up and containing non-focusable + // descendants that can have a11y focus. The expectation is that the first + // descendant will be auto-focused, even if it's not input-focusable. + test('focuses on the first non-focusable descedant', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final List capturedActions = []; + EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) { + capturedActions.add((event.nodeId, event.type, event.arguments)); + }; + + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + scopesRoute: true, + transform: Matrix4.identity().toFloat64(), + children: [ + tester.updateNode( + id: 1, + children: [ + tester.updateNode( + id: 2, + label: 'Heading', + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ), + tester.updateNode( + id: 3, + label: 'Click me!', + hasTap: true, + hasEnabledState: true, + isEnabled: true, + isButton: true, + isFocusable: true, + isFocused: false, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ), + ], + ), + ], + ); + tester.apply(); + + // The focused node is not focusable, so no notification is sent to the + // framework. + expect(capturedActions, isEmpty); + + // However, the element should have gotten the focus. + final DomElement element = owner().debugSemanticsTree![2]!.element; + expect(element.tabIndex, -1); + expect(domDocument.activeElement, element); + + semantics().semanticsEnabled = false; + }); + + // This mostly makes sure the engine doesn't crash if given a completely empty + // dialog trying to find something to focus on. + test('does nothing if nothing is focusable inside the dialog', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final List capturedActions = []; + EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) { + capturedActions.add((event.nodeId, event.type, event.arguments)); + }; + + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + scopesRoute: true, + transform: Matrix4.identity().toFloat64(), + ); + tester.apply(); + + expect(capturedActions, isEmpty); + expect(domDocument.activeElement, domDocument.body); + + semantics().semanticsEnabled = false; + }); } typedef CapturedAction = (int nodeId, ui.SemanticsAction action, Object? args); @@ -2912,11 +3147,16 @@ void _testFocusable() { // Give up focus manager.changeFocus(false); pumpSemantics(); // triggers post-update callbacks + expect(capturedActions, isEmpty); + expect(domDocument.activeElement, element); + + // Browser blurs the element + element.blur(); + expect(domDocument.activeElement, isNot(element)); expect(capturedActions, [ (1, ui.SemanticsAction.didLoseAccessibilityFocus, null), ]); capturedActions.clear(); - expect(domDocument.activeElement, isNot(element)); // Request focus again manager.changeFocus(true); @@ -2927,20 +3167,29 @@ void _testFocusable() { ]); capturedActions.clear(); + // Double-request focus + manager.changeFocus(true); + pumpSemantics(); // triggers post-update callbacks + expect(domDocument.activeElement, element); + expect( + reason: 'Nothing should be sent to the framework on focus re-request.', + capturedActions, isEmpty); + capturedActions.clear(); + // Stop managing manager.stopManaging(); pumpSemantics(); // triggers post-update callbacks expect( - reason: 'Even though the element was blurred after stopManaging there ' - 'should be no notification to the framework because the framework ' - 'should already know. Otherwise, it would not have asked to stop ' - 'managing the node.', + reason: 'There should be no notification to the framework because the ' + 'framework should already know. Otherwise, it would not have ' + 'asked to stop managing the node.', capturedActions, isEmpty, ); - expect(domDocument.activeElement, isNot(element)); + expect(domDocument.activeElement, element); // Attempt to request focus when not managing an element. + element.blur(); manager.changeFocus(true); pumpSemantics(); // triggers post-update callbacks expect( diff --git a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme index b1341fc8d5c2a..cb0776ee41f7b 100644 --- a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme +++ b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme @@ -26,8 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - systemAttachmentLifetime = "keepNever"> + shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES">