From a9a9d0801f03364177894a5706edab088a0787d1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 30 Oct 2026 19:48:37 +0100 Subject: [PATCH 1/2] feat(ui): add onLongPress to StreamButton StreamButton and StreamButton.icon take an onLongPress callback, which also gives the button a long-press action for accessibility services. While it is set, a long press calls it instead of showing the icon button's tooltip; the tooltip still shows on hover. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/components/buttons/button.dart | 28 +++++--- packages/stream_core_flutter/CHANGELOG.md | 1 + .../src/components/buttons/stream_button.dart | 22 ++++++- .../buttons/stream_button_test.dart | 64 +++++++++++++++++++ 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/apps/design_system_gallery/lib/components/buttons/button.dart b/apps/design_system_gallery/lib/components/buttons/button.dart index 37d5cc39..6e167520 100644 --- a/apps/design_system_gallery/lib/components/buttons/button.dart +++ b/apps/design_system_gallery/lib/components/buttons/button.dart @@ -78,6 +78,11 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { description: 'Whether the button toggles its selected state on tap.', ); + final hasLongPress = context.knobs.boolean( + label: 'Long Press', + description: 'Whether the button responds to a long press.', + ); + final showLeadingIcon = !isIconOnly && context.knobs.boolean( @@ -92,22 +97,23 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { description: 'Show an icon after the label.', ); - void onTap() { - if (isSelectable) { - setState(() => _isSelected = !_isSelected); - } + void showMessage(String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - SnackBar( - content: Text( - isSelectable ? 'Button ${_isSelected ? 'selected' : 'deselected'}' : 'Button tapped!', - ), - duration: const Duration(seconds: 1), - ), + SnackBar(content: Text(message), duration: const Duration(seconds: 1)), ); } + void onTap() { + if (isSelectable) { + setState(() => _isSelected = !_isSelected); + } + showMessage(isSelectable ? 'Button ${_isSelected ? 'selected' : 'deselected'}' : 'Button tapped!'); + } + + final onLongPress = hasLongPress && !isDisabled ? () => showMessage('Button long-pressed!') : null; + return Center( child: isIconOnly ? StreamButton.icon( @@ -118,6 +124,7 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { isFloating: isFloating ? true : null, isSelected: isSelectable ? _isSelected : null, onPressed: isDisabled ? null : onTap, + onLongPress: onLongPress, ) : StreamButton( style: style, @@ -126,6 +133,7 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { isFloating: isFloating ? true : null, isSelected: isSelectable ? _isSelected : null, onPressed: isDisabled ? null : onTap, + onLongPress: onLongPress, iconLeft: showLeadingIcon ? const Icon(Icons.add) : null, iconRight: showTrailingIcon ? const Icon(Icons.arrow_forward) : null, child: Text(label), diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index d9229665..44d4c4b4 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -2,6 +2,7 @@ ### ✨ Features +- Added `onLongPress` to `StreamButton` and `StreamButton.icon`. - `StreamMessageText` now links a web address written without a scheme, such as `example.com`, when it ends in a known top-level domain. It opens over `https://`. diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart index 90cd7b22..f1e449dd 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart @@ -72,6 +72,7 @@ class StreamButton extends StatelessWidget { super.key, required Widget child, VoidCallback? onPressed, + VoidCallback? onLongPress, StreamButtonStyle style = .primary, StreamButtonType type = .solid, StreamButtonSize size = .medium, @@ -84,6 +85,7 @@ class StreamButton extends StatelessWidget { }) : props = .new( child: child, onPressed: onPressed, + onLongPress: onLongPress, style: style, type: type, size: size, @@ -112,6 +114,7 @@ class StreamButton extends StatelessWidget { super.key, required Widget icon, VoidCallback? onPressed, + VoidCallback? onLongPress, StreamButtonStyle style = .primary, StreamButtonType type = .solid, StreamButtonSize size = .medium, @@ -122,6 +125,7 @@ class StreamButton extends StatelessWidget { StreamButtonThemeStyle? themeStyle, }) : props = .new( onPressed: onPressed, + onLongPress: onLongPress, style: style, type: type, size: size, @@ -165,6 +169,7 @@ class StreamButtonProps { const StreamButtonProps({ this.child, this.onPressed, + this.onLongPress, this.style = .primary, this.type = .solid, this.size = .medium, @@ -185,9 +190,13 @@ class StreamButtonProps { /// Called when the button is pressed. /// - /// If null, the button will be disabled. + /// If this and [onLongPress] are both null, the button will be disabled. final VoidCallback? onPressed; + /// Called when the button is long-pressed, in place of showing the + /// [tooltip]. + final VoidCallback? onLongPress; + /// The visual style variant of the button. /// /// Determines the color scheme used (primary, secondary, destructive). @@ -236,9 +245,11 @@ class StreamButtonProps { /// When false, the button uses normal focus traversal. final bool autofocus; - /// Text shown in a [Tooltip] on hover / long-press, and used as the + /// Text shown in a [Tooltip] on hover or long-press, and used as the /// button's accessibility label. /// + /// A long press shows it only while [onLongPress] is null. + /// /// Only honoured by [StreamButton.icon]; the regular [StreamButton] /// derives its label from its [child]. /// When null, the icon button has no tooltip. @@ -373,6 +384,7 @@ class _DefaultStreamButtonState extends State { child: ElevatedButton( autofocus: props.autofocus, onPressed: props.onPressed, + onLongPress: props.onLongPress, statesController: _statesController, style: ButtonStyle( tapTargetSize: themeStyle.tapTargetSize, @@ -418,7 +430,11 @@ class _DefaultStreamButtonState extends State { ); if (props.tooltip case final tooltip?) { - button = Tooltip(message: tooltip, child: button); + button = Tooltip( + message: tooltip, + triggerMode: props.onLongPress != null ? .manual : null, + child: button, + ); } return MergeSemantics(child: button); diff --git a/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart b/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart index c50f6992..43b091d4 100644 --- a/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart +++ b/packages/stream_core_flutter/test/components/buttons/stream_button_test.dart @@ -222,6 +222,70 @@ void main() { }); }); + group('StreamButton.icon long press', () { + testWidgets('calls onLongPress instead of showing the tooltip', (tester) async { + var pressed = 0; + var longPressed = 0; + await tester.pumpWidget( + _withStreamTheme( + StreamButton.icon( + icon: const Icon(Icons.send), + tooltip: 'Send', + onPressed: () => pressed++, + onLongPress: () => longPressed++, + ), + ), + ); + + await tester.longPress(find.byType(StreamButton)); + await tester.pumpAndSettle(); + + expect(longPressed, 1); + expect(pressed, 0); + expect(find.text('Send'), findsNothing); + }); + + testWidgets('exposes a long-press action to accessibility services', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamButton.icon( + icon: const Icon(Icons.send), + tooltip: 'Send', + onPressed: () {}, + onLongPress: () {}, + ), + ), + ); + + expect( + tester.getSemantics(find.byType(StreamButton)), + isSemantics(tooltip: 'Send', isButton: true, hasTapAction: true, hasLongPressAction: true), + ); + + handle.dispose(); + }); + + testWidgets('still calls onPressed on a tap', (tester) async { + var pressed = 0; + await tester.pumpWidget( + _withStreamTheme( + StreamButton.icon( + icon: const Icon(Icons.send), + tooltip: 'Send', + onPressed: () => pressed++, + onLongPress: () {}, + ), + ), + ); + + await tester.tap(find.byType(StreamButton)); + + expect(pressed, 1); + }); + }); + group('StreamButton (with child) a11y', () { testWidgets('label comes from child Text widget', (tester) async { final handle = tester.ensureSemantics(); From e454a8274786247cdcce149200937dfdf83eb336 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 30 Oct 2026 19:53:03 +0100 Subject: [PATCH 2/2] refactor(ui): match Flutter's buttons for onLongPress and the tooltip Use Flutter's wording for onPressed and onLongPress, and wrap the icon button in a plain Tooltip as Flutter's buttons do: a long press still calls onLongPress instead of showing the tooltip. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/buttons/stream_button.dart | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart index f1e449dd..a138e322 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart @@ -188,13 +188,16 @@ class StreamButtonProps { /// [iconLeft] as its sole icon (see [StreamButton.icon]). final Widget? child; - /// Called when the button is pressed. + /// Called when the button is tapped or otherwise activated. /// - /// If this and [onLongPress] are both null, the button will be disabled. + /// If this callback and [onLongPress] are null, then the button will be + /// disabled. final VoidCallback? onPressed; - /// Called when the button is long-pressed, in place of showing the - /// [tooltip]. + /// Called when the button is long-pressed. + /// + /// If this callback and [onPressed] are null, then the button will be + /// disabled. final VoidCallback? onLongPress; /// The visual style variant of the button. @@ -245,11 +248,9 @@ class StreamButtonProps { /// When false, the button uses normal focus traversal. final bool autofocus; - /// Text shown in a [Tooltip] on hover or long-press, and used as the + /// Text shown in a [Tooltip] on hover / long-press, and used as the /// button's accessibility label. /// - /// A long press shows it only while [onLongPress] is null. - /// /// Only honoured by [StreamButton.icon]; the regular [StreamButton] /// derives its label from its [child]. /// When null, the icon button has no tooltip. @@ -430,11 +431,7 @@ class _DefaultStreamButtonState extends State { ); if (props.tooltip case final tooltip?) { - button = Tooltip( - message: tooltip, - triggerMode: props.onLongPress != null ? .manual : null, - child: button, - ); + button = Tooltip(message: tooltip, child: button); } return MergeSemantics(child: button);