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..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 @@ -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, @@ -183,11 +188,18 @@ 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 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. + /// + /// If this callback and [onPressed] are null, then the button will be + /// disabled. + final VoidCallback? onLongPress; + /// The visual style variant of the button. /// /// Determines the color scheme used (primary, secondary, destructive). @@ -373,6 +385,7 @@ class _DefaultStreamButtonState extends State { child: ElevatedButton( autofocus: props.autofocus, onPressed: props.onPressed, + onLongPress: props.onLongPress, statesController: _statesController, style: ButtonStyle( tapTargetSize: themeStyle.tapTargetSize, 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();