Accessibility: improve behavior of list items (#4626)

* a11y: add Modifier to improve accessibility of ListItems.

Remove duplication of onChange. As per the documentation, it has to be used only if the behavior is different than the onClick listener of the list item.
It also has the effect to read twice the action when the screen reader is one. See https://github.com/element-hq/element-x-android/pull/4047#discussion_r1888136571 for more details

a11y: remove contentDescription on List item icon, else the text is read twice.

* Ensure that if the ListItem is not enabled, the trailing/leading content is also not enabled.

* Update screenshots

* Fix lint crash.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-04-24 21:53:21 +02:00 committed by GitHub
parent 76e1612e74
commit 2ca541936f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 136 additions and 93 deletions

View file

@ -178,8 +178,14 @@ fun CreatePollView(
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
trailingContent = ListItemContent.Switch(
checked = state.pollKind == PollKind.Undisclosed,
onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
),
onClick = {
state.eventSink(
CreatePollEvents.SetPollKind(
if (state.pollKind == PollKind.Disclosed) PollKind.Undisclosed else PollKind.Disclosed
)
)
},
)
if (state.canDelete) {
ListItem(

View file

@ -267,8 +267,7 @@ private fun RoomAddressSection(
Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_section_footer, homeserverName))
},
onClick = if (isVisibleInRoomDirectory.isSuccess()) onVisibilityChange else null,
trailingContent =
when (isVisibleInRoomDirectory) {
trailingContent = when (isVisibleInRoomDirectory) {
is AsyncData.Uninitialized, is AsyncData.Loading -> {
ListItemContent.Custom {
CircularProgressIndicator(
@ -288,7 +287,6 @@ private fun RoomAddressSection(
is AsyncData.Success -> {
ListItemContent.Switch(
checked = isVisibleInRoomDirectory.data,
onChange = { onVisibilityChange() },
)
}
}
@ -316,7 +314,6 @@ private fun EncryptionSection(
trailingContent = ListItemContent.Switch(
checked = isRoomEncrypted,
enabled = canToggleEncryption,
onChange = { onToggleEncryption() },
),
onClick = if (canToggleEncryption) onToggleEncryption else null
)

View file

@ -132,14 +132,10 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Favourite(),
contentDescription = stringResource(id = CommonStrings.common_favourite),
)
),
trailingContent = ListItemContent.Switch(
checked = contextMenu.isFavorite,
onChange = { isFavorite ->
onFavoriteChange(isFavorite)
},
),
onClick = {
onFavoriteChange(!contextMenu.isFavorite)
@ -157,7 +153,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Settings(),
contentDescription = stringResource(id = CommonStrings.common_settings)
)
),
style = ListItemStyle.Primary,
@ -177,7 +172,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Leave(),
contentDescription = stringResource(id = CommonStrings.action_leave_room)
)
),
style = ListItemStyle.Destructive,

View file

@ -29,7 +29,11 @@ fun CheckboxListItem(
modifier = modifier,
headlineContent = { Text(headline) },
supportingContent = supportingText?.let { @Composable { Text(it) } },
leadingContent = ListItemContent.Checkbox(checked, null, enabled, compact = compactLayout),
leadingContent = ListItemContent.Checkbox(
checked = checked,
enabled = enabled,
compact = compactLayout,
),
trailingContent = trailingContent,
style = style,
enabled = enabled,

View file

@ -37,26 +37,22 @@ sealed interface ListItemContent {
/**
* Default Switch content for [ListItem].
* @param checked The current state of the switch.
* @param onChange Callback when the switch is toggled: it should only be set to override the default click behaviour in the [ListItem].
* @param enabled Whether the switch is enabled or not.
*/
data class Switch(
val checked: Boolean,
val onChange: ((Boolean) -> Unit)? = null,
val enabled: Boolean = true
) : ListItemContent
/**
* Default Checkbox content for [ListItem].
* @param checked The current state of the checkbox.
* @param onChange Callback when the checkbox is toggled: it should only be set to override the default click behaviour in the [ListItem].
* @param enabled Whether the checkbox is enabled or not.
* @param compact Reduces the size of the component to make the wrapping [ListItem] smaller.
* This is especially useful when the [ListItem] is used inside a Dialog. `false` by default.
*/
data class Checkbox(
val checked: Boolean,
val onChange: ((Boolean) -> Unit)? = null,
val enabled: Boolean = true,
val compact: Boolean = false
) : ListItemContent
@ -64,14 +60,12 @@ sealed interface ListItemContent {
/**
* Default RadioButton content for [ListItem].
* @param selected The current state of the radio button.
* @param onClick Callback when the radio button is toggled: it should only be set to override the default click behaviour in the [ListItem].
* @param enabled Whether the radio button is enabled or not.
* @param compact Reduces the size of the component to make the wrapping [ListItem] smaller.
* This is especially useful when the [ListItem] is used inside a Dialog. `false` by default.
*/
data class RadioButton(
val selected: Boolean,
val onClick: (() -> Unit)? = null,
val enabled: Boolean = true,
val compact: Boolean = false
) : ListItemContent
@ -99,24 +93,24 @@ sealed interface ListItemContent {
data class Counter(val count: Int) : ListItemContent
@Composable
fun View() {
fun View(isItemEnabled: Boolean) {
when (this) {
is Switch -> SwitchComponent(
checked = checked,
onCheckedChange = onChange,
enabled = enabled
onCheckedChange = null,
enabled = enabled && isItemEnabled,
)
is Checkbox -> CheckboxComponent(
modifier = if (compact) Modifier.size(maxCompactSize) else Modifier,
checked = checked,
onCheckedChange = onChange,
enabled = enabled
onCheckedChange = null,
enabled = enabled && isItemEnabled,
)
is RadioButton -> RadioButtonComponent(
modifier = if (compact) Modifier.size(maxCompactSize) else Modifier,
selected = selected,
onClick = onClick,
enabled = enabled
onClick = null,
enabled = enabled && isItemEnabled,
)
is Icon -> {
IconComponent(

View file

@ -29,7 +29,11 @@ fun RadioButtonListItem(
modifier = modifier,
headlineContent = { Text(headline) },
supportingContent = supportingText?.let { @Composable { Text(it) } },
leadingContent = ListItemContent.RadioButton(selected, null, enabled, compact = compactLayout),
leadingContent = ListItemContent.RadioButton(
selected = selected,
enabled = enabled,
compact = compactLayout,
),
trailingContent = trailingContent,
style = style,
enabled = enabled,

View file

@ -29,7 +29,10 @@ fun SwitchListItem(
headlineContent = { Text(headline) },
supportingContent = supportingText?.let { @Composable { Text(it) } },
leadingContent = leadingContent,
trailingContent = ListItemContent.Switch(value, null, enabled),
trailingContent = ListItemContent.Switch(
checked = value,
enabled = enabled,
),
style = style,
enabled = enabled,
onClick = { onChange(!value) },

View file

@ -65,6 +65,7 @@ fun PreferenceCheckbox(
checked = isChecked,
enabled = enabled,
),
enabled = enabled,
)
}

View file

@ -9,6 +9,8 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
@ -16,12 +18,9 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -135,7 +134,7 @@ fun ListItem(
CompositionLocalProvider(
LocalContentColor provides leadingContentColor,
) {
content.View()
content.View(isItemEnabled = enabled)
}
}
}
@ -145,7 +144,7 @@ fun ListItem(
LocalTextStyle provides ElementTheme.typography.fontBodyMdRegular,
LocalContentColor provides trailingContentColor,
) {
content.View()
content.View(isItemEnabled = enabled)
}
}
}
@ -158,7 +157,12 @@ fun ListItem(
.then(modifier)
} else {
modifier
},
}
.withAccessibilityModifier(
content = trailingContent ?: leadingContent,
enabled = enabled || alwaysClickable,
onClick = onClick,
),
overlineContent = null,
supportingContent = decoratedSupportingContent,
leadingContent = decoratedLeadingContent,
@ -169,6 +173,45 @@ fun ListItem(
)
}
private fun Modifier.withAccessibilityModifier(
content: ListItemContent?,
enabled: Boolean,
onClick: (() -> Unit)?,
): Modifier = then(
when (content) {
is ListItemContent.Checkbox -> {
Modifier.toggleable(
value = content.checked,
role = Role.Checkbox,
enabled = content.enabled && enabled,
onValueChange = { onClick?.invoke() }
)
}
is ListItemContent.Switch -> {
Modifier.toggleable(
value = content.checked,
role = Role.Switch,
enabled = content.enabled && enabled,
onValueChange = { onClick?.invoke() }
)
}
is ListItemContent.RadioButton -> {
Modifier.selectable(
selected = content.selected,
role = Role.RadioButton,
enabled = content.enabled && enabled,
onClick = { onClick?.invoke() }
)
}
ListItemContent.Badge,
is ListItemContent.Custom,
is ListItemContent.Icon,
is ListItemContent.Text,
is ListItemContent.Counter,
null -> Modifier
}
)
/**
* The style to use for a [ListItem].
*/
@ -546,20 +589,17 @@ private object PreviewItems {
@Composable
fun checkbox(): ListItemContent {
var checked by remember { mutableStateOf(false) }
return ListItemContent.Checkbox(checked = checked, onChange = { checked = !checked })
return ListItemContent.Checkbox(checked = false)
}
@Composable
fun radioButton(): ListItemContent {
var checked by remember { mutableStateOf(false) }
return ListItemContent.RadioButton(selected = checked, onClick = { checked = !checked })
return ListItemContent.RadioButton(selected = false)
}
@Composable
fun switch(): ListItemContent {
var checked by remember { mutableStateOf(false) }
return ListItemContent.Switch(checked = checked, onChange = { checked = !checked })
return ListItemContent.Switch(checked = false)
}
@Composable

View file

@ -164,7 +164,7 @@ internal fun ListSupportingTextSmallPaddingPreview() {
internal fun ListSupportingTextLargePaddingPreview() {
ElementThemedPreview {
Column {
ListItem(headlineContent = { Text("A title") }, leadingContent = ListItemContent.Switch(checked = true, onChange = {}))
ListItem(headlineContent = { Text("A title") }, leadingContent = ListItemContent.Switch(checked = true))
ListSupportingText(
text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more",
contentPadding = ListSupportingTextDefaults.Padding.LargeLeadingContent,

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f87139a0e17dbfb4d56bfcc41dc49924a0d185c356af5a5daaf60ff2465841d7
size 29739
oid sha256:2f23c7910852f16382a5186a085a06014a61e5297f259a20a450947ff11405f1
size 29683

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6754f38f6f193e2bd63758c0f184939e02f2fc0e3742669eb00ec6103c2a752e
size 28724
oid sha256:9fe608e191fa311bdd26cf5e90eb55d25b79c55b536b26ed0a5d2e34343554b6
size 28664

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:931481a1fb63f4dc01f5ec120b655d6acf3b03a6feece8a8fbe0daeaebcb61cc
size 10678
oid sha256:561127e23c0bce4281725a639c95242e120a0ff7f289ec8012294dc627af7cf4
size 10681

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ef70bc4aaafa730bac6584014bc4fa6291fd5290711497bef6334e969e6959b4
size 13250
oid sha256:4fcbf70ce869c505e85f21458bce6e24c2241c0ab7b850681ef4c244dd42c94e
size 13135

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c105ade197c90cbdd773da3cc8082792ae305704a76e211c230f3a4fcc7d5abd
size 17909
oid sha256:79cfec30a9109ed025bfdbf41bfafb75ec6716724f12c5f14f8b322156579e6e
size 17625

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e36b943b5ab7ff1750d63b6ec856d341a04e249c48698d6fb606dce1cdae50c
size 10606
oid sha256:5d922db570b982ca36125a043ba1f57e573760800066f6bfc3e61fb8d17dc8c4
size 10611

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58f68cea7244703ee8e22a5f3ff33ec6d0959c2d695ef7a460cc675637025228
size 13234
oid sha256:b0ff7f9ff66f346e86e82756e403811f136a9813f22589c3cc32c1d60a71ed7f
size 13106

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:386b3da0825689ae023ef738d557711aee454cc73aa53872951091e0c33b780c
size 17644
oid sha256:3dac42d73f49f877fcb6fb3c665603375d6eaca8869442868e734a15e76796da
size 17388

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c9013763c103b76796b2aaedf8fd4f470ca4372ecca8d115b59ff5f00b08f4f3
size 41697
oid sha256:c1548c6f1689a996a63124ffda33b3f7605de5768a4bd6f65c7b1ab469715ddf
size 41723

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7561472314ea7f56f80086897d2547e840c04425e317d62f3acb2a7959ba44e
size 44022
oid sha256:87578f4f5f6c145f884034120d37ca72738d94570250b81920e9367e65f63710
size 43932

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:02fd327d87c9b3e7d4e07a665a5242432c26cd0eaedd05a645c9f44296007dcd
size 48227
oid sha256:82c64c6bf6da748cf9b29081392296f0aeb7d9711dab834e1af62e20dbe2f8da
size 48069

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3caa1ea352417833395592c2d35b0c176ee991748796850b26fefb4c22dcd6da
size 42048
oid sha256:251cdade6428df13fb3ef77d557e4919068d60c60d81279b5e29ec6b2d92b054
size 42064

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8d7dff8473263b10d5e717d4f0de8f0ab7f0ff9678d599823895ef3c28b8e5a
size 44285
oid sha256:f0d5855a3c319d430945515824a337076d237dc63613181cb0cc5717776293c5
size 44241

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1b262c93bf96b3f1cd160ce84203489c315494d2292f3985f514b0feab280bf0
size 48035
oid sha256:64c674cc76cba8bdb27b416a68ae9d4ab170a17f41bc06df2e88e981b7128c69
size 47919

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d4d25a2d81ae68068e3c69c82178a3aedf122eea767167ac743929ddbe739aae
size 29612
oid sha256:203a6626faf4df511645babde9e8eef76698924547c5b051e45b7a2104dc342c
size 29646

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e273356ac0c1c3ba1033f31bfb04d33c82457467f680011804f6ba9b027bf620
size 29523
oid sha256:ad8304ad2b7f854bcd37bde6695e18fe9c6cb50cc579751101536c17113fc4b1
size 29561

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b904675e4c1735ad6bb566ca8ca014532d005b21c77500f64475afe293baba74
size 32213
oid sha256:2c48bc9aa3aab2e897485a98c7d49291b7b3075b777f6e20503be6b06bb9256d
size 32128

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d21d8a2fa0ae1fee321079cc0cd752c24a2e4396cf512a8ad7164b63477b4de9
size 32024
oid sha256:bbfaae2c8c7c3d66d605d4bdad3e7d054ace167977aeefa6faabfa0d20efb8cb
size 31947

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ae4d1c58af99419e6f15d16e50b06d45ec8a0611137bd840f872cbb5eb347ae
size 36455
oid sha256:a67d10e2a98b6d474ea176b94ee6a5985d3f7ebe0b74502eaa7246a8f618f1e9
size 36282

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee41a7e7b529a976df2c5f3e152e6b090e15291abc6a24c5117c050d070e80da
size 36072
oid sha256:6f9f2041e2443931cddced74cf71852407db51d0846f503cc24ec852e6b2c54b
size 35947

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d57a9dc6d67f26bdda0d3192ada45f41945fda7e204763538e42cada37003ea2
size 29906
oid sha256:08473c64d289e750a99b9a0eedb46a0e4d3e2dc4a6f1acfb39f2fc19ba8c4217
size 29935

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8ae9d48a57cd796bbb2a8d962b1094d42e07b0726232958fd45575dce5d6e42
size 29830
oid sha256:47a9c25c523166f10d0acf4498e9a4fd64f4f2ef125b49d2970e9e5f92a3cac1
size 29853

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:64bf18409a011ad7871cec5e25034862add9e4ac2d2ac0051bd15090f11f29cc
size 32388
oid sha256:ad0c8b79a03f82e5d3b1da8764acf7cb0a33c89b093e2b17f3f202d3ab5734c9
size 32351

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:428e23a4e8074a009345b262b4b1652a4c98216353a78603f141251d6c182d43
size 32183
oid sha256:8ae2240ed721eee53d14afea75d8cc718ba32d428e0fba31c9e124d88f42b8e9
size 32146

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1a3871cd50b8a6aea809402de7f7a41ed3afae0da36988c4fbc3cf4639cb4b3a
size 36443
oid sha256:5b678e9204d4b1c248add757b1562a706710c12f997458644418d34db0e02423
size 36299

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6103e4db65702d046e12a460ed355f724f3fd1d07f7cba9c3d9c702d9947b3f5
size 36030
oid sha256:d40308160ebd69910d279499a08b88238b0288bb97ddd139e37ab4044123e573
size 35935