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:
parent
26ef425234
commit
1b217d4649
3 changed files with 114 additions and 102 deletions
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue