Merge pull request #4877 from element-hq/feature/bma/a11yReactions
A11Y: improve accessibility on event reactions.
This commit is contained in:
commit
76d0d4dcdd
6 changed files with 147 additions and 24 deletions
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, "👍️"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue