From 9e12ec7505b5fa6d68b8c1d00d0ae6145bdcb79a Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 13 Oct 2026 14:45:00 +0200 Subject: [PATCH] Improve accessibility of timeline selection mode --- .../components/TimelineItemEventRow.kt | 39 +++++++++--- .../timeline/components/TimelineItemRow.kt | 63 +++++++++---------- .../impl/topbars/SelectionModeTopBar.kt | 15 ++++- 3 files changed, 76 insertions(+), 41 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 2dfea52e4c..c74660f03d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -182,9 +182,11 @@ fun TimelineItemEventRow( onSwipeToReply: () -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, - // Horizontal offset applied to this row's content by selection mode. The read receipts are - // anchored to the end edge and should stay pinned, so they are counter-offset by this amount. + // Horizontal offset by which selection mode slides this row's content aside, to make room for the tick. It is applied here, to the message and the + // thread summary, rather than to the row as a whole: that keeps the row's accessibility node spanning the whole row, tick included, so that a + // screen reader can focus it from anywhere on the row. The read receipts are anchored to the end edge and stay pinned, so they are not slid. selectionContentOffset: Dp = 0.dp, + isSelectionToggle: Boolean = false, eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> // Only pass down a custom clickable lambda if the content can be clicked separately val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } @@ -226,6 +228,7 @@ fun TimelineItemEventRow( val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo val accessibilityActions = rememberTimelineItemAccessibilityActions( + isEnabled = !isSelectionToggle, canReply = canReply, onLongClick = onLongClick, onSwipeToReply = onSwipeToReply, @@ -247,7 +250,11 @@ fun TimelineItemEventRow( val swipeThresholdPx = 40.dp.toPx() val thresholdCrossed = abs(offset) > swipeThresholdPx SwipeSensitivity(3f) { - Box(Modifier.fillMaxWidth()) { + Box( + Modifier + .fillMaxWidth() + .offset(x = selectionContentOffset) + ) { Row(modifier = Modifier.matchParentSize()) { ReplySwipeIndicator({ offset / 120 }) } @@ -268,7 +275,7 @@ fun TimelineItemEventRow( .absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) } .draggable( orientation = Orientation.Horizontal, - enabled = !state.isResettingOnRelease, + enabled = !state.isResettingOnRelease && !isSelectionToggle, onDragStopped = { coroutineScope.launch { if (thresholdCrossed) { @@ -286,6 +293,7 @@ fun TimelineItemEventRow( } } else { TimelineItemEventRowContent( + modifier = Modifier.offset(x = selectionContentOffset), event = event, timelineMode = timelineMode, timelineProtectionState = timelineProtectionState, @@ -311,7 +319,10 @@ fun TimelineItemEventRow( .padding(end = 16.dp) } else { if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp) - }.padding(top = 2.dp), + } + .padding(top = 2.dp) + .offset(x = selectionContentOffset) + .hiddenFromAccessibilityWhile(isSelectionToggle), threadSummary = event.threadInfo.summary, latestEventText = event.threadInfo.latestEventText, isOutgoing = event.isMine, @@ -335,17 +346,30 @@ fun TimelineItemEventRow( // Counter the selection-mode content slide so the end-anchored read receipts stay pinned. modifier = Modifier .padding(top = 4.dp) - .offset(x = -selectionContentOffset) + .hiddenFromAccessibilityWhile(isSelectionToggle) ) } } +/** + * Hides this element and its descendants from screen readers while [hidden] is true. + * + * A clickable descendant stays an accessibility node of its own even though the row merges its descendants, so while the row acts as a selection toggle + * it would otherwise still be reachable and activatable by a screen reader. Reactions already hide themselves this way at all times. + */ +private fun Modifier.hiddenFromAccessibilityWhile(hidden: Boolean): Modifier = if (hidden) { + semantics { hideFromAccessibility() } +} else { + this +} + /** * Exposes the gestures of a timeline item — long press for the action list, swipe for a reply — as TalkBack actions, since neither gesture is reachable * with a screen reader enabled. */ @Composable private fun rememberTimelineItemAccessibilityActions( + isEnabled: Boolean, canReply: Boolean, onLongClick: () -> Unit, onSwipeToReply: () -> Unit, @@ -354,8 +378,9 @@ private fun rememberTimelineItemAccessibilityActions( val replyLabel = stringResource(CommonStrings.action_reply) val latestOnLongClick by rememberUpdatedState(onLongClick) val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply) - return remember(canReply, messageActionsLabel, replyLabel) { + return remember(isEnabled, canReply, messageActionsLabel, replyLabel) { buildList { + if (!isEnabled) return@buildList add( CustomAccessibilityAction(messageActionsLabel) { latestOnLongClick() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index d729ee6532..9dbcb4851d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -29,8 +28,11 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection @@ -122,22 +124,14 @@ internal fun TimelineItemRow( } } val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected } - // Only start-aligned message bubbles slide to make room for the selection indicator: - // - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room. - // - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put. - val isOutgoing = (timelineItem as? TimelineItem.Event)?.isMine == true - val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing val layoutDirection = LocalLayoutDirection.current - val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp Layout( modifier = modifier.then(backgroundModifier), contents = listOf( { TimelineItemRowContent( - modifier = Modifier - .fillMaxWidth() - .offset(x = contentSlide), - selectionContentOffset = contentSlide, + modifier = Modifier.fillMaxWidth(), + selectionData = selectionData, timelineItem = timelineItem, timelineMode = timelineMode, timelineRoomInfo = timelineRoomInfo, @@ -177,11 +171,15 @@ internal fun TimelineItemRow( { if (isSelectionModeActive) { // In selection mode the whole row toggles selection. - Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) { - if (selectableEvent != null) { - onContentClick(selectableEvent) - } - }) + Box( + modifier = Modifier + .clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) { + if (selectableEvent != null) { + onContentClick(selectableEvent) + } + } + .semantics { hideFromAccessibility() } + ) } }, ), @@ -206,17 +204,6 @@ internal fun TimelineItemRow( /** Width reserved for the selection indicator : the row content slides by this amount. */ private val SELECTION_COLUMN_WIDTH = 36.dp -/** - * Whether this item is rendered as a start/end-aligned message bubble, as opposed to a centered - * tile (state event, legacy call invite, call/RTC notification, day divider, grouped events). - * Mirrors the dispatch in [TimelineItemRowContent]: only bubbles shift to reveal the selection - * indicator; centered tiles stay put. - */ -private fun TimelineItem.rendersAsBubble(): Boolean = this is TimelineItem.Event && - content !is TimelineItemStateContent && - content !is TimelineItemLegacyCallInviteContent && - content !is TimelineItemRtcNotificationContent - @Suppress("LongMethod") @Composable private fun TimelineItemRowContent( @@ -242,7 +229,7 @@ private fun TimelineItemRowContent( onSwipeToReply: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, - selectionContentOffset: Dp = 0.dp, + selectionData: TimelineItemSelectionData = TimelineItemSelectionData(), eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, ) { Box(modifier) { @@ -292,6 +279,10 @@ private fun TimelineItemRowContent( } else -> { val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message) + val isSelectionToggle = selectionData.isSelectionModeActive && selectionData.canBeSelected + // Only start-aligned message bubbles slide to make room for the selection indicator. Outgoing (own) messages are end-aligned, + // opposite the indicator, so they already have room, and the centered tiles handled by the branches above never slide. + val contentSlide = if (timelineItem.isMine) 0.dp else SELECTION_COLUMN_WIDTH * selectionData.progress TimelineItemEventRow( modifier = Modifier .semantics(mergeDescendants = true) { @@ -301,6 +292,9 @@ private fun TimelineItemRowContent( } else { timelineItem.safeSenderName } + if (isSelectionToggle) { + toggleableState = ToggleableState(selectionData.isSelected) + } // For Polls, allow the answers to be traversed by Talkback isTraversalGroup = timelineItem.content is TimelineItemPollContent || timelineItem.failedToSend || @@ -309,16 +303,18 @@ private fun TimelineItemRowContent( } // Custom clickable that applies over the whole item for accessibility .then( - if (isTalkbackActive()) { - Modifier + when { + !isTalkbackActive() -> Modifier + // While selecting, the row only toggles the selection. The context menu cannot be opened, so the long press + // that opens it is neither offered nor advertised. + isSelectionToggle -> Modifier.clickable { onContentClick(timelineItem) } + else -> Modifier .combinedClickable( onClick = { onContentClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) .onKeyboardContextMenuAction { onLongClick(timelineItem) } - } else { - Modifier } ), event = timelineItem, @@ -340,7 +336,8 @@ private fun TimelineItemRowContent( onSwipeToReply = { onSwipeToReply(timelineItem) }, onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, eventSink = eventSink, - selectionContentOffset = selectionContentOffset, + selectionContentOffset = contentSlide, + isSelectionToggle = isSelectionToggle, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt index 5cd2b15bda..b5e79b84d7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -12,6 +12,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.tokens.generated.CompoundIcons @@ -34,10 +39,18 @@ fun SelectionModeTopBar( onConfirmClick: () -> Unit, modifier: Modifier = Modifier, ) { + val a11ySelectedCount = pluralStringResource(CommonPlurals.screen_room_selection_count, selectedCount, selectedCount) TopAppBar( modifier = modifier, title = { - Text(text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount)) + Text( + text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount), + modifier = Modifier.semantics { + heading() + liveRegion = LiveRegionMode.Polite + contentDescription = a11ySelectedCount + }, + ) }, navigationIcon = { IconButton(