[a11y] Ensure that the focus is not lost when the send button state change.

This commit is contained in:
Benoit Marty 2025-07-03 17:19:43 +02:00
parent a84f387f4d
commit 24f08e8d20
4 changed files with 67 additions and 17 deletions

View file

@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -39,7 +40,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -280,6 +285,13 @@ fun TextComposer(
else -> sendButton else -> sendButton
} }
val endButtonA11y = endButtonA11y(
composerMode = composerMode,
voiceMessageState = voiceMessageState,
enableVoiceMessages = enableVoiceMessages,
canSendMessage = canSendMessage,
)
val voiceRecording = @Composable { val voiceRecording = @Composable {
when (voiceMessageState) { when (voiceMessageState) {
is VoiceMessageState.Preview -> is VoiceMessageState.Preview ->
@ -323,6 +335,7 @@ fun TextComposer(
) )
}, },
textFormatting = textFormattingOptions, textFormatting = textFormattingOptions,
endButtonA11y = endButtonA11y,
sendButton = sendButton, sendButton = sendButton,
) )
} else { } else {
@ -334,6 +347,7 @@ fun TextComposer(
composerOptionsButton = composerOptionsButton, composerOptionsButton = composerOptionsButton,
textInput = textInput, textInput = textInput,
endButton = sendOrRecordButton, endButton = sendOrRecordButton,
endButtonA11y = endButtonA11y,
voiceRecording = voiceRecording, voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton, voiceDeleteButton = voiceDeleteButton,
) )
@ -359,6 +373,40 @@ fun TextComposer(
} }
} }
@ReadOnlyComposable
@Composable
private fun endButtonA11y(
composerMode: MessageComposerMode,
voiceMessageState: VoiceMessageState,
enableVoiceMessages: Boolean,
canSendMessage: Boolean,
): (SemanticsPropertyReceiver) -> Unit {
val a11ySendButtonDescription = stringResource(
id = when {
enableVoiceMessages && !canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> if (voiceMessageState is VoiceMessageState.Recording) {
CommonStrings.a11y_voice_message_stop_recording
} else {
CommonStrings.a11y_voice_message_record
}
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> CommonStrings.common_sending
false -> CommonStrings.action_send_voice_message
}
}
composerMode.isEditing -> CommonStrings.action_send_edited_message
else -> CommonStrings.action_send_message
}
)
val endButtonA11y: (SemanticsPropertyReceiver.() -> Unit) = {
contentDescription = a11ySendButtonDescription
onClick(null, null)
}
return endButtonA11y
}
@Composable @Composable
private fun StandardLayout( private fun StandardLayout(
voiceMessageState: VoiceMessageState, voiceMessageState: VoiceMessageState,
@ -369,6 +417,7 @@ private fun StandardLayout(
voiceRecording: @Composable () -> Unit, voiceRecording: @Composable () -> Unit,
voiceDeleteButton: @Composable () -> Unit, voiceDeleteButton: @Composable () -> Unit,
endButton: @Composable () -> Unit, endButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -416,7 +465,8 @@ private fun StandardLayout(
Box( Box(
Modifier Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp), .size(48.dp)
.clearAndSetSemantics(endButtonA11y),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
endButton() endButton()
@ -454,6 +504,7 @@ private fun TextFormattingLayout(
dismissTextFormattingButton: @Composable () -> Unit, dismissTextFormattingButton: @Composable () -> Unit,
textFormatting: @Composable () -> Unit, textFormatting: @Composable () -> Unit,
sendButton: @Composable () -> Unit, sendButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
@ -485,10 +536,12 @@ private fun TextFormattingLayout(
textFormatting() textFormatting()
} }
Box( Box(
modifier = Modifier.padding( modifier = Modifier
start = 14.dp, .padding(
end = 6.dp start = 14.dp,
) end = 6.dp,
)
.clearAndSetSemantics(endButtonA11y)
) { ) {
sendButton() sendButton()
} }

View file

@ -21,7 +21,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.res.stringResource
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
@ -32,7 +31,6 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
/** /**
* Send button for the message composer. * Send button for the message composer.
@ -60,10 +58,6 @@ internal fun SendButton(
composerMode.isEditing -> 0.dp composerMode.isEditing -> 0.dp
else -> 2.dp else -> 2.dp
} }
val contentDescription = when {
composerMode.isEditing -> stringResource(CommonStrings.action_edit)
else -> stringResource(CommonStrings.action_send)
}
Box( Box(
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
@ -81,7 +75,8 @@ internal fun SendButton(
.padding(start = iconStartPadding) .padding(start = iconStartPadding)
.align(Alignment.Center), .align(Alignment.Center),
imageVector = iconVector, imageVector = iconVector,
contentDescription = contentDescription, // Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) { tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) { if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary ElementTheme.colors.iconOnSolidPrimary

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
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
@ -26,7 +25,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun VoiceMessageRecorderButton( internal fun VoiceMessageRecorderButton(
@ -70,7 +68,8 @@ private fun StartButton(
Icon( Icon(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.MicOn(), imageVector = CompoundIcons.MicOn(),
contentDescription = stringResource(CommonStrings.a11y_voice_message_record), // Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconSecondary, tint = ElementTheme.colors.iconSecondary,
) )
} }
@ -95,7 +94,8 @@ private fun StopButton(
Icon( Icon(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop, resourceId = CommonDrawables.ic_stop,
contentDescription = stringResource(CommonStrings.a11y_voice_message_stop_recording), // Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary, tint = ElementTheme.colors.iconOnSolidPrimary,
) )
} }

View file

@ -12,7 +12,7 @@
<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_other_user_avatar">"Other user 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>
<string name="a11y_paused_voice_message">"Voice message, duration: %1$s, current position: %2$s"</string> <string name="a11y_paused_voice_message">"Voice message, duration: %1$s, current position: %2$s"</string>
@ -125,7 +125,9 @@
<string name="action_save">"Save"</string> <string name="action_save">"Save"</string>
<string name="action_search">"Search"</string> <string name="action_search">"Search"</string>
<string name="action_send">"Send"</string> <string name="action_send">"Send"</string>
<string name="action_send_edited_message">"Send edited message"</string>
<string name="action_send_message">"Send message"</string> <string name="action_send_message">"Send message"</string>
<string name="action_send_voice_message">"Send voice message"</string>
<string name="action_share">"Share"</string> <string name="action_share">"Share"</string>
<string name="action_share_link">"Share link"</string> <string name="action_share_link">"Share link"</string>
<string name="action_show">"Show"</string> <string name="action_show">"Show"</string>