diff --git a/lib/ui/fixtures/ui_test.dart b/lib/ui/fixtures/ui_test.dart index 492a234b859c4..a02e0ce29499b 100644 --- a/lib/ui/fixtures/ui_test.dart +++ b/lib/ui/fixtures/ui_test.dart @@ -233,6 +233,7 @@ void sendSemanticsUpdate() { childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, headingLevel: 0, + linkUrl: '', ); _semanticsUpdate(builder.build()); } diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index 57bc1fa30726d..4500c08629cf3 100644 --- a/lib/ui/semantics.dart +++ b/lib/ui/semantics.dart @@ -849,6 +849,9 @@ abstract class SemanticsUpdateBuilder { /// inclusive. This attribute is only used for Web platform, and it will have /// no effect on other platforms. /// + /// The `linkUrl` describes the URI that this node links to. If the node is + /// not a link, this should be an empty string. + /// /// See also: /// /// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role @@ -888,6 +891,7 @@ abstract class SemanticsUpdateBuilder { required Int32List childrenInHitTestOrder, required Int32List additionalActions, int headingLevel = 0, + String linkUrl = '', }); /// Update the custom semantics action associated with the given `id`. @@ -959,6 +963,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem required Int32List childrenInHitTestOrder, required Int32List additionalActions, int headingLevel = 0, + String linkUrl = '', }) { assert(_matrix4IsValid(transform)); assert ( @@ -1003,6 +1008,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem childrenInHitTestOrder, additionalActions, headingLevel, + linkUrl, ); } @Native< @@ -1044,7 +1050,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem Handle, Handle, Handle, - Int32)>(symbol: 'SemanticsUpdateBuilder::updateNode') + Int32, + Handle)>(symbol: 'SemanticsUpdateBuilder::updateNode') external void _updateNode( int id, int flags, @@ -1082,7 +1089,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem Int32List childrenInTraversalOrder, Int32List childrenInHitTestOrder, Int32List additionalActions, - int headingLevel); + int headingLevel, + String linkUrl); @override void updateCustomAction({required int id, String? label, String? hint, int overrideId = -1}) { diff --git a/lib/ui/semantics/semantics_node.h b/lib/ui/semantics/semantics_node.h index c27ac35f00a5e..c83990d7ed94c 100644 --- a/lib/ui/semantics/semantics_node.h +++ b/lib/ui/semantics/semantics_node.h @@ -144,6 +144,8 @@ struct SemanticsNode { std::vector childrenInHitTestOrder; std::vector customAccessibilityActions; int32_t headingLevel = 0; + + std::string linkUrl; }; // Contains semantic nodes that need to be updated. diff --git a/lib/ui/semantics/semantics_update_builder.cc b/lib/ui/semantics/semantics_update_builder.cc index 96eb0ce2e1c5e..b853d3ae5226f 100644 --- a/lib/ui/semantics/semantics_update_builder.cc +++ b/lib/ui/semantics/semantics_update_builder.cc @@ -67,7 +67,8 @@ void SemanticsUpdateBuilder::updateNode( const tonic::Int32List& childrenInTraversalOrder, const tonic::Int32List& childrenInHitTestOrder, const tonic::Int32List& localContextActions, - int headingLevel) { + int headingLevel, + std::string linkUrl) { FML_CHECK(scrollChildren == 0 || (scrollChildren > 0 && childrenInHitTestOrder.data())) << "Semantics update contained scrollChildren but did not have " @@ -121,6 +122,7 @@ void SemanticsUpdateBuilder::updateNode( nodes_[id] = node; node.headingLevel = headingLevel; + node.linkUrl = std::move(linkUrl); } void SemanticsUpdateBuilder::updateCustomAction(int id, diff --git a/lib/ui/semantics/semantics_update_builder.h b/lib/ui/semantics/semantics_update_builder.h index cab294b9047a5..e944c251099f9 100644 --- a/lib/ui/semantics/semantics_update_builder.h +++ b/lib/ui/semantics/semantics_update_builder.h @@ -66,7 +66,8 @@ class SemanticsUpdateBuilder const tonic::Int32List& childrenInTraversalOrder, const tonic::Int32List& childrenInHitTestOrder, const tonic::Int32List& customAccessibilityActions, - int headingLevel); + int headingLevel, + std::string linkUrl); void updateCustomAction(int id, std::string label, diff --git a/lib/web_ui/lib/semantics.dart b/lib/web_ui/lib/semantics.dart index e4698e40f2f2c..34242b9830508 100644 --- a/lib/web_ui/lib/semantics.dart +++ b/lib/web_ui/lib/semantics.dart @@ -287,6 +287,7 @@ class SemanticsUpdateBuilder { required Int32List childrenInHitTestOrder, required Int32List additionalActions, int headingLevel = 0, + String? linkUrl, }) { if (transform.length != 16) { throw ArgumentError('transform argument must have 16 entries.'); @@ -326,6 +327,7 @@ class SemanticsUpdateBuilder { additionalActions: additionalActions, platformViewId: platformViewId, headingLevel: headingLevel, + linkUrl: linkUrl, )); } diff --git a/lib/web_ui/lib/src/engine/semantics/link.dart b/lib/web_ui/lib/src/engine/semantics/link.dart index 3108e8cea858a..d957e90d2967b 100644 --- a/lib/web_ui/lib/src/engine/semantics/link.dart +++ b/lib/web_ui/lib/src/engine/semantics/link.dart @@ -18,12 +18,23 @@ class Link extends PrimaryRoleManager { @override DomElement createElement() { final DomElement element = domDocument.createElement('a'); - // TODO(mdebbar): Fill in the real link once the framework sends entire uri. - // https://github.com/flutter/flutter/issues/150263. element.style.display = 'block'; return element; } + @override + void update() { + super.update(); + + if (semanticsObject.isLinkUrlDirty) { + if (semanticsObject.hasLinkUrl) { + element.setAttribute('href', semanticsObject.linkUrl!); + } else { + element.removeAttribute('href'); + } + } + } + @override bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; } diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index c48851d9836a2..28d5de60a9a93 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -234,6 +234,7 @@ class SemanticsNodeUpdate { required this.childrenInHitTestOrder, required this.additionalActions, required this.headingLevel, + this.linkUrl, }); /// See [ui.SemanticsUpdateBuilder.updateNode]. @@ -337,6 +338,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final int headingLevel; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final String? linkUrl; } /// Identifies [PrimaryRoleManager] implementations. @@ -1146,6 +1150,22 @@ class SemanticsObject { _dirtyFields |= _identifierIndex; } + /// See [ui.SemanticsUpdateBuilder.updateNode]. + String? get linkUrl => _linkUrl; + String? _linkUrl; + + /// Whether this object contains a non-empty link URL. + bool get hasLinkUrl => _linkUrl != null && _linkUrl!.isNotEmpty; + + static const int _linkUrlIndex = 1 << 26; + + /// Whether the [linkUrl] field has been updated but has not been + /// applied to the DOM yet. + bool get isLinkUrlDirty => _isDirty(_linkUrlIndex); + void _markLinkUrlDirty() { + _dirtyFields |= _linkUrlIndex; + } + /// A unique permanent identifier of the semantics node in the tree. final int id; @@ -1445,6 +1465,11 @@ class SemanticsObject { _markPlatformViewIdDirty(); } + if (_linkUrl != update.linkUrl) { + _linkUrl = update.linkUrl; + _markLinkUrlDirty(); + } + // Apply updates to the DOM. _updateRoles(); diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 64fa0a54fee44..5dfa87ad1d358 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -3603,6 +3603,28 @@ void _testLink() { expect(object.element.tagName.toLowerCase(), 'a'); expect(object.element.hasAttribute('href'), isFalse); }); + + test('link nodes with linkUrl set the href attribute', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + SemanticsObject pumpSemantics() { + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + isLink: true, + linkUrl: 'https://flutter.dev', + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.element.tagName.toLowerCase(), 'a'); + expect(object.element.getAttribute('href'), 'https://flutter.dev'); + }); } /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that @@ -3645,6 +3667,7 @@ void updateNode( Int32List? childrenInHitTestOrder, Int32List? additionalActions, int headingLevel = 0, + String? linkUrl, }) { transform ??= Float64List.fromList(Matrix4.identity().storage); childrenInTraversalOrder ??= Int32List(0); @@ -3685,6 +3708,7 @@ void updateNode( childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, headingLevel: headingLevel, + linkUrl: linkUrl, ); } diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index f9a626c486cf7..a2d432b430b7a 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -112,6 +112,7 @@ class SemanticsTester { Int32List? additionalActions, List? children, int? headingLevel, + String? linkUrl, }) { // Flags if (hasCheckedState ?? false) { @@ -313,6 +314,7 @@ class SemanticsTester { childrenInHitTestOrder: childIds, additionalActions: additionalActions ?? Int32List(0), headingLevel: headingLevel ?? 0, + linkUrl: linkUrl, ); _nodeUpdates.add(update); return update; diff --git a/shell/platform/embedder/fixtures/main.dart b/shell/platform/embedder/fixtures/main.dart index 0336036f00ee2..96cee3274a02c 100644 --- a/shell/platform/embedder/fixtures/main.dart +++ b/shell/platform/embedder/fixtures/main.dart @@ -178,7 +178,8 @@ Future a11y_main() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), - headingLevel: 0 + headingLevel: 0, + linkUrl: '', ) ..updateNode( id: 84, @@ -214,7 +215,8 @@ Future a11y_main() async { additionalActions: Int32List(0), childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), - headingLevel: 0 + headingLevel: 0, + linkUrl: '', ) ..updateNode( id: 96, @@ -250,7 +252,8 @@ Future a11y_main() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), - headingLevel: 0 + headingLevel: 0, + linkUrl: '', ) ..updateNode( id: 128, @@ -286,7 +289,8 @@ Future a11y_main() async { textDirection: TextDirection.ltr, childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), - headingLevel: 0 + headingLevel: 0, + linkUrl: '', ) ..updateCustomAction( id: 21, @@ -384,6 +388,7 @@ Future a11y_string_attributes() async { textDirection: TextDirection.ltr, additionalActions: Int32List(0), headingLevel: 0, + linkUrl: '', ); PlatformDispatcher.instance.views.first.updateSemantics(builder.build());