From 6136444a48515bd863d95f888b844efebe91bd2f Mon Sep 17 00:00:00 2001 From: Yegor Date: Tue, 1 Aug 2023 15:25:07 -0700 Subject: [PATCH] [web:a11y] add platform view role (flutter/engine#44188) Add `PlatformViewRoleManager`, the primary role manager for platform views. Currently, all it does is manager the `aria-owns` attribute that determines the screen reader traversal order of the platform view w.r.t. surrounding content. This is a partial fix for https://github.com/flutter/flutter/issues/124765. While it does not address literally using the TAB key as a means for traversing widgets, it does address traversal via screen reader shortcuts. --- .../ci/licenses_golden/licenses_flutter | 2 + .../flutter/lib/web_ui/lib/src/engine.dart | 1 + .../platform_views/content_manager.dart | 3 +- .../lib/src/engine/platform_views/slots.dart | 6 +++ .../lib/web_ui/lib/src/engine/semantics.dart | 1 + .../src/engine/semantics/platform_view.dart | 42 +++++++++++++++++++ .../lib/src/engine/semantics/semantics.dart | 24 +++++++---- .../platform_views/content_manager_test.dart | 3 +- .../engine/platform_views/slots_test.dart | 8 ++++ .../test/engine/semantics/semantics_test.dart | 36 +++++++++++++++- .../engine/semantics/semantics_tester.dart | 2 +- 11 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 engine/src/flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index d0b72e31ac..95fc04d7ca 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -2029,6 +2029,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics_helper.dart + ../../../flutter/LICENSE @@ -4726,6 +4727,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics_helper.dart diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index 91ddf97bde..70f3b31de8 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -138,6 +138,7 @@ export 'engine/semantics/image.dart'; export 'engine/semantics/incrementable.dart'; export 'engine/semantics/label_and_value.dart'; export 'engine/semantics/live_region.dart'; +export 'engine/semantics/platform_view.dart'; export 'engine/semantics/scrollable.dart'; export 'engine/semantics/semantics.dart'; export 'engine/semantics/semantics_helper.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart index b5579bc4de..73d951596a 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart @@ -108,7 +108,7 @@ class PlatformViewManager { /// The resulting DOM for the `contents` of a Platform View looks like this: /// /// ```html - /// + /// /// /// /// ``` @@ -134,6 +134,7 @@ class PlatformViewManager { return _contents.putIfAbsent(viewId, () { final DomElement wrapper = domDocument .createElement('flt-platform-view') + ..id = getPlatformViewDomId(viewId) ..setAttribute('slot', slotName); final Function factoryFunction = _factories[viewType]!; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/slots.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/slots.dart index 509ebcbf8a..b0305834cf 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/slots.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/slots.dart @@ -13,6 +13,12 @@ String getPlatformViewSlotName(int viewId) { return 'flt-pv-slot-$viewId'; } +/// Returns the value of the HTML "id" attribute set on the wrapper element that +/// hosts the platform view content. +String getPlatformViewDomId(int viewId) { + return 'flt-pv-$viewId'; +} + /// Creates the HTML markup for the `slot` of a Platform View. /// /// The resulting DOM for a `slot` looks like this: diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart index 201478e912..d1d58ce4a6 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart @@ -9,6 +9,7 @@ export 'semantics/image.dart'; export 'semantics/incrementable.dart'; export 'semantics/label_and_value.dart'; export 'semantics/live_region.dart'; +export 'semantics/platform_view.dart'; export 'semantics/scrollable.dart'; export 'semantics/semantics.dart'; export 'semantics/semantics_helper.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart new file mode 100644 index 0000000000..80321fc77e --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart @@ -0,0 +1,42 @@ +// 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 '../dom.dart'; +import '../platform_views/slots.dart'; +import 'semantics.dart'; + +/// Manages the semantic element corresponding to a platform view. +/// +/// The element in the semantics tree exists only to supply the ARIA traversal +/// order. The actual content of the platform view is managed by +/// [PlatformViewManager]. +/// +/// The traversal order is established using "aria-owns", by pointing to the +/// element that hosts the view contents. As of this writing, Safari on macOS +/// and on iOS does not support "aria-owns". All other browsers on all operating +/// systems support it. +/// +/// See also: +/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-owns +/// * https://bugs.webkit.org/show_bug.cgi?id=223798 +class PlatformViewRoleManager extends PrimaryRoleManager { + PlatformViewRoleManager(SemanticsObject semanticsObject) + : super.withBasics(PrimaryRole.platformView, semanticsObject); + + @override + void update() { + super.update(); + + if (semanticsObject.isPlatformView) { + if (semanticsObject.isPlatformViewIdDirty) { + semanticsObject.element.setAttribute( + 'aria-owns', + getPlatformViewDomId(semanticsObject.platformViewId), + ); + } + } else { + semanticsObject.element.removeAttribute('aria-owns'); + } + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index e30f5f6e11..316c867a5f 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -25,6 +25,7 @@ import 'image.dart'; import 'incrementable.dart'; import 'label_and_value.dart'; import 'live_region.dart'; +import 'platform_view.dart'; import 'scrollable.dart'; import 'semantics_helper.dart'; import 'tappable.dart'; @@ -332,12 +333,6 @@ class SemanticsNodeUpdate { /// Each value corresponds to the most specific role a semantics node plays in /// the semantics tree. enum PrimaryRole { - /// A role used when a more specific role cannot be assigend to - /// a [SemanticsObject]. - /// - /// Provides a label or a value. - generic, - /// Supports incrementing and/or decrementing its value. incrementable, @@ -378,6 +373,15 @@ enum PrimaryRole { /// children. For example, a modal barrier has `scopesRoute` set but marking /// it as a dialog would be wrong. dialog, + + /// The node's primary role is to host a platform view. + platformView, + + /// A role used when a more specific role cannot be assigend to + /// a [SemanticsObject]. + /// + /// Provides a label or a value. + generic, } /// Identifies one of the secondary [RoleManager]s of a [PrimaryRoleManager]. @@ -964,6 +968,9 @@ class SemanticsObject { static const int _platformViewIdIndex = 1 << 23; + /// Whether the [platformViewId] field has been updated but has not been + /// applied to the DOM yet. + bool get isPlatformViewIdDirty => _isDirty(_platformViewIdIndex); void _markPlatformViewIdDirty() { _dirtyFields |= _platformViewIdIndex; } @@ -1462,7 +1469,9 @@ class SemanticsObject { PrimaryRole _getPrimaryRoleIdentifier() { // The most specific role should take precedence. - if (isTextField) { + if (isPlatformView) { + return PrimaryRole.platformView; + } else if (isTextField) { return PrimaryRole.textField; } else if (isIncrementable) { return PrimaryRole.incrementable; @@ -1490,6 +1499,7 @@ class SemanticsObject { PrimaryRole.checkable => Checkable(this), PrimaryRole.dialog => Dialog(this), PrimaryRole.image => ImageRoleManager(this), + PrimaryRole.platformView => PlatformViewRoleManager(this), PrimaryRole.generic => GenericRole(this), }; } diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart index 214d46d079..6de84dbb3a 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart @@ -109,7 +109,8 @@ void testMain() { test('rendered markup contains required attributes', () async { final DomElement content = contentManager.renderContent(viewType, viewId, null); - expect(content.getAttribute('slot'), contains('$viewId')); + expect(content.getAttribute('slot'), getPlatformViewSlotName(viewId)); + expect(content.getAttribute('id'), getPlatformViewDomId(viewId)); final DomElement userContent = content.querySelector('div')!; expect(userContent.style.height, '100%'); diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_views/slots_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_views/slots_test.dart index e694a74072..bc6d175209 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/platform_views/slots_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/platform_views/slots_test.dart @@ -35,4 +35,12 @@ void testMain() { }); }); }); + + test('getPlatformViewSlotName', () { + expect(getPlatformViewSlotName(42), 'flt-pv-slot-42'); + }); + + test('getPlatformViewDomId', () { + expect(getPlatformViewDomId(42), 'flt-pv-42'); + }); } diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index 4c4f51eb0a..674fd04a98 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -2277,6 +2277,38 @@ void _testLiveRegion() { } void _testPlatformView() { + test('sets and updates aria-owns', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + // Set. + { + final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder(); + updateNode( + builder, + platformViewId: 5, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + semantics().updateSemantics(builder.build()); + expectSemanticsTree(''); + } + + // Update. + { + final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder(); + updateNode( + builder, + platformViewId: 42, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + semantics().updateSemantics(builder.build()); + expectSemanticsTree(''); + } + + semantics().semanticsEnabled = false; + }); + test('is transparent w.r.t. hit testing', () async { semantics() ..debugOverrideTimestampFunction(() => _testTime) @@ -2290,7 +2322,7 @@ void _testPlatformView() { ); semantics().updateSemantics(builder.build()); - expectSemanticsTree(''); + expectSemanticsTree(''); final DomElement element = appHostNode.querySelector('flt-semantics')!; expect(element.style.pointerEvents, 'none'); @@ -2375,7 +2407,7 @@ void _testPlatformView() { - + '''); diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart index d8884e7eb9..881acdcfa6 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -301,7 +301,7 @@ class SemanticsTester { currentValueLength: currentValueLength ?? 0, textSelectionBase: textSelectionBase ?? 0, textSelectionExtent: textSelectionExtent ?? 0, - platformViewId: platformViewId ?? 0, + platformViewId: platformViewId ?? -1, scrollChildren: scrollChildren ?? 0, scrollIndex: scrollIndex ?? 0, scrollPosition: scrollPosition ?? 0,