From 1b217d464936eded43a5318f453440f555a3c27b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Jan 2026 10:01:23 +0100 Subject: [PATCH] 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. --- .../libraries/textcomposer/TextComposer.kt | 62 ++++++++++++--- .../textcomposer/components/SendButton.kt | 76 +++++++++--------- .../components/VoiceMessageRecorderButton.kt | 78 +++++++------------ 3 files changed, 114 insertions(+), 102 deletions(-) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index f2070c9610..200c6e63fb 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -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() } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt index a136f63b09..e3ccd383f3 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt @@ -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) + } } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt index 32fe2847c2..b512154375 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt @@ -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, + ) + } } }