Merge pull request #4875 from element-hq/feature/bma/a11yPolls

[a11y] Improve screen reader on polls
This commit is contained in:
Benoit Marty 2025-06-13 15:53:41 +02:00 committed by GitHub
commit 1a8f38e29a
10 changed files with 76 additions and 14 deletions

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -31,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
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.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
@ -137,6 +139,8 @@ internal fun TimelineItemRow(
} else { } else {
timelineItem.safeSenderName timelineItem.safeSenderName
} }
// For Polls, allow the answers to be traversed by Talkback
isTraversalGroup = timelineItem.content is TimelineItemPollContent
} }
// Custom clickable that applies over the whole item for accessibility // Custom clickable that applies over the whole item for accessibility
.then( .then(

View file

@ -47,7 +47,10 @@ class TimelineItemPollViewTest {
) )
} }
val answer = content.answerItems[answerIndex].answer val answer = content.answerItems[answerIndex].answer
rule.onNode(hasText(answer.text)).performClick() rule.onNode(
matcher = hasText(answer.text),
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle(TimelineEvents.SelectPollAnswer(content.eventId!!, answer.id)) eventsRecorder.assertSingle(TimelineEvents.SelectPollAnswer(content.eventId!!, answer.id))
} }

View file

@ -20,9 +20,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.poll.api.R
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.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -32,14 +36,42 @@ import io.element.android.libraries.designsystem.theme.progressIndicatorTrackCol
import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun PollAnswerView( internal fun PollAnswerView(
answerItem: PollAnswerItem, answerItem: PollAnswerItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val nbVotesText = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount,
)
val a11yText = buildString {
val sentenceDelimiter = stringResource(CommonStrings.common_sentence_delimiter)
append(answerItem.answer.text.removeSuffix("."))
if (answerItem.showVotes) {
append(sentenceDelimiter)
append(nbVotesText)
if (answerItem.votesCount != 0) {
append(sentenceDelimiter)
(answerItem.percentage * 100).toInt().let { percent ->
append(pluralStringResource(R.plurals.a11y_polls_percent_of_total, percent, percent))
}
}
if (answerItem.isWinner) {
append(sentenceDelimiter)
append(stringResource(R.string.a11y_polls_winning_answer))
}
}
}
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier
.fillMaxWidth()
.clearAndSetSemantics {
contentDescription = a11yText
},
) { ) {
Icon( Icon(
imageVector = if (answerItem.isSelected) { imageVector = if (answerItem.isSelected) {
@ -70,11 +102,6 @@ internal fun PollAnswerView(
style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular, style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular,
) )
if (answerItem.showVotes) { if (answerItem.showVotes) {
val text = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount
)
Row( Row(
modifier = Modifier.align(Alignment.Bottom), modifier = Modifier.align(Alignment.Bottom),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -87,13 +114,13 @@ internal fun PollAnswerView(
) )
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
Text( Text(
text = text, text = nbVotesText,
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
) )
} else { } else {
Text( Text(
text = text, text = nbVotesText,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
) )

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d percent of total votes"</item>
<item quantity="other">"%1$d percents of total votes"</item>
</plurals>
<string name="a11y_polls_winning_answer">"This is the winning answer"</string>
</resources>

View file

@ -143,7 +143,7 @@ fun CreatePollView(
trailingContent = ListItemContent.Custom { trailingContent = ListItemContent.Custom {
Icon( Icon(
imageVector = CompoundIcons.Delete(), imageVector = CompoundIcons.Delete(),
contentDescription = null, contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text),
modifier = Modifier.clickable(answer.canDelete) { modifier = Modifier.clickable(answer.canDelete) {
state.eventSink(CreatePollEvents.RemoveAnswer(index)) state.eventSink(CreatePollEvents.RemoveAnswer(index))
}, },

View file

@ -29,6 +29,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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
@ -178,7 +180,9 @@ private fun PollHistoryList(
if (pollHistoryItems.isEmpty()) { if (pollHistoryItems.isEmpty()) {
item { item {
Column( Column(
modifier = Modifier.fillParentMaxSize().padding(bottom = 24.dp), modifier = Modifier
.fillParentMaxSize()
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -191,7 +195,9 @@ private fun PollHistoryList(
text = emptyStringResource, text = emptyStringResource,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp, horizontal = 16.dp), modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@ -227,7 +233,10 @@ private fun PollHistoryItemRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Surface( Surface(
modifier = modifier, modifier = modifier.semantics(mergeDescendants = true) {
// Allow the answers to be traversed by Talkback
isTraversalGroup = true
},
border = BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), border = BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary),
shape = RoundedCornerShape(size = 12.dp) shape = RoundedCornerShape(size = 12.dp)
) { ) {

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Hide votes"</string> <string name="screen_create_poll_anonymous_headline">"Hide votes"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string> <string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Your changes have not been saved. Are you sure you want to go back?"</string> <string name="screen_create_poll_cancel_confirmation_content_android">"Your changes have not been saved. Are you sure you want to go back?"</string>
<string name="screen_create_poll_delete_option_a11y">"Delete option %1$s"</string>
<string name="screen_create_poll_question_desc">"Question or topic"</string> <string name="screen_create_poll_question_desc">"Question or topic"</string>
<string name="screen_create_poll_question_hint">"What is the poll about?"</string> <string name="screen_create_poll_question_hint">"What is the poll about?"</string>
<string name="screen_create_poll_title">"Create Poll"</string> <string name="screen_create_poll_title">"Create Poll"</string>

View file

@ -131,7 +131,10 @@ class PollHistoryViewTest {
rule.setPollHistoryViewView( rule.setPollHistoryViewView(
state = state, state = state,
) )
rule.onNodeWithText(answer.text).performClick() rule.onNodeWithText(
text = answer.text,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
PollHistoryEvents.SelectPollAnswer(eventId, answer.id) PollHistoryEvents.SelectPollAnswer(eventId, answer.id)
) )

View file

@ -259,6 +259,7 @@ Reason: %1$s."</string>
<string name="common_sending">"Sending…"</string> <string name="common_sending">"Sending…"</string>
<string name="common_sending_failed">"Sending failed"</string> <string name="common_sending_failed">"Sending failed"</string>
<string name="common_sent">"Sent"</string> <string name="common_sent">"Sent"</string>
<string name="common_sentence_delimiter">". "</string>
<string name="common_server_not_supported">"Server not supported"</string> <string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string> <string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string> <string name="common_settings">"Settings"</string>

View file

@ -242,6 +242,12 @@
"screen_polls_history_.*" "screen_polls_history_.*"
] ]
}, },
{
"name" : ":features:poll:api",
"includeRegex" : [
"a11y\\.polls\\..*"
]
},
{ {
"name" : ":features:securebackup:impl", "name" : ":features:securebackup:impl",
"includeRegex" : [ "includeRegex" : [