Merge pull request #6650 from element-hq/feature/bma/a11yFixes

[a11y] Fix a set of issues
This commit is contained in:
Benoit Marty 2026-05-04 11:50:15 +02:00 committed by GitHub
commit abc540b379
44 changed files with 271 additions and 169 deletions

View file

@ -11,7 +11,6 @@ package io.element.android.libraries.architecture
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
@ -88,11 +87,9 @@ inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.OverlayView(
@Composable
inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.BackstackWithOverlayBox(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {},
) {
Box(modifier = modifier) {
BackstackView()
OverlayView()
content()
}
}

View file

@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@ -148,7 +150,11 @@ private fun TitleAndDescription(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = titleColor,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.semantics {
heading()
},
)
if (trailingContent != null) {
Spacer(Modifier.width(12.dp))

View file

@ -17,6 +17,8 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -48,6 +50,9 @@ fun ListSectionHeader(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
modifier = Modifier.semantics {
heading()
},
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,

View file

@ -12,13 +12,12 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -28,7 +27,6 @@ import androidx.compose.runtime.rememberUpdatedState
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -91,33 +89,31 @@ fun MediaPlayerControllerView(
.widthIn(max = 480.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val bgColor = if (state.isPlaying) {
ElementTheme.colors.bgCanvasDefault
val colors = if (state.isPlaying) {
IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCanvasDefault,
contentColor = ElementTheme.colors.iconPrimary,
)
} else {
ElementTheme.colors.textPrimary
IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.iconPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
}
Box(
IconButton(
modifier = Modifier
.size(36.dp)
.background(
color = bgColor,
shape = CircleShape,
)
.clip(CircleShape)
.clickable { onTogglePlay() }
.padding(8.dp),
contentAlignment = Alignment.Center,
.size(36.dp),
onClick = onTogglePlay,
colors = colors,
) {
if (state.isPlaying) {
Icon(
imageVector = CompoundIcons.PauseSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.a11y_pause)
)
} else {
Icon(
imageVector = CompoundIcons.PlaySolid(),
tint = ElementTheme.colors.iconOnSolidPrimary,
contentDescription = stringResource(CommonStrings.a11y_play)
)
}

View file

@ -120,6 +120,52 @@ fun MediaViewerView(
Scaffold(
modifier,
containerColor = Color.Transparent,
topBar = {
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
) {
when (currentData) {
is MediaViewerPageData.MediaViewerData -> {
MediaViewerTopBar(
data = currentData,
canShowInfo = state.canShowInfo,
onBackClick = onBackClick,
onShareClick = {
state.eventSink(MediaViewerEvent.Share(currentData))
},
onSaveClick = {
state.eventSink(MediaViewerEvent.SaveOnDisk(currentData))
},
onInfoClick = {
state.eventSink(MediaViewerEvent.OpenInfo(currentData))
},
)
}
else -> {
TopAppBar(
title = {
if (currentData is MediaViewerPageData.Loading) {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(id = CommonStrings.common_loading_more),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = bgCanvasWithTransparency,
),
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
val pagerState = rememberPagerState(state.currentIndex, 0f) {
@ -186,69 +232,22 @@ fun MediaViewerView(
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
)
// Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier.fillMaxSize()
) {
MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
caption = dataForPage.mediaInfo.caption,
onHeightChange = { bottomPaddingInPixels = it },
)
}
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
MediaViewerBottomBar(
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
caption = dataForPage.mediaInfo.caption,
onHeightChange = { bottomPaddingInPixels = it },
)
}
}
}
}
}
// Top bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
when (currentData) {
is MediaViewerPageData.MediaViewerData -> {
MediaViewerTopBar(
data = currentData,
canShowInfo = state.canShowInfo,
onBackClick = onBackClick,
onShareClick = {
state.eventSink(MediaViewerEvent.Share(currentData))
},
onSaveClick = {
state.eventSink(MediaViewerEvent.SaveOnDisk(currentData))
},
onInfoClick = {
state.eventSink(MediaViewerEvent.OpenInfo(currentData))
},
)
}
else -> {
TopAppBar(
title = {
if (currentData is MediaViewerPageData.Loading) {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(id = CommonStrings.common_loading_more),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = bgCanvasWithTransparency,
),
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
}
}
}
}
when (val bottomSheetState = state.mediaBottomSheetState) {
@ -373,11 +372,12 @@ private fun MediaViewerPage(
isUserSelected = isUserSelected,
audioFocus = audioFocus,
)
ThumbnailView(
mediaInfo = data.mediaInfo,
thumbnailSource = data.thumbnailSource,
isVisible = showThumbnail,
)
if (showThumbnail) {
ThumbnailView(
mediaInfo = data.mediaInfo,
thumbnailSource = data.thumbnailSource,
)
}
if (showError) {
ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown),
@ -603,7 +603,6 @@ private val maxCaptionHeightLandscape = 128.dp
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
isVisible: Boolean,
mediaInfo: MediaInfo,
modifier: Modifier = Modifier,
) {
@ -611,21 +610,19 @@ private fun ThumbnailView(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (isVisible) {
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.File(mediaInfo.filename, mediaInfo.mimeType)
)
val alpha = if (LocalInspectionMode.current) 0.1f else 1f
AsyncImage(
modifier = Modifier
.fillMaxSize()
.alpha(alpha),
model = mediaRequestData,
contentScale = ContentScale.Fit,
contentDescription = null,
)
}
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.File(mediaInfo.filename, mediaInfo.mimeType)
)
val alpha = if (LocalInspectionMode.current) 0.1f else 1f
AsyncImage(
modifier = Modifier
.fillMaxSize()
.alpha(alpha),
model = mediaRequestData,
contentScale = ContentScale.Fit,
contentDescription = null,
)
}
}

View file

@ -679,7 +679,7 @@ private fun TextInputBox(
.align(Alignment.CenterEnd),
imageVector = CompoundIcons.InfoSolid(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
contentDescription = stringResource(CommonStrings.a11y_info),
)
if (showBottomSheet) {
CaptionWarningBottomSheet(

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.ui.utils.a11y
import android.app.Activity
import android.app.Application
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun hasExternalKeyboard(): Boolean {
val activity = requireNotNull(LocalActivity.current)
var hasExternalKeyboard by remember { mutableStateOf(activity.resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
DisposableEffect(Unit) {
val callback = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
// We do not have access to onActivityConfigurationChanged, so update the value when tha Activity is resumed
hasExternalKeyboard = activity.resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS
}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}
activity.registerActivityLifecycleCallbacks(callback)
onDispose {
activity.unregisterActivityLifecycleCallbacks(callback)
}
}
}
return hasExternalKeyboard
}

View file

@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.ui.utils.time
package io.element.android.libraries.ui.utils.a11y
import android.view.accessibility.AccessibilityManager
import androidx.compose.runtime.Composable