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/inbox/compose/HorizonInboxComposeScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt index 47fc1f97e3..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 @@ -47,8 +47,6 @@ 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.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 @@ -168,13 +166,14 @@ private fun HorizonInboxComposeTopBar( stringResource(R.string.inboxComposeTitle), style = HorizonTypography.h2, color = HorizonColors.Text.title(), - modifier = Modifier.padding(horizontal = 12.dp) + modifier = Modifier + .padding(horizontal = 12.dp) ) }, actions = { IconButton( iconRes = R.drawable.close, - contentDescription = null, + contentDescription = stringResource(R.string.close), color = IconButtonColor.Inverse, elevation = HorizonElevation.level4, onClick = { @@ -274,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/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..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 @@ -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 @@ -51,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 @@ -192,6 +193,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, @@ -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/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/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/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/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/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/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 diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index b1d91ae53c..e205605507 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -479,6 +479,12 @@ Exit Cancel Delete note + Delete + Cancel + Download + Retry + Read + Unread Previous module item Open AI Assistant Open notebook