Skip to content

Commit 5e38683

Browse files
authored
Composer attachment picker accessibility improvements (#6404)
* Announce composer attachments button as Open/Close based on picker visibility The attachments toggle button in the message composer announced as "Attachments, double tap to activate" in both states, which is misleading when the picker is already open and tapping closes it. Switch to a state-aware content description: "Open attachments" when the picker is hidden, "Close attachments" when it is shown. Removes the now-unused stream_compose_attachments string from all locales; the new strings live under the stream_compose_message_composer_* namespace alongside other composer button labels. * Apply trailing comma to MessageComposerLeadingContent * Use Tab semantics on attachment picker tabs The picker tabs (Media, Camera, Files, Polls, Commands) used a FilledIconToggleButton, which emits Role.Checkbox + a toggleable state. TalkBack announced an active tab as "Checked, Media, checkbox, double tap to toggle" — wrong role and a misleading action hint. Override the per-tab semantics with semantics(mergeDescendants = true) to declare Role.Tab + selected, while keeping the underlying focusable / focused / clickable semantics intact so the tab stays discoverable to accessibility services. Role.Tab takes precedence over the lingering ToggleableState in TalkBack's announcement, so the active tab now announces as "Media, tab, selected". * Make attachment picker grid items announceable to TalkBack The image/video grid items in the attachment picker had no contentDescription, no role, and no selected state in the accessibility tree. TalkBack focused them silently, leaving users unable to identify or select photos or videos. Add merged semantics on each item (contentDescription "Photo" or "Video", Role.Button, selected = isSelected, plus the existing testTag) and mark the decorative RadioCheck and VideoBadge overlays as non-semantic with clearAndSetSemantics so they no longer announce phantom checkbox state. Translations added for the 7 supported locales. * Override file picker item action hint to "select" TalkBack announced "Double tap to activate" for file items in the picker, which is misleading because tapping selects the file for attachment, not activates it. Add merged semantics on each file row with Role.Button, the current selected state, and an onClick action labelled "select" so TalkBack now announces "Double tap to select". Mark the decorative RadioCheck and RadioButton indicators with clearAndSetSemantics so they no longer contribute phantom toggle/radio state to the merged announcement. Translations added for the 7 supported locales. * Announce composer attachment chip cancel icon as "Remove attachment" The "x" button on each composer attachment chip announced as "Cancel" — misleading because the button removes a specific attachment from the message, not abandons an unrelated action. Add an optional contentDescription parameter to ComposerCancelIcon (defaulted to the existing stream_compose_cancel for backwards compatibility) and pass "Remove attachment" from the three composer attachment chip call sites: media, file, and audio recording. Other ComposerCancelIcon usages (edit indicator, quoted message, link preview) keep the original "Cancel" label since they aren't attachment chips. Translations added for the 7 supported locales. * Announce attachment add and remove via composer live region Tapping a thumbnail to add an attachment, or capturing a photo via the camera tab, played only a brief system sound and made no TalkBack announcement, leaving users without confirmation that the attachment was added. Removing an attachment was equally silent. Add an internal MessageComposerLiveRegion composable that mounts inside the composer's root column (always present) and watches attachment list size. On size increase it announces the type of the latest attachment ("Photo attached", "Video attached", "Audio recording attached", or "File attached"); on size decrease it announces "Attachment removed". The text is hosted in a hidden Text marked with LiveRegionMode.Polite + invisibleToUser, so it is not navigable but is announced when its value changes. Translations added for the 7 supported locales. Camera capture flows through the same composer state, so the same live region also confirms photo capture without further wiring. * Switch attachment picker action hint to "remove" when item is selected When a picker item is already selected, double-tapping toggles it off rather than selecting it. The action hint should reflect that — TalkBack should announce "double tap to remove" for already-selected items, and "double tap to select" for not-yet-selected items. Branch the onClick semantic action label on the item's selected state in both the image grid (DefaultImagesPickerItem) and the file list row (DefaultFilesPickerItem). Translations added for the 7 supported locales. * Announce CreatePoll sheet title before the auto-focused input When the Create Poll sheet opened, TalkBack jumped straight to announcing the IME and the auto-focused Question field, never mentioning the screen the user had just entered. Mark the "Create Poll" title in PollCreationHeader as a heading and add a paneTitle semantic property on the screen root so accessibility services announce the new pane on entry. * Refine attachment picker accessibility per TalkBack verification Real-device TalkBack testing surfaced three adjustments to the original audit fixes: - Composer +/× button: hearing "Close attachments" right after opening the picker reads backwards. Switch to the standard expandable-button pattern — contentDescription stays as "Attachments" + stateDescription "collapsed" / "expanded". Add paneTitle on the picker root so the new pane is announced on entry. - Picker tabs leaked the underlying ToggleableState ("checked") through semantics(mergeDescendants = true). Switch to clearAndSetSemantics so only Role.Tab + selected reach TalkBack. - The Compose liveRegion semantic on a hidden Text never fired — TalkBack skips zero-size / transparent nodes. Switch to View.announceForAccessibility() via LocalView. Rename the composable to MessageComposerAttachmentAnnouncer. .com> * Pass onClickLabel via clickable instead of duplicate semantics action Move the picker item's accessibility click label from a duplicated `semantics { onClick(label = …) }` block onto `Modifier.clickable`'s `onClickLabel` parameter, removing the order dependency between the two modifiers. * Cover composer attachment announcer's type-resolution branches Extract `announceAddedAttachment` to `internal` (annotated `@VisibleForTesting`) and add a JVM unit test exercising the image / video / audio recording / file / null branches. * Use hideFromAccessibility() for decorative attachment-picker children Replaces empty-block clearAndSetSemantics {} on the picker checkmark/radio icons, the video duration badge, and the file-type label with the more intent-revealing semantics { hideFromAccessibility() }. Non-empty clearAndSetSemantics {…} sites that replace semantics with a clean set are left as-is — hideFromAccessibility() would hide the node entirely.
1 parent 41a2304 commit 5e38683

25 files changed

Lines changed: 416 additions & 24 deletions

File tree

stream-chat-android-compose/api/stream-chat-android-compose.api

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1062,7 +1062,7 @@ public final class io/getstream/chat/android/compose/ui/components/ComposableSin
10621062
}
10631063

10641064
public final class io/getstream/chat/android/compose/ui/components/ComposerCancelIconKt {
1065-
public static final fun ComposerCancelIcon (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V
1065+
public static final fun ComposerCancelIcon (Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V
10661066
}
10671067

10681068
public final class io/getstream/chat/android/compose/ui/components/EmptyContentKt {

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ComposerCancelIcon.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ import io.getstream.chat.android.compose.ui.util.clickable
3636
* Represents a simple cancel icon that is used primarily for attachments.
3737
*
3838
* @param modifier Modifier for styling.
39+
* @param contentDescription Text used by accessibility services to describe what this icon represents.
3940
* @param onClick Handler when the user clicks on the icon.
4041
*/
4142
@Composable
4243
public fun ComposerCancelIcon(
4344
modifier: Modifier = Modifier,
45+
contentDescription: String = stringResource(R.string.stream_compose_cancel),
4446
onClick: () -> Unit,
4547
) {
4648
val colors = ChatTheme.colors
@@ -53,7 +55,7 @@ public fun ComposerCancelIcon(
5355
.size(20.dp)
5456
.clickable(bounded = false, onClick = onClick),
5557
painter = painterResource(R.drawable.stream_design_ic_xmark_small),
56-
contentDescription = stringResource(R.string.stream_compose_cancel),
58+
contentDescription = contentDescription,
5759
tint = colors.controlRemoveIcon,
5860
)
5961
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/attachments/files/FileTypeIcon.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import androidx.compose.ui.Alignment
3131
import androidx.compose.ui.Modifier
3232
import androidx.compose.ui.platform.LocalDensity
3333
import androidx.compose.ui.res.painterResource
34-
import androidx.compose.ui.semantics.clearAndSetSemantics
34+
import androidx.compose.ui.semantics.hideFromAccessibility
35+
import androidx.compose.ui.semantics.semantics
3536
import androidx.compose.ui.text.font.FontWeight
3637
import androidx.compose.ui.tooling.preview.Preview
3738
import androidx.compose.ui.unit.dp
@@ -59,7 +60,7 @@ internal fun FileTypeIcon(data: FileIconData, modifier: Modifier = Modifier) {
5960
color = StreamPrimitiveColors.baseWhite,
6061
modifier = Modifier
6162
.padding(bottom = 4.dp)
62-
.clearAndSetSemantics {},
63+
.semantics { hideFromAccessibility() },
6364
)
6465
}
6566
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/attachments/files/FilesPicker.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ import androidx.compose.ui.Modifier
3434
import androidx.compose.ui.platform.testTag
3535
import androidx.compose.ui.res.painterResource
3636
import androidx.compose.ui.res.stringResource
37+
import androidx.compose.ui.semantics.Role
38+
import androidx.compose.ui.semantics.hideFromAccessibility
39+
import androidx.compose.ui.semantics.role
40+
import androidx.compose.ui.semantics.selected
41+
import androidx.compose.ui.semantics.semantics
3742
import androidx.compose.ui.tooling.preview.Preview
3843
import androidx.compose.ui.unit.dp
3944
import io.getstream.chat.android.compose.R
@@ -126,10 +131,21 @@ internal fun DefaultFilesPickerItem(
126131
onItemSelected: (AttachmentPickerItemState) -> Unit,
127132
allowMultipleSelection: Boolean = true,
128133
) {
134+
val onClickLabel = stringResource(
135+
if (fileItem.isSelected) {
136+
R.string.stream_compose_attachment_picker_remove
137+
} else {
138+
R.string.stream_compose_attachment_picker_select
139+
},
140+
)
129141
Row(
130142
Modifier
131143
.fillMaxWidth()
132-
.clickable { onItemSelected(fileItem) }
144+
.semantics(mergeDescendants = true) {
145+
role = Role.Button
146+
selected = fileItem.isSelected
147+
}
148+
.clickable(onClickLabel = onClickLabel) { onItemSelected(fileItem) }
133149
.padding(StreamTokens.spacingSm),
134150
verticalAlignment = Alignment.CenterVertically,
135151
horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm),
@@ -158,11 +174,13 @@ internal fun DefaultFilesPickerItem(
158174

159175
if (allowMultipleSelection) {
160176
RadioCheck(
177+
modifier = Modifier.semantics { hideFromAccessibility() },
161178
checked = fileItem.isSelected,
162179
onCheckedChange = null,
163180
)
164181
} else {
165182
RadioButton(
183+
modifier = Modifier.semantics { hideFromAccessibility() },
166184
checked = fileItem.isSelected,
167185
onCheckedChange = null,
168186
)

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/attachments/images/ImagesPicker.kt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ import androidx.compose.ui.platform.LocalContext
3838
import androidx.compose.ui.platform.testTag
3939
import androidx.compose.ui.res.painterResource
4040
import androidx.compose.ui.res.stringResource
41+
import androidx.compose.ui.semantics.Role
42+
import androidx.compose.ui.semantics.contentDescription
43+
import androidx.compose.ui.semantics.hideFromAccessibility
44+
import androidx.compose.ui.semantics.role
45+
import androidx.compose.ui.semantics.selected
46+
import androidx.compose.ui.semantics.semantics
47+
import androidx.compose.ui.semantics.testTag
4148
import androidx.compose.ui.tooling.preview.Preview
4249
import androidx.compose.ui.unit.dp
4350
import coil3.request.ImageRequest
@@ -107,6 +114,7 @@ public fun ImagesPicker(
107114
* @param imageItem The attachment item.
108115
* @param onImageSelected Handler when the user selects the image.
109116
*/
117+
@Suppress("LongMethod")
110118
@Composable
111119
internal fun DefaultImagesPickerItem(
112120
imageItem: AttachmentPickerItemState,
@@ -125,12 +133,32 @@ internal fun DefaultImagesPickerItem(
125133
}
126134
.build()
127135

136+
val itemContentDescription = stringResource(
137+
if (isVideo) {
138+
R.string.stream_compose_attachment_picker_video
139+
} else {
140+
R.string.stream_compose_attachment_picker_photo
141+
},
142+
)
143+
val onClickLabel = stringResource(
144+
if (imageItem.isSelected) {
145+
R.string.stream_compose_attachment_picker_remove
146+
} else {
147+
R.string.stream_compose_attachment_picker_select
148+
},
149+
)
150+
128151
Box(
129152
modifier = Modifier
130153
.aspectRatio(1f)
131154
.clip(ItemShape)
132-
.clickable { onImageSelected(imageItem) }
133-
.testTag("Stream_AttachmentPickerSampleImage"),
155+
.semantics(mergeDescendants = true) {
156+
contentDescription = itemContentDescription
157+
role = Role.Button
158+
selected = imageItem.isSelected
159+
testTag = "Stream_AttachmentPickerSampleImage"
160+
}
161+
.clickable(onClickLabel = onClickLabel) { onImageSelected(imageItem) },
134162
) {
135163
StreamAsyncImage(
136164
imageRequest = imageRequest,
@@ -142,7 +170,8 @@ internal fun DefaultImagesPickerItem(
142170
RadioCheck(
143171
modifier = Modifier
144172
.align(Alignment.TopEnd)
145-
.padding(StreamTokens.spacingXs),
173+
.padding(StreamTokens.spacingXs)
174+
.semantics { hideFromAccessibility() },
146175
borderColor = ChatTheme.colors.borderCoreOnAccent,
147176
checked = imageItem.isSelected,
148177
onCheckedChange = null,
@@ -152,7 +181,8 @@ internal fun DefaultImagesPickerItem(
152181
VideoBadge(
153182
modifier = Modifier
154183
.align(Alignment.BottomStart)
155-
.padding(StreamTokens.spacingXs),
184+
.padding(StreamTokens.spacingXs)
185+
.semantics { hideFromAccessibility() },
156186
durationInSeconds = attachmentMetaData.videoLength,
157187
)
158188
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.compose.ui.components.composer
18+
19+
import androidx.annotation.VisibleForTesting
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.LaunchedEffect
22+
import androidx.compose.runtime.getValue
23+
import androidx.compose.runtime.mutableIntStateOf
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.runtime.setValue
26+
import androidx.compose.ui.platform.LocalView
27+
import androidx.compose.ui.res.stringResource
28+
import io.getstream.chat.android.client.utils.attachment.isAudioRecording
29+
import io.getstream.chat.android.client.utils.attachment.isImage
30+
import io.getstream.chat.android.client.utils.attachment.isVideo
31+
import io.getstream.chat.android.compose.R
32+
import io.getstream.chat.android.models.Attachment
33+
34+
/**
35+
* Announces composer attachment additions and removals to accessibility services. Must be mounted
36+
* for the lifetime of the composer so size deltas are observed across attach and remove
37+
* transitions.
38+
*
39+
* @param attachments Current composer attachments. Size deltas drive the announcement.
40+
*/
41+
@Composable
42+
internal fun MessageComposerAttachmentAnnouncer(attachments: List<Attachment>) {
43+
val view = LocalView.current
44+
val photoAttached = stringResource(R.string.stream_compose_attachment_added_photo)
45+
val videoAttached = stringResource(R.string.stream_compose_attachment_added_video)
46+
val fileAttached = stringResource(R.string.stream_compose_attachment_added_file)
47+
val audioAttached = stringResource(R.string.stream_compose_attachment_added_audio)
48+
val attachmentRemoved = stringResource(R.string.stream_compose_attachment_removed)
49+
50+
var lastSize by remember { mutableIntStateOf(attachments.size) }
51+
52+
LaunchedEffect(attachments.size) {
53+
val currentSize = attachments.size
54+
val message = when {
55+
currentSize > lastSize -> announceAddedAttachment(
56+
added = attachments.lastOrNull(),
57+
photoAttached = photoAttached,
58+
videoAttached = videoAttached,
59+
audioAttached = audioAttached,
60+
fileAttached = fileAttached,
61+
)
62+
currentSize < lastSize -> attachmentRemoved
63+
else -> null
64+
}
65+
if (message != null) {
66+
view.announceForAccessibility(message)
67+
}
68+
lastSize = currentSize
69+
}
70+
}
71+
72+
@VisibleForTesting
73+
internal fun announceAddedAttachment(
74+
added: Attachment?,
75+
photoAttached: String,
76+
videoAttached: String,
77+
audioAttached: String,
78+
fileAttached: String,
79+
): String = when {
80+
added == null -> ""
81+
added.isImage() -> photoAttached
82+
added.isVideo() -> videoAttached
83+
added.isAudioRecording() -> audioAttached
84+
else -> fileAttached
85+
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/MessageInput.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ public fun MessageInput(
116116
.animateContentSize(alignment = Alignment.BottomStart),
117117
verticalArrangement = Arrangement.Bottom,
118118
) {
119+
MessageComposerAttachmentAnnouncer(attachments = messageComposerState.attachments)
120+
119121
MessageInputTop(
120122
messageComposerState = messageComposerState,
121123
onAttachmentRemoved = onAttachmentRemoved,

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import androidx.compose.runtime.remember
2727
import androidx.compose.ui.Modifier
2828
import androidx.compose.ui.platform.LocalContext
2929
import androidx.compose.ui.platform.testTag
30+
import androidx.compose.ui.res.stringResource
31+
import androidx.compose.ui.semantics.paneTitle
32+
import androidx.compose.ui.semantics.semantics
3033
import io.getstream.chat.android.compose.R
3134
import io.getstream.chat.android.compose.ui.theme.AttachmentPickerContentParams
3235
import io.getstream.chat.android.compose.ui.theme.AttachmentSystemPickerParams
@@ -109,8 +112,11 @@ public fun AttachmentPicker(
109112
actions.onAttachmentsSelected(attachmentsPickerViewModel.getAttachmentsFromMetadata(metaData))
110113
}
111114

115+
val pickerTitle = stringResource(R.string.stream_compose_attachment_picker)
112116
Surface(
113-
modifier = modifier.testTag("Stream_AttachmentsPicker"),
117+
modifier = modifier
118+
.testTag("Stream_AttachmentsPicker")
119+
.semantics { paneTitle = pickerTitle },
114120
color = ChatTheme.colors.backgroundCoreElevation1,
115121
) {
116122
if (ChatTheme.config.attachmentPicker.useSystemPicker) {

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ import androidx.compose.ui.graphics.Color
3535
import androidx.compose.ui.platform.testTag
3636
import androidx.compose.ui.res.painterResource
3737
import androidx.compose.ui.res.stringResource
38+
import androidx.compose.ui.semantics.Role
39+
import androidx.compose.ui.semantics.clearAndSetSemantics
40+
import androidx.compose.ui.semantics.contentDescription
41+
import androidx.compose.ui.semantics.onClick
42+
import androidx.compose.ui.semantics.role
43+
import androidx.compose.ui.semantics.selected
44+
import androidx.compose.ui.semantics.testTag
3845
import androidx.compose.ui.tooling.preview.Preview
3946
import androidx.compose.ui.unit.dp
4047
import io.getstream.chat.android.compose.R
@@ -132,8 +139,20 @@ private fun AttachmentPickerToggleButton(
132139
isSelected: Boolean,
133140
onClick: () -> Unit,
134141
) {
142+
val description = stringResource(pickerTypeInfo.contentDescription)
135143
FilledIconToggleButton(
136-
modifier = Modifier.size(48.dp),
144+
modifier = Modifier
145+
.size(48.dp)
146+
.clearAndSetSemantics {
147+
role = Role.Tab
148+
selected = isSelected
149+
contentDescription = description
150+
testTag = pickerTypeInfo.testTag
151+
onClick {
152+
onClick()
153+
true
154+
}
155+
},
137156
checked = isSelected,
138157
onCheckedChange = { onClick() },
139158
colors = IconButtonDefaults.filledIconToggleButtonColors(
@@ -144,9 +163,8 @@ private fun AttachmentPickerToggleButton(
144163
),
145164
) {
146165
Icon(
147-
modifier = Modifier.testTag(pickerTypeInfo.testTag),
148166
painter = painterResource(pickerTypeInfo.icon),
149-
contentDescription = stringResource(pickerTypeInfo.contentDescription),
167+
contentDescription = null,
150168
)
151169
}
152170
}

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/poll/CreatePollScreen.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ import androidx.compose.runtime.mutableStateOf
3535
import androidx.compose.runtime.remember
3636
import androidx.compose.runtime.setValue
3737
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.res.stringResource
39+
import androidx.compose.ui.semantics.paneTitle
40+
import androidx.compose.ui.semantics.semantics
3841
import androidx.compose.ui.tooling.preview.Preview
3942
import androidx.compose.ui.unit.dp
4043
import androidx.lifecycle.viewmodel.compose.viewModel
44+
import io.getstream.chat.android.compose.R
4145
import io.getstream.chat.android.compose.ui.theme.ChatTheme
4246
import io.getstream.chat.android.compose.ui.theme.StreamTokens
4347
import io.getstream.chat.android.models.CreatePollParams
@@ -73,10 +77,12 @@ public fun CreatePollScreen(
7377
backAction()
7478
}
7579
}
80+
val paneTitleText = stringResource(R.string.stream_compose_poll_title)
7681
Scaffold(
7782
modifier = Modifier
7883
.systemBarsPadding()
79-
.imePadding(),
84+
.imePadding()
85+
.semantics { paneTitle = paneTitleText },
8086
containerColor = ChatTheme.colors.backgroundCoreApp,
8187
topBar = {
8288
PollCreationHeader(

0 commit comments

Comments
 (0)