A11Y: ensure a11y focus is not lost and reset to the back button when the user use the keyboard to focus the send button and press the space bar to perform a click.

This commit is contained in:
Benoit Marty
2026-01-08 10:01:23 +01:00
parent 26ef425234
commit 1b217d4649
3 changed files with 114 additions and 102 deletions

View File

@@ -39,6 +39,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
@@ -61,6 +63,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.IconColorButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
@@ -123,9 +126,6 @@ fun TextComposer(
is TextEditorState.Markdown -> state.state.text.value()
is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown
}
val onSendClick = {
onSendMessage()
}
val onPlayVoiceMessageClick = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Play)
@@ -238,20 +238,17 @@ fun TextComposer(
val sendButton = @Composable {
SendButton(
canSendMessage = canSendMessage,
onClick = onSendClick,
composerMode = composerMode,
)
}
val recordVoiceButton = @Composable {
VoiceMessageRecorderButton(
isRecording = voiceMessageState is VoiceMessageState.Recording,
onEvent = onVoiceRecorderEvent,
)
}
val sendVoiceButton = @Composable {
SendButton(
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
onClick = onSendVoiceMessage,
composerMode = composerMode,
)
}
@@ -265,6 +262,39 @@ fun TextComposer(
@Composable { TextFormatting(state = it.richTextEditorState) }
}
val hapticFeedback = LocalHapticFeedback.current
fun performHapticFeedback() {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
fun endButtonClickStandard() = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle -> {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Start)
}
is VoiceMessageState.Recording -> {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Stop)
}
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> {
// No op
}
false -> onSendVoiceMessage()
}
}
else -> onSendMessage()
}
fun endButtonClickFormatting() {
if (canSendMessage) {
onSendMessage()
}
}
val sendOrRecordButton = when {
!canSendMessage ->
when (voiceMessageState) {
@@ -330,8 +360,9 @@ fun TextComposer(
)
},
textFormatting = textFormattingOptions,
endButtonA11y = endButtonA11y,
sendButton = sendButton,
endButtonClick = ::endButtonClickFormatting,
endButtonA11y = endButtonA11y,
)
} else {
StandardLayout(
@@ -341,6 +372,7 @@ fun TextComposer(
composerOptionsButton = composerOptionsButton,
textInput = textInput,
endButton = sendOrRecordButton,
endButtonClick = ::endButtonClickStandard,
endButtonA11y = endButtonA11y,
voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton,
@@ -409,6 +441,7 @@ private fun StandardLayout(
voiceRecording: @Composable () -> Unit,
voiceDeleteButton: @Composable () -> Unit,
endButton: @Composable () -> Unit,
endButtonClick: () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
modifier: Modifier = Modifier,
) {
@@ -454,12 +487,13 @@ private fun StandardLayout(
textInput()
}
}
Box(
Modifier
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
IconButton(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp)
.clearAndSetSemantics(endButtonA11y),
contentAlignment = Alignment.Center,
onClick = endButtonClick,
) {
endButton()
}
@@ -496,6 +530,7 @@ private fun TextFormattingLayout(
dismissTextFormattingButton: @Composable () -> Unit,
textFormatting: @Composable () -> Unit,
sendButton: @Composable () -> Unit,
endButtonClick: () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
modifier: Modifier = Modifier
) {
@@ -527,13 +562,16 @@ private fun TextFormattingLayout(
Box(modifier = Modifier.weight(1f)) {
textFormatting()
}
Box(
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
IconButton(
modifier = Modifier
.padding(
start = 14.dp,
end = 6.dp,
)
.clearAndSetSemantics(endButtonA11y)
.size(48.dp)
.clearAndSetSemantics(endButtonA11y),
onClick = endButtonClick,
) {
sendButton()
}

View File

@@ -41,48 +41,40 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
@Composable
internal fun SendButton(
canSendMessage: Boolean,
onClick: () -> Unit,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier,
) {
IconButton(
val iconVector = when {
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
Box(
modifier = modifier
.size(48.dp),
onClick = onClick,
enabled = canSendMessage,
.clip(CircleShape)
.size(36.dp)
.buttonBackgroundModifier(canSendMessage)
) {
val iconVector = when {
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
Box(
Icon(
modifier = Modifier
.clip(CircleShape)
.size(36.dp)
.buttonBackgroundModifier(canSendMessage)
) {
Icon(
modifier = Modifier
.padding(start = iconStartPadding)
.align(Alignment.Center),
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconPrimary
}
.padding(start = iconStartPadding)
.align(Alignment.Center),
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconQuaternary
ElementTheme.colors.iconPrimary
}
)
}
} else {
ElementTheme.colors.iconQuaternary
}
)
}
}
@@ -117,9 +109,17 @@ internal fun SendButtonPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "")
Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = true, onClick = {}, composerMode = editMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = editMode)
IconButton(onClick = {}) {
SendButton(canSendMessage = true, composerMode = normalMode)
}
IconButton(onClick = {}) {
SendButton(canSendMessage = false, composerMode = normalMode)
}
IconButton(onClick = {}) {
SendButton(canSendMessage = true, composerMode = editMode)
}
IconButton(onClick = {}) {
SendButton(canSendMessage = false, composerMode = editMode)
}
}
}

View File

@@ -14,9 +14,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -25,49 +24,25 @@ 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.IconButton
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@Composable
internal fun VoiceMessageRecorderButton(
isRecording: Boolean,
onEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (isRecording) {
StopButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Stop)
}
)
StopButton(modifier)
} else {
StartButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Start)
}
)
StartButton(modifier)
}
}
@Composable
private fun StartButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier.size(48.dp),
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
modifier = modifier.size(24.dp),
imageVector = CompoundIcons.MicOn(),
// Note: accessibility is managed in TextComposer.
contentDescription = null,
@@ -77,41 +52,40 @@ private fun StartButton(
@Composable
private fun StopButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
) {
Box(
Modifier
modifier
.size(36.dp)
.background(
color = ElementTheme.colors.bgActionPrimaryRest,
shape = CircleShape,
)
)
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview {
Row {
VoiceMessageRecorderButton(
isRecording = false,
onEvent = {},
)
VoiceMessageRecorderButton(
isRecording = true,
onEvent = {},
)
IconButton(onClick = {}) {
VoiceMessageRecorderButton(
isRecording = false,
)
}
IconButton(onClick = {}) {
VoiceMessageRecorderButton(
isRecording = true,
)
}
}
}