diff --git a/packages/material_ui/lib/src/ink_well.dart b/packages/material_ui/lib/src/ink_well.dart index 35c8c6bb4032..f8f60f7719ac 100644 --- a/packages/material_ui/lib/src/ink_well.dart +++ b/packages/material_ui/lib/src/ink_well.dart @@ -315,6 +315,9 @@ class InkResponse extends StatelessWidget { this.onSecondaryTapUp, this.onSecondaryTapDown, this.onSecondaryTapCancel, + this.onTertiaryTapUp, + this.onTertiaryTapDown, + this.onTertiaryTapCancel, this.onHighlightChanged, this.onHover, this.mouseCursor, @@ -410,6 +413,35 @@ class InkResponse extends StatelessWidget { /// * [kSecondaryButton], the button this callback responds to. final GestureTapCallback? onSecondaryTapCancel; + /// Called when the user taps down on this part of the material with a + /// tertiary button. + /// + /// A common use case for the tertiary button is opening a link in a new tab + /// or window with a middle click of a mouse. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + final GestureTapDownCallback? onTertiaryTapDown; + + /// Called when the user releases a tertiary button tap that was started on + /// this part of the material. + /// + /// See also: + /// + /// * [onTertiaryTapDown], which is called when the tertiary button tap + /// starts. + /// * [kTertiaryButton], the button this callback responds to. + final GestureTapUpCallback? onTertiaryTapUp; + + /// Called when the user cancels a tertiary button tap that was started on + /// this part of the material. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + final GestureTapCancelCallback? onTertiaryTapCancel; + /// Called when this part of the material either becomes highlighted or stops /// being highlighted. /// @@ -674,6 +706,9 @@ class InkResponse extends StatelessWidget { onSecondaryTapUp: onSecondaryTapUp, onSecondaryTapDown: onSecondaryTapDown, onSecondaryTapCancel: onSecondaryTapCancel, + onTertiaryTapUp: onTertiaryTapUp, + onTertiaryTapDown: onTertiaryTapDown, + onTertiaryTapCancel: onTertiaryTapCancel, onHighlightChanged: onHighlightChanged, onHover: onHover, mouseCursor: mouseCursor, @@ -732,6 +767,9 @@ class _InkResponseStateWidget extends StatefulWidget { this.onSecondaryTapUp, this.onSecondaryTapDown, this.onSecondaryTapCancel, + this.onTertiaryTapUp, + this.onTertiaryTapDown, + this.onTertiaryTapCancel, this.onHighlightChanged, this.onHover, this.mouseCursor, @@ -771,6 +809,9 @@ class _InkResponseStateWidget extends StatefulWidget { final GestureTapUpCallback? onSecondaryTapUp; final GestureTapDownCallback? onSecondaryTapDown; final GestureTapCallback? onSecondaryTapCancel; + final GestureTapUpCallback? onTertiaryTapUp; + final GestureTapDownCallback? onTertiaryTapDown; + final GestureTapCancelCallback? onTertiaryTapCancel; final ValueChanged? onHighlightChanged; final ValueChanged? onHover; final MouseCursor? mouseCursor; @@ -815,6 +856,9 @@ class _InkResponseStateWidget extends StatefulWidget { if (onSecondaryTapUp != null) 'secondary tap up', if (onSecondaryTapDown != null) 'secondary tap down', if (onSecondaryTapCancel != null) 'secondary tap cancel', + if (onTertiaryTapUp != null) 'tertiary tap up', + if (onTertiaryTapDown != null) 'tertiary tap down', + if (onTertiaryTapCancel != null) 'tertiary tap cancel', ]; properties.add(IterableProperty('gestures', gestures, ifEmpty: '')); properties.add(DiagnosticsProperty('mouseCursor', mouseCursor)); @@ -1183,6 +1227,25 @@ class _InkResponseState extends State<_InkResponseStateWidget> widget.onSecondaryTapUp?.call(details); } + void handleTertiaryTapDown(TapDownDetails details) { + handleAnyTapDown(details); + widget.onTertiaryTapDown?.call(details); + } + + void handleTertiaryTapUp(TapUpDetails details) { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + widget.onTertiaryTapUp?.call(details); + } + + void handleTertiaryTapCancel() { + _currentSplash?.cancel(); + _currentSplash = null; + widget.onTertiaryTapCancel?.call(); + updateHighlight(_HighlightType.pressed, value: false); + } + void _startNewSplash({TapDownDetails? details, BuildContext? context}) { assert(details != null || context != null); @@ -1284,7 +1347,9 @@ class _InkResponseState extends State<_InkResponseStateWidget> } bool isWidgetEnabled(_InkResponseStateWidget widget) { - return _primaryButtonEnabled(widget) || _secondaryButtonEnabled(widget); + return _primaryButtonEnabled(widget) || + _secondaryButtonEnabled(widget) || + _tertiaryButtonEnabled(widget); } bool _primaryButtonEnabled(_InkResponseStateWidget widget) { @@ -1302,9 +1367,14 @@ class _InkResponseState extends State<_InkResponseStateWidget> widget.onSecondaryTapDown != null; } + bool _tertiaryButtonEnabled(_InkResponseStateWidget widget) { + return widget.onTertiaryTapUp != null || widget.onTertiaryTapDown != null; + } + bool get enabled => isWidgetEnabled(widget); bool get _primaryEnabled => _primaryButtonEnabled(widget); bool get _secondaryEnabled => _secondaryButtonEnabled(widget); + bool get _tertiaryEnabled => _tertiaryButtonEnabled(widget); void handleMouseEnter(PointerEnterEvent event) { _hovering = true; @@ -1409,6 +1479,9 @@ class _InkResponseState extends State<_InkResponseStateWidget> onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp : null, onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null, onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null, + onTertiaryTapDown: _tertiaryEnabled ? handleTertiaryTapDown : null, + onTertiaryTapUp: _tertiaryEnabled ? handleTertiaryTapUp : null, + onTertiaryTapCancel: _tertiaryEnabled ? handleTertiaryTapCancel : null, behavior: HitTestBehavior.opaque, excludeFromSemantics: true, child: widget.child, @@ -1524,6 +1597,9 @@ class InkWell extends InkResponse { super.onSecondaryTapUp, super.onSecondaryTapDown, super.onSecondaryTapCancel, + super.onTertiaryTapUp, + super.onTertiaryTapDown, + super.onTertiaryTapCancel, super.onHighlightChanged, super.onHover, super.mouseCursor, diff --git a/packages/material_ui/pending_changelogs/change_2026_08_28_inkwell_tertiary_tap.yaml b/packages/material_ui/pending_changelogs/change_2026_08_28_inkwell_tertiary_tap.yaml new file mode 100644 index 000000000000..fd6145685a19 --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_28_inkwell_tertiary_tap.yaml @@ -0,0 +1,3 @@ +changelog: | + - Adds `onTertiaryTapDown`, `onTertiaryTapUp` and `onTertiaryTapCancel` to `InkResponse` and `InkWell`, so a tertiary (middle) button click can be handled directly. +version: minor diff --git a/packages/material_ui/test/ink_well_test.dart b/packages/material_ui/test/ink_well_test.dart index 28f045711ce5..b8fc14212a89 100644 --- a/packages/material_ui/test/ink_well_test.dart +++ b/packages/material_ui/test/ink_well_test.dart @@ -2500,6 +2500,102 @@ void main() { }, ); + // Regression test for https://github.com/flutter/flutter/issues/129058. + testWidgets('InkWell tertiary tap test', (WidgetTester tester) async { + final log = []; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: InkWell( + onTertiaryTapDown: (TapDownDetails details) { + log.add('tertiary-tap-down'); + }, + onTertiaryTapUp: (TapUpDetails details) { + log.add('tertiary-tap-up'); + }, + onTertiaryTapCancel: () { + log.add('tertiary-tap-cancel'); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(InkWell), pointer: 1, buttons: kTertiaryButton); + + expect(log, equals(['tertiary-tap-down', 'tertiary-tap-up'])); + log.clear(); + + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(InkWell)), + pointer: 2, + buttons: kTertiaryButton, + ); + await gesture.moveTo(const Offset(100, 100)); + await gesture.up(); + + expect(log, equals(['tertiary-tap-down', 'tertiary-tap-cancel'])); + }); + + testWidgets('InkWell tertiary tap highlights only when a tertiary callback is defined', ( + WidgetTester tester, + ) async { + final log = []; + + Widget buildFrame({required bool withTertiaryCallback}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: SizedBox( + width: 100.0, + height: 100.0, + child: InkWell( + onTap: () {}, + onHighlightChanged: log.add, + onTertiaryTapUp: withTertiaryCallback ? (TapUpDetails details) {} : null, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(withTertiaryCallback: true)); + + TestGesture gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + buttons: kTertiaryButton, + ); + await tester.pump(const Duration(milliseconds: 200)); + + expect(log, equals([true])); + log.clear(); + + await gesture.up(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(log, equals([false])); + log.clear(); + + // Without a tertiary callback, a tertiary button press is ignored. + await tester.pumpWidget(buildFrame(withTertiaryCallback: false)); + + gesture = await tester.startGesture( + tester.getRect(find.byType(InkWell)).center, + buttons: kTertiaryButton, + ); + await tester.pump(const Duration(milliseconds: 200)); + + expect(log, isEmpty); + + await gesture.up(); + }); + testWidgets('try out hoverDuration property', (WidgetTester tester) async { final log = [];