diff --git a/packages/material_ui/lib/src/search_anchor.dart b/packages/material_ui/lib/src/search_anchor.dart index 845de4d3ddb1..e4bb5454518c 100644 --- a/packages/material_ui/lib/src/search_anchor.dart +++ b/packages/material_ui/lib/src/search_anchor.dart @@ -32,6 +32,7 @@ import 'material_state.dart'; import 'search_bar_theme.dart'; import 'search_view_theme.dart'; import 'text_field.dart'; +import 'text_selection_theme.dart'; import 'text_theme.dart'; import 'theme.dart'; @@ -241,7 +242,7 @@ class SearchAnchor extends StatefulWidget { TextInputAction? textInputAction, TextInputType? keyboardType, EdgeInsets scrollPadding, - EditableTextContextMenuBuilder contextMenuBuilder, + EditableTextContextMenuBuilder? contextMenuBuilder, bool enabled, SmartDashesType? smartDashesType, SmartQuotesType? smartQuotesType, @@ -1309,7 +1310,7 @@ class _SearchAnchorWithSearchBar extends SearchAnchor { super.textInputAction, super.keyboardType, EdgeInsets scrollPadding = const EdgeInsets.all(20.0), - EditableTextContextMenuBuilder contextMenuBuilder = SearchBar._defaultContextMenuBuilder, + EditableTextContextMenuBuilder? contextMenuBuilder = SearchBar._defaultContextMenuBuilder, super.enabled, super.smartDashesType, super.smartQuotesType, @@ -1812,6 +1813,12 @@ class _SearchBarState extends State { ) .toList(); + final EditableTextContextMenuBuilder? resolvedContextMenuBuilder = + widget.contextMenuBuilder == SearchBar._defaultContextMenuBuilder + ? (TextSelectionTheme.of(context).contextMenuBuilder ?? + SearchBar._defaultContextMenuBuilder) + : widget.contextMenuBuilder; + return ConstrainedBox( constraints: widget.constraints ?? searchBarTheme.constraints ?? defaults.constraints!, child: Opacity( @@ -1875,7 +1882,7 @@ class _SearchBarState extends State { textInputAction: widget.textInputAction, keyboardType: widget.keyboardType, scrollPadding: widget.scrollPadding, - contextMenuBuilder: widget.contextMenuBuilder, + contextMenuBuilder: resolvedContextMenuBuilder, smartDashesType: widget.smartDashesType, smartQuotesType: widget.smartQuotesType, ), diff --git a/packages/material_ui/lib/src/selectable_text.dart b/packages/material_ui/lib/src/selectable_text.dart index 171b9b83547e..7cc1456f0110 100644 --- a/packages/material_ui/lib/src/selectable_text.dart +++ b/packages/material_ui/lib/src/selectable_text.dart @@ -20,6 +20,7 @@ import 'adaptive_text_selection_toolbar.dart'; import 'desktop_text_selection.dart'; import 'magnifier.dart'; import 'text_selection.dart'; +import 'text_selection_theme.dart'; import 'theme.dart'; // Examples can assume: @@ -757,12 +758,20 @@ class _SelectableTextState extends State widget.style ?? _controller._textSpan.style, ); } + final TextScaler? effectiveScaler = widget.textScaler ?? switch (widget.textScaleFactor) { null => null, final double textScaleFactor => TextScaler.linear(textScaleFactor), }; + + final EditableTextContextMenuBuilder? resolvedContextMenuBuilder = + widget.contextMenuBuilder == SelectableText._defaultContextMenuBuilder + ? (TextSelectionTheme.of(context).contextMenuBuilder ?? + SelectableText._defaultContextMenuBuilder) + : widget.contextMenuBuilder; + final Widget child = RepaintBoundary( child: EditableText( key: editableTextKey, @@ -805,7 +814,7 @@ class _SelectableTextState extends State scrollPhysics: widget.scrollPhysics, scrollBehavior: widget.scrollBehavior, autofillHints: null, - contextMenuBuilder: widget.contextMenuBuilder, + contextMenuBuilder: resolvedContextMenuBuilder, ), ); diff --git a/packages/material_ui/lib/src/text_field.dart b/packages/material_ui/lib/src/text_field.dart index e1baf38d7448..6cf6ee031bcb 100644 --- a/packages/material_ui/lib/src/text_field.dart +++ b/packages/material_ui/lib/src/text_field.dart @@ -29,6 +29,7 @@ import 'material_state.dart'; import 'selectable_text.dart' show iOSHorizontalOffset; import 'spell_check_suggestions_toolbar.dart'; import 'text_selection.dart'; +import 'text_selection_theme.dart'; import 'theme.dart'; export 'package:flutter/services.dart' @@ -888,6 +889,8 @@ class TextField extends StatefulWidget { /// * [AdaptiveTextSelectionToolbar], which is built by default. /// * [BrowserContextMenu], which allows the browser's context menu on web to /// be disabled and Flutter-rendered context menus to appear. + /// * [ThemeData.textSelectionTheme], which provides a way to override the + /// default context menu. final EditableTextContextMenuBuilder? contextMenuBuilder; /// Determine whether this text field can request the primary focus. @@ -1696,6 +1699,12 @@ class _TextFieldState extends State }; } + final EditableTextContextMenuBuilder? resolvedContextMenuBuilder = + widget.contextMenuBuilder == TextField._defaultContextMenuBuilder + ? (TextSelectionTheme.of(context).contextMenuBuilder ?? + TextField._defaultContextMenuBuilder) + : widget.contextMenuBuilder; + Widget child = RepaintBoundary( child: UnmanagedRestorationScope( bucket: bucket, @@ -1767,7 +1776,7 @@ class _TextFieldState extends State enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, enableInlinePrediction: widget.enableInlinePrediction, contentInsertionConfiguration: widget.contentInsertionConfiguration, - contextMenuBuilder: widget.contextMenuBuilder, + contextMenuBuilder: resolvedContextMenuBuilder, spellCheckConfiguration: spellCheckConfiguration, magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration, diff --git a/packages/material_ui/lib/src/text_form_field.dart b/packages/material_ui/lib/src/text_form_field.dart index 43139398487c..dd8824910564 100644 --- a/packages/material_ui/lib/src/text_form_field.dart +++ b/packages/material_ui/lib/src/text_form_field.dart @@ -12,6 +12,7 @@ import 'adaptive_text_selection_toolbar.dart'; import 'input_decorator.dart'; import 'material_state.dart'; import 'text_field.dart'; +import 'text_selection_theme.dart'; export 'package:flutter/services.dart' show SmartDashesType, SmartQuotesType; @@ -246,6 +247,12 @@ class TextFormField extends FormField { onChanged?.call(value); } + final EditableTextContextMenuBuilder? resolvedContextMenuBuilder = + contextMenuBuilder == _defaultContextMenuBuilder + ? (TextSelectionTheme.of(field.context).contextMenuBuilder ?? + _defaultContextMenuBuilder) + : contextMenuBuilder; + return UnmanagedRestorationScope( bucket: field.bucket, child: TextField( @@ -309,7 +316,7 @@ class TextFormField extends FormField { scrollController: scrollController, enableIMEPersonalizedLearning: enableIMEPersonalizedLearning, mouseCursor: mouseCursor, - contextMenuBuilder: contextMenuBuilder, + contextMenuBuilder: resolvedContextMenuBuilder, spellCheckConfiguration: spellCheckConfiguration, magnifierConfiguration: magnifierConfiguration, undoController: undoController, diff --git a/packages/material_ui/lib/src/text_selection_theme.dart b/packages/material_ui/lib/src/text_selection_theme.dart index a90d96eba5d7..6c690035f3b2 100644 --- a/packages/material_ui/lib/src/text_selection_theme.dart +++ b/packages/material_ui/lib/src/text_selection_theme.dart @@ -36,7 +36,12 @@ import 'theme.dart'; @immutable class TextSelectionThemeData with Diagnosticable { /// Creates the set of properties used to configure [TextField]s. - const TextSelectionThemeData({this.cursorColor, this.selectionColor, this.selectionHandleColor}); + const TextSelectionThemeData({ + this.cursorColor, + this.selectionColor, + this.selectionHandleColor, + this.contextMenuBuilder, + }); /// The color of the cursor in the text field. /// @@ -58,17 +63,31 @@ class TextSelectionThemeData with Diagnosticable { /// containing your [TextField] or [SelectableText] with a [CupertinoTheme]. final Color? selectionHandleColor; + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// Provides a way to globally override the default Flutter context menu for + /// text fields and selectable text. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + /// * [BrowserContextMenu], which allows the browser's context menu on web to + /// be disabled and Flutter-rendered context menus to appear. + final EditableTextContextMenuBuilder? contextMenuBuilder; + /// Creates a copy of this object with the given fields replaced with the /// specified values. TextSelectionThemeData copyWith({ Color? cursorColor, Color? selectionColor, Color? selectionHandleColor, + EditableTextContextMenuBuilder? contextMenuBuilder, }) { return TextSelectionThemeData( cursorColor: cursorColor ?? this.cursorColor, selectionColor: selectionColor ?? this.selectionColor, selectionHandleColor: selectionHandleColor ?? this.selectionHandleColor, + contextMenuBuilder: contextMenuBuilder ?? this.contextMenuBuilder, ); } @@ -89,11 +108,23 @@ class TextSelectionThemeData with Diagnosticable { cursorColor: Color.lerp(a?.cursorColor, b?.cursorColor, t), selectionColor: Color.lerp(a?.selectionColor, b?.selectionColor, t), selectionHandleColor: Color.lerp(a?.selectionHandleColor, b?.selectionHandleColor, t), + contextMenuBuilder: _lerpContextMenuBuilder(a, b, t), ); } + /// Linear interpolation doesn't make much sense between two context menu builders, + /// so instead of interpolating, choose one based on the value of 't'. + static EditableTextContextMenuBuilder? _lerpContextMenuBuilder( + TextSelectionThemeData? a, + TextSelectionThemeData? b, + double t, + ) { + return t < 0.5 ? a?.contextMenuBuilder : b?.contextMenuBuilder; + } + @override - int get hashCode => Object.hash(cursorColor, selectionColor, selectionHandleColor); + int get hashCode => + Object.hash(cursorColor, selectionColor, selectionHandleColor, contextMenuBuilder); @override bool operator ==(Object other) { @@ -106,7 +137,8 @@ class TextSelectionThemeData with Diagnosticable { return other is TextSelectionThemeData && other.cursorColor == cursorColor && other.selectionColor == selectionColor && - other.selectionHandleColor == selectionHandleColor; + other.selectionHandleColor == selectionHandleColor && + other.contextMenuBuilder == contextMenuBuilder; } @override @@ -115,6 +147,13 @@ class TextSelectionThemeData with Diagnosticable { properties.add(ColorProperty('cursorColor', cursorColor, defaultValue: null)); properties.add(ColorProperty('selectionColor', selectionColor, defaultValue: null)); properties.add(ColorProperty('selectionHandleColor', selectionHandleColor, defaultValue: null)); + properties.add( + DiagnosticsProperty( + 'contextMenuBuilder', + contextMenuBuilder, + defaultValue: null, + ), + ); } } diff --git a/packages/material_ui/pending_changelogs/change_2026_09_11_1789133831528.yaml b/packages/material_ui/pending_changelogs/change_2026_09_11_1789133831528.yaml new file mode 100644 index 000000000000..cec96e5f323a --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_09_11_1789133831528.yaml @@ -0,0 +1,3 @@ +changelog: | + - Adds a global context menu builder override to TextSelectionThemeData. +version: minor diff --git a/packages/material_ui/test/search_anchor_test.dart b/packages/material_ui/test/search_anchor_test.dart index d810d74dce07..716c8904082e 100644 --- a/packages/material_ui/test/search_anchor_test.dart +++ b/packages/material_ui/test/search_anchor_test.dart @@ -3824,6 +3824,137 @@ void main() { ); }); + testWidgets('SearchBar uses ThemeData.textSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: const Material(child: SearchBar()), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext searchBarContext = tester.element(find.byType(SearchBar)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + searchBarContext, + editableTextState, + ); + + expect(contextMenu, isA()); + }); + + testWidgets('SearchBar prefers local TextSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + Widget localTextSelectionThemeContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: TextSelectionTheme( + data: TextSelectionThemeData( + contextMenuBuilder: localTextSelectionThemeContextMenuBuilder, + ), + child: const SearchBar(), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext searchBarContext = tester.element(find.byType(SearchBar)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + searchBarContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + }); + + testWidgets('SearchBar.contextMenuBuilder overrides TextSelectionTheme and ThemeData', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + Widget localTextSelectionThemeContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + Widget searchBarContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const SizedBox(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: TextSelectionTheme( + data: TextSelectionThemeData( + contextMenuBuilder: localTextSelectionThemeContextMenuBuilder, + ), + child: SearchBar(contextMenuBuilder: searchBarContextMenuBuilder), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext searchBarContext = tester.element(find.byType(SearchBar)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + searchBarContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + expect(contextMenu, isNot(isA())); + }); + testWidgets('SearchAnchor.bar.contextMenuBuilder is passed through to EditableText', ( WidgetTester tester, ) async { @@ -3856,6 +3987,162 @@ void main() { expect(find.byType(Placeholder), findsOneWidget); }); + testWidgets('SearchAnchor.bar uses ThemeData.textSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext searchAnchorContext = tester.element(find.byType(SearchBar)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + searchAnchorContext, + editableTextState, + ); + + expect(contextMenu, isA()); + }); + + testWidgets( + 'contextMenuBuilder changes from default to null', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: SearchBar(key: key)), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // Set contextMenuBuilder to null. + await tester.pumpWidget( + MaterialApp( + home: Material(child: SearchBar(key: key, contextMenuBuilder: null)), + ), + ); + + // Trigger build one more time... + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Padding( + padding: EdgeInsets.zero, + child: SearchBar(key: key, contextMenuBuilder: null), + ), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + + testWidgets( + 'SearchAnchor.bar contextMenuBuilder changes from default to null', + (WidgetTester tester) async { + FutureOr> suggestionsBuilder( + BuildContext context, + SearchController controller, + ) { + return []; + } + + await tester.pumpWidget( + MaterialApp( + home: Material(child: SearchAnchor.bar(suggestionsBuilder: suggestionsBuilder)), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // Set contextMenuBuilder to null. + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + suggestionsBuilder: suggestionsBuilder, + contextMenuBuilder: null, + ), + ), + ), + ); + + // Trigger build one more time... + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Padding( + padding: EdgeInsets.zero, + child: SearchAnchor.bar( + suggestionsBuilder: suggestionsBuilder, + contextMenuBuilder: null, + ), + ), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + testWidgets( 'iOS uses the system context menu by default if supported', (WidgetTester tester) async { diff --git a/packages/material_ui/test/selectable_text_test.dart b/packages/material_ui/test/selectable_text_test.dart index 1f80b02a937e..2f9c6404954d 100644 --- a/packages/material_ui/test/selectable_text_test.dart +++ b/packages/material_ui/test/selectable_text_test.dart @@ -5604,6 +5604,199 @@ void main() { ); group('context menu', () { + testWidgets('SelectableText uses ThemeData.textSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: const Material(child: SelectableText('one two three')), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext selectableTextContext = tester.element(find.byType(SelectableText)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + selectableTextContext, + editableTextState, + ); + + expect(contextMenu, isA()); + }); + + testWidgets('SelectableText prefers local TextSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + Widget localTextSelectionThemeContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: TextSelectionTheme( + data: TextSelectionThemeData( + contextMenuBuilder: localTextSelectionThemeContextMenuBuilder, + ), + child: const SelectableText('one two three'), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext selectableTextContext = tester.element(find.byType(SelectableText)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + selectableTextContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + }); + + testWidgets('SelectableText.contextMenuBuilder overrides TextSelectionTheme and ThemeData', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + Widget localTextSelectionThemeContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + Widget selectableTextContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const SizedBox(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: TextSelectionTheme( + data: TextSelectionThemeData( + contextMenuBuilder: localTextSelectionThemeContextMenuBuilder, + ), + child: SelectableText( + 'one two three', + contextMenuBuilder: selectableTextContextMenuBuilder, + ), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext selectableTextContext = tester.element(find.byType(SelectableText)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + selectableTextContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + expect(contextMenu, isNot(isA())); + }); + + testWidgets( + 'contextMenuBuilder changes from default to null', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + const data = 'one two three'; + + await tester.pumpWidget( + MaterialApp( + home: Material(child: SelectableText(data, key: key)), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + Finder textFinder = find.byType(SelectableText); + Finder editableTextFinder = find.descendant( + of: textFinder, + matching: find.byType(EditableText), + ); + await tester.longPress(editableTextFinder); + tester.state(editableTextFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // Set contextMenuBuilder to null. + await tester.pumpWidget( + MaterialApp( + home: Material(child: SelectableText(data, key: key, contextMenuBuilder: null)), + ), + ); + + // Trigger build one more time... + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Padding( + padding: EdgeInsets.zero, + child: SelectableText(data, key: key, contextMenuBuilder: null), + ), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + textFinder = find.byType(SelectableText); + editableTextFinder = find.descendant(of: textFinder, matching: find.byType(EditableText)); + await tester.longPress(editableTextFinder); + tester.state(editableTextFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + // Regression test for https://github.com/flutter/flutter/issues/169001. testWidgets( 'iOS does not use the system context menu by default even when supported', diff --git a/packages/material_ui/test/text_field_test.dart b/packages/material_ui/test/text_field_test.dart index 7b2e24e5b8ea..a516117fe854 100644 --- a/packages/material_ui/test/text_field_test.dart +++ b/packages/material_ui/test/text_field_test.dart @@ -17188,7 +17188,7 @@ void main() { await tester.pump(); // Wait for autofocus to take effect. // Long-press to bring up the context menu. - final Finder textFinder = find.byType(EditableText); + Finder textFinder = find.byType(EditableText); await tester.longPress(textFinder); tester.state(textFinder).showToolbar(); await tester.pump(); @@ -17215,10 +17215,151 @@ void main() { ), ), ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); }, skip: kIsWeb, // [intended] on web the browser handles the context menu. ); + testWidgets('TextField uses ThemeData.textSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: const Material(child: TextField()), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext textFieldContext = tester.element(find.byType(TextField)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + textFieldContext, + editableTextState, + ); + + expect(contextMenu, isA()); + }); + + testWidgets('TextField prefers local TextSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + Widget localTextSelectionThemeContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: TextSelectionTheme( + data: TextSelectionThemeData( + contextMenuBuilder: localTextSelectionThemeContextMenuBuilder, + ), + child: const TextField(), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext textFieldContext = tester.element(find.byType(TextField)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + textFieldContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + }); + + testWidgets('TextField.contextMenuBuilder overrides TextSelectionTheme and ThemeData', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + Widget localTextSelectionThemeContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + Widget textFieldContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const SizedBox(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: TextSelectionTheme( + data: TextSelectionThemeData( + contextMenuBuilder: localTextSelectionThemeContextMenuBuilder, + ), + child: TextField(contextMenuBuilder: textFieldContextMenuBuilder), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext textFieldContext = tester.element(find.byType(TextField)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + textFieldContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + expect(contextMenu, isNot(isA())); + }); + testWidgets( 'iOS uses the system context menu by default if supported', (WidgetTester tester) async { diff --git a/packages/material_ui/test/text_form_field_test.dart b/packages/material_ui/test/text_form_field_test.dart index b4b553e43b45..dab3157dd40f 100644 --- a/packages/material_ui/test/text_form_field_test.dart +++ b/packages/material_ui/test/text_form_field_test.dart @@ -1739,6 +1739,174 @@ void main() { ); group('context menu', () { + testWidgets('TextFormField uses ThemeData.textSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material(child: TextFormField()), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext textFormFieldContext = tester.element(find.byType(TextFormField)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + textFormFieldContext, + editableTextState, + ); + + expect(contextMenu, isA()); + }); + + testWidgets('TextFormField prefers local TextSelectionTheme contextMenuBuilder', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + Widget localTextSelectionThemeContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Icon(Icons.search); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material( + child: TextSelectionTheme( + data: TextSelectionThemeData( + contextMenuBuilder: localTextSelectionThemeContextMenuBuilder, + ), + child: TextFormField(), + ), + ), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext textFormFieldContext = tester.element(find.byType(TextFormField)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + textFormFieldContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + }); + + testWidgets('TextFormField.contextMenuBuilder overrides ThemeData.textSelectionTheme', ( + WidgetTester tester, + ) async { + Widget themeDataContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const Placeholder(); + } + + Widget textFormFieldContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return const SizedBox(); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + textSelectionTheme: TextSelectionThemeData( + contextMenuBuilder: themeDataContextMenuBuilder, + ), + ), + home: Material(child: TextFormField(contextMenuBuilder: textFormFieldContextMenuBuilder)), + ), + ); + + final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); + final BuildContext textFormFieldContext = tester.element(find.byType(TextFormField)); + final Widget contextMenu = editableTextState.widget.contextMenuBuilder!( + textFormFieldContext, + editableTextState, + ); + + expect(contextMenu, isA()); + expect(contextMenu, isNot(isA())); + }); + + testWidgets( + 'contextMenuBuilder changes from default to null', + (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextFormField(key: key)), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + + // Set contextMenuBuilder to null. + await tester.pumpWidget( + MaterialApp( + home: Material(child: TextFormField(key: key, contextMenuBuilder: null)), + ), + ); + + // Trigger build one more time... + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Padding( + padding: EdgeInsets.zero, + child: TextFormField(key: key, contextMenuBuilder: null), + ), + ), + ), + ); + + await tester.pump(); // Wait for autofocus to take effect. + + // Long-press to bring up the context menu. + textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pump(); + + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + }, + skip: kIsWeb, // [intended] on web the browser handles the context menu. + ); + testWidgets( 'iOS uses the system context menu by default if supported', (WidgetTester tester) async { diff --git a/packages/material_ui/test/text_selection_theme_test.dart b/packages/material_ui/test/text_selection_theme_test.dart index 7531109d743c..7647e594e940 100644 --- a/packages/material_ui/test/text_selection_theme_test.dart +++ b/packages/material_ui/test/text_selection_theme_test.dart @@ -6,7 +6,32 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; +class CustomContextMenu extends AdaptiveTextSelectionToolbar { + const CustomContextMenu.buttonItems({ + super.key, + required super.anchors, + required super.buttonItems, + }) : super.buttonItems(); +} + void main() { + CustomContextMenu defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return CustomContextMenu.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: [ + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + }, + label: 'Context Button Item', + ), + ], + ); + } + test('TextSelectionThemeData copyWith, ==, hashCode basics', () { expect(const TextSelectionThemeData(), const TextSelectionThemeData().copyWith()); expect( @@ -21,11 +46,64 @@ void main() { expect(identical(TextSelectionThemeData.lerp(data, data, 0.5), data), true); }); + group('TextSelectionThemeData lerp contextMenuBuilder', () { + Widget aBuilder(BuildContext context, EditableTextState editableTextState) { + return const Placeholder(); + } + + Widget bBuilder(BuildContext context, EditableTextState editableTextState) { + return const Icon(Icons.search); + } + + const withoutBuilder = TextSelectionThemeData(); + final withABuilder = TextSelectionThemeData(contextMenuBuilder: aBuilder); + final withBBuilder = TextSelectionThemeData(contextMenuBuilder: bBuilder); + + test('returns null when both are null', () { + expect(TextSelectionThemeData.lerp(null, null, 0.5)?.contextMenuBuilder, null); + expect( + TextSelectionThemeData.lerp(withoutBuilder, withoutBuilder, 0.5)?.contextMenuBuilder, + null, + ); + }); + + test('returns a below 0.5 and b at or above 0.5', () { + expect( + TextSelectionThemeData.lerp(withABuilder, withBBuilder, 0)!.contextMenuBuilder, + aBuilder, + ); + expect( + TextSelectionThemeData.lerp(withABuilder, withBBuilder, 0.49)!.contextMenuBuilder, + aBuilder, + ); + expect( + TextSelectionThemeData.lerp(withABuilder, withBBuilder, 0.5)!.contextMenuBuilder, + bBuilder, + ); + expect( + TextSelectionThemeData.lerp(withABuilder, withBBuilder, 1)!.contextMenuBuilder, + bBuilder, + ); + }); + + test('can produce a null result when the chosen side is null', () { + expect( + TextSelectionThemeData.lerp(withABuilder, withoutBuilder, 0.5)!.contextMenuBuilder, + null, + ); + expect( + TextSelectionThemeData.lerp(withoutBuilder, withBBuilder, 0)!.contextMenuBuilder, + null, + ); + }); + }); + test('TextSelectionThemeData null fields by default', () { const theme = TextSelectionThemeData(); expect(theme.cursorColor, null); expect(theme.selectionColor, null); expect(theme.selectionHandleColor, null); + expect(theme.contextMenuBuilder, null); }); testWidgets('Default TextSelectionThemeData debugFillProperties', (WidgetTester tester) async { @@ -42,14 +120,22 @@ void main() { testWidgets('TextSelectionThemeData implements debugFillProperties', (WidgetTester tester) async { final builder = DiagnosticPropertiesBuilder(); - const TextSelectionThemeData( - cursorColor: Color(0xffeeffaa), - selectionColor: Color(0x88888888), - selectionHandleColor: Color(0xaabbccdd), + TextSelectionThemeData( + cursorColor: const Color(0xffeeffaa), + selectionColor: const Color(0x88888888), + selectionHandleColor: const Color(0xaabbccdd), + contextMenuBuilder: defaultContextMenuBuilder, ).debugFillProperties(builder); - final List description = builder.properties + // The contextMenuBuilder property is checked separately below: its + // Function.toString() representation is compiler-dependent (VM vs + // dart2js/DDC), so it can't be compared as an exact string. + final List properties = builder.properties .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .toList(); + + final List description = properties + .where((DiagnosticsNode node) => node.name != 'contextMenuBuilder') .map((DiagnosticsNode node) => node.toString()) .toList(); @@ -58,6 +144,11 @@ void main() { 'selectionColor: ${const Color(0x88888888)}', 'selectionHandleColor: ${const Color(0xaabbccdd)}', ]); + + final DiagnosticsNode contextMenuBuilderNode = properties.singleWhere( + (DiagnosticsNode node) => node.name == 'contextMenuBuilder', + ); + expect(contextMenuBuilderNode.value, defaultContextMenuBuilder); }); testWidgets('Material2 - Empty textSelectionTheme will use defaults', ( @@ -87,6 +178,12 @@ void main() { expect(renderEditable.cursorColor, defaultCursorColor); expect(renderEditable.selectionColor, defaultSelectionColor); + final BuildContext textFieldContext = tester.element(find.byType(TextField)); + final EditableTextContextMenuBuilder? themeContextMenuBuilder = TextSelectionTheme.of( + textFieldContext, + ).contextMenuBuilder; + expect(themeContextMenuBuilder, null); + // Test the selection handle color. await tester.pumpWidget( MaterialApp( @@ -136,6 +233,12 @@ void main() { expect(renderEditable.cursorColor, defaultCursorColor); expect(renderEditable.selectionColor, defaultSelectionColor); + final BuildContext textFieldContext = tester.element(find.byType(TextField)); + final EditableTextContextMenuBuilder? themeContextMenuBuilder = TextSelectionTheme.of( + textFieldContext, + ).contextMenuBuilder; + expect(themeContextMenuBuilder, null); + // Test the selection handle color. await tester.pumpWidget( MaterialApp( @@ -159,10 +262,11 @@ void main() { }); testWidgets('ThemeData.textSelectionTheme will be used if provided', (WidgetTester tester) async { - const textSelectionTheme = TextSelectionThemeData( - cursorColor: Color(0xffaabbcc), - selectionColor: Color(0x88888888), - selectionHandleColor: Color(0x00ccbbaa), + final textSelectionTheme = TextSelectionThemeData( + cursorColor: const Color(0xffaabbcc), + selectionColor: const Color(0x88888888), + selectionHandleColor: const Color(0x00ccbbaa), + contextMenuBuilder: defaultContextMenuBuilder, ); final ThemeData theme = ThemeData.fallback().copyWith(textSelectionTheme: textSelectionTheme); @@ -184,6 +288,11 @@ void main() { final RenderEditable renderEditable = editableTextState.renderEditable; expect(renderEditable.cursorColor, textSelectionTheme.cursorColor); expect(renderEditable.selectionColor, textSelectionTheme.selectionColor); + final BuildContext textFieldContext = tester.element(find.byType(TextField)); + final EditableTextContextMenuBuilder? themeContextMenuBuilder = TextSelectionTheme.of( + textFieldContext, + ).contextMenuBuilder; + expect(themeContextMenuBuilder, textSelectionTheme.contextMenuBuilder); // Test the selection handle color. await tester.pumpWidget( @@ -218,10 +327,11 @@ void main() { final ThemeData theme = ThemeData.fallback().copyWith( textSelectionTheme: defaultTextSelectionTheme, ); - const widgetTextSelectionTheme = TextSelectionThemeData( - cursorColor: Color(0xffddeeff), - selectionColor: Color(0x44444444), - selectionHandleColor: Color(0x00ffeedd), + final widgetTextSelectionTheme = TextSelectionThemeData( + cursorColor: const Color(0xffddeeff), + selectionColor: const Color(0x44444444), + selectionHandleColor: const Color(0x00ffeedd), + contextMenuBuilder: defaultContextMenuBuilder, ); EditableText.debugDeterministicCursor = true; @@ -232,10 +342,10 @@ void main() { await tester.pumpWidget( MaterialApp( theme: theme, - home: const Material( + home: Material( child: TextSelectionTheme( data: widgetTextSelectionTheme, - child: TextField(autofocus: true), + child: const TextField(autofocus: true), ), ), ), @@ -246,6 +356,12 @@ void main() { expect(renderEditable.cursorColor, widgetTextSelectionTheme.cursorColor); expect(renderEditable.selectionColor, widgetTextSelectionTheme.selectionColor); + final BuildContext textFieldContext = tester.element(find.byType(TextField)); + final EditableTextContextMenuBuilder? themeContextMenuBuilder = TextSelectionTheme.of( + textFieldContext, + ).contextMenuBuilder; + expect(themeContextMenuBuilder, widgetTextSelectionTheme.contextMenuBuilder); + // Test the selection handle color. await tester.pumpWidget( MaterialApp( @@ -289,10 +405,13 @@ void main() { await tester.pumpWidget( MaterialApp( theme: theme, - home: const Material( + home: Material( child: TextSelectionTheme( data: widgetTextSelectionTheme, - child: TextField(cursorColor: cursorColor), + child: TextField( + cursorColor: cursorColor, + contextMenuBuilder: defaultContextMenuBuilder, + ), ), ), ), @@ -301,15 +420,20 @@ void main() { final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); final RenderEditable renderEditable = editableTextState.renderEditable; expect(renderEditable.cursorColor, cursorColor.withAlpha(0)); + expect(editableTextState.widget.contextMenuBuilder, defaultContextMenuBuilder); // Test SelectableText's cursor color. await tester.pumpWidget( MaterialApp( theme: theme, - home: const Material( + home: Material( child: TextSelectionTheme( data: widgetTextSelectionTheme, - child: SelectableText('foobar', cursorColor: cursorColor), + child: SelectableText( + 'foobar', + cursorColor: cursorColor, + contextMenuBuilder: defaultContextMenuBuilder, + ), ), ), ), @@ -318,6 +442,7 @@ void main() { final EditableTextState selectableTextState = tester.firstState(find.byType(EditableText)); final RenderEditable renderSelectable = selectableTextState.renderEditable; expect(renderSelectable.cursorColor, cursorColor.withAlpha(0)); + expect(selectableTextState.widget.contextMenuBuilder, defaultContextMenuBuilder); }); testWidgets('TextSelectionThem overrides DefaultSelectionStyle', (WidgetTester tester) async { @@ -336,9 +461,10 @@ void main() { child: Container( key: defaultSelectionStyle, child: TextSelectionTheme( - data: const TextSelectionThemeData( + data: TextSelectionThemeData( selectionColor: themeSelectionColor, cursorColor: themeCursorColor, + contextMenuBuilder: defaultContextMenuBuilder, ), child: Placeholder(key: themeStyle), ), @@ -352,10 +478,14 @@ void main() { DefaultSelectionStyle style = DefaultSelectionStyle.of(defaultSelectionStyleContext); expect(style.selectionColor, defaultSelectionColor); expect(style.cursorColor, defaultCursorColor); + TextSelectionThemeData textSelectionTheme = TextSelectionTheme.of(defaultSelectionStyleContext); + expect(textSelectionTheme.contextMenuBuilder, null); final BuildContext themeStyleContext = tester.element(find.byKey(themeStyle)); style = DefaultSelectionStyle.of(themeStyleContext); expect(style.selectionColor, themeSelectionColor); expect(style.cursorColor, themeCursorColor); + textSelectionTheme = TextSelectionTheme.of(themeStyleContext); + expect(textSelectionTheme.contextMenuBuilder, defaultContextMenuBuilder); }); }