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, onSwipeToReply: () -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// Horizontal offset applied to this row's content by selection mode. The read receipts are // 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
// anchored to the end edge and should stay pinned, so they are counter-offset by this amount. // 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, selectionContentOffset: Dp = 0.dp,
isSelectionToggle: Boolean = false,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
// Only pass down a custom clickable lambda if the content can be clicked separately // Only pass down a custom clickable lambda if the content can be clicked separately
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
@@ -226,6 +228,7 @@ fun TimelineItemEventRow(
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
val accessibilityActions = rememberTimelineItemAccessibilityActions( val accessibilityActions = rememberTimelineItemAccessibilityActions(
isEnabled = !isSelectionToggle,
canReply = canReply, canReply = canReply,
onLongClick = onLongClick, onLongClick = onLongClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
@@ -247,7 +250,11 @@ fun TimelineItemEventRow(
val swipeThresholdPx = 40.dp.toPx() val swipeThresholdPx = 40.dp.toPx()
val thresholdCrossed = abs(offset) > swipeThresholdPx val thresholdCrossed = abs(offset) > swipeThresholdPx
SwipeSensitivity(3f) { SwipeSensitivity(3f) {
Box(Modifier.fillMaxWidth()) { Box(
Modifier
.fillMaxWidth()
.offset(x = selectionContentOffset)
) {
Row(modifier = Modifier.matchParentSize()) { Row(modifier = Modifier.matchParentSize()) {
ReplySwipeIndicator({ offset / 120 }) ReplySwipeIndicator({ offset / 120 })
} }
@@ -268,7 +275,7 @@ fun TimelineItemEventRow(
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) } .absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
.draggable( .draggable(
orientation = Orientation.Horizontal, orientation = Orientation.Horizontal,
enabled = !state.isResettingOnRelease, enabled = !state.isResettingOnRelease && !isSelectionToggle,
onDragStopped = { onDragStopped = {
coroutineScope.launch { coroutineScope.launch {
if (thresholdCrossed) { if (thresholdCrossed) {
@@ -286,6 +293,7 @@ fun TimelineItemEventRow(
} }
} else { } else {
TimelineItemEventRowContent( TimelineItemEventRowContent(
modifier = Modifier.offset(x = selectionContentOffset),
event = event, event = event,
timelineMode = timelineMode, timelineMode = timelineMode,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
@@ -311,7 +319,10 @@ fun TimelineItemEventRow(
.padding(end = 16.dp) .padding(end = 16.dp)
} else { } else {
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp) 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, threadSummary = event.threadInfo.summary,
latestEventText = event.threadInfo.latestEventText, latestEventText = event.threadInfo.latestEventText,
isOutgoing = event.isMine, isOutgoing = event.isMine,
@@ -335,17 +346,30 @@ fun TimelineItemEventRow(
// Counter the selection-mode content slide so the end-anchored read receipts stay pinned. // Counter the selection-mode content slide so the end-anchored read receipts stay pinned.
modifier = Modifier modifier = Modifier
.padding(top = 4.dp) .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 * 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. * with a screen reader enabled.
*/ */
@Composable @Composable
private fun rememberTimelineItemAccessibilityActions( private fun rememberTimelineItemAccessibilityActions(
isEnabled: Boolean,
canReply: Boolean, canReply: Boolean,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onSwipeToReply: () -> Unit, onSwipeToReply: () -> Unit,
@@ -354,8 +378,9 @@ private fun rememberTimelineItemAccessibilityActions(
val replyLabel = stringResource(CommonStrings.action_reply) val replyLabel = stringResource(CommonStrings.action_reply)
val latestOnLongClick by rememberUpdatedState(onLongClick) val latestOnLongClick by rememberUpdatedState(onLongClick)
val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply) val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply)
return remember(canReply, messageActionsLabel, replyLabel) { return remember(isEnabled, canReply, messageActionsLabel, replyLabel) {
buildList { buildList {
if (!isEnabled) return@buildList
add( add(
CustomAccessibilityAction(messageActionsLabel) { CustomAccessibilityAction(messageActionsLabel) {
latestOnLongClick() latestOnLongClick()
@@ -13,7 +13,6 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics 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.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -122,22 +124,14 @@ internal fun TimelineItemRow(
} }
} }
val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected } 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 layoutDirection = LocalLayoutDirection.current
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
Layout( Layout(
modifier = modifier.then(backgroundModifier), modifier = modifier.then(backgroundModifier),
contents = listOf( contents = listOf(
{ {
TimelineItemRowContent( TimelineItemRowContent(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() selectionData = selectionData,
.offset(x = contentSlide),
selectionContentOffset = contentSlide,
timelineItem = timelineItem, timelineItem = timelineItem,
timelineMode = timelineMode, timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
@@ -177,11 +171,15 @@ internal fun TimelineItemRow(
{ {
if (isSelectionModeActive) { if (isSelectionModeActive) {
// In selection mode the whole row toggles selection. // In selection mode the whole row toggles selection.
Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) { Box(
if (selectableEvent != null) { modifier = Modifier
onContentClick(selectableEvent) .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. */ /** Width reserved for the selection indicator : the row content slides by this amount. */
private val SELECTION_COLUMN_WIDTH = 36.dp 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") @Suppress("LongMethod")
@Composable @Composable
private fun TimelineItemRowContent( private fun TimelineItemRowContent(
@@ -242,7 +229,7 @@ private fun TimelineItemRowContent(
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selectionContentOffset: Dp = 0.dp, selectionData: TimelineItemSelectionData = TimelineItemSelectionData(),
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
) { ) {
Box(modifier) { Box(modifier) {
@@ -292,6 +279,10 @@ private fun TimelineItemRowContent(
} }
else -> { else -> {
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message) 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( TimelineItemEventRow(
modifier = Modifier modifier = Modifier
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
@@ -301,6 +292,9 @@ private fun TimelineItemRowContent(
} else { } else {
timelineItem.safeSenderName timelineItem.safeSenderName
} }
if (isSelectionToggle) {
toggleableState = ToggleableState(selectionData.isSelected)
}
// For Polls, allow the answers to be traversed by Talkback // For Polls, allow the answers to be traversed by Talkback
isTraversalGroup = timelineItem.content is TimelineItemPollContent || isTraversalGroup = timelineItem.content is TimelineItemPollContent ||
timelineItem.failedToSend || timelineItem.failedToSend ||
@@ -309,16 +303,18 @@ private fun TimelineItemRowContent(
} }
// Custom clickable that applies over the whole item for accessibility // Custom clickable that applies over the whole item for accessibility
.then( .then(
if (isTalkbackActive()) { when {
Modifier !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( .combinedClickable(
onClick = { onContentClick(timelineItem) }, onClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
) )
.onKeyboardContextMenuAction { onLongClick(timelineItem) } .onKeyboardContextMenuAction { onLongClick(timelineItem) }
} else {
Modifier
} }
), ),
event = timelineItem, event = timelineItem,
@@ -340,7 +336,8 @@ private fun TimelineItemRowContent(
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink, eventSink = eventSink,
selectionContentOffset = selectionContentOffset, selectionContentOffset = contentSlide,
isSelectionToggle = isSelectionToggle,
eventContentView = { contentModifier, onContentLayoutChange -> eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange) eventContentView(timelineItem, contentModifier, onContentLayoutChange)
}, },
@@ -12,6 +12,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource 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.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@@ -34,10 +39,18 @@ fun SelectionModeTopBar(
onConfirmClick: () -> Unit, onConfirmClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val a11ySelectedCount = pluralStringResource(CommonPlurals.screen_room_selection_count, selectedCount, selectedCount)
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = { 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 = { navigationIcon = {
IconButton( IconButton(