diff --git a/flutter/lib/common/widgets/peer_card.dart b/flutter/lib/common/widgets/peer_card.dart index 1f9f3ed7f..86ce7c623 100644 --- a/flutter/lib/common/widgets/peer_card.dart +++ b/flutter/lib/common/widgets/peer_card.dart @@ -1,4 +1,5 @@ import 'package:bot_toast/bot_toast.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; @@ -62,6 +63,8 @@ class _PeerCardState extends State<_PeerCard> final PeerTabModel peerTabModel = Provider.of(context); final peer = super.widget.peer; return GestureDetector( + // Web card actions have their own semantics; preserve pointer double-clicks. + excludeFromSemantics: isWebDesktop, onDoubleTap: peerTabModel.multiSelectionMode ? null : () => widget.connect(context, peer.id), @@ -423,7 +426,7 @@ class _PeerCardState extends State<_PeerCard> final PeerTabModel peerTabModel = Provider.of(context); final selected = peerTabModel.isPeerSelected(peer.id); if (peerTabModel.multiSelectionMode) { - return Padding( + final checkbox = Padding( padding: const EdgeInsets.all(12), child: selected ? Icon( @@ -432,7 +435,16 @@ class _PeerCardState extends State<_PeerCard> ) : Icon(Icons.check_box_outline_blank), ); + return isWebDesktop + ? Semantics( + container: true, + label: formatID(peer.id), + checked: selected, + onTap: () => peerTabModel.select(peer), + child: checkbox) + : checkbox; } else { + if (isWebDesktop) return _actionMoreWeb(peer); return InkWell( child: const Padding( padding: EdgeInsets.all(12), child: Icon(Icons.more_vert)), @@ -457,19 +469,27 @@ class _PeerCardState extends State<_PeerCard> color: MyTheme.accent, ) : Icon(Icons.check_box_outline_blank); + final checkbox = isWebDesktop + ? Semantics( + container: true, + label: formatID(peer.id), + checked: selected, + onTap: () => peerTabModel.select(peer), + child: icon) + : icon; bool last = peerTabModel.isShiftDown && peer.id == peerTabModel.lastId; double right = isTile ? 4 : 0; if (last) { return Container( decoration: BoxDecoration( border: Border.all(color: MyTheme.accent, width: 1)), - child: icon, + child: checkbox, ).marginOnly(right: right); } else { - return icon.marginOnly(right: right); + return checkbox.marginOnly(right: right); } } else { - return _actionMore(peer); + return isWebDesktop ? _actionMoreWeb(peer) : _actionMore(peer); } } @@ -482,6 +502,20 @@ class _PeerCardState extends State<_PeerCard> onPointerUp: (_) => _showPeerMenu(peer.id), child: build_more(context)); + Widget _actionMoreWeb(Peer peer) => Semantics( + container: true, + button: true, + label: translate('More'), + child: Builder( + builder: (context) => build_more(context, onTap: () { + final box = context.findRenderObject() as RenderBox; + final position = + box.localToGlobal(box.size.center(Offset.zero)); + _menuPos = RelativeRect.fromLTRB( + position.dx, position.dy, position.dx, position.dy); + _showPeerMenu(peer.id); + }))); + bool _shouldBuildPasswordIcon(Peer peer) { if (gFFI.peerTabModel.currentTab != PeerTabIndex.ab.index) return false; if (gFFI.abModel.current.isPersonal()) return false; @@ -1474,11 +1508,12 @@ Widget getOnline(double rightPadding, bool online) { radius: 3, backgroundColor: online ? Colors.green : kColorWarn))); } -Widget build_more(BuildContext context, {bool invert = false}) { +Widget build_more(BuildContext context, + {bool invert = false, VoidCallback? onTap}) { final RxBool hover = false.obs; - return InkWell( + final button = InkWell( borderRadius: BorderRadius.circular(14), - onTap: () {}, + onTap: onTap ?? () {}, onHover: (value) => hover.value = value, child: Obx(() => CircleAvatar( radius: 14, @@ -1498,6 +1533,19 @@ Widget build_more(BuildContext context, {bool invert = false}) { .titleLarge ?.color ?.withOpacity(0.5))))); + if (onTap == null) return button; + // Serial taps preserve cancellation without waiting for the card's double tap. + return RawGestureDetector( + excludeFromSemantics: true, + gestures: { + SerialTapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => SerialTapGestureRecognizer( + allowedButtonsFilter: (buttons) => + buttons == kPrimaryButton), + (recognizer) => recognizer.onSerialTapUp = (_) => onTap()), + }, + child: button); } class TagPainter extends CustomPainter { diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 5f234cb69..09250a86c 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -577,6 +577,8 @@ _registerEventHandler() { Widget keyListenerBuilder(BuildContext context, Widget? child) { return RawKeyboardListener( + // Keep this keyboard-only wrapper from taking focus away from web inputs. + includeSemantics: !isWeb, // `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4" focusNode: FocusNode(skipTraversal: isWeb), child: child ?? Container(), diff --git a/flutter/test/peer_card_menu_semantics_test.dart b/flutter/test/peer_card_menu_semantics_test.dart new file mode 100644 index 000000000..0df48b2cd --- /dev/null +++ b/flutter/test/peer_card_menu_semantics_test.dart @@ -0,0 +1,71 @@ +import 'dart:ui' show PointerDeviceKind, SemanticsAction; + +import 'package:flutter/gestures.dart' show kSecondaryMouseButton; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common/widgets/peer_card.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const doubleClickInterval = Duration(milliseconds: 75); + const dragOffset = Offset(150, 0); + // Keep pointer cases semantics-off to cover parent/child gesture competition. + // VM taps bypass browser click-to-semantics conversion; verify that in a browser. + for (final action in ['physical clicks', 'semantic tap', 'keyboard']) { + testWidgets('More $action opens once without connecting the card', + (tester) async { + var opened = 0; + var connected = 0; + await tester.pumpWidget(MaterialApp( + home: Material( + child: GestureDetector( + excludeFromSemantics: true, + onTap: () {}, + onDoubleTap: () => connected++, + onLongPress: () {}, + child: Builder(builder: (context) { + return Center( + child: build_more(context, onTap: () { + opened++; + showMenu( + context: context, + position: RelativeRect.fill, + items: const [PopupMenuItem(child: Text('Connect'))], + ); + })); + }), + ), + ), + )); + final button = find.byIcon(Icons.more_vert); + final position = tester.getCenter(button); + if (action == 'semantic tap') { + final node = tester.getSemantics(button); + node.owner!.performAction(node.id, SemanticsAction.tap); + } else if (action == 'keyboard') { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + } else { + await tester.tapAt(position, + kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + expect(opened, 0, reason: 'A secondary click must not open More'); + await tester.dragFrom(position, dragOffset, + kind: PointerDeviceKind.mouse); + await tester.pumpAndSettle(); + expect(opened, 0, reason: 'Dragging out must cancel the More tap'); + await tester.tapAt(position, kind: PointerDeviceKind.mouse); + } + expect(opened, 1, + reason: 'More must not wait for a possible double click'); + await tester.pump(); + if (action == 'physical clicks') { + await tester.pump(doubleClickInterval); + await tester.tapAt(position, kind: PointerDeviceKind.mouse); + } + await tester.pumpAndSettle(); + expect({'menu': opened, 'connection': connected}, + {'menu': 1, 'connection': 0}); + }, semanticsEnabled: action != 'physical clicks'); + } +}