Merge pull request #7905 from element-hq/feature/fga/timeline-multi-forward-ay11

Improve accessibility of timeline selection mode
This commit is contained in:
ganfra
2026-10-14 18:56:38 +02:00
committed by GitHub
3 changed files with 76 additions and 41 deletions
@@ -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()
@@ -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)
},
@@ -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(