From 16427987ba25357fbc59d7fbdf60bdf83a5b8644 Mon Sep 17 00:00:00 2001 From: Juan Tugores Date: Wed, 7 Feb 2024 12:28:49 -0800 Subject: [PATCH] Make the web engine publish view forward focus and unfocus events --- ci/licenses_golden/licenses_flutter | 2 + lib/web_ui/lib/src/engine.dart | 1 + lib/web_ui/lib/src/engine/dom.dart | 4 + .../lib/src/engine/platform_dispatcher.dart | 2 + .../view_focus_binding.dart | 79 ++++++++++ .../view_embedder/global_html_attributes.dart | 5 +- .../view_focus_binding_test.dart | 147 ++++++++++++++++++ 7 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart create mode 100644 lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index e87ee01ef7089..298ec2069424b 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -6002,6 +6002,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/onscreen_logging.dart + ../.. ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/picture.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/message_handler.dart + ../../../flutter/LICENSE @@ -8842,6 +8843,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/onscreen_logging.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/picture.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher/app_lifecycle_state.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/message_handler.dart diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index 14bda2390fdcc..9697966fe3bf0 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -124,6 +124,7 @@ export 'engine/onscreen_logging.dart'; export 'engine/picture.dart'; export 'engine/platform_dispatcher.dart'; export 'engine/platform_dispatcher/app_lifecycle_state.dart'; +export 'engine/platform_dispatcher/view_focus_binding.dart'; export 'engine/platform_views.dart'; export 'engine/platform_views/content_manager.dart'; export 'engine/platform_views/message_handler.dart'; diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index 943307d27b393..8f336de619eab 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -608,6 +608,10 @@ extension DomElementExtension on DomElement { external DomElement? _querySelector(JSString selectors); DomElement? querySelector(String selectors) => _querySelector(selectors.toJS); + @JS('closest') + external DomElement? _closest(JSString selectors); + DomElement? closest(String selectors) => _closest(selectors.toJS); + @JS('matches') external JSBoolean _matches(JSString selectors); bool matches(String selectors) => _matches(selectors.toJS).toDart; diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 5dac8c3a0250d..3be345954a669 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -78,6 +78,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { _addLocaleChangedListener(); registerHotRestartListener(dispose); AppLifecycleState.instance.addListener(_setAppLifecycleState); + ViewFocusBinding.instance.addListener(invokeOnViewFocusChange); _onViewDisposedListener = viewManager.onViewDisposed.listen((_) { // Send a metrics changed event to the framework when a view is disposed. // View creation/resize is handled by the `_didResize` handler in the @@ -114,6 +115,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { _removeLocaleChangedListener(); HighContrastSupport.instance.removeListener(_updateHighContrast); AppLifecycleState.instance.removeListener(_setAppLifecycleState); + ViewFocusBinding.instance.removeListener(invokeOnViewFocusChange); _onViewDisposedListener.cancel(); viewManager.dispose(); } 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 new file mode 100644 index 0000000000000..7a24036a64beb --- /dev/null +++ b/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart @@ -0,0 +1,79 @@ +// 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: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._(); + + /// The [ViewFocusBinding] singleton. + static final ViewFocusBinding instance = ViewFocusBinding._(); + + final List _listeners = []; + + /// Subscribes the [listener] to [ui.ViewFocusEvent] events. + void addListener(ui.ViewFocusChangeCallback listener) { + if (_listeners.isEmpty) { + domDocument.body?.addEventListener(_focusin, _focusChangeHandler, true); + domDocument.body?.addEventListener(_focusout, _focusChangeHandler, true); + } + _listeners.add(listener); + } + + /// Removes the [listener] from the [ui.ViewFocusEvent] events subscription. + void removeListener(ui.ViewFocusChangeCallback listener) { + _listeners.remove(listener); + if (_listeners.isEmpty) { + domDocument.body?.removeEventListener(_focusin, _focusChangeHandler, true); + domDocument.body?.removeEventListener(_focusout, _focusChangeHandler, true); + } + } + + void _notify(ui.ViewFocusEvent event) { + for (final ui.ViewFocusChangeCallback listener in _listeners) { + listener(event); + } + } + + int? _lastViewId; + late final DomEventListener _focusChangeHandler = createDomEventListener((DomEvent event) { + final int? viewId = _viewId(domDocument.activeElement); + if (viewId == _lastViewId) { + return; + } + + final ui.ViewFocusEvent event; + if (viewId == null) { + event = ui.ViewFocusEvent( + viewId: _lastViewId!, + state: ui.ViewFocusState.unfocused, + direction: ui.ViewFocusDirection.undefined, + ); + } else { + event = ui.ViewFocusEvent( + viewId: viewId, + state: ui.ViewFocusState.focused, + direction: ui.ViewFocusDirection.forward, + ); + } + _lastViewId = viewId; + _notify(event); + }); + + 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); + } + + static const String _focusin = 'focusin'; + static const String _focusout = 'focusout'; +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/global_html_attributes.dart b/lib/web_ui/lib/src/engine/view_embedder/global_html_attributes.dart index 8d23f177c8014..1af573c93d207 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/global_html_attributes.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/global_html_attributes.dart @@ -17,6 +17,9 @@ import '../dom.dart'; class GlobalHtmlAttributes { GlobalHtmlAttributes({required this.rootElement, required this.hostElement}); + /// The [FlutterView.viewId] attribute name. + static const String flutterViewIdAttributeName = 'flt-view-id'; + final DomElement rootElement; final DomElement hostElement; @@ -34,7 +37,7 @@ class GlobalHtmlAttributes { // Example: // // document.querySelector('flutter-view[flt-view-id="$viewId"]') - rootElement.setAttribute('flt-view-id', viewId); + rootElement.setAttribute(flutterViewIdAttributeName, viewId); // How was the current renderer selected? final String rendererSelection = autoDetectRenderer 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 new file mode 100644 index 0000000000000..ecac6a35489db --- /dev/null +++ b/lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart @@ -0,0 +1,147 @@ +// 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'; +import 'package:ui/ui.dart' as ui; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + group(ViewFocusBinding, () { + late EnginePlatformDispatcher platformDispatcher; + + setUp(() { + platformDispatcher = EnginePlatformDispatcher.instance; + domDocument.activeElement?.blur(); + }); + + 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); + }); + + 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); + }); + + 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(); + + 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, view1.viewId); + expect(viewFocusEvents[1].state, ui.ViewFocusState.unfocused); + expect(viewFocusEvents[1].direction, ui.ViewFocusDirection.undefined); + + expect(viewFocusEvents[2].viewId, view2.viewId); + expect(viewFocusEvents[2].state, ui.ViewFocusState.focused); + expect(viewFocusEvents[2].direction, ui.ViewFocusDirection.forward); + }); + + 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(4)); + + expect(viewFocusEvents[0].viewId, view1.viewId); + expect(viewFocusEvents[0].state, ui.ViewFocusState.focused); + expect(viewFocusEvents[0].direction, ui.ViewFocusDirection.forward); + + expect(viewFocusEvents[1].viewId, view1.viewId); + expect(viewFocusEvents[1].state, ui.ViewFocusState.unfocused); + expect(viewFocusEvents[1].direction, ui.ViewFocusDirection.undefined); + + expect(viewFocusEvents[2].viewId, view2.viewId); + expect(viewFocusEvents[2].state, ui.ViewFocusState.focused); + expect(viewFocusEvents[2].direction, ui.ViewFocusDirection.forward); + + expect(viewFocusEvents[3].viewId, view2.viewId); + expect(viewFocusEvents[3].state, ui.ViewFocusState.unfocused); + expect(viewFocusEvents[3].direction, ui.ViewFocusDirection.undefined); + }); + }); +}