Skip to content

Commit 564beb3

Browse files
authored
Merge pull request #4877 from element-hq/feature/bma/a11yReactions
A11Y: improve accessibility on event reactions.
2 parents 4e93079 + dbd0e4a commit 564beb3

File tree

6 files changed

+147
-24
lines changed

6 files changed

+147
-24
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import androidx.compose.ui.graphics.Color
3939
import androidx.compose.ui.platform.LocalContext
4040
import androidx.compose.ui.res.stringResource
4141
import androidx.compose.ui.semantics.clearAndSetSemantics
42-
import androidx.compose.ui.semantics.contentDescription
42+
import androidx.compose.ui.semantics.onClick
4343
import androidx.compose.ui.semantics.semantics
4444
import androidx.compose.ui.semantics.traversalIndex
4545
import androidx.compose.ui.text.style.TextAlign
@@ -53,6 +53,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
5353
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity
5454
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None
5555
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice
56+
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
5657
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
5758
import io.element.android.features.messages.impl.timeline.model.TimelineItem
5859
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@@ -438,11 +439,10 @@ private fun EmojiButton(
438439
} else {
439440
Color.Transparent
440441
}
441-
val description = if (isHighlighted) {
442-
stringResource(id = CommonStrings.a11y_remove_reaction_with, emoji)
443-
} else {
444-
stringResource(id = CommonStrings.a11y_react_with, emoji)
445-
}
442+
val a11yClickLabel = a11yReactionAction(
443+
emoji = emoji,
444+
userAlreadyReacted = isHighlighted,
445+
)
446446
Box(
447447
modifier = modifier
448448
.size(48.dp)
@@ -454,7 +454,12 @@ private fun EmojiButton(
454454
interactionSource = remember { MutableInteractionSource() }
455455
)
456456
.semantics {
457-
contentDescription = description
457+
onClick(
458+
label = a11yClickLabel,
459+
) {
460+
onClick(emoji)
461+
true
462+
}
458463
},
459464
contentAlignment = Alignment.Center
460465
) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.timeline.a11y
9+
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.runtime.ReadOnlyComposable
12+
import androidx.compose.ui.res.pluralStringResource
13+
import androidx.compose.ui.res.stringResource
14+
import io.element.android.features.messages.impl.R
15+
import io.element.android.libraries.ui.strings.CommonStrings
16+
17+
@Composable
18+
@ReadOnlyComposable
19+
fun a11yReactionAction(
20+
emoji: String,
21+
userAlreadyReacted: Boolean,
22+
): String {
23+
return if (userAlreadyReacted) {
24+
stringResource(id = CommonStrings.a11y_remove_reaction_with, emoji)
25+
} else {
26+
stringResource(id = CommonStrings.a11y_react_with, emoji)
27+
}
28+
}
29+
30+
@Composable
31+
@ReadOnlyComposable
32+
fun a11yReactionDetails(
33+
emoji: String,
34+
userAlreadyReacted: Boolean,
35+
reactionCount: Int,
36+
): String {
37+
val reaction = if (emoji.startsWith("mxc://")) {
38+
stringResource(CommonStrings.common_an_image)
39+
} else {
40+
emoji
41+
}
42+
return if (userAlreadyReacted) {
43+
if (reactionCount == 1) {
44+
stringResource(R.string.screen_room_timeline_reaction_you_a11y, reaction)
45+
} else {
46+
pluralStringResource(
47+
R.plurals.screen_room_timeline_reaction_including_you_a11y,
48+
reactionCount - 1,
49+
reactionCount - 1,
50+
reaction,
51+
)
52+
}
53+
} else {
54+
pluralStringResource(
55+
R.plurals.screen_room_timeline_reaction_a11y,
56+
reactionCount,
57+
reactionCount,
58+
reaction,
59+
)
60+
}
61+
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,17 @@ import androidx.compose.ui.Modifier
2929
import androidx.compose.ui.draw.clip
3030
import androidx.compose.ui.graphics.Color
3131
import androidx.compose.ui.res.stringResource
32+
import androidx.compose.ui.semantics.clearAndSetSemantics
33+
import androidx.compose.ui.semantics.contentDescription
34+
import androidx.compose.ui.semantics.onClick
3235
import androidx.compose.ui.tooling.preview.PreviewParameter
3336
import androidx.compose.ui.unit.dp
3437
import androidx.compose.ui.unit.sp
3538
import coil3.compose.AsyncImage
3639
import io.element.android.compound.theme.ElementTheme
3740
import io.element.android.features.messages.impl.R
41+
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
42+
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails
3843
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
3944
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
4045
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
@@ -68,6 +73,27 @@ fun MessagesReactionButton(
6873
buttonColor
6974
}
7075

76+
val a11yText = when (content) {
77+
is MessagesReactionsButtonContent.Icon -> stringResource(id = R.string.screen_room_timeline_add_reaction)
78+
is MessagesReactionsButtonContent.Text -> content.text
79+
is MessagesReactionsButtonContent.Reaction -> {
80+
a11yReactionDetails(
81+
emoji = content.reaction.key,
82+
userAlreadyReacted = content.isHighlighted,
83+
reactionCount = content.reaction.count,
84+
)
85+
}
86+
}
87+
88+
val a11yClickLabel = if (content is MessagesReactionsButtonContent.Reaction) {
89+
a11yReactionAction(
90+
emoji = content.reaction.key,
91+
userAlreadyReacted = content.isHighlighted
92+
)
93+
} else {
94+
""
95+
}
96+
7197
Surface(
7298
modifier = modifier
7399
.background(Color.Transparent)
@@ -86,7 +112,18 @@ fun MessagesReactionButton(
86112
// Inner border, to highlight when selected
87113
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
88114
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
89-
.padding(vertical = 4.dp, horizontal = 10.dp),
115+
.padding(vertical = 4.dp, horizontal = 10.dp)
116+
.clearAndSetSemantics {
117+
contentDescription = a11yText
118+
if (content is MessagesReactionsButtonContent.Reaction) {
119+
onClick(
120+
label = a11yClickLabel
121+
) {
122+
onClick()
123+
true
124+
}
125+
}
126+
},
90127
color = buttonColor
91128
) {
92129
when (content) {

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,18 @@ import androidx.compose.runtime.remember
2222
import androidx.compose.ui.Alignment
2323
import androidx.compose.ui.Modifier
2424
import androidx.compose.ui.graphics.Color
25-
import androidx.compose.ui.res.stringResource
2625
import androidx.compose.ui.semantics.clearAndSetSemantics
2726
import androidx.compose.ui.semantics.contentDescription
2827
import androidx.compose.ui.unit.TextUnit
2928
import androidx.compose.ui.unit.dp
3029
import androidx.compose.ui.unit.sp
3130
import io.element.android.compound.theme.ElementTheme
3231
import io.element.android.emojibasebindings.Emoji
32+
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
3333
import io.element.android.libraries.designsystem.preview.ElementPreview
3434
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
3535
import io.element.android.libraries.designsystem.text.toDp
3636
import io.element.android.libraries.designsystem.theme.components.Text
37-
import io.element.android.libraries.ui.strings.CommonStrings
3837

3938
@Composable
4039
fun EmojiItem(
@@ -49,11 +48,10 @@ fun EmojiItem(
4948
} else {
5049
Color.Transparent
5150
}
52-
val description = if (isSelected) {
53-
stringResource(id = CommonStrings.a11y_remove_reaction_with, item.unicode)
54-
} else {
55-
stringResource(id = CommonStrings.a11y_react_with, item.unicode)
56-
}
51+
val description = a11yReactionAction(
52+
emoji = item.unicode,
53+
userAlreadyReacted = isSelected,
54+
)
5755
Box(
5856
modifier = modifier
5957
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.items
2727
import androidx.compose.foundation.lazy.rememberLazyListState
2828
import androidx.compose.foundation.pager.HorizontalPager
2929
import androidx.compose.foundation.pager.rememberPagerState
30+
import androidx.compose.foundation.selection.selectable
3031
import androidx.compose.foundation.shape.CornerSize
3132
import androidx.compose.foundation.shape.RoundedCornerShape
3233
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -45,12 +46,17 @@ import androidx.compose.ui.Alignment
4546
import androidx.compose.ui.Modifier
4647
import androidx.compose.ui.draw.clip
4748
import androidx.compose.ui.graphics.Color
49+
import androidx.compose.ui.semantics.Role
50+
import androidx.compose.ui.semantics.clearAndSetSemantics
51+
import androidx.compose.ui.semantics.contentDescription
52+
import androidx.compose.ui.semantics.semantics
4853
import androidx.compose.ui.text.style.TextOverflow
4954
import androidx.compose.ui.tooling.preview.PreviewParameter
5055
import androidx.compose.ui.unit.dp
5156
import androidx.compose.ui.unit.sp
5257
import coil3.compose.AsyncImage
5358
import io.element.android.compound.theme.ElementTheme
59+
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails
5460
import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO
5561
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
5662
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -140,9 +146,7 @@ private fun ReactionSummaryViewContent(
140146
HorizontalPager(state = pagerState) { page ->
141147
LazyColumn(modifier = Modifier.fillMaxHeight()) {
142148
items(summary.reactions[page].senders) { sender ->
143-
144149
val user = sender.user ?: MatrixUser(userId = sender.senderId)
145-
146150
SenderRow(
147151
avatarData = user.getAvatarData(AvatarSize.UserListItem),
148152
name = user.displayName ?: user.userId.value,
@@ -166,21 +170,32 @@ private fun AggregatedReactionButton(
166170
} else {
167171
Color.Transparent
168172
}
169-
170173
val textColor = if (isHighlighted) {
171174
MaterialTheme.colorScheme.inversePrimary
172175
} else {
173176
ElementTheme.colors.textPrimary
174177
}
175-
176178
val roundedCornerShape = RoundedCornerShape(corner = CornerSize(percent = 50))
179+
val a11yText = a11yReactionDetails(
180+
emoji = reaction.key,
181+
userAlreadyReacted = reaction.isHighlighted,
182+
reactionCount = reaction.count,
183+
)
177184
Surface(
178185
modifier = Modifier
179186
.background(buttonColor, roundedCornerShape)
180187
.clip(roundedCornerShape)
181188
.clickable(onClick = onClick)
182-
.padding(vertical = 8.dp, horizontal = 12.dp),
183-
color = buttonColor
189+
.padding(vertical = 8.dp, horizontal = 12.dp)
190+
.selectable(
191+
selected = isHighlighted,
192+
role = Role.Tab,
193+
onClick = onClick,
194+
)
195+
.clearAndSetSemantics {
196+
contentDescription = a11yText
197+
},
198+
color = buttonColor,
184199
) {
185200
Row(
186201
verticalAlignment = Alignment.CenterVertically,
@@ -230,7 +245,8 @@ private fun SenderRow(
230245
modifier = Modifier
231246
.fillMaxWidth()
232247
.heightIn(min = 56.dp)
233-
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
248+
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp)
249+
.semantics(mergeDescendants = true) {},
234250
verticalAlignment = Alignment.CenterVertically
235251
) {
236252
Avatar(avatarData)

features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,10 @@ class MessagesViewTest {
391391
rule.setMessagesView(
392392
state = state,
393393
)
394-
rule.onAllNodesWithText("👍️").onFirst().performClick()
394+
rule.onAllNodesWithText(
395+
text = "👍️",
396+
useUnmergedTree = true,
397+
).onFirst().performClick()
395398
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventOrTransactionId))
396399
}
397400

@@ -411,7 +414,10 @@ class MessagesViewTest {
411414
rule.setMessagesView(
412415
state = state,
413416
)
414-
rule.onAllNodesWithText("👍️").onFirst().performTouchInput { longClick() }
417+
rule.onAllNodesWithText(
418+
text = "👍️",
419+
useUnmergedTree = true,
420+
).onFirst().performTouchInput { longClick() }
415421
eventsRecorder.assertSingle(ReactionSummaryEvents.ShowReactionSummary(timelineItem.eventId!!, timelineItem.reactionsState.reactions, "👍️"))
416422
}
417423

0 commit comments

Comments
 (0)