Make PollContentView a11y friendly

Improves a bit how screen readers read polls in the timeline.
- Adds a few `contentDescription` so that talkback reads “poll” or “ended poll” before the poll question.
- Changes the compose structure of the answers so that they are properly scanned by the screen reader. This meant getting rid of the `IconToggleButton` which was made redundant by the use of the `selectable`.
This commit is contained in:
Marco Romano 2023-09-14 13:45:01 +02:00
parent 5c14badb5b
commit 44a9da8fbf
3 changed files with 74 additions and 77 deletions

View file

@ -21,24 +21,21 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconToggleButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
@ -47,41 +44,33 @@ import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonPlurals
@Composable @Composable
fun PollAnswerView( internal fun PollAnswerView(
answerItem: PollAnswerItem, answerItem: PollAnswerItem,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth()
.selectable(
selected = answerItem.isSelected,
enabled = answerItem.isEnabled,
onClick = onClick,
role = Role.RadioButton,
)
) { ) {
IconToggleButton( Icon(
modifier = Modifier.size(22.dp), imageVector = if (answerItem.isSelected) {
checked = answerItem.isSelected, Icons.Default.CheckCircle
enabled = answerItem.isEnabled, } else {
colors = IconButtonDefaults.iconToggleButtonColors( Icons.Default.RadioButtonUnchecked
contentColor = ElementTheme.colors.iconSecondary, },
checkedContentColor = ElementTheme.colors.iconPrimary, contentDescription = null,
disabledContentColor = ElementTheme.colors.iconDisabled, modifier = Modifier
), .padding(0.5.dp)
onCheckedChange = { onClick() }, .size(22.dp),
) { tint = if (answerItem.isEnabled) {
Icon( if (answerItem.isSelected) {
imageVector = if (answerItem.isSelected) { ElementTheme.colors.iconPrimary
Icons.Default.CheckCircle
} else { } else {
Icons.Default.RadioButtonUnchecked ElementTheme.colors.iconSecondary
}, }
contentDescription = null, } else {
) ElementTheme.colors.iconDisabled
} },
)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column { Column {
Row { Row {
@ -119,65 +108,58 @@ fun PollAnswerView(
} }
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementThemedPreview { internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false), answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false),
onClick = { },
) )
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun PollAnswerDisclosedSelectedPreview() = ElementThemedPreview { internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true), answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true),
onClick = { }
) )
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementThemedPreview { internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false), answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false),
onClick = { },
) )
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun PollAnswerUndisclosedSelectedPreview() = ElementThemedPreview { internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true), answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true),
onClick = { }
) )
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementThemedPreview { internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false, isEnabled = false, isWinner = true), answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false, isEnabled = false, isWinner = true),
onClick = { }
) )
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementThemedPreview { internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = true), answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = true),
onClick = { }
) )
} }
@Preview @DayNightPreviews
@Composable @Composable
internal fun PollAnswerEndedSelectedPreview() = ElementThemedPreview { internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false), answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false),
onClick = { }
) )
} }

View file

@ -23,11 +23,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.VectorIcons import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.DayNightPreviews
@ -56,24 +59,24 @@ fun PollContentView(
} }
Column( Column(
modifier = modifier modifier = modifier.fillMaxWidth(),
.selectableGroup()
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
PollTitle(title = question, isPollEnded = isPollEnded) PollTitle(title = question, isPollEnded = isPollEnded)
PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected) PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected)
when { if (isPollEnded || pollKind == PollKind.Disclosed) {
isPollEnded || pollKind == PollKind.Disclosed -> DisclosedPollBottomNotice(answerItems) val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } }
pollKind == PollKind.Undisclosed -> UndisclosedPollBottomNotice() DisclosedPollBottomNotice(votesCount = votesCount)
} else {
UndisclosedPollBottomNotice()
} }
} }
} }
@Composable @Composable
internal fun PollTitle( private fun PollTitle(
title: String, title: String,
isPollEnded: Boolean, isPollEnded: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -85,13 +88,13 @@ internal fun PollTitle(
if (isPollEnded) { if (isPollEnded) {
Icon( Icon(
resourceId = VectorIcons.PollEnd, resourceId = VectorIcons.PollEnd,
contentDescription = null, contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
} else { } else {
Icon( Icon(
resourceId = VectorIcons.Poll, resourceId = VectorIcons.Poll,
contentDescription = null, contentDescription = stringResource(id = CommonStrings.a11y_poll),
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
} }
@ -103,27 +106,35 @@ internal fun PollTitle(
} }
@Composable @Composable
internal fun PollAnswers( private fun PollAnswers(
answerItems: ImmutableList<PollAnswerItem>, answerItems: ImmutableList<PollAnswerItem>,
onAnswerSelected: (PollAnswer) -> Unit, onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(
answerItems.forEach { answerItem -> modifier = modifier.selectableGroup(),
PollAnswerView( verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier, ) {
answerItem = answerItem, answerItems.forEach {
onClick = { onAnswerSelected(answerItem.answer) } PollAnswerView(
) answerItem = it,
modifier = Modifier
.selectable(
selected = it.isSelected,
enabled = it.isEnabled,
onClick = { onAnswerSelected(it.answer) },
role = Role.RadioButton,
),
)
}
} }
} }
@Composable @Composable
internal fun ColumnScope.DisclosedPollBottomNotice( private fun ColumnScope.DisclosedPollBottomNotice(
answerItems: ImmutableList<PollAnswerItem>, votesCount: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val votesCount = answerItems.sumOf { it.votesCount }
Text( Text(
modifier = modifier.align(Alignment.End), modifier = modifier.align(Alignment.End),
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
@ -133,7 +144,9 @@ internal fun ColumnScope.DisclosedPollBottomNotice(
} }
@Composable @Composable
fun ColumnScope.UndisclosedPollBottomNotice(modifier: Modifier = Modifier) { private fun ColumnScope.UndisclosedPollBottomNotice(
modifier: Modifier = Modifier
) {
Text( Text(
modifier = modifier modifier = modifier
.align(Alignment.Start) .align(Alignment.Start)

View file

@ -3,6 +3,8 @@
<string name="a11y_hide_password">"Hide password"</string> <string name="a11y_hide_password">"Hide password"</string>
<string name="a11y_notifications_mentions_only">"Mentions only"</string> <string name="a11y_notifications_mentions_only">"Mentions only"</string>
<string name="a11y_notifications_muted">"Muted"</string> <string name="a11y_notifications_muted">"Muted"</string>
<string name="a11y_poll">"Poll"</string>
<string name="a11y_poll_end">"Ended poll"</string>
<string name="a11y_send_files">"Send files"</string> <string name="a11y_send_files">"Send files"</string>
<string name="a11y_show_password">"Show password"</string> <string name="a11y_show_password">"Show password"</string>
<string name="a11y_user_menu">"User menu"</string> <string name="a11y_user_menu">"User menu"</string>