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

View file

@ -41,48 +41,40 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
@Composable @Composable
internal fun SendButton( internal fun SendButton(
canSendMessage: Boolean, canSendMessage: Boolean,
onClick: () -> Unit,
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
modifier: Modifier = Modifier, 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 modifier = modifier
.size(48.dp), .clip(CircleShape)
onClick = onClick, .size(36.dp)
enabled = canSendMessage, .buttonBackgroundModifier(canSendMessage)
) { ) {
val iconVector = when { Icon(
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
Box(
modifier = Modifier modifier = Modifier
.clip(CircleShape) .padding(start = iconStartPadding)
.size(36.dp) .align(Alignment.Center),
.buttonBackgroundModifier(canSendMessage) imageVector = iconVector,
) { // Note: accessibility is managed in TextComposer.
Icon( contentDescription = null,
modifier = Modifier tint = if (canSendMessage) {
.padding(start = iconStartPadding) if (ElementTheme.colors.isLight) {
.align(Alignment.Center), ElementTheme.colors.iconOnSolidPrimary
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconPrimary
}
} else { } else {
ElementTheme.colors.iconQuaternary ElementTheme.colors.iconPrimary
} }
) } else {
} ElementTheme.colors.iconQuaternary
}
)
} }
} }
@ -117,9 +109,17 @@ internal fun SendButtonPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "") val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "")
Row { Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode) IconButton(onClick = {}) {
SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode) SendButton(canSendMessage = true, composerMode = normalMode)
SendButton(canSendMessage = true, onClick = {}, composerMode = editMode) }
SendButton(canSendMessage = false, onClick = {}, composerMode = editMode) 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.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
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
@ -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.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
@Composable @Composable
internal fun VoiceMessageRecorderButton( internal fun VoiceMessageRecorderButton(
isRecording: Boolean, isRecording: Boolean,
onEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (isRecording) { if (isRecording) {
StopButton( StopButton(modifier)
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Stop)
}
)
} else { } else {
StartButton( StartButton(modifier)
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Start)
}
)
} }
} }
@Composable @Composable
private fun StartButton( private fun StartButton(
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier.size(48.dp),
onClick = onClick,
) { ) {
Icon( Icon(
modifier = Modifier.size(24.dp), modifier = modifier.size(24.dp),
imageVector = CompoundIcons.MicOn(), imageVector = CompoundIcons.MicOn(),
// Note: accessibility is managed in TextComposer. // Note: accessibility is managed in TextComposer.
contentDescription = null, contentDescription = null,
@ -77,41 +52,40 @@ private fun StartButton(
@Composable @Composable
private fun StopButton( private fun StopButton(
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
) { ) {
Box( Box(
Modifier modifier
.size(36.dp) .size(36.dp)
.background( .background(
color = ElementTheme.colors.bgActionPrimaryRest, color = ElementTheme.colors.bgActionPrimaryRest,
shape = CircleShape, shape = CircleShape,
) ),
) contentAlignment = Alignment.Center,
Icon( ) {
modifier = Modifier.size(24.dp), Icon(
resourceId = CommonDrawables.ic_stop, modifier = Modifier.size(24.dp),
// Note: accessibility is managed in TextComposer. resourceId = CommonDrawables.ic_stop,
contentDescription = null, // Note: accessibility is managed in TextComposer.
tint = ElementTheme.colors.iconOnSolidPrimary, contentDescription = null,
) tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview { internal fun VoiceMessageRecorderButtonPreview() = ElementPreview {
Row { Row {
VoiceMessageRecorderButton( IconButton(onClick = {}) {
isRecording = false, VoiceMessageRecorderButton(
onEvent = {}, isRecording = false,
) )
VoiceMessageRecorderButton( }
isRecording = true, IconButton(onClick = {}) {
onEvent = {}, VoiceMessageRecorderButton(
) isRecording = true,
)
}
} }
} }