Skip to content

Commit

Permalink
[web:a11y] add platform view role (flutter#44188)
Browse files Browse the repository at this point in the history
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 flutter/flutter#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.
  • Loading branch information
yjbanov authored Aug 1, 2023
1 parent c203443 commit afe200d
Show file tree
Hide file tree
Showing 11 changed files with 116 additions and 12 deletions.
2 changes: 2 additions & 0 deletions ci/licenses_golden/licenses_flutter
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ class PlatformViewManager {
/// The resulting DOM for the `contents` of a Platform View looks like this:
///
/// ```html
/// <flt-platform-view slot="...">
/// <flt-platform-view id="flt-pv-VIEW_ID" slot="...">
/// <arbitrary-html-elements />
/// </flt-platform-view-slot>
/// ```
Expand All @@ -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]!;
Expand Down
6 changes: 6 additions & 0 deletions lib/web_ui/lib/src/engine/platform_views/slots.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
42 changes: 42 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/platform_view.dart
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
24 changes: 17 additions & 7 deletions lib/web_ui/lib/src/engine/semantics/semantics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%');
Expand Down
8 changes: 8 additions & 0 deletions lib/web_ui/test/engine/platform_views/slots_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,12 @@ void testMain() {
});
});
});

test('getPlatformViewSlotName', () {
expect(getPlatformViewSlotName(42), 'flt-pv-slot-42');
});

test('getPlatformViewDomId', () {
expect(getPlatformViewDomId(42), 'flt-pv-42');
});
}
36 changes: 34 additions & 2 deletions lib/web_ui/test/engine/semantics/semantics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('<sem aria-owns="flt-pv-5" style="$rootSemanticStyle"></sem>');
}

// 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('<sem aria-owns="flt-pv-42" style="$rootSemanticStyle"></sem>');
}

semantics().semanticsEnabled = false;
});

test('is transparent w.r.t. hit testing', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
Expand All @@ -2290,7 +2322,7 @@ void _testPlatformView() {
);
semantics().updateSemantics(builder.build());

expectSemanticsTree('<sem style="$rootSemanticStyle"></sem>');
expectSemanticsTree('<sem aria-owns="flt-pv-5" style="$rootSemanticStyle"></sem>');
final DomElement element = appHostNode.querySelector('flt-semantics')!;
expect(element.style.pointerEvents, 'none');

Expand Down Expand Up @@ -2375,7 +2407,7 @@ void _testPlatformView() {
<sem style="$rootSemanticStyle">
<sem-c>
<sem style="z-index: 3"></sem>
<sem style="z-index: 2"></sem>
<sem style="z-index: 2" aria-owns="flt-pv-0"></sem>
<sem style="z-index: 1"></sem>
</sem-c>
</sem>''');
Expand Down
2 changes: 1 addition & 1 deletion lib/web_ui/test/engine/semantics/semantics_tester.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit afe200d

Please sign in to comment.