From 519c84f124cd4ce9cd178e8068bda336ce89993f Mon Sep 17 00:00:00 2001 From: Ahtsham0715 Date: Fri, 28 Aug 2026 22:17:56 +0500 Subject: [PATCH] [material_ui] Add tertiary tap callbacks to InkResponse and InkWell `GestureDetector` exposes `onTertiaryTapDown`, `onTertiaryTapUp` and `onTertiaryTapCancel`, but `InkResponse`/`InkWell` only forwarded the primary and secondary button callbacks. This meant a middle click on an `InkWell` (a common way to open a link in a new tab or window) could not be handled without wrapping the widget in another gesture detector. This adds the three tertiary callbacks, mirroring the existing secondary button plumbing: the ink splash and pressed highlight are started on tertiary tap down and resolved on tap up or cancel, and the widget only listens for the tertiary button when at least one of `onTertiaryTapDown`/`onTertiaryTapUp` is provided, so existing widgets are unaffected. Fixes https://github.com/flutter/flutter/issues/129058 --- packages/material_ui/lib/src/ink_well.dart | 78 ++++++++++++++- ...hange_2026_08_28_inkwell_tertiary_tap.yaml | 3 + packages/material_ui/test/ink_well_test.dart | 96 +++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 packages/material_ui/pending_changelogs/change_2026_08_28_inkwell_tertiary_tap.yaml 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 = [];