diff --git a/packages/url_launcher/url_launcher_web/CHANGELOG.md b/packages/url_launcher/url_launcher_web/CHANGELOG.md index 0baf6389c8b9..f923d118fca3 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,6 +1,10 @@ -## NEXT +## 2.4.0 -* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Enhances handling of out-of-order events. +* Adds support for clicks with a modifier key (e.g. cmd+click). +* Improves support for semantics. +* Applies the `target` attribute to semantic links. +* Updates minimum supported SDK version to Flutter 3.27/Dart 3.6. ## 2.3.3 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 b3b1890f23c2..b8c11fa32da1 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 @@ -4,6 +4,7 @@ import 'dart:js_interop'; import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; @@ -18,6 +19,24 @@ import 'package:web/web.dart' as html; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final List pushedRouteNames = []; + late Future Function(String) originalPushFunction; + + setUp(() { + pushedRouteNames.clear(); + originalPushFunction = pushRouteToFrameworkFunction; + pushRouteToFrameworkFunction = (String routeName) { + pushedRouteNames.add(routeName); + return Future.value(ByteData(0)); + }; + }); + + tearDown(() { + pushRouteToFrameworkFunction = originalPushFunction; + pushedRouteNames.clear(); + LinkViewController.debugReset(); + }); + group('Link Widget', () { testWidgets('creates anchor with correct attributes', (WidgetTester tester) async { @@ -77,10 +96,6 @@ void main() { expect(anchor.getAttribute('href'), ui_web.urlStrategy?.prepareExternalUrl(uri3.toString())); expect(anchor.getAttribute('target'), '_self'); - - // Needed when testing on on Chrome98 headless in CI. - // See https://github.com/flutter/flutter/issues/121161 - await tester.pumpAndSettle(); }); testWidgets('sizes itself correctly', (WidgetTester tester) async { @@ -114,10 +129,6 @@ void main() { // `ConstrainedBox` widget. expect(containerSize.width, 100.0); expect(containerSize.height, 100.0); - - // Needed when testing on on Chrome98 headless in CI. - // See https://github.com/flutter/flutter/issues/121161 - await tester.pumpAndSettle(); }); // See: https://github.com/flutter/plugins/pull/3522#discussion_r574703724 @@ -138,10 +149,6 @@ void main() { final html.Element anchor = _findSingleAnchor(); expect(anchor.hasAttribute('href'), false); - - // Needed when testing on on Chrome98 headless in CI. - // See https://github.com/flutter/flutter/issues/121161 - await tester.pumpAndSettle(); }); testWidgets('can be created and disposed', (WidgetTester tester) async { @@ -173,10 +180,6 @@ void main() { 800, maxScrolls: 1000, ); - - // Needed when testing on on Chrome98 headless in CI. - // See https://github.com/flutter/flutter/issues/121161 - await tester.pumpAndSettle(); }); }); @@ -196,221 +199,931 @@ void main() { testWidgets('click to navigate to internal link', (WidgetTester tester) async { - final TestNavigatorObserver observer = TestNavigatorObserver(); final Uri uri = Uri.parse('/foobar'); FollowLink? followLinkCallback; await tester.pumpWidget(MaterialApp( - navigatorObservers: [observer], routes: { '/foobar': (BuildContext context) => const Text('Internal route'), }, - home: Directionality( - textDirection: TextDirection.ltr, - child: WebLinkDelegate(TestLinkInfo( - uri: uri, - target: LinkTarget.blank, - builder: (BuildContext context, FollowLink? followLink) { - followLinkCallback = followLink; - return const SizedBox(width: 100, height: 100); - }, - )), - ), + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), )); // Platform view creation happens asynchronously. await tester.pumpAndSettle(); await tester.pump(); - expect(observer.currentRouteName, '/'); + expect(pushedRouteNames, isEmpty); expect(testPlugin.launches, isEmpty); final html.Element anchor = _findSingleAnchor(); await followLinkCallback!(); - _simulateClick(anchor); - await tester.pumpAndSettle(); + final html.Event event = _simulateClick(anchor); // Internal links should navigate the app to the specified route. There // should be no calls to `launchUrl`. - expect(observer.currentRouteName, '/foobar'); + expect(pushedRouteNames, ['/foobar']); expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isTrue); }); testWidgets('keydown to navigate to internal link', (WidgetTester tester) async { - final TestNavigatorObserver observer = TestNavigatorObserver(); final Uri uri = Uri.parse('/foobar'); FollowLink? followLinkCallback; await tester.pumpWidget(MaterialApp( - navigatorObservers: [observer], routes: { '/foobar': (BuildContext context) => const Text('Internal route'), }, - home: Directionality( - textDirection: TextDirection.ltr, - child: WebLinkDelegate(TestLinkInfo( - uri: uri, - target: LinkTarget.blank, - builder: (BuildContext context, FollowLink? followLink) { - followLinkCallback = followLink; - return const SizedBox(width: 100, height: 100); - }, - )), - ), + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), )); // Platform view creation happens asynchronously. await tester.pumpAndSettle(); await tester.pump(); - expect(observer.currentRouteName, '/'); + expect(pushedRouteNames, isEmpty); expect(testPlugin.launches, isEmpty); final html.Element anchor = _findSingleAnchor(); await followLinkCallback!(); - _simulateKeydown(anchor); - await tester.pumpAndSettle(); + final html.KeyboardEvent event = _simulateKeydown(anchor); // Internal links should navigate the app to the specified route. There // should be no calls to `launchUrl`. - expect(observer.currentRouteName, '/foobar'); + expect(pushedRouteNames, ['/foobar']); expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isFalse); }); testWidgets('click to navigate to external link', (WidgetTester tester) async { - final TestNavigatorObserver observer = TestNavigatorObserver(); - final Uri uri = Uri.parse('https://google.com'); + final Uri uri = Uri.parse('https://flutter.dev'); FollowLink? followLinkCallback; await tester.pumpWidget(MaterialApp( - navigatorObservers: [observer], - home: Directionality( - textDirection: TextDirection.ltr, - child: WebLinkDelegate(TestLinkInfo( - uri: uri, - target: LinkTarget.blank, - builder: (BuildContext context, FollowLink? followLink) { - followLinkCallback = followLink; - return const SizedBox(width: 100, height: 100); - }, - )), - ), + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), )); // Platform view creation happens asynchronously. await tester.pumpAndSettle(); await tester.pump(); - expect(observer.currentRouteName, '/'); + expect(pushedRouteNames, isEmpty); expect(testPlugin.launches, isEmpty); final html.Element anchor = _findSingleAnchor(); await followLinkCallback!(); - _simulateClick(anchor); - await tester.pumpAndSettle(); + final html.Event event = _simulateClick(anchor); // External links that are triggered by a click are left to be handled by // the browser, so there should be no change to the app's route name, and // no calls to `launchUrl`. - expect(observer.currentRouteName, '/'); + expect(pushedRouteNames, isEmpty); expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isFalse); }); testWidgets('keydown to navigate to external link', (WidgetTester tester) async { - final TestNavigatorObserver observer = TestNavigatorObserver(); - final Uri uri = Uri.parse('https://google.com'); + final Uri uri = Uri.parse('https://flutter.dev'); FollowLink? followLinkCallback; await tester.pumpWidget(MaterialApp( - navigatorObservers: [observer], - home: Directionality( - textDirection: TextDirection.ltr, - child: WebLinkDelegate(TestLinkInfo( - uri: uri, - target: LinkTarget.blank, - builder: (BuildContext context, FollowLink? followLink) { - followLinkCallback = followLink; - return const SizedBox(width: 100, height: 100); - }, - )), - ), + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), )); // Platform view creation happens asynchronously. await tester.pumpAndSettle(); await tester.pump(); - expect(observer.currentRouteName, '/'); + expect(pushedRouteNames, isEmpty); expect(testPlugin.launches, isEmpty); final html.Element anchor = _findSingleAnchor(); await followLinkCallback!(); - _simulateKeydown(anchor); - await tester.pumpAndSettle(); + final html.KeyboardEvent event = _simulateKeydown(anchor); // External links that are triggered by keyboard are handled by calling // `launchUrl`, and there's no change to the app's route name. - expect(observer.currentRouteName, '/'); - expect(testPlugin.launches, ['https://google.com']); + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, ['https://flutter.dev']); + expect(event.defaultPrevented, isFalse); }); - }); -} -html.Element _findSingleAnchor() { - final List foundAnchors = []; - final html.NodeList anchors = html.document.querySelectorAll('a'); - for (int i = 0; i < anchors.length; i++) { - final html.Element anchor = anchors.item(i)! as html.Element; - if (anchor.hasProperty(linkViewIdProperty.toJS).toDart) { - foundAnchors.add(anchor); - } - } + testWidgets('click on mismatched link', (WidgetTester tester) async { + final Uri uri1 = Uri.parse('/foobar1'); + final Uri uri2 = Uri.parse('/foobar2'); + FollowLink? followLinkCallback1; + FollowLink? followLinkCallback2; - return foundAnchors.single; -} + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar1': (BuildContext context) => const Text('Internal route 1'), + '/foobar2': (BuildContext context) => const Text('Internal route 2'), + }, + home: Column( + children: [ + WebLinkDelegate(TestLinkInfo( + uri: uri1, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback1 = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + WebLinkDelegate(TestLinkInfo( + uri: uri2, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback2 = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + ], + ), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); -void _simulateClick(html.Element target) { - // Stop the browser from navigating away from the test suite. - target.addEventListener( - 'click', - (html.Event e) { - e.preventDefault(); - }.toJS); - // Synthesize a click event. - target.dispatchEvent( - html.MouseEvent( - 'click', - html.MouseEventInit( - bubbles: true, - cancelable: true, - ), - ), - ); -} + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); -void _simulateKeydown(html.Element target) { - target.dispatchEvent( - html.KeyboardEvent( - 'keydown', - html.KeyboardEventInit( - bubbles: true, - cancelable: true, - code: 'Space', - ), - ), - ); -} + final [ + html.Element anchor1, + html.Element anchor2, + ...List rest, + ] = _findAllAnchors(); + expect(rest, isEmpty); -class TestNavigatorObserver extends NavigatorObserver { - String? currentRouteName; + await followLinkCallback2!(); + // Click on mismatched link. + final html.Event event1 = _simulateClick(anchor1); - @override - void didPush(Route route, Route? previousRoute) { - currentRouteName = route.settings.name; - } + // The link shouldn't have been triggered. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event1.defaultPrevented, isTrue); + + // Click on mismatched link (in reverse order). + final html.Event event2 = _simulateClick(anchor2); + await followLinkCallback1!(); + + // The link shouldn't have been triggered. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event2.defaultPrevented, isTrue); + + await followLinkCallback2!(); + // Click on the correct link. + final html.Event event = _simulateClick(anchor2); + + // The link should've been triggered now. + expect(pushedRouteNames, ['/foobar2']); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isTrue); + }); + + testWidgets('trigger signals are reset after a delay', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element anchor = _findSingleAnchor(); + + // A large delay between signals should reset the previous signal. + await followLinkCallback!(); + await Future.delayed(const Duration(seconds: 1)); + final html.Event event1 = _simulateClick(anchor); + + // The link shouldn't have been triggered. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event1.defaultPrevented, isFalse); + + await Future.delayed(const Duration(seconds: 1)); + + // Signals with large delay (in reverse order). + final html.Event event2 = _simulateClick(anchor); + await Future.delayed(const Duration(seconds: 1)); + await followLinkCallback!(); + + // The link shouldn't have been triggered. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event2.defaultPrevented, isFalse); + + await Future.delayed(const Duration(seconds: 1)); + + // A small delay is okay. + await followLinkCallback!(); + await Future.delayed(const Duration(milliseconds: 100)); + final html.Event event3 = _simulateClick(anchor); + + // The link should've been triggered now. + expect(pushedRouteNames, ['/foobar']); + expect(testPlugin.launches, isEmpty); + expect(event3.defaultPrevented, isTrue); + }); + + testWidgets('ignores clicks on non-Flutter link', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element nonFlutterAnchor = html.document.createElement('a'); + nonFlutterAnchor.setAttribute('href', '/non-flutter'); + + await followLinkCallback!(); + final html.Event event = _simulateClick(nonFlutterAnchor); + + // The link shouldn't have been triggered. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isFalse); + }); + + testWidgets('handles cmd+click correctly', (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element anchor = _findSingleAnchor(); + + await followLinkCallback!(); + final html.Event event = _simulateClick(anchor, metaKey: true); + + // When a modifier key is present, we should let the browser handle the + // navigation. That means we do nothing on our side. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isFalse); + }); + + testWidgets('ignores keydown when it is a modifier key', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element anchor = _findSingleAnchor(); + + final html.KeyboardEvent event1 = _simulateKeydown(anchor, metaKey: true); + await followLinkCallback!(); + + // When the pressed key is a modifier key, we should ignore it. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event1.defaultPrevented, isFalse); + + // If later we receive another trigger, it should work. + final html.KeyboardEvent event2 = _simulateKeydown(anchor); + + // Now the link should be triggered. + expect(pushedRouteNames, ['/foobar']); + expect(testPlugin.launches, isEmpty); + expect(event2.defaultPrevented, isFalse); + }); + }); + + group('Follows links (reversed order)', () { + late TestUrlLauncherPlugin testPlugin; + late UrlLauncherPlatform originalPlugin; + + setUp(() { + originalPlugin = UrlLauncherPlatform.instance; + testPlugin = TestUrlLauncherPlugin(); + UrlLauncherPlatform.instance = testPlugin; + }); + + tearDown(() { + UrlLauncherPlatform.instance = originalPlugin; + }); + + testWidgets('click to navigate to internal link', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element anchor = _findSingleAnchor(); + + final html.Event event = _simulateClick(anchor); + await followLinkCallback!(); + + // Internal links should navigate the app to the specified route. There + // should be no calls to `launchUrl`. + expect(pushedRouteNames, ['/foobar']); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isTrue); + }); + + testWidgets('keydown to navigate to internal link', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element anchor = _findSingleAnchor(); + + final html.KeyboardEvent event = _simulateKeydown(anchor); + await followLinkCallback!(); + + // Internal links should navigate the app to the specified route. There + // should be no calls to `launchUrl`. + expect(pushedRouteNames, ['/foobar']); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isFalse); + }); + + testWidgets('click to navigate to external link', + (WidgetTester tester) async { + final Uri uri = Uri.parse('https://flutter.dev'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element anchor = _findSingleAnchor(); + + final html.Event event = _simulateClick(anchor); + await followLinkCallback!(); + + // External links that are triggered by a click are left to be handled by + // the browser, so there should be no change to the app's route name, and + // no calls to `launchUrl`. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isFalse); + }); + + testWidgets('keydown to navigate to external link', + (WidgetTester tester) async { + final Uri uri = Uri.parse('https://flutter.dev'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + final html.Element anchor = _findSingleAnchor(); + + final html.KeyboardEvent event = _simulateKeydown(anchor); + await followLinkCallback!(); + + // External links that are triggered by keyboard are handled by calling + // `launchUrl`, and there's no change to the app's route name. + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, ['https://flutter.dev']); + expect(event.defaultPrevented, isFalse); + }); + }); + + group('Link semantics', () { + late TestUrlLauncherPlugin testPlugin; + late UrlLauncherPlatform originalPlugin; + + setUp(() { + originalPlugin = UrlLauncherPlatform.instance; + testPlugin = TestUrlLauncherPlugin(); + UrlLauncherPlatform.instance = testPlugin; + }); + + tearDown(() { + UrlLauncherPlatform.instance = originalPlugin; + }); + + testWidgets('produces the correct semantics tree with a button', + (WidgetTester tester) async { + final SemanticsHandle semanticsHandle = tester.ensureSemantics(); + final Key linkKey = UniqueKey(); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: WebLinkDelegate( + key: linkKey, + semanticsIdentifier: 'test-link-12', + TestLinkInfo( + uri: Uri.parse('https://foobar/example?q=1'), + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + return ElevatedButton( + onPressed: followLink, + child: const Text('Button Link Text'), + ); + }, + ), + ), + )); + + final Finder linkFinder = find.byKey(linkKey); + expect( + tester.getSemantics(find.descendant( + of: linkFinder, + matching: find.byType(Semantics).first, + )), + matchesSemantics( + isLink: true, + identifier: 'test-link-12', + // linkUrl: 'https://foobar/example?q=1', + children: [ + matchesSemantics( + hasTapAction: true, + hasEnabledState: true, + hasFocusAction: true, + isEnabled: true, + isButton: true, + isFocusable: true, + label: 'Button Link Text', + ), + ], + ), + ); + + semanticsHandle.dispose(); + }); + + testWidgets('produces the correct semantics tree with text', + (WidgetTester tester) async { + final SemanticsHandle semanticsHandle = tester.ensureSemantics(); + final Key linkKey = UniqueKey(); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: WebLinkDelegate( + key: linkKey, + semanticsIdentifier: 'test-link-43', + TestLinkInfo( + uri: Uri.parse('https://foobar/example?q=1'), + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + return GestureDetector( + onTap: followLink, + child: const Text('Link Text'), + ); + }, + ), + ), + )); + + final Finder linkFinder = find.byKey(linkKey); + expect( + tester.getSemantics(find.descendant( + of: linkFinder, + matching: find.byType(Semantics), + )), + matchesSemantics( + isLink: true, + hasTapAction: true, + identifier: 'test-link-43', + // linkUrl: 'https://foobar/example?q=1', + label: 'Link Text', + ), + ); + + semanticsHandle.dispose(); + }); + + testWidgets('handles clicks on semantic link with a button', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate( + semanticsIdentifier: 'test-link-27', + TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return ElevatedButton( + onPressed: () {}, + child: const Text('My Button Link'), + ); + }, + ), + ), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + final html.Element semanticsHost = + html.document.createElement('flt-semantics-host'); + html.document.body!.append(semanticsHost); + final html.Element semanticsAnchor = html.document.createElement('a') + ..setAttribute('id', 'flt-semantic-node-99') + ..setAttribute('flt-semantics-identifier', 'test-link-27') + ..setAttribute('href', '/foobar'); + semanticsHost.append(semanticsAnchor); + final html.Element semanticsContainer = + html.document.createElement('flt-semantics-container'); + semanticsAnchor.append(semanticsContainer); + final html.Element semanticsButton = + html.document.createElement('flt-semantics') + ..setAttribute('role', 'button') + ..textContent = 'My Button Link'; + semanticsContainer.append(semanticsButton); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + await followLinkCallback!(); + // Click on the button (child of the anchor). + final html.Event event1 = _simulateClick(semanticsButton); + + expect(pushedRouteNames, ['/foobar']); + expect(testPlugin.launches, isEmpty); + expect(event1.defaultPrevented, isTrue); + pushedRouteNames.clear(); + + await followLinkCallback!(); + // Click on the anchor itself. + final html.Event event2 = _simulateClick(semanticsAnchor); + + expect(pushedRouteNames, ['/foobar']); + expect(testPlugin.launches, isEmpty); + expect(event2.defaultPrevented, isTrue); + }); + + testWidgets('handles clicks on semantic link with text', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate( + semanticsIdentifier: 'test-link-71', + TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return GestureDetector( + onTap: () {}, + child: const Text('My Link'), + ); + }, + ), + ), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + final html.Element semanticsHost = + html.document.createElement('flt-semantics-host'); + html.document.body!.append(semanticsHost); + final html.Element semanticsAnchor = html.document.createElement('a') + ..setAttribute('id', 'flt-semantic-node-99') + ..setAttribute('flt-semantics-identifier', 'test-link-71') + ..setAttribute('href', '/foobar') + ..textContent = 'My Text Link'; + semanticsHost.append(semanticsAnchor); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + await followLinkCallback!(); + final html.Event event = _simulateClick(semanticsAnchor); + + expect(pushedRouteNames, ['/foobar']); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isTrue); + }); + + // TODO(mdebbar): Remove this test after the engine PR [1] makes it to stable. + // [1] https://github.com/flutter/engine/pull/52720 + testWidgets('handles clicks on (old) semantic link with a button', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return const SizedBox(width: 100, height: 100); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + final html.Element semanticsHost = + html.document.createElement('flt-semantics-host'); + html.document.body!.append(semanticsHost); + final html.Element semanticsAnchor = html.document.createElement('a') + ..setAttribute('id', 'flt-semantic-node-99') + ..setAttribute('href', '#'); + semanticsHost.append(semanticsAnchor); + final html.Element semanticsContainer = + html.document.createElement('flt-semantics-container'); + semanticsAnchor.append(semanticsContainer); + final html.Element semanticsButton = + html.document.createElement('flt-semantics') + ..setAttribute('role', 'button') + ..textContent = 'My Button'; + semanticsContainer.append(semanticsButton); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + await followLinkCallback!(); + final html.Event event1 = _simulateClick(semanticsButton); + + // Before the changes land in the web engine, this will not trigger the + // link properly. + expect(pushedRouteNames, []); + expect(testPlugin.launches, isEmpty); + expect(event1.defaultPrevented, isFalse); + }); + + // TODO(mdebbar): Remove this test after the engine PR [1] makes it to stable. + // [1] https://github.com/flutter/engine/pull/52720 + testWidgets('handles clicks on (old) semantic link with text', + (WidgetTester tester) async { + final Uri uri = Uri.parse('/foobar'); + FollowLink? followLinkCallback; + + await tester.pumpWidget(MaterialApp( + routes: { + '/foobar': (BuildContext context) => const Text('Internal route'), + }, + home: WebLinkDelegate(TestLinkInfo( + uri: uri, + target: LinkTarget.blank, + builder: (BuildContext context, FollowLink? followLink) { + followLinkCallback = followLink; + return GestureDetector( + onTap: () {}, + child: const Text('My Link'), + ); + }, + )), + )); + // Platform view creation happens asynchronously. + await tester.pumpAndSettle(); + await tester.pump(); + + final html.Element semanticsHost = + html.document.createElement('flt-semantics-host'); + html.document.body!.append(semanticsHost); + final html.Element semanticsAnchor = html.document.createElement('a') + ..setAttribute('id', 'flt-semantic-node-99') + ..setAttribute('href', '#') + ..textContent = 'My Text Link'; + semanticsHost.append(semanticsAnchor); + + expect(pushedRouteNames, isEmpty); + expect(testPlugin.launches, isEmpty); + + await followLinkCallback!(); + final html.Event event = _simulateClick(semanticsAnchor); + + // Before the changes land in the web engine, this will not trigger the + // link properly. + expect(pushedRouteNames, []); + expect(testPlugin.launches, isEmpty); + expect(event.defaultPrevented, isFalse); + }); + }); +} + +List _findAllAnchors() { + final List foundAnchors = []; + final html.NodeList anchors = html.document.querySelectorAll('a'); + for (int i = 0; i < anchors.length; i++) { + final html.Element anchor = anchors.item(i)! as html.Element; + if (anchor.hasProperty(linkViewIdProperty.toJS).toDart) { + foundAnchors.add(anchor); + } + } + + return foundAnchors; +} + +html.Element _findSingleAnchor() { + return _findAllAnchors().single; +} + +html.MouseEvent _simulateClick(html.Element target, {bool metaKey = false}) { + // // Stop the browser from navigating away from the test suite. + // target.addEventListener( + // 'click', + // (html.Event e) { + // e.preventDefault(); + // }.toJS); + final html.MouseEvent mouseEvent = html.MouseEvent( + 'click', + html.MouseEventInit( + bubbles: true, + cancelable: true, + metaKey: metaKey, + ), + ); + LinkViewController.handleGlobalClick(event: mouseEvent, target: target); + return mouseEvent; +} + +html.KeyboardEvent _simulateKeydown(html.Element target, + {bool metaKey = false}) { + final html.KeyboardEvent keydownEvent = html.KeyboardEvent( + 'keydown', + html.KeyboardEventInit( + bubbles: true, + cancelable: true, + metaKey: metaKey, + // code: 'Space', + )); + LinkViewController.handleGlobalKeydown(event: keydownEvent); + return keydownEvent; } class TestLinkInfo extends LinkInfo { diff --git a/packages/url_launcher/url_launcher_web/example/pubspec.yaml b/packages/url_launcher/url_launcher_web/example/pubspec.yaml index a70f27951b9e..b734fade0f7e 100644 --- a/packages/url_launcher/url_launcher_web/example/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/example/pubspec.yaml @@ -2,8 +2,8 @@ name: regular_integration_tests publish_to: none environment: - sdk: ^3.4.0 - flutter: ">=3.22.0" + sdk: ^3.6.0 + flutter: ">=3.27.0" dependencies: flutter: 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 3a286fb40749..3540ac97f518 100644 --- a/packages/url_launcher/url_launcher_web/lib/src/link.dart +++ b/packages/url_launcher/url_launcher_web/lib/src/link.dart @@ -28,20 +28,51 @@ typedef HtmlViewFactory = html.Element Function(int viewId); /// Factory that returns the link DOM element for each unique view id. HtmlViewFactory get linkViewFactory => LinkViewController._viewFactory; +/// The function used to push routes to the Flutter framework. +@visibleForTesting +Future Function(String) pushRouteToFrameworkFunction = + (String routeName) => pushRouteNameToFramework(null, routeName); + /// The delegate for building the [Link] widget on the web. /// /// It uses a platform view to render an anchor element in the DOM. class WebLinkDelegate extends StatefulWidget { /// Creates a delegate for the given [link]. - const WebLinkDelegate(this.link, {super.key}); + const WebLinkDelegate(this.link, {super.key, this.semanticsIdentifier}); /// Information about the link built by the app. final LinkInfo link; + /// A user-provided identifier to be as the [SemanticsProperties.identifier] + /// for the link. + /// + /// This identifier is optional and is only useful for testing purposes. + final String? semanticsIdentifier; + @override WebLinkDelegateState createState() => WebLinkDelegateState(); } +extension on Uri { + String getHref() { + if (hasScheme) { + // External URIs are not modified. + return toString(); + } + + if (ui_web.urlStrategy == null) { + // If there's no UrlStrategy, we leave the URI as is. + return toString(); + } + + // In case an internal uri is given, the uri must be properly encoded + // using the currently used UrlStrategy. + return ui_web.urlStrategy!.prepareExternalUrl(toString()); + } +} + +int _nextSemanticsIdentifier = 0; + /// The link delegate used on the web platform. /// /// For external URIs, it lets the browser do its thing. For app route names, it @@ -49,6 +80,15 @@ class WebLinkDelegate extends StatefulWidget { class WebLinkDelegateState extends State { late LinkViewController _controller; + late final String _semanticsIdentifier; + + @override + void initState() { + super.initState(); + _semanticsIdentifier = + widget.semanticsIdentifier ?? 'link-${_nextSemanticsIdentifier++}'; + } + @override void didUpdateWidget(WebLinkDelegate oldWidget) { super.didUpdateWidget(oldWidget); @@ -61,7 +101,7 @@ class WebLinkDelegateState extends State { } Future _followLink() { - LinkViewController.registerHitTest(_controller); + LinkViewController.onFollowLink(_controller.viewId); return Future.value(); } @@ -70,88 +110,273 @@ class WebLinkDelegateState extends State { return Stack( fit: StackFit.passthrough, children: [ - widget.link.builder( - context, - widget.link.isDisabled ? null : _followLink, - ), + _buildChild(context), Positioned.fill( - child: PlatformViewLink( - viewType: linkViewType, - onCreatePlatformView: (PlatformViewCreationParams params) { - _controller = LinkViewController.fromParams(params); - return _controller - ..setUri(widget.link.uri) - ..setTarget(widget.link.target); - }, - surfaceFactory: - (BuildContext context, PlatformViewController controller) { - return PlatformViewSurface( - controller: controller, - gestureRecognizers: const >{}, - hitTestBehavior: PlatformViewHitTestBehavior.transparent, - ); - }, - ), + child: _buildPlatformView(context), ), ], ); } + + Widget _buildChild(BuildContext context) { + return Semantics( + link: true, + identifier: _semanticsIdentifier, + linkUrl: widget.link.uri, + child: widget.link.builder( + context, + widget.link.isDisabled ? null : _followLink, + ), + ); + } + + Widget _buildPlatformView(BuildContext context) { + return ExcludeFocus( + child: ExcludeSemantics( + child: PlatformViewLink( + viewType: linkViewType, + onCreatePlatformView: (PlatformViewCreationParams params) { + _controller = + LinkViewController.fromParams(params, _semanticsIdentifier); + return _controller + ..setUri(widget.link.uri) + ..setTarget(widget.link.target); + }, + surfaceFactory: + (BuildContext context, PlatformViewController controller) { + return PlatformViewSurface( + controller: controller, + gestureRecognizers: const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + ); + }, + ), + ), + ); + } } final JSAny _useCapture = {'capture': true}.jsify()!; +/// Signature for the function that triggers a link. +typedef TriggerLinkCallback = void Function( + int viewId, html.MouseEvent? mouseEvent); + +/// Keeps track of the signals required to trigger a link. +/// +/// Currently, the signals required are: +/// +/// 1. A FollowLink signal. This signal indicates that a hit test for the link +/// was registered on the app/framework side. +/// +/// 2. A DOM event signal. This signal indicates that a click or keyboard event +/// was received on the link's corresponding DOM element. +/// +/// These signals can arrive in any order depending on how the user triggers +/// the link. +/// +/// Each signal may be accompanied by a view ID. The view IDs, when present, +/// must match in order for the trigger to be considered valid. +/// +/// The signals are reset after a certain delay to prevent them from getting +/// stale. The delay is specified by [staleTimeout]. +class LinkTriggerSignals { + /// Creates a [LinkTriggerSignals] instance that calls [triggerLink] when all + /// the signals are received within a [staleTimeout] duration. + LinkTriggerSignals({ + required this.triggerLink, + required this.staleTimeout, + }); + + /// The function to be called when all signals have been received and the link + /// is ready to be triggered. + final TriggerLinkCallback triggerLink; + + /// Specifies the duration after which the signals are considered stale. + /// + /// Signals have to arrive within [staleTimeout] duration between them to be + /// considered valid. If they don't, the signals are reset. + final Duration staleTimeout; + + bool get _hasAllSignals => _hasFollowLink && _hasDomEvent; + + int? get _viewId { + assert(!isViewIdMismatched); + return _viewIdFromFollowLink ?? _viewIdFromDomEvent; + } + + /// Triggers the link if all signals are ready and there's no view ID + /// mismatch. + /// + /// If the view IDs from the signals are mismatched, the signals are reset and + /// the browser is prevented from navigating. + /// + /// If the signals are ready, the link is triggered and the signals are reset. + void triggerLinkIfReady() { + if (isViewIdMismatched) { + // When view IDs from signals are mismatched, let's reset the signals and + // prevent the browser from navigating. + _mouseEvent?.preventDefault(); + reset(); + return; + } + + if (_hasAllSignals) { + triggerLink(_viewId!, _mouseEvent); + reset(); + } + } + + /// Handles a FollowLink signal from [viewId]. + void onFollowLink({required int viewId}) { + _hasFollowLink = true; + _viewIdFromFollowLink = viewId; + _didUpdate(); + } + + /// Handles a [mouseEvent] signal from a specific [viewId]. + /// + /// * [viewId] identifies the view where the Link widget was rendered to. It + /// is nullable because it cannot be determined for some DOM events. + /// * [mouseEvent] is the DOM MouseEvent that triggered this signal. It is + /// nullable because some signals may come from a keyboard event. + /// + /// If `mouseEvent` is not null, `viewId` becomes mandatory. If `viewId` is + /// not present in this case, a [StateError] is thrown. + void onMouseEvent({ + required int? viewId, + required html.MouseEvent? mouseEvent, + }) { + if (mouseEvent != null && viewId == null) { + throw StateError('`viewId` must be provided for mouse events'); + } + _hasDomEvent = true; + _viewIdFromDomEvent = viewId; + _mouseEvent = mouseEvent; + _didUpdate(); + } + + bool _hasFollowLink = false; + bool _hasDomEvent = false; + + int? _viewIdFromFollowLink; + int? _viewIdFromDomEvent; + + html.MouseEvent? _mouseEvent; + + /// Whether the view ID from various signals have a mismatch. + /// + /// When a signal's view ID is missing, it's not considered a mismatch. + bool get isViewIdMismatched { + if (_viewIdFromFollowLink == null || _viewIdFromDomEvent == null) { + // A missing view ID is not considered a mismatch. + return false; + } + + return _viewIdFromFollowLink != _viewIdFromDomEvent; + } + + Timer? _resetTimer; + + void _didUpdate() { + _resetTimer?.cancel(); + _resetTimer = Timer(staleTimeout, reset); + } + + /// Reset all signals to their initial state. + void reset() { + _resetTimer?.cancel(); + _resetTimer = null; + + _hasFollowLink = false; + _hasDomEvent = false; + + _viewIdFromFollowLink = null; + _viewIdFromDomEvent = null; + + _mouseEvent = null; + } +} + /// Controls link views. class LinkViewController extends PlatformViewController { /// Creates a [LinkViewController] instance with the unique [viewId]. - LinkViewController(this.viewId) { - if (_instances.isEmpty) { + LinkViewController(this.viewId, this._semanticsIdentifier) { + if (_instancesByViewId.isEmpty) { // This is the first controller being created, attach the global click // listener. - - // Why listen in the capture phase? - // - // To ensure we always receive the event even if the engine calls - // `stopPropagation`. - html.window - .addEventListener('keydown', _jsGlobalKeydownListener, _useCapture); - html.window.addEventListener('click', _jsGlobalClickListener); + _attachGlobalListeners(); } - _instances[viewId] = this; + _instancesByViewId[viewId] = this; + _instancesBySemanticsIdentifier[_semanticsIdentifier] = this; } /// Creates and initializes a [LinkViewController] instance with the given /// platform view [params]. factory LinkViewController.fromParams( PlatformViewCreationParams params, + String semanticsIdentifier, ) { final int viewId = params.id; - final LinkViewController controller = LinkViewController(viewId); + final LinkViewController controller = + LinkViewController(viewId, semanticsIdentifier); controller._initialize().then((_) { /// Because _initialize is async, it can happen that [LinkViewController.dispose] /// may get called before this `then` callback. /// Check that the `controller` that was created by this factory is not /// disposed before calling `onPlatformViewCreated`. - if (_instances[viewId] == controller) { + if (_instancesByViewId[viewId] == controller) { params.onPlatformViewCreated(viewId); } }); return controller; } - static final Map _instances = + static final Map _instancesByViewId = {}; + static final Map _instancesBySemanticsIdentifier = + {}; static html.Element _viewFactory(int viewId) { - return _instances[viewId]!._element; + return _instancesByViewId[viewId]!._element; } - static int? _hitTestedViewId; + static final LinkTriggerSignals _triggerSignals = LinkTriggerSignals( + triggerLink: _triggerLink, + staleTimeout: const Duration(milliseconds: 500), + ); static final JSFunction _jsGlobalKeydownListener = _onGlobalKeydown.toJS; static final JSFunction _jsGlobalClickListener = _onGlobalClick.toJS; + static void _attachGlobalListeners() { + // Why listen in the capture phase? + // + // To ensure we always receive the event even if the engine calls + // `stopPropagation`. + html.window + ..addEventListener('keydown', _jsGlobalKeydownListener, _useCapture) + ..addEventListener('click', _jsGlobalClickListener, _useCapture); + + // TODO(mdebbar): Cleanup the global listeners on hot restart. + // https://github.com/flutter/flutter/issues/148133 + } + + static void _detachGlobalListeners() { + html.window + ..removeEventListener('keydown', _jsGlobalKeydownListener, _useCapture) + ..removeEventListener('click', _jsGlobalClickListener, _useCapture); + } + static void _onGlobalKeydown(html.KeyboardEvent event) { + handleGlobalKeydown(event: event); + } + + /// Global keydown handler that's called for every keydown event on the + /// window. + @visibleForTesting + static void handleGlobalKeydown({required html.KeyboardEvent event}) { // Why not use `event.target`? // // Because the target is usually and not the element, so @@ -189,7 +414,7 @@ class LinkViewController extends PlatformViewController { // 2. The user presses the Enter key to trigger the link. // 3. The framework receives the Enter keydown event: // - The event is dispatched to the button widget. - // - The button widget calls `onPressed` and therefor `followLink`. + // - The button widget calls `onPressed` and therefore `followLink`. // - `followLink` calls `LinkViewController.registerHitTest`. // - `LinkViewController.registerHitTest` sets `_hitTestedViewId`. // 4. The `LinkViewController` also receives the keydown event: @@ -197,41 +422,65 @@ class LinkViewController extends PlatformViewController { // - If `_hitTestedViewId` is set, it means the app triggered the link. // - We navigate to the Link's URI. - // The keydown event is not directly associated with the target Link, so - // we need to look for the recently hit tested Link to handle the event. - if (_hitTestedViewId != null) { - _instances[_hitTestedViewId]?._onDomKeydown(); + if (_isModifierKey(event)) { + // Modifier keys (i.e. Shift, Ctrl, Alt, Meta) cannot trigger a Link. + return; } - // After the keyboard event has been received, clean up the hit test state - // so we can start fresh on the next event. - unregisterHitTest(); + + // The keydown event is not directly associated with the target Link, so + // we can't find the `viewId` from the event. + _triggerSignals.onMouseEvent(viewId: null, mouseEvent: null); + _triggerSignals.triggerLinkIfReady(); } static void _onGlobalClick(html.MouseEvent event) { - final int? viewId = getViewIdFromTarget(event); - _instances[viewId]?._onDomClick(event); - // After the DOM click event has been received, clean up the hit test state - // so we can start fresh on the next event. - unregisterHitTest(); + handleGlobalClick( + event: event, + target: event.target as html.Element?, + ); } - /// Call this method to indicate that a hit test has been registered for the - /// given [controller]. - /// - /// The [onClick] callback is invoked when the anchor element receives a - /// `click` from the browser. - static void registerHitTest(LinkViewController controller) { - _hitTestedViewId = controller.viewId; + /// Global click handler that's called for every click event on the window. + @visibleForTesting + static void handleGlobalClick({ + required html.MouseEvent event, + required html.Element? target, + }) { + // We only want to handle clicks that land on *our* links. That could be a + // platform view link or a semantics link. + final int? viewIdFromTarget = + _getViewIdFromLink(target) ?? _getViewIdFromSemanticsLink(target); + + if (viewIdFromTarget == null) { + // The click target was not one of our links, so we don't want to + // interfere with it. + // + // We also want to reset the signals to make sure we start fresh on the + // next click. + _triggerSignals.reset(); + return; + } + + _triggerSignals.onMouseEvent( + viewId: viewIdFromTarget, + mouseEvent: event, + ); + + _triggerSignals.triggerLinkIfReady(); } - /// Removes all information about previously registered hit tests. - static void unregisterHitTest() { - _hitTestedViewId = null; + /// Call this method to indicate that a hit test has been registered for the + /// given [viewId]. + static void onFollowLink(int viewId) { + _triggerSignals.onFollowLink(viewId: viewId); + _triggerSignals.triggerLinkIfReady(); } @override final int viewId; + final String _semanticsIdentifier; + late html.HTMLElement _element; Future _initialize() async { @@ -248,6 +497,9 @@ class LinkViewController extends PlatformViewController { // - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target _element.setAttribute('rel', 'noreferrer noopener'); + _element.setAttribute('aria-hidden', 'true'); + _element.setAttribute('tabIndex', '-1'); + final Map args = { 'id': viewId, 'viewType': linkViewType, @@ -255,49 +507,38 @@ class LinkViewController extends PlatformViewController { await SystemChannels.platform_views.invokeMethod('create', args); } - void _onDomKeydown() { - assert( - _hitTestedViewId == viewId, - 'Keydown event should only be handled by the hit tested Link', - ); - - if (_isExternalLink) { - // External links are not handled by the browser when triggered via a - // keydown, so we have to launch the url manually. - UrlLauncherPlatform.instance - .launchUrl(_uri.toString(), const LaunchOptions()); + /// Triggers the Link that has already received all the required signals. + /// + /// It also handles logic for external vs internal links, triggered by a mouse + /// vs keyboard event. + static void _triggerLink(int viewId, html.MouseEvent? mouseEvent) { + final LinkViewController controller = _instancesByViewId[viewId]!; + + if (mouseEvent != null && _isModifierKey(mouseEvent)) { + // When the click is accompanied by a modifier key (e.g. cmd+click or + // shift+click), we want to let the browser do its thing (e.g. open a new + // tab or a new window). return; } - // A uri that doesn't have a scheme is an internal route name. In this - // case, we push it via Flutter's navigation system instead of using - // `launchUrl`. - final String routeName = _uri.toString(); - pushRouteNameToFramework(null, routeName); - } - - void _onDomClick(html.MouseEvent event) { - final bool isHitTested = _hitTestedViewId == viewId; - if (!isHitTested) { - // There was no hit test registered for this click. This means the click - // landed on the anchor element but not on the underlying widget. In this - // case, we prevent the browser from following the click. - event.preventDefault(); - return; - } + if (controller._isExternalLink) { + if (mouseEvent == null) { + // When external links are triggered by keyboard, they are not handled by + // the browser. So we have to launch the url manually. + UrlLauncherPlatform.instance + .launchUrl(controller._uri.toString(), const LaunchOptions()); + } - if (_isExternalLink) { - // External links will be handled by the browser, so we don't have to do - // anything. + // When triggerd by a mouse event, external links will be handled by the + // browser, so we don't have to do anything. return; } - // A uri that doesn't have a scheme is an internal route name. In this - // case, we push it via Flutter's navigation system instead of letting the - // browser handle it. - event.preventDefault(); - final String routeName = _uri.toString(); - pushRouteNameToFramework(null, routeName); + // Internal links are pushed through Flutter's navigation system instead of + // letting the browser handle it. + mouseEvent?.preventDefault(); + final String routeName = controller._uri.toString(); + pushRouteToFrameworkFunction(routeName); } Uri? _uri; @@ -311,22 +552,20 @@ class LinkViewController extends PlatformViewController { if (uri == null) { _element.removeAttribute('href'); } else { - String href = uri.toString(); - // in case an internal uri is given, the url mus be properly encoded - // using the currently used [UrlStrategy] - if (!uri.hasScheme) { - href = ui_web.urlStrategy?.prepareExternalUrl(href) ?? href; - } - _element.setAttribute('href', href); + _element.setAttribute('href', uri.getHref()); } } + late LinkTarget _target; + String get _htmlTargetAttribute => _getHtmlTargetAttribute(_target); + /// Set the [LinkTarget] value for this link. void setTarget(LinkTarget target) { - _element.setAttribute('target', _getHtmlTarget(target)); + _target = target; + _element.setAttribute('target', _htmlTargetAttribute); } - String _getHtmlTarget(LinkTarget target) { + String _getHtmlTargetAttribute(LinkTarget target) { switch (target) { case LinkTarget.defaultTarget: case LinkTarget.self: @@ -342,6 +581,40 @@ class LinkViewController extends PlatformViewController { return '_self'; } + /// Finds the view ID in the Link's semantic element. + /// + /// Returns null if [target] is not a semantics element for one of our Links. + static int? _getViewIdFromSemanticsLink(html.Element? target) { + // TODO(mdebbar): The whole could be inside a shadow root. In that case, + // the target is always the shadow root (because we are listening on window). + if (target == null) { + return null; + } + if (!_isWithinSemanticsTree(target)) { + return null; + } + + final html.Element? semanticsLink = _getClosestSemanticsLink(target); + if (semanticsLink == null) { + return null; + } + + final String? semanticsIdentifier = + semanticsLink.getAttribute('flt-semantics-identifier'); + if (semanticsIdentifier == null) { + return null; + } + + final LinkViewController? controller = + _instancesBySemanticsIdentifier[semanticsIdentifier]; + if (controller == null) { + return null; + } + + semanticsLink.setAttribute('target', controller._htmlTargetAttribute); + return controller.viewId; + } + @override Future clearFocus() async { // Currently this does nothing on Flutter Web. @@ -356,49 +629,60 @@ class LinkViewController extends PlatformViewController { @override Future dispose() async { - assert(_instances[viewId] == this); - _instances.remove(viewId); - if (_instances.isEmpty) { - html.window.removeEventListener('click', _jsGlobalClickListener); - html.window.removeEventListener( - 'keydown', _jsGlobalKeydownListener, _useCapture); + assert(_instancesByViewId[viewId] == this); + assert(_instancesBySemanticsIdentifier[_semanticsIdentifier] == this); + + _instancesByViewId.remove(viewId); + _instancesBySemanticsIdentifier.remove(_semanticsIdentifier); + + if (_instancesByViewId.isEmpty) { + _detachGlobalListeners(); } await SystemChannels.platform_views.invokeMethod('dispose', viewId); } -} -/// Finds the view id of the DOM element targeted by the [event]. -int? getViewIdFromTarget(html.Event event) { - final html.Element? linkElement = getLinkElementFromTarget(event); - if (linkElement != null) { - return linkElement.getProperty(linkViewIdProperty.toJS).toDartInt; + /// Resets all link-related state. + @visibleForTesting + static Future debugReset() async { + _triggerSignals.reset(); + for (final LinkViewController instance in _instancesByViewId.values) { + await instance.dispose(); + } } - return null; } -/// Finds the targeted DOM element by the [event]. +/// Finds the view ID in the Link's platform view element. /// -/// It handles the case where the target element is inside a shadow DOM too. -html.Element? getLinkElementFromTarget(html.Event event) { - final html.EventTarget? target = event.target; - if (target != null && target is html.Element) { - if (isLinkElement(target)) { - return target; - } - if (target.shadowRoot != null) { - final html.Node? child = target.shadowRoot!.lastChild; - if (child != null && child is html.Element && isLinkElement(child)) { - return child; - } - } +/// Returns null if [target] is not a platform view of one of our Links. +int? _getViewIdFromLink(html.Element? target) { + final JSString linkViewIdPropertyJS = linkViewIdProperty.toJS; + if (target != null && target.tagName.toLowerCase() == 'a') { + return target.getProperty(linkViewIdPropertyJS)?.toDartInt; } return null; } -/// Checks if the given [element] is a link that was created by -/// [LinkViewController]. -bool isLinkElement(html.Element? element) { - return element != null && - element.tagName == 'A' && - element.hasProperty(linkViewIdProperty.toJS).toDart; +/// Whether [element] is within the semantics tree of a Flutter View. +bool _isWithinSemanticsTree(html.Element element) { + return element.closest('flt-semantics-host') != null; +} + +/// Returns the closest semantics link ancestor of the given [element]. +/// +/// If [element] itself is a link, it is returned. +html.Element? _getClosestSemanticsLink(html.Element element) { + assert(_isWithinSemanticsTree(element)); + return element.closest('a[id^="flt-semantic-node-"]'); +} + +bool _isModifierKey(html.Event event) { + // This method accepts both KeyboardEvent and MouseEvent but there's no common + // interface that contains the `ctrlKey`, `altKey`, `metaKey`, and `shiftKey` + // properties. So we have to cast the event to either `KeyboardEvent` or + // `MouseEvent` to access these properties. + // + // It's safe to cast both event types to `KeyboardEvent` because it's just + // JS-interop and has no concrete runtime type. + event as html.KeyboardEvent; + return event.ctrlKey || event.altKey || event.metaKey || event.shiftKey; } diff --git a/packages/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index 7b9068d19153..ee13f34f7523 100644 --- a/packages/url_launcher/url_launcher_web/pubspec.yaml +++ b/packages/url_launcher/url_launcher_web/pubspec.yaml @@ -2,11 +2,11 @@ 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.3.3 +version: 2.4.0 environment: - sdk: ^3.4.0 - flutter: ">=3.22.0" + sdk: ^3.6.0 + flutter: ">=3.27.0" flutter: plugin: