From e87ee8a7bafcabd0893b88c096c29946afd338d3 Mon Sep 17 00:00:00 2001 From: Kenny Lopez Date: Sat, 15 Aug 2026 12:39:25 +0100 Subject: [PATCH] Improve message action accessibility Co-authored-by: Kenny Lopez Signed-off-by: Kenny Lopez --- .../Runner/NativeMessageActionSurface.swift | 8 ++- mobile/ios/RunnerTests/RunnerTests.swift | 6 ++ .../message_action_popover.dart | 68 +++++++++++++++---- .../channels/message_actions_test.dart | 22 ++++++ 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/mobile/ios/Runner/NativeMessageActionSurface.swift b/mobile/ios/Runner/NativeMessageActionSurface.swift index 0e4668693..b348292a0 100644 --- a/mobile/ios/Runner/NativeMessageActionSurface.swift +++ b/mobile/ios/Runner/NativeMessageActionSurface.swift @@ -73,6 +73,11 @@ enum NativeMessageActionSurfaceLayout { @available(iOS 16.0, *) enum NativeMessageActionSurfaceAppearance { + // The native list is only one sibling inside the Flutter-owned dialog. + // Keeping it non-modal leaves the reaction tray and dismiss barrier + // reachable to VoiceOver. + static let actionListAccessibilityViewIsModal = false + static func interfaceStyle(from value: Any?) -> UIUserInterfaceStyle { switch value as? String { case "dark": @@ -280,7 +285,8 @@ final class NativeMessageActionSurfacePlatformView: NSObject, surfaceView.backgroundColor = .clear surfaceView.clipsToBounds = false - surfaceView.accessibilityViewIsModal = true + surfaceView.accessibilityViewIsModal = + NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal surfaceView.overrideUserInterfaceStyle = interfaceStyle backdropView.translatesAutoresizingMaskIntoConstraints = false diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 4fc4fe3bf..c05032304 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -482,6 +482,12 @@ class RunnerTests: XCTestCase { ) } + func testNativeMessageActionListDoesNotHideDialogSiblings() { + XCTAssertFalse( + NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal + ) + } + func testNativeMessageActionSurfaceMatchesFlutterInterfaceStyle() { XCTAssertEqual( NativeMessageActionSurfaceAppearance.interfaceStyle(from: "dark"), diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart index 1f4c684dc..104f55788 100644 --- a/mobile/lib/features/channels/message_actions/message_action_popover.dart +++ b/mobile/lib/features/channels/message_actions/message_action_popover.dart @@ -1,6 +1,7 @@ part of '../message_actions.dart'; const _messageActionRowHeight = 48.0; +const _messageActionRowVerticalPadding = Grid.xxs; const _messageActionSeparatorHeight = 0.5; const _messageActionVerticalInset = Grid.half; const _messageActionMenuMaxWidth = 288.0; @@ -524,11 +525,10 @@ class _MessageActionsPopover extends StatelessWidget { final availableHeight = math.max(1.0, safeBottom - safeTop); final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth); final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth); - final preferredMenuHeight = _messageActionSurfacePreferredHeight( - actions, - ); + final menuLayout = _MessageActionSurfaceLayout.from(context, actions); + final preferredMenuHeight = menuLayout.preferredHeight; final menuBudget = math.max( - _messageActionRowHeight, + menuLayout.rowHeight, availableHeight - _reactionTrayMaxHeight - (_messageActionGap * 2) - @@ -805,6 +805,7 @@ class _MessageActionSurface extends StatelessWidget { @override Widget build(BuildContext context) { + final menuLayout = _MessageActionSurfaceLayout.from(context, actions); return Material( key: const ValueKey('message-action-surface'), color: context.colors.surface, @@ -841,6 +842,7 @@ class _MessageActionSurface extends StatelessWidget { ), _MessageActionRow( action: actions[index], + height: menuLayout.rowHeight, onSelected: onSelected, ), ], @@ -854,9 +856,14 @@ class _MessageActionSurface extends StatelessWidget { class _MessageActionRow extends StatelessWidget { final _PopoverMessageAction action; + final double height; final ValueChanged onSelected; - const _MessageActionRow({required this.action, required this.onSelected}); + const _MessageActionRow({ + required this.action, + required this.height, + required this.onSelected, + }); @override Widget build(BuildContext context) { @@ -874,7 +881,7 @@ class _MessageActionRow extends StatelessWidget { onSelected(action.id); }, child: SizedBox( - height: _messageActionRowHeight, + height: height, child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xs), child: Row( @@ -905,14 +912,45 @@ class _MessageActionRow extends StatelessWidget { } } -double _messageActionSurfacePreferredHeight( - List<_PopoverMessageAction> actions, -) { - var separatorCount = 0; - for (var index = 1; index < actions.length; index++) { - if (actions[index - 1].group != actions[index].group) separatorCount += 1; +class _MessageActionSurfaceLayout { + final double rowHeight; + final double preferredHeight; + + const _MessageActionSurfaceLayout({ + required this.rowHeight, + required this.preferredHeight, + }); + + factory _MessageActionSurfaceLayout.from( + BuildContext context, + List<_PopoverMessageAction> actions, + ) { + final textPainter = TextPainter( + text: TextSpan( + text: 'Message action', + style: context.textTheme.bodyLarge, + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final rowHeight = math.max( + _messageActionRowHeight, + textPainter.height + (_messageActionRowVerticalPadding * 2), + ); + textPainter.dispose(); + + var separatorCount = 0; + for (var index = 1; index < actions.length; index++) { + if (actions[index - 1].group != actions[index].group) separatorCount += 1; + } + final preferredHeight = + (_messageActionVerticalInset * 2) + + (actions.length * rowHeight) + + (separatorCount * _messageActionSeparatorHeight); + return _MessageActionSurfaceLayout( + rowHeight: rowHeight, + preferredHeight: preferredHeight, + ); } - return (_messageActionVerticalInset * 2) + - (actions.length * _messageActionRowHeight) + - (separatorCount * _messageActionSeparatorHeight); } diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 9d98603e3..187c92504 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -214,6 +214,7 @@ Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover( ReminderService? reminderService, bool disableAnimations = false, EdgeInsets viewInsets = EdgeInsets.zero, + TextScaler textScaler = TextScaler.noScaling, FocusNode? composerFocusNode, bool composerInitiallyFocused = false, Rect anchorRect = const Rect.fromLTWH(32, 260, 300, 72), @@ -239,6 +240,7 @@ Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover( data: MediaQuery.of(context).copyWith( disableAnimations: disableAnimations, viewInsets: viewInsets, + textScaler: textScaler, ), child: child!, ), @@ -792,6 +794,26 @@ void main() { await _dismissMessageActionsPopover(tester); }); + testWidgets('fallback action rows grow with accessibility text', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(rootId: 'root-9'), + prefs: prefs, + textScaler: const TextScaler.linear(3), + ); + + final rowFinder = find.byKey( + const ValueKey('message-action-followThread'), + ); + expect(tester.getSize(rowFinder).height, greaterThan(48)); + expect(tester.takeException(), isNull); + + await _dismissMessageActionsPopover(tester); + }); + testWidgets('orders primary, utility, and destructive action groups', ( tester, ) async {