Merge pull request #5007 from element-hq/feature/bma/a11y/reportRoom

[a11y] Fix several issues around accessibility
This commit is contained in:
Benoit Marty 2025-07-10 16:36:10 +02:00 committed by GitHub
commit f39304d9d2
10 changed files with 57 additions and 50 deletions

View file

@ -176,7 +176,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon( leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector( iconSource = IconSource.Vector(
CompoundIcons.ChatProblem(), CompoundIcons.ChatProblem(),
contentDescription = stringResource(CommonStrings.action_report_room),
) )
), ),
style = ListItemStyle.Destructive, style = ListItemStyle.Destructive,

View file

@ -18,8 +18,6 @@ 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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
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 io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -44,15 +42,15 @@ fun TimelineEventTimestampView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val formattedTime = event.sentTime val formattedTime = event.sentTime
val hasError = event.localSendState is LocalEventSendState.Failed val hasError = event.failedToSend
val hasEncryptionCritical = event.messageShield?.isCritical.orFalse() val hasEncryptionCritical = event.messageShield?.isCritical.orFalse()
val isMessageEdited = event.content.isEdited() val isMessageEdited = event.content.isEdited()
val isMessageRedacted = event.content.isRedacted() val isMessageRedacted = event.content.isRedacted()
val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
Row( Row(
modifier = Modifier modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier), .then(modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isMessageEdited) { if (isMessageEdited) {
@ -76,11 +74,13 @@ fun TimelineEventTimestampView(
contentDescription = stringResource(id = CommonStrings.common_sending_failed), contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint, tint = tint,
modifier = Modifier modifier = Modifier
.size(15.dp, 18.dp) .size(15.dp, 18.dp)
.clickable(isVerifiedUserSendFailure) { .clickable(
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) enabled = isVerifiedUserSendFailure,
} onClickLabel = stringResource(CommonStrings.action_open_context_menu),
.semantics { hideFromAccessibility() } ) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
}
) )
} }
@ -89,13 +89,14 @@ fun TimelineEventTimestampView(
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
Icon( Icon(
imageVector = shield.toIcon(), imageVector = shield.toIcon(),
contentDescription = shield.toText(), contentDescription = stringResource(id = CommonStrings.a11y_encryption_details),
modifier = Modifier modifier = Modifier
.size(15.dp) .size(15.dp)
.clickable { .clickable(
onClickLabel = stringResource(CommonStrings.a11y_view_details),
) {
eventSink(TimelineEvents.ShowShieldDialog(shield)) eventSink(TimelineEvents.ShowShieldDialog(shield))
} },
.semantics { hideFromAccessibility() },
tint = shield.toIconColor(), tint = shield.toIconColor(),
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))

View file

@ -140,7 +140,10 @@ internal fun TimelineItemRow(
timelineItem.safeSenderName timelineItem.safeSenderName
} }
// For Polls, allow the answers to be traversed by Talkback // For Polls, allow the answers to be traversed by Talkback
isTraversalGroup = timelineItem.content is TimelineItemPollContent isTraversalGroup = timelineItem.content is TimelineItemPollContent ||
timelineItem.failedToSend ||
timelineItem.messageShield != null
// TODO Also set to true when the event has link(s)
} }
// Custom clickable that applies over the whole item for accessibility // Custom clickable that applies over the whole item for accessibility
.then( .then(

View file

@ -116,7 +116,7 @@ class TimelineViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val contentDescription = rule.activity.getString(CommonStrings.event_shield_reason_unverified_identity) val contentDescription = rule.activity.getString(CommonStrings.a11y_encryption_details)
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(

View file

@ -4,5 +4,6 @@
<item quantity="one">"%1$d percent of total votes"</item> <item quantity="one">"%1$d percent of total votes"</item>
<item quantity="other">"%1$d percents of total votes"</item> <item quantity="other">"%1$d percents of total votes"</item>
</plurals> </plurals>
<string name="a11y_polls_will_remove_selection">"Will remove previous selection"</string>
<string name="a11y_polls_winning_answer">"This is the winning answer"</string> <string name="a11y_polls_winning_answer">"This is the winning answer"</string>
</resources> </resources>

View file

@ -225,7 +225,10 @@ class ChangeRolesViewTest {
) )
// Unselect the user from the row list // Unselect the user from the row list
val contentDescription = rule.activity.getString(CommonStrings.action_remove) val contentDescription = rule.activity.getString(CommonStrings.action_remove)
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(
label = contentDescription,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.QueryChanged(""),
@ -248,7 +251,7 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = state, state = state,
) )
// Select the user from the row list // Select the user from the user list
rule.onNodeWithText("Carol").performClick() rule.onNodeWithText("Carol").performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -271,8 +274,11 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = state, state = state,
) )
// Select the user from the rom list // Unselect the user from the user list
rule.onAllNodesWithText("Bob")[1].performClick() rule.onAllNodesWithText(
text = "Bob",
useUnmergedTree = true,
)[1].performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.QueryChanged(""),

View file

@ -1,29 +0,0 @@
/*
* Copyright 2023, 2024 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.libraries.androidutils.accessibility
import android.content.Context
import android.view.accessibility.AccessibilityManager
import androidx.core.content.getSystemService
/**
* Whether a screen reader is enabled.
*
* Avoid changing UI or app behavior based on the state of accessibility.
* See [AccessibilityManager.isTouchExplorationEnabled] for more details.
*
* @return true if the screen reader is enabled.
*/
fun Context.isScreenReaderEnabled(): Boolean {
val accessibilityManager = getSystemService<AccessibilityManager>()
?: return false
return accessibilityManager.let {
it.isEnabled && it.isTouchExplorationEnabled
}
}

View file

@ -62,6 +62,7 @@ fun EditableAvatarView(
modifier = Modifier modifier = Modifier
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClickLabel = stringResource(CommonStrings.a11y_edit_avatar),
onClick = onAvatarClick, onClick = onAvatarClick,
indication = ripple(bounded = false), indication = ripple(bounded = false),
) )

View file

@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
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.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -48,9 +51,24 @@ fun SelectedUser(
onUserRemove: (MatrixUser) -> Unit, onUserRemove: (MatrixUser) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val actionRemove = stringResource(id = CommonStrings.action_remove)
Box( Box(
modifier = modifier modifier = modifier
.width(AvatarSize.SelectedUser.dp) .width(AvatarSize.SelectedUser.dp)
.clearAndSetSemantics {
contentDescription = matrixUser.getBestName()
if (canRemove) {
// Note: this does not set the click effect to the whole Box
// when talkback is not enabled
onClick(
label = actionRemove,
action = {
onUserRemove(matrixUser)
true
}
)
}
}
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -83,6 +101,7 @@ fun SelectedUser(
) { ) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
// Note: keep the context description for the test
contentDescription = stringResource(id = CommonStrings.action_remove), contentDescription = stringResource(id = CommonStrings.action_remove),
tint = ElementTheme.colors.iconOnSolidPrimary, tint = ElementTheme.colors.iconOnSolidPrimary,
modifier = Modifier.padding(2.dp) modifier = Modifier.padding(2.dp)

View file

@ -7,11 +7,16 @@
<item quantity="one">"%1$d digit entered"</item> <item quantity="one">"%1$d digit entered"</item>
<item quantity="other">"%1$d digits entered"</item> <item quantity="other">"%1$d digits entered"</item>
</plurals> </plurals>
<string name="a11y_edit_avatar">"Edit avatar"</string>
<string name="a11y_encryption_details">"Encryption details"</string>
<string name="a11y_hide_password">"Hide password"</string> <string name="a11y_hide_password">"Hide password"</string>
<string name="a11y_join_call">"Join call"</string> <string name="a11y_join_call">"Join call"</string>
<string name="a11y_jump_to_bottom">"Jump to bottom"</string> <string name="a11y_jump_to_bottom">"Jump to bottom"</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_notifications_new_mentions">"New mentions"</string>
<string name="a11y_notifications_new_messages">"New messages"</string>
<string name="a11y_notifications_ongoing_call">"Ongoing call"</string>
<string name="a11y_other_user_avatar">"Other user\'s avatar"</string> <string name="a11y_other_user_avatar">"Other user\'s avatar"</string>
<string name="a11y_page_n">"Page %1$d"</string> <string name="a11y_page_n">"Page %1$d"</string>
<string name="a11y_pause">"Pause"</string> <string name="a11y_pause">"Pause"</string>
@ -35,6 +40,7 @@
<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_start_call">"Start a call"</string> <string name="a11y_start_call">"Start a call"</string>
<string name="a11y_time_limited_action_required">"Time limited action required"</string>
<string name="a11y_user_avatar">"User avatar"</string> <string name="a11y_user_avatar">"User avatar"</string>
<string name="a11y_user_menu">"User menu"</string> <string name="a11y_user_menu">"User menu"</string>
<string name="a11y_view_avatar">"View avatar"</string> <string name="a11y_view_avatar">"View avatar"</string>