From f5dd73e89c053fca7e9a3d5486cb37c99fb31785 Mon Sep 17 00:00:00 2001 From: Pranjal Singh Date: Wed, 18 Mar 2026 10:50:36 +0530 Subject: [PATCH 1/4] Adding strings for expand and collapsed for stackable snackbar --- .../tokenized/notification/StackableSnackbar.kt | 15 +++++++++++++++ .../src/main/res/values/strings.xml | 2 ++ 2 files changed, 17 insertions(+) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 3f7411c08..85ddf2a17 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,6 +56,7 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode @@ -81,6 +83,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarEntryAn import com.microsoft.fluentui.theme.token.controlTokens.StackableSnackbarExitAnimationType import com.microsoft.fluentui.tokenized.controls.Button import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.pow @@ -441,6 +444,7 @@ fun SnackBarStack( snackBarStackConfig: SnackBarStackConfig = SnackBarStackConfig() ) { val localDensity = LocalDensity.current + val view = LocalView.current val totalVisibleSnackbars by remember { derivedStateOf { state.sizeVisible() } } val targetHeight = if (totalVisibleSnackbars == 0) { @@ -456,11 +460,22 @@ fun SnackBarStack( label = SnackBarLabels.STACK_HEIGHT_ANIMATION ) + val expandedAnnouncement = stringResource(R.string.expanded_announcement) + val collapsedAnnouncement = stringResource(R.string.collapsed_announcement) + val screenWidth = LocalConfiguration.current.screenWidthDp.dp val screenWidthPx = with(localDensity) { screenWidth.toPx() } val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new snackbar added + + LaunchedEffect(Unit) { + snapshotFlow { state.expanded } + .drop(1) // dropping the first emission since it's not a result of user interaction and can cause unwanted announcements on initial load. + .collect { isExpanded -> + view.announceForAccessibility(if (isExpanded) expandedAnnouncement else collapsedAnnouncement) + } + } Column( modifier = Modifier .fillMaxWidth() diff --git a/fluentui_notification/src/main/res/values/strings.xml b/fluentui_notification/src/main/res/values/strings.xml index e5104fdf8..f439234a3 100644 --- a/fluentui_notification/src/main/res/values/strings.xml +++ b/fluentui_notification/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Dismiss Expanded notifications + List Expanded + List Collapsed \ No newline at end of file From 5ba369a8cdd4be9684bab29967d8a86b6a6a48c2 Mon Sep 17 00:00:00 2001 From: Pranjal Singh Date: Fri, 27 Mar 2026 11:23:47 +0530 Subject: [PATCH 2/4] Adding talkback for individual snackbar items --- .../notification/StackableSnackbar.kt | 64 +++++++++++++++++-- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index f954790da..1c3a0b83b 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -1,5 +1,6 @@ package com.microsoft.fluentui.tokenized.notification +import android.util.Log import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutLinearInEasing @@ -11,6 +12,7 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -49,6 +51,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput @@ -56,13 +61,12 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -182,6 +186,10 @@ class SnackBarStackState( var expanded by mutableStateOf(false) private set + var selectedItemId by mutableStateOf(null) + private set + var focusRequestToken by mutableIntStateOf(0) + private set internal var maxCurrentSize = maxCollapsedSize internal var combinedStackHeight by mutableIntStateOf(0) @@ -239,6 +247,10 @@ class SnackBarStackState( return false } + fun updateSelectedItem(id: String) { + selectedItemId = id + } + /** * Removes a snackbar with an exit animation. The actual removal from the state list is * delayed to allow the animation to complete. @@ -270,6 +282,12 @@ class SnackBarStackState( expanded = !expanded maxCurrentSize = if (expanded) maxExpandedSize else maxCollapsedSize onVisibleSizeChange() + if (expanded) { + selectedItemId = snapshotStateList.firstOrNull { it.visibility.value == ItemVisibility.Visible }?.model?.id + focusRequestToken++ + } else { + selectedItemId = null + } } /** @@ -447,6 +465,7 @@ fun SnackBarStack( ) { val localDensity = LocalDensity.current val view = LocalView.current + val focusManager = LocalFocusManager.current val totalVisibleSnackbars by remember { derivedStateOf { state.sizeVisible() } } val targetHeight = if (totalVisibleSnackbars == 0) { @@ -475,9 +494,27 @@ fun SnackBarStack( snapshotFlow { state.expanded } .drop(1) // dropping the first emission since it's not a result of user interaction and can cause unwanted announcements on initial load. .collect { isExpanded -> + if (!isExpanded) { + focusManager.clearFocus() + } view.announceForAccessibility(if (isExpanded) expandedAnnouncement else collapsedAnnouncement) } } + + val focusRequesters = remember { mutableMapOf() } + + LaunchedEffect(state.focusRequestToken) { + val targetId = state.selectedItemId ?: return@LaunchedEffect + delay(ANIMATION_DURATION_MS.toLong()) + focusRequesters[targetId]?.let { requester -> + try { + requester.requestFocus() + } catch (e: Exception) { + Log.e("SnackBarStack", "Failed to request focus for snackbar with id $targetId", e) + } + } + } + Column( modifier = Modifier .fillMaxWidth() @@ -507,7 +544,8 @@ fun SnackBarStack( state.showLastHidden() }, snackBarStackConfig = snackBarStackConfig, - screenWidthPx = screenWidthPx + screenWidthPx = screenWidthPx, + focusRequester = focusRequesters.getOrPut(snackBarModel.model.id) { FocusRequester() } ) } } @@ -533,7 +571,8 @@ private fun SnackBarStackItem( trueIndex: Int, onSwipedAway: (String) -> Unit, snackBarStackConfig: SnackBarStackConfig, - screenWidthPx: Float + screenWidthPx: Float, + focusRequester: FocusRequester ) { val modelWrapper = state.snapshotStateList[trueIndex] val model = modelWrapper.model @@ -723,9 +762,22 @@ private fun SnackBarStackItem( ) .clip(RoundedCornerShape(8.dp)) .background(token.backgroundBrush(snackBarInfo)) - .semantics { - liveRegion = LiveRegionMode.Polite + .focusRequester(focusRequester) + .onFocusChanged{ + if (it.isFocused) { + state.updateSelectedItem(model.id) + } } + .focusable() + .then( + if (state.expanded) { + Modifier.semantics { + contentDescription = "${trueIndex+1} of ${state.snapshotStateList.size} ${model.message}" + } + } else { + Modifier + } + ) .testTag(SnackBarTestTags.SNACK_BAR), verticalAlignment = Alignment.CenterVertically ) { From 916d020c38d5b291b06494e62fbea0b092535c51 Mon Sep 17 00:00:00 2001 From: Pranjal Singh Date: Mon, 30 Mar 2026 10:18:58 +0530 Subject: [PATCH 3/4] self review --- .../notification/StackableSnackbar.kt | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 1c3a0b83b..77045aad6 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -186,7 +186,7 @@ class SnackBarStackState( var expanded by mutableStateOf(false) private set - var selectedItemId by mutableStateOf(null) + var selectedItemId: String? = null private set var focusRequestToken by mutableIntStateOf(0) private set @@ -500,21 +500,6 @@ fun SnackBarStack( view.announceForAccessibility(if (isExpanded) expandedAnnouncement else collapsedAnnouncement) } } - - val focusRequesters = remember { mutableMapOf() } - - LaunchedEffect(state.focusRequestToken) { - val targetId = state.selectedItemId ?: return@LaunchedEffect - delay(ANIMATION_DURATION_MS.toLong()) - focusRequesters[targetId]?.let { requester -> - try { - requester.requestFocus() - } catch (e: Exception) { - Log.e("SnackBarStack", "Failed to request focus for snackbar with id $targetId", e) - } - } - } - Column( modifier = Modifier .fillMaxWidth() @@ -544,8 +529,7 @@ fun SnackBarStack( state.showLastHidden() }, snackBarStackConfig = snackBarStackConfig, - screenWidthPx = screenWidthPx, - focusRequester = focusRequesters.getOrPut(snackBarModel.model.id) { FocusRequester() } + screenWidthPx = screenWidthPx ) } } @@ -571,8 +555,7 @@ private fun SnackBarStackItem( trueIndex: Int, onSwipedAway: (String) -> Unit, snackBarStackConfig: SnackBarStackConfig, - screenWidthPx: Float, - focusRequester: FocusRequester + screenWidthPx: Float ) { val modelWrapper = state.snapshotStateList[trueIndex] val model = modelWrapper.model @@ -588,6 +571,19 @@ private fun SnackBarStackItem( val entryAnimationType = token.entryAnimationType(snackBarInfo) val exitAnimationType = token.exitAnimationType(snackBarInfo) + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(state.focusRequestToken) { + if (state.selectedItemId == model.id) { + delay(ANIMATION_DURATION_MS.toLong()) + try { + focusRequester.requestFocus() + } catch (e: Exception) { + Log.e("SnackBarStackItem", "Failed to request focus for snackbar with id ${model.id}", e) + } + } + } + // Vertical Offset Animation: Related to Stack Expansion/Collapse and Item Position in Stack val initialYOffset = when (entryAnimationType) { StackableSnackbarEntryAnimationType.SlideInFromAbove -> -with(localDensity) { cardHeight.toPx() } From 921f6dea18269b141d5f10cb066db891bd2e5825 Mon Sep 17 00:00:00 2001 From: Pranjal Singh Date: Mon, 30 Mar 2026 10:41:21 +0530 Subject: [PATCH 4/4] making localization announcement configuration and locale change aware --- .../fluentui/tokenized/notification/StackableSnackbar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt index 77045aad6..fb91622c3 100644 --- a/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt +++ b/fluentui_notification/src/main/java/com/microsoft/fluentui/tokenized/notification/StackableSnackbar.kt @@ -490,7 +490,7 @@ fun SnackBarStack( val scrollState = rememberScrollState() //TODO: Keep Focus Anchored To the Bottom when expanded and new snackbar added - LaunchedEffect(Unit) { + LaunchedEffect(expandedAnnouncement, collapsedAnnouncement) { snapshotFlow { state.expanded } .drop(1) // dropping the first emission since it's not a result of user interaction and can cause unwanted announcements on initial load. .collect { isExpanded ->