From 06d560df06e8eba0a7ba3cd0f2827c707b5f2ffa Mon Sep 17 00:00:00 2001 From: pedromassango Date: Wed, 21 May 2025 00:39:36 +0200 Subject: [PATCH 01/10] base --- .../integration_test/link_widget_test.dart | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index 678848b15a4..e06fe841356 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:url_launcher_platform_interface/link.dart'; @@ -181,6 +182,52 @@ void main() { maxScrolls: 1000, ); }); + + testWidgets('Link widget TAB traversal - expected behavior', + (WidgetTester tester) async { + // Pump the app + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + autofocus: true, + child: Row( + children: [ + WebLinkDelegate(TestLinkInfo( + uri: null, + target: LinkTarget.defaultTarget, + builder: (BuildContext context, FollowLink? followLink) { + return ElevatedButton( + onPressed: () {}, child: const Text('First')); + }, + )), + WebLinkDelegate(TestLinkInfo( + uri: null, + target: LinkTarget.defaultTarget, + builder: (BuildContext context, FollowLink? followLink) { + return ElevatedButton( + onPressed: () {}, child: const Text('Second')); + }, + )), + ], + ), + ), + )); + + // Wait for rendering + await tester.pumpAndSettle(); + + // Send two TAB key presses + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + // Find the second button + final secondButton = find.widgetWithText(ElevatedButton, 'Second'); + final focusNode = Focus.of(tester.element(secondButton)); + + expect(focusNode.hasFocus, isTrue); + }); }); group('Follows links', () { From 11e78256c944fb49c30162c0e1188750933add08 Mon Sep 17 00:00:00 2001 From: pedromassango Date: Fri, 15 Aug 2025 11:31:36 +0200 Subject: [PATCH 02/10] fix: Link widget Tab traversal --- .../integration_test/link_widget_test.dart | 88 ++++++++++--------- .../url_launcher_web/lib/src/link.dart | 1 + 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index e06fe841356..41ebbf54db7 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -5,9 +5,13 @@ import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'dart:typed_data'; +import 'dart:ui' show SemanticsFlag; import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show SemanticsNode; +import 'package:flutter/semantics.dart' + show DebugSemanticsDumpOrder, SemanticsData; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -183,51 +187,55 @@ void main() { ); }); - testWidgets('Link widget TAB traversal - expected behavior', - (WidgetTester tester) async { - // Pump the app - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - autofocus: true, - child: Row( - children: [ - WebLinkDelegate(TestLinkInfo( - uri: null, - target: LinkTarget.defaultTarget, - builder: (BuildContext context, FollowLink? followLink) { - return ElevatedButton( - onPressed: () {}, child: const Text('First')); - }, - )), - WebLinkDelegate(TestLinkInfo( - uri: null, - target: LinkTarget.defaultTarget, - builder: (BuildContext context, FollowLink? followLink) { - return ElevatedButton( - onPressed: () {}, child: const Text('Second')); - }, - )), - ], + testWidgets( + 'excludeSemantics: true ensures clean link semantics without conflicts', + (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Column( + children: [ + WebLinkDelegate( + TestLinkInfo( + uri: Uri.parse('https://dart.dev/xyz'), + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + return ElevatedButton( + onPressed: followLink, + child: const Text('First Button'), + ); + }, + ), ), - ), - )); + ], + ), + ), + )); - // Wait for rendering - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + final SemanticsHandle handle = tester.ensureSemantics(); + + final Finder firstButtonFinder = find.text('First Button'); + expect(firstButtonFinder, findsOneWidget); + + final SemanticsNode firstSemantics = + tester.getSemantics(firstButtonFinder); + final SemanticsData firstData = firstSemantics.getSemanticsData(); - // Send two TAB key presses - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); + handle.dispose(); + expect( + firstData.hasFlag(SemanticsFlag.isLink), + isTrue, + reason: 'Button should be treated as link with excludeSemantics: true', + ); - // Find the second button - final secondButton = find.widgetWithText(ElevatedButton, 'Second'); - final focusNode = Focus.of(tester.element(secondButton)); + expect(firstData.hasFlag(SemanticsFlag.isButton), isFalse, + reason: + 'semantics should be excluded to prevent TAB navigation conflicts', + skip: true); - expect(focusNode.hasFocus, isTrue); - }); + expect(firstData.linkUrl?.toString(), equals('https://dart.dev/xyz')); + handle.dispose(); + }); }); group('Follows links', () { diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart index c24d47876ec..ec759336ea8 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -121,6 +121,7 @@ class WebLinkDelegateState extends State { Widget _buildChild(BuildContext context) { return Semantics( link: true, + excludeSemantics: true, identifier: _semanticsIdentifier, linkUrl: widget.link.uri, child: widget.link.builder( From 68ec601b47338649bcdd004a5709b65e93282b80 Mon Sep 17 00:00:00 2001 From: pedromassango Date: Fri, 15 Aug 2025 11:39:48 +0200 Subject: [PATCH 03/10] wip --- .../example/integration_test/link_widget_test.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index 41ebbf54db7..cc05e8de433 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -9,9 +9,7 @@ import 'dart:ui' show SemanticsFlag; import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show SemanticsNode; -import 'package:flutter/semantics.dart' - show DebugSemanticsDumpOrder, SemanticsData; +import 'package:flutter/rendering.dart' show SemanticsData, SemanticsNode; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -221,7 +219,6 @@ void main() { tester.getSemantics(firstButtonFinder); final SemanticsData firstData = firstSemantics.getSemanticsData(); - handle.dispose(); expect( firstData.hasFlag(SemanticsFlag.isLink), isTrue, From 0b2497c276e30d51509e8e8b83e595a4fd785eb6 Mon Sep 17 00:00:00 2001 From: pedromassango Date: Fri, 15 Aug 2025 11:44:52 +0200 Subject: [PATCH 04/10] wip --- .../example/integration_test/link_widget_test.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index cc05e8de433..f06f47d8395 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -225,10 +225,12 @@ void main() { reason: 'Button should be treated as link with excludeSemantics: true', ); - expect(firstData.hasFlag(SemanticsFlag.isButton), isFalse, - reason: - 'semantics should be excluded to prevent TAB navigation conflicts', - skip: true); + expect( + firstData.hasFlag(SemanticsFlag.isButton), + isFalse, + reason: + 'semantics should be excluded to prevent TAB navigation conflicts', + ); expect(firstData.linkUrl?.toString(), equals('https://dart.dev/xyz')); handle.dispose(); From dc9fec13425245b97f47efdbfe2dd2b6e3ad8c94 Mon Sep 17 00:00:00 2001 From: pedromassango Date: Fri, 15 Aug 2025 12:30:19 +0200 Subject: [PATCH 05/10] wip --- .../example/integration_test/link_widget_test.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index f06f47d8395..704925d2015 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -212,11 +212,10 @@ void main() { await tester.pumpAndSettle(); final SemanticsHandle handle = tester.ensureSemantics(); - final Finder firstButtonFinder = find.text('First Button'); - expect(firstButtonFinder, findsOneWidget); + final Finder buttonFinder = find.text('First Button'); + expect(buttonFinder, findsOneWidget); - final SemanticsNode firstSemantics = - tester.getSemantics(firstButtonFinder); + final SemanticsNode firstSemantics = tester.getSemantics(buttonFinder); final SemanticsData firstData = firstSemantics.getSemanticsData(); expect( From 5f2cb623478ff4fafa21475a9c05d5f602fde2a3 Mon Sep 17 00:00:00 2001 From: pedromassango Date: Tue, 26 Aug 2025 23:16:15 +0200 Subject: [PATCH 06/10] wip --- .../integration_test/link_widget_test.dart | 67 ++++++++----------- .../url_launcher_web/lib/src/link.dart | 17 ++--- 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index 3ac22123525..1d81a2fcbe4 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -212,54 +212,41 @@ void main() { ); }); - testWidgets( - 'excludeSemantics: true ensures clean link semantics without conflicts', - (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: Column( - children: [ - WebLinkDelegate( - TestLinkInfo( - uri: Uri.parse('https://dart.dev/xyz'), - target: LinkTarget.blank, - builder: (BuildContext context, FollowLink? followLink) { - return ElevatedButton( - onPressed: followLink, - child: const Text('First Button'), - ); - }, + testWidgets('MergeSemantics is always present to avoid duplicate nodes', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + WebLinkDelegate( + TestLinkInfo( + uri: Uri.parse('https://dart.dev/xyz'), + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + return ElevatedButton( + onPressed: followLink, + child: const Text('First Button'), + ); + }, + ), ), - ), - ], + ], + ), ), ), - )); + ); await tester.pumpAndSettle(); - final SemanticsHandle handle = tester.ensureSemantics(); - final Finder buttonFinder = find.text('First Button'); + final Finder buttonFinder = find.byType(ElevatedButton); expect(buttonFinder, findsOneWidget); - final SemanticsNode firstSemantics = tester.getSemantics(buttonFinder); - final SemanticsData firstData = firstSemantics.getSemanticsData(); - - expect( - firstData.hasFlag(SemanticsFlag.isLink), - isTrue, - reason: 'Button should be treated as link with excludeSemantics: true', - ); - - expect( - firstData.hasFlag(SemanticsFlag.isButton), - isFalse, - reason: - 'semantics should be excluded to prevent TAB navigation conflicts', - ); - - expect(firstData.linkUrl?.toString(), equals('https://dart.dev/xyz')); - handle.dispose(); + final Element buttonElement = tester.element(buttonFinder); + final MergeSemantics? parentWidget = + buttonElement.findAncestorWidgetOfExactType(); + expect(parentWidget, isNotNull); }); }); diff --git a/packages/url_launcher/url_launcher_web/lib/src/link.dart b/packages/url_launcher/url_launcher_web/lib/src/link.dart index 412b9200942..8a633f44295 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -117,14 +117,15 @@ class WebLinkDelegateState extends State { } Widget _buildChild(BuildContext context) { - return Semantics( - link: true, - excludeSemantics: true, - identifier: _semanticsIdentifier, - linkUrl: widget.link.uri, - child: widget.link.builder( - context, - widget.link.isDisabled ? null : _followLink, + return MergeSemantics( + child: Semantics( + link: true, + identifier: _semanticsIdentifier, + linkUrl: widget.link.uri, + child: widget.link.builder( + context, + widget.link.isDisabled ? null : _followLink, + ), ), ); } From a376b78fe20f823461bd2d4f29d594b7cf677d88 Mon Sep 17 00:00:00 2001 From: pedromassango Date: Tue, 26 Aug 2025 23:17:01 +0200 Subject: [PATCH 07/10] nit --- .../example/integration_test/link_widget_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index 1d81a2fcbe4..87f5dbff798 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -5,11 +5,9 @@ import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'dart:typed_data'; -import 'dart:ui' show SemanticsFlag; import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show SemanticsData, SemanticsNode; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; From 4ac086ddf101f6b60a4f12e0c7544ce9f8d606b2 Mon Sep 17 00:00:00 2001 From: pedromassango Date: Tue, 26 Aug 2025 23:17:38 +0200 Subject: [PATCH 08/10] nit --- .../example/integration_test/link_widget_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart index 87f5dbff798..130d136af6c 100644 --- a/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart +++ b/packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart @@ -8,7 +8,6 @@ import 'dart:typed_data'; import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:url_launcher_platform_interface/link.dart'; From a9b517e30ab5e53d99d96b59c0d91859f70c56cd Mon Sep 17 00:00:00 2001 From: pedromassango Date: Thu, 28 Aug 2025 21:30:47 +0200 Subject: [PATCH 09/10] update CHANGELOG.md --- packages/url_launcher/url_launcher_web/CHANGELOG.md | 4 ++++ packages/url_launcher/url_launcher_web/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md index 6c3c1b01071..a896e001228 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,5 +1,9 @@ ## NEXT +* Ensure link widget merge its semantic node with its children to avoid duplicate nodes. + +## 2.4.2 + * Updates minimum supported SDK version to Flutter 3.29/Dart 3.7. ## 2.4.1 diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index 820e20f349d..7e0f0e6f9ff 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_web description: Web platform implementation of url_launcher repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 2.4.1 +version: 2.4.2 environment: sdk: ^3.7.0 From abaaa01d87c9ada8d5ec188b130b2e6dd888025e Mon Sep 17 00:00:00 2001 From: pedromassango Date: Thu, 4 Sep 2025 21:48:54 +0200 Subject: [PATCH 10/10] nit --- packages/url_launcher/url_launcher_web/CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md index a896e001228..686dbed01b0 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,9 +1,6 @@ -## NEXT - -* Ensure link widget merge its semantic node with its children to avoid duplicate nodes. - ## 2.4.2 +* Ensure link widget merge its semantic node with its children to avoid duplicate nodes. * Updates minimum supported SDK version to Flutter 3.29/Dart 3.7. ## 2.4.1