From 50b5eedd10d36f6b0b210c8cfc15c65994fe3a59 Mon Sep 17 00:00:00 2001 From: Yegor Jbanov Date: Mon, 31 Jul 2023 14:24:22 -0700 Subject: [PATCH] [web:a11y] add platform view role --- .../platform_views/content_manager.dart | 3 +- .../lib/src/engine/platform_views/slots.dart | 6 +++ .../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 +- 8 files changed, 112 insertions(+), 12 deletions(-) create mode 100644 lib/web_ui/lib/src/engine/semantics/platform_view.dart diff --git a/lib/web_ui/lib/src/engine/platform_views/content_manager.dart b/lib/web_ui/lib/src/engine/platform_views/content_manager.dart index b5579bc4def25..c0145a553dd08 100644 --- a/lib/web_ui/lib/src/engine/platform_views/content_manager.dart +++ b/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/lib/web_ui/lib/src/engine/platform_views/slots.dart b/lib/web_ui/lib/src/engine/platform_views/slots.dart index 509ebcbf8ab96..b0305834cf9d3 100644 --- a/lib/web_ui/lib/src/engine/platform_views/slots.dart +++ b/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/lib/web_ui/lib/src/engine/semantics/platform_view.dart b/lib/web_ui/lib/src/engine/semantics/platform_view.dart new file mode 100644 index 0000000000000..80321fc77e03e --- /dev/null +++ b/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/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index e30f5f6e1166e..316c867a5fef0 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/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/lib/web_ui/test/engine/platform_views/content_manager_test.dart b/lib/web_ui/test/engine/platform_views/content_manager_test.dart index 214d46d079dc0..6de84dbb3ab99 100644 --- a/lib/web_ui/test/engine/platform_views/content_manager_test.dart +++ b/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/lib/web_ui/test/engine/platform_views/slots_test.dart b/lib/web_ui/test/engine/platform_views/slots_test.dart index e694a74072eaa..bc6d175209c8d 100644 --- a/lib/web_ui/test/engine/platform_views/slots_test.dart +++ b/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/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 4c4f51eb0a49b..674fd04a98e6b 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/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/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index d8884e7eb955a..881acdcfa6fb9 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/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,