From 942cfe9eb0bc62bb38e241c099e4bdacb99806f5 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 10 Dec 2025 09:58:34 +0100 Subject: [PATCH 1/6] Refactor inbox details --- .../details/HorizonInboxDetailsScreen.kt | 39 +++++++++++++------ .../details/HorizonInboxDetailsViewModel.kt | 6 +-- .../inbox/list/HorizonInboxListScreen.kt | 2 +- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt index 7e63437e3e..184945be90 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt @@ -21,6 +21,7 @@ import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -31,7 +32,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -50,6 +52,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -198,25 +202,39 @@ private fun HorizonInboxDetailsContent( .clip(HorizonCornerRadius.level4Top) .background(HorizonColors.Surface.pageSecondary()) ) { + val scrollState = rememberLazyListState( + if (state.bottomLayout) { + state.items.lastIndex + } else { + 0 + } + ) LazyColumn( + verticalArrangement = if (state.bottomLayout) Arrangement.Bottom else Arrangement.Top, + state = scrollState, modifier = Modifier - .fillMaxSize() - .background(HorizonColors.Surface.pageSecondary()), - reverseLayout = state.bottomLayout, + .weight(1f) + .background(HorizonColors.Surface.pageSecondary()) + .semantics { + isTraversalGroup = true + }, contentPadding = PaddingValues(top = 16.dp) ) { - if (state.replyState != null) { - stickyHeader { HorizonInboxReplyContent(state.replyState) } - } - items(state.items) { + itemsIndexed(state.items) { index, item -> Column { - HorizonInboxDetailsItem(it) - if ((state.bottomLayout && it != state.items.firstOrNull()) || (!state.bottomLayout && it != state.items.lastOrNull())) { + HorizonInboxDetailsItem( + item, + Modifier.semantics(true) {} + ) + if (item != state.items.lastOrNull()) { HorizonDivider() } } } } + if (state.replyState != null) { + HorizonInboxReplyContent(state.replyState) + } } } @@ -448,7 +466,6 @@ private fun HorizonInboxDetailsScreenPreview() { onReplyTextValueChange = {}, onSendReply = {} ), - bottomLayout = true ) ) } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt index 0beb4050f1..bbcbdda23e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt @@ -134,7 +134,7 @@ class HorizonInboxDetailsViewModel @Inject constructor( attachment.toAttachmentUiState() } ) - }, + }.reversed(), replyState = getReplyState(), bottomLayout = true ) @@ -382,7 +382,7 @@ class HorizonInboxDetailsViewModel @Inject constructor( _uiState.update { it.copy( - items = conversation.messages.map { message -> + items = it.items + conversation.messages.map { message -> HorizonInboxDetailsItem( author = conversation.participants.firstOrNull { it.id == message.authorId }?.name.orEmpty(), date = message.createdAt.toDate() ?: Date(), @@ -392,7 +392,7 @@ class HorizonInboxDetailsViewModel @Inject constructor( attachment.toAttachmentUiState() } ) - } + it.items, + }, replyState = it.replyState?.copy( replyTextValue = TextFieldValue(""), isLoading = false, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt index ce3e5977c6..62d99c46f7 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -192,6 +191,7 @@ private fun LazyListScope.inboxHeader( ) { IconButton( iconRes = R.drawable.arrow_back, + contentDescription = stringResource(R.string.a11yNavigateBack), size = IconButtonSize.NORMAL, color = IconButtonColor.Inverse, elevation = HorizonElevation.level4, From ae0811cc6c62c5658be2b8f9bd2c783be903163d Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 10 Dec 2025 12:32:54 +0100 Subject: [PATCH 2/6] Attachments a11y improvements --- .../horizonui/molecules/filedrop/FileDrop.kt | 22 ++++++++++++------- libs/horizon/src/main/res/values/strings.xml | 4 ++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/filedrop/FileDrop.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/filedrop/FileDrop.kt index 6a97064a04..fe7616f5b2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/filedrop/FileDrop.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/filedrop/FileDrop.kt @@ -118,6 +118,7 @@ fun FileDrop( sealed class FileDropItemState( open val fileName: String, open val actionIconRes: Int, + open val actionIconContentDescriptionRes: Int, open val onActionClick: (() -> Unit)? = null, open val onClick: (() -> Unit)? = null ) { @@ -125,34 +126,38 @@ sealed class FileDropItemState( override val fileName: String, override val onActionClick: (() -> Unit)? = null, override val onClick: (() -> Unit)? = null, - override val actionIconRes: Int = R.drawable.delete + override val actionIconRes: Int = R.drawable.delete, + override val actionIconContentDescriptionRes: Int = R.string.a11y_delete ) : - FileDropItemState(fileName, actionIconRes) + FileDropItemState(fileName, actionIconRes, actionIconContentDescriptionRes) data class InProgress( override val fileName: String, val progress: Float? = null, override val onActionClick: (() -> Unit)? = null, override val onClick: (() -> Unit)? = null, - override val actionIconRes: Int = R.drawable.close + override val actionIconRes: Int = R.drawable.close, + override val actionIconContentDescriptionRes: Int = R.string.a11y_cancel ) : - FileDropItemState(fileName, actionIconRes) + FileDropItemState(fileName, actionIconRes, actionIconContentDescriptionRes) data class NoLongerEditable( override val fileName: String, override val onActionClick: (() -> Unit)? = null, override val onClick: (() -> Unit)? = null, - override val actionIconRes: Int = R.drawable.download + override val actionIconRes: Int = R.drawable.download, + override val actionIconContentDescriptionRes: Int = R.string.a11y_download ) : - FileDropItemState(fileName, actionIconRes) + FileDropItemState(fileName, actionIconRes, actionIconContentDescriptionRes) data class Error( override val fileName: String, override val onActionClick: (() -> Unit)? = null, override val onClick: (() -> Unit)? = null, - override val actionIconRes: Int = R.drawable.refresh + override val actionIconRes: Int = R.drawable.refresh, + override val actionIconContentDescriptionRes: Int = R.string.a11y_retry ) : - FileDropItemState(fileName, actionIconRes) + FileDropItemState(fileName, actionIconRes, actionIconContentDescriptionRes) } @@ -217,6 +222,7 @@ fun FileDropItem( state.onActionClick?.let { IconButton( iconRes = state.actionIconRes, + contentDescription = stringResource(state.actionIconContentDescriptionRes), size = IconButtonSize.SMALL, color = IconButtonColor.Inverse, onClick = it diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 8e87a2d57e..503a50e4f6 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -479,4 +479,8 @@ Exit Cancel Delete note + Delete + Cancel + Download + Retry \ No newline at end of file From a4adfc04aaf04c4bbffe7aa523ec878566a8effa Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Wed, 10 Dec 2025 12:45:10 +0100 Subject: [PATCH 3/6] Compose screen a11y --- .../compose/HorizonInboxComposeScreen.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt index 47fc1f97e3..a7dc9c34c8 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt @@ -19,6 +19,7 @@ package com.instructure.horizon.features.inbox.compose import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,9 +43,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -92,6 +96,7 @@ import com.instructure.horizon.horizonui.organisms.inputs.textfield.TextFieldInp import com.instructure.horizon.horizonui.organisms.inputs.textfield.TextFieldState import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.getActivityOrNull +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -162,13 +167,26 @@ private fun HorizonInboxComposeTopBar( state: HorizonInboxComposeUiState, navController: NavHostController, ) { + val requester = FocusRequester() + var requestFocus by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(Unit) { + delay(50) + if(requestFocus) { + requester.requestFocus() + requestFocus = false + } + } + TopAppBar( title = { Text( stringResource(R.string.inboxComposeTitle), style = HorizonTypography.h2, color = HorizonColors.Text.title(), - modifier = Modifier.padding(horizontal = 12.dp) + modifier = Modifier + .padding(horizontal = 12.dp) + .focusRequester(requester) + .focusable() ) }, actions = { From 6d75469e179898687b7b263f812e18e666037c30 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 11 Dec 2025 11:55:11 +0100 Subject: [PATCH 4/6] Fix dropdowns --- .../compose/HorizonInboxComposeScreen.kt | 27 +-- .../horizon/horizonui/molecules/Tag.kt | 33 +++- .../inputs/common/InputDropDownPopup.kt | 164 ++++++++++-------- .../inputs/multiselect/MultiSelect.kt | 19 ++ .../multiselectsearch/MultiSelectSearch.kt | 1 + .../singleselectimage/SingleSelectImage.kt | 18 +- 6 files changed, 160 insertions(+), 102 deletions(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt index a7dc9c34c8..e814aea413 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt @@ -19,7 +19,6 @@ package com.instructure.horizon.features.inbox.compose import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -43,16 +42,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -96,7 +90,6 @@ import com.instructure.horizon.horizonui.organisms.inputs.textfield.TextFieldInp import com.instructure.horizon.horizonui.organisms.inputs.textfield.TextFieldState import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.getActivityOrNull -import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -167,16 +160,6 @@ private fun HorizonInboxComposeTopBar( state: HorizonInboxComposeUiState, navController: NavHostController, ) { - val requester = FocusRequester() - var requestFocus by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { - delay(50) - if(requestFocus) { - requester.requestFocus() - requestFocus = false - } - } - TopAppBar( title = { Text( @@ -185,8 +168,6 @@ private fun HorizonInboxComposeTopBar( color = HorizonColors.Text.title(), modifier = Modifier .padding(horizontal = 12.dp) - .focusRequester(requester) - .focusable() ) }, actions = { @@ -292,13 +273,7 @@ private fun CourseRecipientPickerSection(state: HorizonInboxComposeUiState) { onMenuOpenChanged = { isRecipientPickerOpened = it }, minSearchQueryLengthForMenu = state.minQueryLength ) - val context = LocalContext.current - MultiSelectSearch( - recipientPickerState, - Modifier.semantics { - contentDescription = context.getString(R.string.a11y_inboxComposeSelectCourse) - } - ) + MultiSelectSearch(recipientPickerState) HorizonSpace(SpaceSize.SPACE_12) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/Tag.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/Tag.kt index d622d0dfe6..62037256b4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/Tag.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/Tag.kt @@ -36,6 +36,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -78,7 +83,24 @@ fun Tag( } val badgePadding = if (dismissible && type == TagType.INLINE) PaddingValues(top = 8.dp, end = 8.dp) else PaddingValues() val alphaModifier = if (enabled) modifier else modifier.alpha(0.5f) - Box(contentAlignment = Alignment.TopEnd, modifier = alphaModifier.padding(badgePadding)) { + val dismissText = stringResource(R.string.tag_dismiss) + + Box( + contentAlignment = Alignment.TopEnd, + modifier = alphaModifier + .padding(badgePadding) + .semantics(mergeDescendants = true) { + if (!enabled) { + disabled() + } + if (dismissible && enabled) { + onClick(label = dismissText) { + onDismiss() + true + } + } + } + ) { Box( modifier = Modifier .background( @@ -96,10 +118,13 @@ fun Tag( Icon( painter = painterResource(R.drawable.close), tint = HorizonColors.Icon.default(), - contentDescription = stringResource(R.string.tag_dismiss), + contentDescription = null, modifier = Modifier .size(size.iconSize) - .clickable { onDismiss() }) + .clickable(enabled = enabled) { onDismiss() } + .clearAndSetSemantics { hideFromAccessibility() } + ) + } } } @@ -107,7 +132,7 @@ fun Tag( Badge( content = BadgeContent.Icon( iconRes = R.drawable.close_small, - contentDescription = stringResource(R.string.tag_dismiss) + contentDescription = null ), modifier = Modifier.offset(x = 8.dp, y = (-8).dp) ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/common/InputDropDownPopup.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/common/InputDropDownPopup.kt index f0fe2e226e..faf3b850e3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/common/InputDropDownPopup.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/common/InputDropDownPopup.kt @@ -18,6 +18,7 @@ package com.instructure.horizon.horizonui.organisms.inputs.common import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith @@ -35,14 +36,19 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import android.content.Context +import android.view.accessibility.AccessibilityManager import com.instructure.horizon.R import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius @@ -70,85 +76,101 @@ fun InputDropDownPopup( SingleSelectItem(selectionOption.toString()) }, ) { - Popup( - alignment = Alignment.TopStart, - offset = IntOffset( - -SpaceSize.SPACE_8.value.toPx, - verticalOffsetPx + SpaceSize.SPACE_8.value.toPx - ), - onDismissRequest = { onMenuOpenChanged(false) }, - properties = PopupProperties(focusable = isMenuOpen && isFocusable) - ) { - AnimatedVisibility( - isMenuOpen, - enter = expandVertically(expandFrom = Alignment.Top), - exit = shrinkVertically(shrinkTowards = Alignment.Top), - label = "InputDropDownPopupAnimation", + val visibleState = remember { MutableTransitionState(isMenuOpen) } + + LaunchedEffect(isMenuOpen) { + visibleState.targetState = isMenuOpen + } + + val context = LocalContext.current + val accessibilityManager = remember { + context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + } + val isTalkBackEnabled = accessibilityManager.isEnabled && accessibilityManager.isTouchExplorationEnabled + + if (!visibleState.isIdle || visibleState.currentState) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset( + -SpaceSize.SPACE_8.value.toPx, + verticalOffsetPx + SpaceSize.SPACE_8.value.toPx + ), + onDismissRequest = { onMenuOpenChanged(false) }, + properties = PopupProperties( + focusable = visibleState.targetState && (isFocusable || isTalkBackEnabled) + ) ) { - Card( - modifier = modifier - .padding(bottom = 8.dp) - .padding(horizontal = 8.dp) - .conditional(width == null) { - fillMaxWidth() - } - .conditional(width != null) { - width(width!!) - }, - shape = HorizonCornerRadius.level2, - colors = CardDefaults.cardColors() - .copy(containerColor = HorizonColors.Surface.pageSecondary()), - border = if (isMenuOpen) { - BorderStroke( - width = 1.dp, - color = HorizonColors.LineAndBorder.containerStroke() - ) - } else { - null - }, + AnimatedVisibility( + visibleState = visibleState, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + label = "InputDropDownPopupAnimation", ) { - AnimatedContent( - isLoading, - transitionSpec = { expandVertically() togetherWith shrinkVertically() } - ) { isLoading -> - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - ) { - if (isLoading) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Spinner(size = SpinnerSize.EXTRA_SMALL) - } - } else if (options.isEmpty()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - SingleSelectItem(stringResource(R.string.noOptionsAvailable)) - } - } else { - options.forEach { selectionOption -> + Card( + modifier = modifier + .padding(bottom = 8.dp) + .padding(horizontal = 8.dp) + .conditional(width == null) { + fillMaxWidth() + } + .conditional(width != null) { + width(width!!) + }, + shape = HorizonCornerRadius.level2, + colors = CardDefaults.cardColors() + .copy(containerColor = HorizonColors.Surface.pageSecondary()), + border = if (visibleState.targetState) { + BorderStroke( + width = 1.dp, + color = HorizonColors.LineAndBorder.containerStroke() + ) + } else { + null + }, + ) { + AnimatedContent( + isLoading, + transitionSpec = { expandVertically() togetherWith shrinkVertically() } + ) { isLoading -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + ) { + if (isLoading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Spinner(size = SpinnerSize.EXTRA_SMALL) + } + } else if (options.isEmpty()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .conditional(optionEnabled(selectionOption)) { - clickable { - onOptionSelected(selectionOption) - if (closeMenuAfterSelection(selectionOption)) { - onMenuOpenChanged(false) + ) { + SingleSelectItem(stringResource(R.string.noOptionsAvailable)) + } + } else { + options.forEach { selectionOption -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .conditional(optionEnabled(selectionOption)) { + clickable { + onOptionSelected(selectionOption) + if (closeMenuAfterSelection(selectionOption)) { + onMenuOpenChanged(false) + } } } - } - ) { - item(selectionOption) + ) { + item(selectionOption) + } } } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselect/MultiSelect.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselect/MultiSelect.kt index 289d8694f1..9705d139c5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselect/MultiSelect.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselect/MultiSelect.kt @@ -40,6 +40,12 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.instructure.canvasapi2.utils.ContextKeeper @@ -58,6 +64,9 @@ fun MultiSelect( state: MultiSelectState, modifier: Modifier = Modifier ) { + val expandedState = stringResource(R.string.a11y_expanded) + val collapsedState = stringResource(R.string.a11y_collapsed) + Input( label = state.label, helperText = state.helperText, @@ -70,6 +79,15 @@ fun MultiSelect( ) { Column( modifier = Modifier + .semantics(mergeDescendants = false) { + role = Role.DropdownList + stateDescription = if (state.isMenuOpen) expandedState else collapsedState + contentDescription = if (state.selectedOptions.isEmpty()) { + state.label ?: "" + } else { + "${state.label}, ${state.selectedOptions.size} items selected" + } + } ) { val localDensity = LocalDensity.current var heightInPx by remember { mutableIntStateOf(0) } @@ -141,6 +159,7 @@ private fun MultiSelectContent(state: MultiSelectState) { MultiSelectInputSize.Small -> TagSize.SMALL MultiSelectInputSize.Medium -> TagSize.MEDIUM }, + enabled = state.enabled, dismissible = true, onDismiss = { state.onOptionRemoved(selectedOption) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselectsearch/MultiSelectSearch.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselectsearch/MultiSelectSearch.kt index 13da75ef2a..cd4cd4a83a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselectsearch/MultiSelectSearch.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/multiselectsearch/MultiSelectSearch.kt @@ -175,6 +175,7 @@ private fun MultiSelectContent(state: MultiSelectSearchState) { MultiSelectSearchInputSize.Small -> TagSize.SMALL MultiSelectSearchInputSize.Medium -> TagSize.MEDIUM }, + enabled = state.enabled, dismissible = true, onDismiss = { state.onOptionRemoved(selectedOption) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselectimage/SingleSelectImage.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselectimage/SingleSelectImage.kt index fe9e9d8b15..9cd97264a5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselectimage/SingleSelectImage.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselectimage/SingleSelectImage.kt @@ -42,6 +42,12 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap @@ -58,7 +64,8 @@ fun SingleSelectImage( state: SingleSelectImageState, modifier: Modifier = Modifier ) { - + val expandedState = stringResource(R.string.a11y_expanded) + val collapsedState = stringResource(R.string.a11y_collapsed) Input( label = state.label, helperText = state.helperText, @@ -68,6 +75,15 @@ fun SingleSelectImage( .onFocusChanged { state.onFocusChanged(it.isFocused) } + .clearAndSetSemantics { + role = Role.DropdownList + stateDescription = if (state.isMenuOpen) expandedState else collapsedState + contentDescription = if (state.selectedOption != null) { + "${state.label}, ${state.selectedOption}" + } else { + state.label ?: "" + } + } ) { Column( modifier = Modifier From fbca6323156ab90f626e78d0dea97a2bda5382f2 Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Thu, 11 Dec 2025 14:25:11 +0100 Subject: [PATCH 5/6] Fix tests --- .../horizon/ui/features/inbox/HorizonInboxComposeUiTest.kt | 2 +- .../dashboard/widget/timespent/DashboardTimeSpentWidget.kt | 2 +- .../widget/timespent/card/DashboardTimeSpentCardContent.kt | 2 +- .../horizonui/organisms/inputs/singleselect/SingleSelect.kt | 4 ++-- libs/horizon/src/main/res/values/strings.xml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/inbox/HorizonInboxComposeUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/inbox/HorizonInboxComposeUiTest.kt index 8f0a0950d9..4266ee31d8 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/inbox/HorizonInboxComposeUiTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/inbox/HorizonInboxComposeUiTest.kt @@ -54,7 +54,7 @@ class HorizonInboxComposeUiTest { HorizonInboxComposeScreen(uiState, pickerState, rememberNavController()) } - composeTestRule.onNodeWithContentDescription("Select Course") + composeTestRule.onNodeWithContentDescription("Course") .assertIsDisplayed() } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt index ee15812d5d..9294290e4d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt @@ -72,7 +72,7 @@ fun DashboardTimeSpentSection( } DashboardItemState.ERROR -> { DashboardWidgetCardError( - stringResource(R.string.dashboardTimeSpentTitle), + stringResource(R.string.dashboardTimeLearningTitle), R.drawable.schedule, HorizonColors.PrimitivesHoney.honey12(), false, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt index 12dc48d1e1..0a2d3350a6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt @@ -62,7 +62,7 @@ fun DashboardTimeSpentCardContent( modifier: Modifier = Modifier, ) { DashboardWidgetCard( - stringResource(R.string.dashboardTimeSpentTitle), + stringResource(R.string.dashboardTimeLearningTitle), R.drawable.schedule, HorizonColors.PrimitivesHoney.honey12(), modifier, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselect/SingleSelect.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselect/SingleSelect.kt index 1f606993e2..82be340a4c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselect/SingleSelect.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/inputs/singleselect/SingleSelect.kt @@ -76,9 +76,9 @@ fun SingleSelect( role = Role.DropdownList stateDescription = if (state.isMenuOpen) expandedState else collapsedState contentDescription = if (state.selectedOption != null) { - "${state.label}, ${state.selectedOption}" + "${state.label ?: state.placeHolderText ?: ""}, ${state.selectedOption}" } else { - state.label ?: "" + state.label ?: state.placeHolderText ?: "" } } ) { diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 503a50e4f6..e2ffe3fe65 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -369,7 +369,7 @@ Please try again. Refresh Program details - Time learning + Time learning This widget will update once data becomes available. We weren\'t able to load this content.\nPlease try again. Refresh From a8d48da20aba76e6475bbcd53ab69b8aab96f9fa Mon Sep 17 00:00:00 2001 From: domonkosadam Date: Fri, 12 Dec 2025 18:03:26 +0100 Subject: [PATCH 6/6] Fix findings --- .../compose/HorizonInboxComposeScreen.kt | 2 +- .../inbox/list/HorizonInboxListScreen.kt | 7 ++++ .../horizonui/molecules/ActionBottomSheet.kt | 2 ++ .../organisms/controls/CheckboxItem.kt | 35 +++++++++++++++---- libs/horizon/src/main/res/values/strings.xml | 2 ++ 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt index e814aea413..e4a64e4c0b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt @@ -173,7 +173,7 @@ private fun HorizonInboxComposeTopBar( actions = { IconButton( iconRes = R.drawable.close, - contentDescription = null, + contentDescription = stringResource(R.string.close), color = IconButtonColor.Inverse, elevation = HorizonElevation.level4, onClick = { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt index 62d99c46f7..08dddce21f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt @@ -50,6 +50,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -338,9 +340,14 @@ private fun InboxContentItem( onClick: () -> Unit, modifier: Modifier = Modifier ) { + val readState = stringResource(R.string.a11y_readInboxMessage) + val unreadState = stringResource(R.string.a11y_unreadInboxMessage) Column( modifier = modifier .clickable { onClick() } + .semantics(true) { + stateDescription = if (item.isUnread) unreadState else readState + } ) { Column( modifier = Modifier diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/ActionBottomSheet.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/ActionBottomSheet.kt index 2f60ea3e50..4696604e0a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/ActionBottomSheet.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/ActionBottomSheet.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -86,6 +87,7 @@ fun ActionBottomSheet( ) IconButton( iconRes = R.drawable.close, color = IconButtonColor.Inverse, + contentDescription = stringResource(R.string.a11y_close), onClick = { localCoroutineScope.launch { sheetState.hide() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/controls/CheckboxItem.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/controls/CheckboxItem.kt index 0d49645fcd..22d5e16214 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/controls/CheckboxItem.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/controls/CheckboxItem.kt @@ -17,12 +17,15 @@ package com.instructure.horizon.horizonui.organisms.controls import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.selection.triStateToggleable import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxColors import androidx.compose.material3.TriStateCheckbox import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -47,14 +50,24 @@ data class CheckboxItemState( @Composable fun TriStateCheckboxItem(state: TriStateCheckboxItemState, modifier: Modifier = Modifier) { val alphaModifier = if (state.enabled) modifier else modifier.alpha(0.5f) - Row(modifier = alphaModifier) { + + Row( + modifier = alphaModifier + .triStateToggleable( + state = state.toggleableState, + onClick = state.onClick ?: {}, + enabled = state.enabled + ) + ) { val error = state.controlsContentState.error != null TriStateCheckbox( state = state.toggleableState, - onClick = state.onClick, + onClick = {}, enabled = state.enabled, colors = horizonCheckboxColors(error), - modifier = Modifier.size(20.dp) + modifier = Modifier + .size(20.dp) + .clearAndSetSemantics {} ) HorizonSpace(SpaceSize.SPACE_8) ControlsContent(state = state.controlsContentState) @@ -64,14 +77,24 @@ fun TriStateCheckboxItem(state: TriStateCheckboxItemState, modifier: Modifier = @Composable fun CheckboxItem(state: CheckboxItemState, modifier: Modifier = Modifier) { val alphaModifier = if (state.enabled) modifier else modifier.alpha(0.5f) - Row(modifier = alphaModifier) { + + Row( + modifier = alphaModifier + .toggleable( + value = state.checked, + onValueChange = state.onCheckedChanged ?: {}, + enabled = state.enabled + ) + ) { val error = state.controlsContentState.error != null Checkbox( checked = state.checked, - onCheckedChange = state.onCheckedChanged, + onCheckedChange = null, enabled = state.enabled, colors = horizonCheckboxColors(error), - modifier = Modifier.size(20.dp) + modifier = Modifier + .size(20.dp) + .clearAndSetSemantics {} ) HorizonSpace(SpaceSize.SPACE_8) ControlsContent(state = state.controlsContentState) diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index e2ffe3fe65..811e466828 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -483,4 +483,6 @@ Cancel Download Retry + Read + Unread \ No newline at end of file