diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index a41aeb2c40f4d..20588af1054f7 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -79,7 +79,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { _addLocaleChangedListener(); registerHotRestartListener(dispose); AppLifecycleState.instance.addListener(_setAppLifecycleState); - ViewFocusBinding.instance.addListener(invokeOnViewFocusChange); + _viewFocusBinding.init(); domDocument.body?.prepend(accessibilityPlaceholder); _onViewDisposedListener = viewManager.onViewDisposed.listen((_) { // Send a metrics changed event to the framework when a view is disposed. @@ -123,7 +123,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { _removeLocaleChangedListener(); HighContrastSupport.instance.removeListener(_updateHighContrast); AppLifecycleState.instance.removeListener(_setAppLifecycleState); - ViewFocusBinding.instance.removeListener(invokeOnViewFocusChange); + _viewFocusBinding.dispose(); accessibilityPlaceholder.remove(); _onViewDisposedListener.cancel(); viewManager.dispose(); @@ -228,6 +228,9 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { } } + late final ViewFocusBinding _viewFocusBinding = + ViewFocusBinding(viewManager, invokeOnViewFocusChange); + @override ui.ViewFocusChangeCallback? get onViewFocusChange => _onViewFocusChange; ui.ViewFocusChangeCallback? _onViewFocusChange; @@ -248,7 +251,6 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { ); } - @override void requestViewFocusChange({ required int viewId, @@ -258,7 +260,6 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { // TODO(tugorez): implement this method. At the moment will be a no op call. } - /// A set of views which have rendered in the current `onBeginFrame` or /// `onDrawFrame` scope. Set? _viewsRenderedInCurrentFrame; diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart b/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart index db0cd066da42b..9f9852bd48ed8 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart @@ -2,47 +2,36 @@ // 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/src/engine.dart'; import 'package:ui/ui.dart' as ui; /// Tracks the [FlutterView]s focus changes. final class ViewFocusBinding { - /// Creates a [ViewFocusBinding] instance. - ViewFocusBinding._(); + ViewFocusBinding(this._viewManager, this._onViewFocusChange); - /// The [ViewFocusBinding] singleton. - static final ViewFocusBinding instance = ViewFocusBinding._(); + final FlutterViewManager _viewManager; + final ui.ViewFocusChangeCallback _onViewFocusChange; - final List _listeners = []; int? _lastViewId; ui.ViewFocusDirection _viewFocusDirection = ui.ViewFocusDirection.forward; - /// Subscribes the [listener] to [ui.ViewFocusEvent] events. - void addListener(ui.ViewFocusChangeCallback listener) { - if (_listeners.isEmpty) { - domDocument.body?.addEventListener(_keyDown, _handleKeyDown); - domDocument.body?.addEventListener(_keyUp, _handleKeyUp); - domDocument.body?.addEventListener(_focusin, _handleFocusin); - domDocument.body?.addEventListener(_focusout, _handleFocusout); - } - _listeners.add(listener); - } + StreamSubscription? _onViewCreatedListener; - /// Removes the [listener] from the [ui.ViewFocusEvent] events subscription. - void removeListener(ui.ViewFocusChangeCallback listener) { - _listeners.remove(listener); - if (_listeners.isEmpty) { - domDocument.body?.removeEventListener(_keyDown, _handleKeyDown); - domDocument.body?.removeEventListener(_keyUp, _handleKeyUp); - domDocument.body?.removeEventListener(_focusin, _handleFocusin); - domDocument.body?.removeEventListener(_focusout, _handleFocusout); - } + void init() { + domDocument.body?.addEventListener(_keyDown, _handleKeyDown); + domDocument.body?.addEventListener(_keyUp, _handleKeyUp); + domDocument.body?.addEventListener(_focusin, _handleFocusin); + domDocument.body?.addEventListener(_focusout, _handleFocusout); + _onViewCreatedListener = _viewManager.onViewCreated.listen(_handleViewCreated); } - void _notify(ui.ViewFocusEvent event) { - for (final ui.ViewFocusChangeCallback listener in _listeners) { - listener(event); - } + void dispose() { + domDocument.body?.removeEventListener(_keyDown, _handleKeyDown); + domDocument.body?.removeEventListener(_keyUp, _handleKeyUp); + domDocument.body?.removeEventListener(_focusin, _handleFocusin); + domDocument.body?.removeEventListener(_focusout, _handleFocusout); + _onViewCreatedListener?.cancel(); } late final DomEventListener _handleFocusin = createDomEventListener((DomEvent event) { @@ -71,6 +60,7 @@ final class ViewFocusBinding { if (viewId == _lastViewId) { return; } + final ui.ViewFocusEvent event; if (viewId == null) { event = ui.ViewFocusEvent( @@ -85,18 +75,44 @@ final class ViewFocusBinding { direction: _viewFocusDirection, ); } + _maybeMarkViewAsFocusable(_lastViewId, reachableByKeyboard: true); + _maybeMarkViewAsFocusable(viewId, reachableByKeyboard: false); _lastViewId = viewId; - _notify(event); + _onViewFocusChange(event); + } + + int? _viewId(DomElement? element) { + final DomElement? rootElement = element?.closest(DomManager.flutterViewTagName); + if (rootElement == null) { + return null; + } + return _viewManager.viewIdForRootElement(rootElement); } - static int? _viewId(DomElement? element) { - final DomElement? viewElement = element?.closest( - DomManager.flutterViewTagName, - ); - final String? viewIdAttribute = viewElement?.getAttribute( - GlobalHtmlAttributes.flutterViewIdAttributeName, - ); - return viewIdAttribute == null ? null : int.tryParse(viewIdAttribute); + void _handleViewCreated(int viewId) { + _maybeMarkViewAsFocusable(viewId, reachableByKeyboard: true); + } + + void _maybeMarkViewAsFocusable( + int? viewId, { + required bool reachableByKeyboard, + }) { + if (viewId == null) { + return; + } + + final DomElement? rootElement = _viewManager[viewId]?.dom.rootElement; + if (EngineSemantics.instance.semanticsEnabled) { + rootElement?.removeAttribute('tabindex'); + } else { + // A tabindex with value zero means the DOM element can be reached by using + // the keyboard (tab, shift + tab). When its value is -1 it is still focusable + // but can't be focused by the result of keyboard events This is specially + // important when the semantics tree is enabled as it puts DOM nodes inside + // the flutter view and having it with a zero tabindex messes the focus + // traversal order when pressing tab or shift tab. + rootElement?.setAttribute('tabindex', reachableByKeyboard ? 0 : -1); + } } static const String _focusin = 'focusin'; diff --git a/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart b/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart index e93956f0325a1..684e5ea667c22 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart @@ -96,6 +96,16 @@ class FlutterViewManager { return _jsViewOptions[viewId]; } + /// Returns the [viewId] if [rootElement] corresponds to any of the [views]. + int? viewIdForRootElement(DomElement rootElement) { + for(final EngineFlutterView view in views) { + if (view.dom.rootElement == rootElement) { + return view.viewId; + } + } + return null; + } + void dispose() { // We need to call `toList()` in order to avoid concurrent modification // inside the loop. diff --git a/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart b/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart index 3cdff41224b97..5080b852c1d4a 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart @@ -116,6 +116,11 @@ void applyGlobalCssRulesToSheet( // Hide placeholder text '$cssSelectorPrefix .flt-text-editing::placeholder {' ' opacity: 0;' + '}' + + // Hide outline when the flutter-view root element is focused. + '$cssSelectorPrefix:focus {' + ' outline: none;' '}', ); diff --git a/lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart b/lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart index d220ed7eff19a..8610d394fff90 100644 --- a/lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart +++ b/lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart @@ -1,6 +1,7 @@ // 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 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; @@ -12,140 +13,168 @@ void main() { void testMain() { group(ViewFocusBinding, () { - late EnginePlatformDispatcher platformDispatcher; + late List dispatchedViewFocusEvents; + late EnginePlatformDispatcher dispatcher; setUp(() { - platformDispatcher = EnginePlatformDispatcher.instance; domDocument.activeElement?.blur(); + EngineSemantics.instance.semanticsEnabled = false; + + dispatcher = EnginePlatformDispatcher.instance; + dispatchedViewFocusEvents = []; + dispatcher.onViewFocusChange = dispatchedViewFocusEvents.add; + }); + + test('The view is focusable and reachable by keyboard when registered', () async { + final EngineFlutterView view = createAndRegisterView(dispatcher); + + + // The root element should have a tabindex="0" to make the flutter view + // focusable and reachable by the keyboard. + expect(view.dom.rootElement.getAttribute('tabindex'), '0'); + }); + + test('The view is focusable but not reachable by keyboard when focused', () async { + final EngineFlutterView view = createAndRegisterView(dispatcher); + + view.dom.rootElement.focus(); + + // The root element should have a tabindex="-1" to make the flutter view + // focusable but not reachable by the keyboard. + expect(view.dom.rootElement.getAttribute('tabindex'), '-1'); + }); + + test('marks the focusable views as reachable by the keyboard or not', () async { + final EngineFlutterView view1 = createAndRegisterView(dispatcher); + final EngineFlutterView view2 = createAndRegisterView(dispatcher); + + expect(view1.dom.rootElement.getAttribute('tabindex'), '0'); + expect(view2.dom.rootElement.getAttribute('tabindex'), '0'); + + view1.dom.rootElement.focus(); + expect(view1.dom.rootElement.getAttribute('tabindex'), '-1'); + expect(view2.dom.rootElement.getAttribute('tabindex'), '0'); + + view2.dom.rootElement.focus(); + expect(view1.dom.rootElement.getAttribute('tabindex'), '0'); + expect(view2.dom.rootElement.getAttribute('tabindex'), '-1'); + + view2.dom.rootElement.blur(); + expect(view1.dom.rootElement.getAttribute('tabindex'), '0'); + expect(view2.dom.rootElement.getAttribute('tabindex'), '0'); + }); + + test('never marks the views as focusable with semantincs enabled', () async { + EngineSemantics.instance.semanticsEnabled = true; + + final EngineFlutterView view1 = createAndRegisterView(dispatcher); + final EngineFlutterView view2 = createAndRegisterView(dispatcher); + + expect(view1.dom.rootElement.getAttribute('tabindex'), isNull); + expect(view2.dom.rootElement.getAttribute('tabindex'), isNull); + + view1.dom.rootElement.focus(); + expect(view1.dom.rootElement.getAttribute('tabindex'), isNull); + expect(view2.dom.rootElement.getAttribute('tabindex'), isNull); + + view2.dom.rootElement.focus(); + expect(view1.dom.rootElement.getAttribute('tabindex'), isNull); + expect(view2.dom.rootElement.getAttribute('tabindex'), isNull); + + view2.dom.rootElement.blur(); + expect(view1.dom.rootElement.getAttribute('tabindex'), isNull); + expect(view2.dom.rootElement.getAttribute('tabindex'), isNull); }); test('fires a focus event - a view was focused', () async { - final List viewFocusEvents = []; - final DomElement div = createDomElement('div'); - final EngineFlutterView view = EngineFlutterView(platformDispatcher, div); - final DomElement focusableViewElement = div - .querySelector(DomManager.flutterViewTagName)! - ..setAttribute('tabindex', 0); - - platformDispatcher.onViewFocusChange = viewFocusEvents.add; - domDocument.body!.append(div); - focusableViewElement.focus(); - - expect(viewFocusEvents, hasLength(1)); - - expect(viewFocusEvents[0].viewId, view.viewId); - expect(viewFocusEvents[0].state, ui.ViewFocusState.focused); - expect(viewFocusEvents[0].direction, ui.ViewFocusDirection.forward); + final EngineFlutterView view = createAndRegisterView(dispatcher); + + view.dom.rootElement.focus(); + + expect(dispatchedViewFocusEvents, hasLength(1)); + + expect(dispatchedViewFocusEvents[0].viewId, view.viewId); + expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused); + expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward); }); test('fires a focus event - a view was unfocused', () async { - final List viewFocusEvents = []; - final DomElement div = createDomElement('div'); - final EngineFlutterView view = EngineFlutterView(platformDispatcher, div); - final DomElement focusableViewElement = div - .querySelector(DomManager.flutterViewTagName)! - ..setAttribute('tabindex', 0); - - platformDispatcher.onViewFocusChange = viewFocusEvents.add; - domDocument.body!.append(div); - focusableViewElement.focus(); - focusableViewElement.blur(); - - expect(viewFocusEvents, hasLength(2)); - - expect(viewFocusEvents[0].viewId, view.viewId); - expect(viewFocusEvents[0].state, ui.ViewFocusState.focused); - expect(viewFocusEvents[0].direction, ui.ViewFocusDirection.forward); - - expect(viewFocusEvents[1].viewId, view.viewId); - expect(viewFocusEvents[1].state, ui.ViewFocusState.unfocused); - expect(viewFocusEvents[1].direction, ui.ViewFocusDirection.undefined); + final EngineFlutterView view = createAndRegisterView(dispatcher); + + view.dom.rootElement.focus(); + view.dom.rootElement.blur(); + + expect(dispatchedViewFocusEvents, hasLength(2)); + + expect(dispatchedViewFocusEvents[0].viewId, view.viewId); + expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused); + expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward); + + expect(dispatchedViewFocusEvents[1].viewId, view.viewId); + expect(dispatchedViewFocusEvents[1].state, ui.ViewFocusState.unfocused); + expect(dispatchedViewFocusEvents[1].direction, ui.ViewFocusDirection.undefined); }); test('fires a focus event - focus transitions between views', () async { - final List viewFocusEvents = []; - final DomElement div1 = createDomElement('div'); - final DomElement div2 = createDomElement('div'); - final EngineFlutterView view1 = - EngineFlutterView(platformDispatcher, div1); - final EngineFlutterView view2 = - EngineFlutterView(platformDispatcher, div2); - final DomElement focusableViewElement1 = div1 - .querySelector(DomManager.flutterViewTagName)! - ..setAttribute('tabindex', 0); - final DomElement focusableViewElement2 = div2 - .querySelector(DomManager.flutterViewTagName)! - ..setAttribute('tabindex', 0); - - domDocument.body!.append(div1); - domDocument.body!.append(div2); - - platformDispatcher.onViewFocusChange = viewFocusEvents.add; - - focusableViewElement1.focus(); - focusableViewElement2.focus(); + final EngineFlutterView view1 = createAndRegisterView(dispatcher); + final EngineFlutterView view2 = createAndRegisterView(dispatcher); + + view1.dom.rootElement.focus(); + view2.dom.rootElement.focus(); // The statements simulate the user pressing shift + tab in the keyboard. // Synthetic keyboard events do not trigger focus changes. domDocument.body!.pressTabKey(shift: true); - focusableViewElement1.focus(); + view1.dom.rootElement.focus(); domDocument.body!.releaseTabKey(); - expect(viewFocusEvents, hasLength(3)); + expect(dispatchedViewFocusEvents, hasLength(3)); - expect(viewFocusEvents[0].viewId, view1.viewId); - expect(viewFocusEvents[0].state, ui.ViewFocusState.focused); - expect(viewFocusEvents[0].direction, ui.ViewFocusDirection.forward); + expect(dispatchedViewFocusEvents[0].viewId, view1.viewId); + expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused); + expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward); - expect(viewFocusEvents[1].viewId, view2.viewId); - expect(viewFocusEvents[1].state, ui.ViewFocusState.focused); - expect(viewFocusEvents[1].direction, ui.ViewFocusDirection.forward); + expect(dispatchedViewFocusEvents[1].viewId, view2.viewId); + expect(dispatchedViewFocusEvents[1].state, ui.ViewFocusState.focused); + expect(dispatchedViewFocusEvents[1].direction, ui.ViewFocusDirection.forward); - expect(viewFocusEvents[2].viewId, view1.viewId); - expect(viewFocusEvents[2].state, ui.ViewFocusState.focused); - expect(viewFocusEvents[2].direction, ui.ViewFocusDirection.backward); + expect(dispatchedViewFocusEvents[2].viewId, view1.viewId); + expect(dispatchedViewFocusEvents[2].state, ui.ViewFocusState.focused); + expect(dispatchedViewFocusEvents[2].direction, ui.ViewFocusDirection.backward); }); test('fires a focus event - focus transitions on and off views', () async { - final List viewFocusEvents = []; - final DomElement div1 = createDomElement('div'); - final DomElement div2 = createDomElement('div'); - final EngineFlutterView view1 = - EngineFlutterView(platformDispatcher, div1); - final EngineFlutterView view2 = - EngineFlutterView(platformDispatcher, div2); - final DomElement focusableViewElement1 = div1 - .querySelector(DomManager.flutterViewTagName)! - ..setAttribute('tabindex', 0); - final DomElement focusableViewElement2 = div2 - .querySelector(DomManager.flutterViewTagName)! - ..setAttribute('tabindex', 0); - - domDocument.body!.append(div1); - domDocument.body!.append(div2); - - platformDispatcher.onViewFocusChange = viewFocusEvents.add; - - focusableViewElement1.focus(); - focusableViewElement2.focus(); - focusableViewElement2.blur(); - - expect(viewFocusEvents, hasLength(3)); - - expect(viewFocusEvents[0].viewId, view1.viewId); - expect(viewFocusEvents[0].state, ui.ViewFocusState.focused); - expect(viewFocusEvents[0].direction, ui.ViewFocusDirection.forward); - - expect(viewFocusEvents[1].viewId, view2.viewId); - expect(viewFocusEvents[1].state, ui.ViewFocusState.focused); - expect(viewFocusEvents[1].direction, ui.ViewFocusDirection.forward); - - expect(viewFocusEvents[2].viewId, view2.viewId); - expect(viewFocusEvents[2].state, ui.ViewFocusState.unfocused); - expect(viewFocusEvents[2].direction, ui.ViewFocusDirection.undefined); + final EngineFlutterView view1 = createAndRegisterView(dispatcher); + final EngineFlutterView view2 = createAndRegisterView(dispatcher); + + view1.dom.rootElement.focus(); + view2.dom.rootElement.focus(); + view2.dom.rootElement.blur(); + + expect(dispatchedViewFocusEvents, hasLength(3)); + + expect(dispatchedViewFocusEvents[0].viewId, view1.viewId); + expect(dispatchedViewFocusEvents[0].state, ui.ViewFocusState.focused); + expect(dispatchedViewFocusEvents[0].direction, ui.ViewFocusDirection.forward); + + expect(dispatchedViewFocusEvents[1].viewId, view2.viewId); + expect(dispatchedViewFocusEvents[1].state, ui.ViewFocusState.focused); + expect(dispatchedViewFocusEvents[1].direction, ui.ViewFocusDirection.forward); + + expect(dispatchedViewFocusEvents[2].viewId, view2.viewId); + expect(dispatchedViewFocusEvents[2].state, ui.ViewFocusState.unfocused); + expect(dispatchedViewFocusEvents[2].direction, ui.ViewFocusDirection.undefined); }); }); } +EngineFlutterView createAndRegisterView(EnginePlatformDispatcher dispatcher) { + final DomElement div = createDomElement('div'); + final EngineFlutterView view = EngineFlutterView(dispatcher, div); + domDocument.body!.append(div); + dispatcher.viewManager.registerView(view); + return view; +} + extension on DomElement { void pressTabKey({bool shift = false}) { dispatchKeyboardEvent(type: 'keydown', key: 'Tab', shiftKey: shift); diff --git a/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart b/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart index 04fb618e9dc6c..5dbec513268d0 100644 --- a/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart +++ b/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart @@ -101,5 +101,15 @@ Future doTests() async { reason: 'Should fire dispose event for view'); }); }); + + group('viewIdForRootElement', () { + test('works', () { + final EngineFlutterView view = EngineFlutterView(platformDispatcher, createDomElement('div')); + final int viewId = view.viewId; + viewManager.registerView(view); + + expect(viewManager.viewIdForRootElement(view.dom.rootElement), viewId); + }); + }); }); } diff --git a/lib/web_ui/test/engine/view_embedder/style_manager_test.dart b/lib/web_ui/test/engine/view_embedder/style_manager_test.dart index bff6acc86b1e2..97634fef7c006 100644 --- a/lib/web_ui/test/engine/view_embedder/style_manager_test.dart +++ b/lib/web_ui/test/engine/view_embedder/style_manager_test.dart @@ -14,6 +14,24 @@ void main() { void doTests() { group('StyleManager', () { + test('attachGlobalStyles hides the outline when focused', () { + final DomElement flutterViewElement = createDomElement(DomManager.flutterViewTagName); + + domDocument.body!.append(flutterViewElement); + StyleManager.attachGlobalStyles( + node: flutterViewElement, + styleId: 'testing', + styleNonce: 'testing', + cssSelectorPrefix: DomManager.flutterViewTagName, + ); + final String expected = browserEngine == BrowserEngine.firefox + ? 'rgb(0, 0, 0) 0px' + : 'rgb(0, 0, 0) none 0px'; + final String got = domWindow.getComputedStyle(flutterViewElement, 'focus').outline; + + expect(got, expected); + }); + test('styleSceneHost', () { expect( () => StyleManager.styleSceneHost(createDomHTMLDivElement()),