SelectableRegion should use flutter rendered menu on the web for Android and iOS (#177122)

This change updates the default context menu when using
`SelectionArea`/`SelectableRegion` on Android or iOS web.

Previously: the native browser menu would be used.
After this change: the flutter rendered context menu is used.

Fixes https://github.com/flutter/flutter/issues/151468

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

---------

Co-authored-by: Renzo Olivares <roliv@google.com>
This commit is contained in:
Renzo Olivares 2025-10-17 12:07:42 -07:00 committed by GitHub
parent 0ed3ad930d
commit 6fc3313409
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 242 additions and 114 deletions

View File

@ -303,18 +303,21 @@ class SelectableRegion extends StatefulWidget {
}) {
final bool canCopy = selectionGeometry.status == SelectionStatus.uncollapsed;
final bool canSelectAll = selectionGeometry.hasContent;
final bool platformCanShare = switch (defaultTargetPlatform) {
TargetPlatform.android => selectionGeometry.status == SelectionStatus.uncollapsed,
TargetPlatform.macOS ||
TargetPlatform.fuchsia ||
TargetPlatform.linux ||
TargetPlatform.windows => false,
// TODO(bleroux): the share button should be shown on iOS but the share
// functionality requires some changes on the engine side because, on iPad,
// it needs an anchor for the popup.
// See: https://github.com/flutter/flutter/issues/141775.
TargetPlatform.iOS => false,
};
// The share button is not supported on the web.
final bool platformCanShare =
!kIsWeb &&
switch (defaultTargetPlatform) {
TargetPlatform.android => selectionGeometry.status == SelectionStatus.uncollapsed,
TargetPlatform.macOS ||
TargetPlatform.fuchsia ||
TargetPlatform.linux ||
TargetPlatform.windows => false,
// TODO(bleroux): the share button should be shown on iOS but the share
// functionality requires some changes on the engine side because, on iPad,
// it needs an anchor for the popup.
// See: https://github.com/flutter/flutter/issues/141775.
TargetPlatform.iOS => false,
};
final bool canShare = onShare != null && platformCanShare;
// On Android, the share button is before the select all button.
@ -410,7 +413,14 @@ class SelectableRegionState extends State<SelectableRegion>
SelectedContent? _lastSelectedContent;
/// Whether the native browser context menu is enabled.
bool get _webContextMenuEnabled => kIsWeb && BrowserContextMenu.enabled;
// TODO(Renzo-Olivares): Re-enable web context menu for Android
// and iOS when https://github.com/flutter/flutter/issues/177123
// is resolved.
bool get _webContextMenuEnabled =>
kIsWeb &&
BrowserContextMenu.enabled &&
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
/// The [SelectionOverlay] that is currently visible on the screen.
///

View File

@ -130,6 +130,50 @@ void main() {
variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}),
);
testWidgets(
'builds the default context menu by default on Android and iOS web',
(WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectionArea(focusNode: focusNode, child: const Text('How are you?')),
),
);
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Show the toolbar by longpressing.
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(
find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)),
);
final TestGesture gesture = await tester.startGesture(
textOffsetToPosition(paragraph1, 6),
); // at the 'r'
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
// `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
},
// TODO(Renzo-Olivares): Remove this test when the web context menu
// for Android and iOS is re-enabled.
// See: https://github.com/flutter/flutter/issues/177123.
// [intended] Android and iOS use the flutter rendered menu on the web.
skip:
!kIsWeb ||
!<TargetPlatform>{
TargetPlatform.android,
TargetPlatform.iOS,
}.contains(defaultTargetPlatform),
);
testWidgets(
'builds the default context menu by default',
(WidgetTester tester) async {

View File

@ -24,6 +24,14 @@ extension on web.CSSRuleList {
Iterable<web.CSSRule?>.generate(length, (int index) => item(index));
}
bool _browserContextMenuEnabled() {
// TODO(Renzo-Olivares): Remove this when the web context menu
// for Android and iOS is re-enabled.
// See: https://github.com/flutter/flutter/issues/177123.
return kIsWeb &&
!<TargetPlatform>{TargetPlatform.android, TargetPlatform.iOS}.contains(defaultTargetPlatform);
}
void main() {
late FakePlatformViewRegistry fakePlatformViewRegistry;
@ -39,128 +47,144 @@ void main() {
PlatformSelectableRegionContextMenu.debugResetRegistry();
});
testWidgets('DOM element is set up correctly', (WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
testWidgets(
'DOM element is set up correctly',
(WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
),
),
);
);
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
expect(element, isNotNull);
expect(element.style.width, '100%');
expect(element.style.height, '100%');
expect(element.classList.length, 1);
expect(element, isNotNull);
expect(element.style.width, '100%');
expect(element.style.height, '100%');
expect(element.classList.length, 1);
final int numberOfStyleElements = getNumberOfStyleElements();
expect(numberOfStyleElements, 1);
});
final int numberOfStyleElements = getNumberOfStyleElements();
expect(numberOfStyleElements, 1);
},
skip: !_browserContextMenuEnabled(), // [intended]
);
testWidgets('only one <style> is inserted into the DOM', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: ListView(
children: <Widget>[
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
],
testWidgets(
'only one <style> is inserted into the DOM',
(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: ListView(
children: <Widget>[
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
SelectableRegion(
selectionControls: EmptyTextSelectionControls(),
child: const Placeholder(),
),
],
),
),
),
);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
final int numberOfStyleElements = getNumberOfStyleElements();
expect(numberOfStyleElements, 1);
});
final int numberOfStyleElements = getNumberOfStyleElements();
expect(numberOfStyleElements, 1);
},
skip: !_browserContextMenuEnabled(), // [intended]
);
testWidgets('right click can trigger select word', (WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
testWidgets(
'right click can trigger select word',
(WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
final UniqueKey spy = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: SelectionSpy(key: spy),
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
final UniqueKey spy = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: SelectionSpy(key: spy),
),
),
),
);
);
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
expect(element, isNotNull);
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
expect(element, isNotNull);
focusNode.requestFocus();
await tester.pump();
focusNode.requestFocus();
await tester.pump();
// Dispatch right click.
element.dispatchEvent(
web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)),
);
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(
find.byKey(spy),
);
expect(renderSelectionSpy.events, isNotEmpty);
// Dispatch right click.
element.dispatchEvent(
web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)),
);
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(
find.byKey(spy),
);
expect(renderSelectionSpy.events, isNotEmpty);
SelectWordSelectionEvent? selectWordEvent;
for (final SelectionEvent event in renderSelectionSpy.events) {
if (event is SelectWordSelectionEvent) {
selectWordEvent = event;
break;
SelectWordSelectionEvent? selectWordEvent;
for (final SelectionEvent event in renderSelectionSpy.events) {
if (event is SelectWordSelectionEvent) {
selectWordEvent = event;
break;
}
}
}
expect(selectWordEvent, isNotNull);
expect((selectWordEvent!.globalPosition.dx - 200).abs() < precisionErrorTolerance, isTrue);
expect((selectWordEvent.globalPosition.dy - 300).abs() < precisionErrorTolerance, isTrue);
});
expect(selectWordEvent, isNotNull);
expect((selectWordEvent!.globalPosition.dx - 200).abs() < precisionErrorTolerance, isTrue);
expect((selectWordEvent.globalPosition.dy - 300).abs() < precisionErrorTolerance, isTrue);
},
skip: !_browserContextMenuEnabled(), // [intended]
);
// Regression test for https://github.com/flutter/flutter/issues/157579
testWidgets('prevents default action of mousedown events', (WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
testWidgets(
'prevents default action of mousedown events',
(WidgetTester tester) async {
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
selectionControls: emptyTextSelectionControls,
child: const SizedBox.shrink(),
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
selectionControls: emptyTextSelectionControls,
child: const SizedBox.shrink(),
),
),
),
);
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
expect(element, isNotNull);
for (int i = 0; i <= 4; i++) {
final web.MouseEvent event = web.MouseEvent(
'mousedown',
web.MouseEventInit(button: i, clientX: 200, clientY: 300, cancelable: true),
);
element.dispatchEvent(event);
expect(event.defaultPrevented, isTrue);
}
});
final web.HTMLElement element =
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
expect(element, isNotNull);
for (int i = 0; i <= 4; i++) {
final web.MouseEvent event = web.MouseEvent(
'mousedown',
web.MouseEventInit(button: i, clientX: 200, clientY: 300, cancelable: true),
);
element.dispatchEvent(event);
expect(event.defaultPrevented, isTrue);
}
},
skip: !_browserContextMenuEnabled(), // [intended]
);
}
void removeAllStyleElements() {

View File

@ -6446,6 +6446,56 @@ void main() {
},
skip: !kIsWeb, // [intended] This test verifies web behavior.
);
testWidgets(
'uses contextMenuBuilder by default on Android and iOS web',
(WidgetTester tester) async {
final UniqueKey contextMenu = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder:
(BuildContext context, SelectableRegionState selectableRegionState) {
return SizedBox.shrink(key: contextMenu);
},
child: const Text('How are you?'),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(contextMenu), findsNothing);
// Show the toolbar by longpressing.
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(
find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)),
);
final TestGesture gesture = await tester.startGesture(
textOffsetToPosition(paragraph1, 6),
); // at the 'r'
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
// `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(contextMenu), findsOneWidget);
},
// TODO(Renzo-Olivares): Remove this test when the web context menu
// for Android and iOS is re-enabled.
// See: https://github.com/flutter/flutter/issues/177123.
// [intended] Android and iOS use the flutter rendered menu on the web.
skip:
!kIsWeb ||
!<TargetPlatform>{
TargetPlatform.android,
TargetPlatform.iOS,
}.contains(defaultTargetPlatform),
);
});
testWidgets('Multiple selectables on a single line should be in screen order', (