Merge pull request #4877 from element-hq/feature/bma/a11yReactions

A11Y: improve accessibility on event reactions.
This commit is contained in:
Benoit Marty 2025-06-16 20:57:58 +02:00 committed by GitHub
commit 76d0d4dcdd
6 changed files with 147 additions and 24 deletions

View file

@ -39,7 +39,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -53,6 +53,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.MessageShieldView import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -438,11 +439,10 @@ private fun EmojiButton(
} else { } else {
Color.Transparent Color.Transparent
} }
val description = if (isHighlighted) { val a11yClickLabel = a11yReactionAction(
stringResource(id = CommonStrings.a11y_remove_reaction_with, emoji) emoji = emoji,
} else { userAlreadyReacted = isHighlighted,
stringResource(id = CommonStrings.a11y_react_with, emoji) )
}
Box( Box(
modifier = modifier modifier = modifier
.size(48.dp) .size(48.dp)
@ -454,7 +454,12 @@ private fun EmojiButton(
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) )
.semantics { .semantics {
contentDescription = description onClick(
label = a11yClickLabel,
) {
onClick(emoji)
true
}
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {

View file

@ -0,0 +1,61 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.a11y
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ReadOnlyComposable
fun a11yReactionAction(
emoji: String,
userAlreadyReacted: Boolean,
): String {
return if (userAlreadyReacted) {
stringResource(id = CommonStrings.a11y_remove_reaction_with, emoji)
} else {
stringResource(id = CommonStrings.a11y_react_with, emoji)
}
}
@Composable
@ReadOnlyComposable
fun a11yReactionDetails(
emoji: String,
userAlreadyReacted: Boolean,
reactionCount: Int,
): String {
val reaction = if (emoji.startsWith("mxc://")) {
stringResource(CommonStrings.common_an_image)
} else {
emoji
}
return if (userAlreadyReacted) {
if (reactionCount == 1) {
stringResource(R.string.screen_room_timeline_reaction_you_a11y, reaction)
} else {
pluralStringResource(
R.plurals.screen_room_timeline_reaction_including_you_a11y,
reactionCount - 1,
reactionCount - 1,
reaction,
)
}
} else {
pluralStringResource(
R.plurals.screen_room_timeline_reaction_a11y,
reactionCount,
reactionCount,
reaction,
)
}
}

View file

@ -29,12 +29,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
@ -68,6 +73,27 @@ fun MessagesReactionButton(
buttonColor buttonColor
} }
val a11yText = when (content) {
is MessagesReactionsButtonContent.Icon -> stringResource(id = R.string.screen_room_timeline_add_reaction)
is MessagesReactionsButtonContent.Text -> content.text
is MessagesReactionsButtonContent.Reaction -> {
a11yReactionDetails(
emoji = content.reaction.key,
userAlreadyReacted = content.isHighlighted,
reactionCount = content.reaction.count,
)
}
}
val a11yClickLabel = if (content is MessagesReactionsButtonContent.Reaction) {
a11yReactionAction(
emoji = content.reaction.key,
userAlreadyReacted = content.isHighlighted
)
} else {
""
}
Surface( Surface(
modifier = modifier modifier = modifier
.background(Color.Transparent) .background(Color.Transparent)
@ -86,7 +112,18 @@ fun MessagesReactionButton(
// Inner border, to highlight when selected // Inner border, to highlight when selected
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp))) .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp))) .background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
.padding(vertical = 4.dp, horizontal = 10.dp), .padding(vertical = 4.dp, horizontal = 10.dp)
.clearAndSetSemantics {
contentDescription = a11yText
if (content is MessagesReactionsButtonContent.Reaction) {
onClick(
label = a11yClickLabel
) {
onClick()
true
}
}
},
color = buttonColor color = buttonColor
) { ) {
when (content) { when (content) {

View file

@ -22,7 +22,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
@ -30,11 +29,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun EmojiItem( fun EmojiItem(
@ -49,11 +48,10 @@ fun EmojiItem(
} else { } else {
Color.Transparent Color.Transparent
} }
val description = if (isSelected) { val description = a11yReactionAction(
stringResource(id = CommonStrings.a11y_remove_reaction_with, item.unicode) emoji = item.unicode,
} else { userAlreadyReacted = isSelected,
stringResource(id = CommonStrings.a11y_react_with, item.unicode) )
}
Box( Box(
modifier = modifier modifier = modifier
.sizeIn(minWidth = 40.dp, minHeight = 40.dp) .sizeIn(minWidth = 40.dp, minHeight = 40.dp)

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -45,12 +46,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails
import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -140,9 +146,7 @@ private fun ReactionSummaryViewContent(
HorizontalPager(state = pagerState) { page -> HorizontalPager(state = pagerState) { page ->
LazyColumn(modifier = Modifier.fillMaxHeight()) { LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(summary.reactions[page].senders) { sender -> items(summary.reactions[page].senders) { sender ->
val user = sender.user ?: MatrixUser(userId = sender.senderId) val user = sender.user ?: MatrixUser(userId = sender.senderId)
SenderRow( SenderRow(
avatarData = user.getAvatarData(AvatarSize.UserListItem), avatarData = user.getAvatarData(AvatarSize.UserListItem),
name = user.displayName ?: user.userId.value, name = user.displayName ?: user.userId.value,
@ -166,21 +170,32 @@ private fun AggregatedReactionButton(
} else { } else {
Color.Transparent Color.Transparent
} }
val textColor = if (isHighlighted) { val textColor = if (isHighlighted) {
MaterialTheme.colorScheme.inversePrimary MaterialTheme.colorScheme.inversePrimary
} else { } else {
ElementTheme.colors.textPrimary ElementTheme.colors.textPrimary
} }
val roundedCornerShape = RoundedCornerShape(corner = CornerSize(percent = 50)) val roundedCornerShape = RoundedCornerShape(corner = CornerSize(percent = 50))
val a11yText = a11yReactionDetails(
emoji = reaction.key,
userAlreadyReacted = reaction.isHighlighted,
reactionCount = reaction.count,
)
Surface( Surface(
modifier = Modifier modifier = Modifier
.background(buttonColor, roundedCornerShape) .background(buttonColor, roundedCornerShape)
.clip(roundedCornerShape) .clip(roundedCornerShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 12.dp), .padding(vertical = 8.dp, horizontal = 12.dp)
color = buttonColor .selectable(
selected = isHighlighted,
role = Role.Tab,
onClick = onClick,
)
.clearAndSetSemantics {
contentDescription = a11yText
},
color = buttonColor,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -230,7 +245,8 @@ private fun SenderRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 56.dp) .heightIn(min = 56.dp)
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp), .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp)
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar(avatarData) Avatar(avatarData)

View file

@ -391,7 +391,10 @@ class MessagesViewTest {
rule.setMessagesView( rule.setMessagesView(
state = state, state = state,
) )
rule.onAllNodesWithText("👍️").onFirst().performClick() rule.onAllNodesWithText(
text = "👍️",
useUnmergedTree = true,
).onFirst().performClick()
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventOrTransactionId)) eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventOrTransactionId))
} }
@ -411,7 +414,10 @@ class MessagesViewTest {
rule.setMessagesView( rule.setMessagesView(
state = state, state = state,
) )
rule.onAllNodesWithText("👍️").onFirst().performTouchInput { longClick() } rule.onAllNodesWithText(
text = "👍️",
useUnmergedTree = true,
).onFirst().performTouchInput { longClick() }
eventsRecorder.assertSingle(ReactionSummaryEvents.ShowReactionSummary(timelineItem.eventId!!, timelineItem.reactionsState.reactions, "👍️")) eventsRecorder.assertSingle(ReactionSummaryEvents.ShowReactionSummary(timelineItem.eventId!!, timelineItem.reactionsState.reactions, "👍️"))
} }