@@ -14,12 +14,7 @@ import androidx.activity.result.PickVisualMediaRequest
1414import androidx.activity.result.contract.ActivityResultContracts
1515import androidx.compose.foundation.Image
1616import androidx.compose.foundation.background
17- import androidx.compose.foundation.border
1817import androidx.compose.foundation.clickable
19- import androidx.compose.foundation.gestures.Orientation
20- import androidx.compose.foundation.gestures.rememberScrollableState
21- import androidx.compose.foundation.gestures.scrollable
22- import androidx.compose.foundation.horizontalScroll
2318import androidx.compose.foundation.isSystemInDarkTheme
2419import androidx.compose.foundation.layout.Arrangement
2520import androidx.compose.foundation.layout.Box
@@ -37,7 +32,6 @@ import androidx.compose.foundation.layout.size
3732import androidx.compose.foundation.layout.width
3833import androidx.compose.foundation.lazy.grid.GridCells
3934import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
40- import androidx.compose.foundation.rememberScrollState
4135import androidx.compose.foundation.shape.RoundedCornerShape
4236import androidx.compose.foundation.text.BasicTextField
4337import androidx.compose.material.icons.Icons
@@ -46,9 +40,12 @@ import androidx.compose.material.icons.filled.PhotoCamera
4640import androidx.compose.material.icons.filled.PhotoLibrary
4741import androidx.compose.material.icons.filled.Search
4842import androidx.compose.material3.ColorScheme
43+ import androidx.compose.material3.ExperimentalMaterial3Api
4944import androidx.compose.material3.Icon
5045import androidx.compose.material3.IconButton
5146import androidx.compose.material3.MaterialTheme
47+ import androidx.compose.material3.SecondaryScrollableTabRow
48+ import androidx.compose.material3.Tab
5249import androidx.compose.material3.Text
5350import androidx.compose.material3.darkColorScheme
5451import androidx.compose.material3.dynamicDarkColorScheme
@@ -80,7 +77,12 @@ import androidx.compose.ui.platform.LocalFocusManager
8077import androidx.compose.ui.platform.LocalSoftwareKeyboardController
8178import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
8279import androidx.compose.ui.res.stringResource
80+ import androidx.compose.ui.semantics.LiveRegionMode
8381import androidx.compose.ui.semantics.Role
82+ import androidx.compose.ui.semantics.clearAndSetSemantics
83+ import androidx.compose.ui.semantics.contentDescription
84+ import androidx.compose.ui.semantics.heading
85+ import androidx.compose.ui.semantics.liveRegion
8486import androidx.compose.ui.semantics.role
8587import androidx.compose.ui.semantics.semantics
8688import androidx.compose.ui.text.TextStyle
@@ -140,19 +142,12 @@ private const val MEDIA_STACK_CORNER_DP = 18
140142private const val MEDIA_STACK_ICON_SIZE_DP = 28
141143private const val MEDIA_STACK_LABEL_SP = 13
142144
143- private const val TABS_VERTICAL_PAD_DP = 4
144- private const val TABS_BOTTOM_PAD_DP = 6
145- private const val TABS_CONTENT_VERTICAL_PAD_DP = 4
146- private const val TABS_CONTENT_HORIZONTAL_PAD_DP = 16
147- private const val TABS_GAP_DP = 8
148- private const val CHIP_HEIGHT_DP = 36
149- private const val CHIP_HORIZONTAL_PAD_DP = 14
150- private const val CHIP_CORNER_DP = 18
151- private const val CHIP_BORDER_WIDTH_DP = 1
152- private const val CHIP_FONT_SP = 13
153- private const val CHIP_LETTER_SPACING_SP = 0.2
154-
155- private const val SEARCH_TOP_PAD_DP = 8
145+ // Matches M3's internal `Tab.HorizontalTextPadding` — the horizontal padding
146+ // applied by `Tab` between its layout bounds and the text label inside.
147+ // Hardcoded because the upstream constant is `internal`.
148+ private const val TAB_INTERNAL_TEXT_PAD_DP = 16
149+
150+ private const val SEARCH_TOP_PAD_DP = 12
156151private const val SEARCH_HORIZONTAL_PAD_DP = 20
157152private const val SEARCH_BOTTOM_PAD_DP = 10
158153private const val SEARCH_HEIGHT_DP = 40
@@ -188,7 +183,7 @@ private const val DISABLED_ALPHA = 0.5f
188183 * Bottom-sheet block inserter. The outer shell stays a `BottomSheetDialog` so
189184 * `GutenbergView`'s integration surface is unchanged; everything visible is
190185 * Compose content matching the Variation B design handoff — header row,
191- * pill category tabs, rounded search, 5-column tonal tile grid.
186+ * scrollable secondary tabs, rounded search, 5-column tonal tile grid.
192187 *
193188 * The sheet background and 28dp top corners are drawn by Compose directly; the
194189 * dialog's default white pill background is cleared so it doesn't fight the
@@ -411,7 +406,9 @@ private fun Header(onClose: () -> Unit) {
411406 fontSize = HEADER_TITLE_SP .sp,
412407 fontWeight = FontWeight .Medium ,
413408 letterSpacing = HEADER_TITLE_LETTER_SPACING_SP .sp,
414- modifier = Modifier .weight(1f ),
409+ modifier = Modifier
410+ .weight(1f )
411+ .semantics { heading() },
415412 )
416413 CloseButton (onClose = onClose)
417414 }
@@ -558,81 +555,31 @@ private fun MediaActionTile(
558555 }
559556}
560557
558+ @OptIn(ExperimentalMaterial3Api ::class )
561559@Composable
562560private fun CategoryTabs (
563561 selected : BlockPickerTab ,
564562 onSelect : (BlockPickerTab ) -> Unit ,
565563) {
566- val scrollState = rememberScrollState()
567- val verticalRelay = rememberScrollableState { 0f }
568- Box (
569- modifier = Modifier
570- .fillMaxWidth()
571- .padding(top = TABS_VERTICAL_PAD_DP .dp, bottom = TABS_BOTTOM_PAD_DP .dp),
564+ val tabs = BlockPickerTab .entries
565+ val selectedIndex = tabs.indexOf(selected).coerceAtLeast(0 )
566+ // Tab labels sit 16dp inside the Tab layout (M3's internal
567+ // `HorizontalTextPadding`), so set `edgePadding` to `SEARCH_HORIZONTAL_PAD_DP
568+ // - 16` to align the first label's leading edge with the search bar's
569+ // outer edge instead of the default 52dp.
570+ SecondaryScrollableTabRow (
571+ selectedTabIndex = selectedIndex,
572+ containerColor = ComposeColor .Transparent ,
573+ edgePadding = (SEARCH_HORIZONTAL_PAD_DP - TAB_INTERNAL_TEXT_PAD_DP ).dp,
574+ modifier = Modifier .fillMaxWidth(),
572575 ) {
573- Row (
574- horizontalArrangement = Arrangement .spacedBy(TABS_GAP_DP .dp),
575- modifier = Modifier
576- .horizontalScroll(scrollState)
577- .scrollable(verticalRelay, Orientation .Vertical )
578- .padding(
579- horizontal = TABS_CONTENT_HORIZONTAL_PAD_DP .dp,
580- vertical = TABS_CONTENT_VERTICAL_PAD_DP .dp,
581- ),
582- ) {
583- BlockPickerTab .entries.forEach { tab ->
584- CategoryChip (
585- label = stringResource(tab.labelRes),
586- selected = tab == selected,
587- onClick = { onSelect(tab) },
588- )
589- }
590- }
591- }
592- }
593-
594- @Composable
595- private fun CategoryChip (
596- label : String ,
597- selected : Boolean ,
598- onClick : () -> Unit ,
599- ) {
600- val background = if (selected) {
601- MaterialTheme .colorScheme.primary
602- } else {
603- ComposeColor .Transparent
604- }
605- val textColor = if (selected) {
606- MaterialTheme .colorScheme.onPrimary
607- } else {
608- MaterialTheme .colorScheme.onSurface
609- }
610- val borderColor = if (selected) {
611- ComposeColor .Transparent
612- } else {
613- MaterialTheme .colorScheme.outlineVariant
614- }
615- Box (
616- contentAlignment = Alignment .Center ,
617- modifier = Modifier
618- .height(CHIP_HEIGHT_DP .dp)
619- .clip(RoundedCornerShape (CHIP_CORNER_DP .dp))
620- .background(background)
621- .border(
622- width = CHIP_BORDER_WIDTH_DP .dp,
623- color = borderColor,
624- shape = RoundedCornerShape (CHIP_CORNER_DP .dp),
576+ tabs.forEach { tab ->
577+ Tab (
578+ selected = tab == selected,
579+ onClick = { onSelect(tab) },
580+ text = { Text (stringResource(tab.labelRes)) },
625581 )
626- .clickable(onClick = onClick)
627- .padding(horizontal = CHIP_HORIZONTAL_PAD_DP .dp),
628- ) {
629- Text (
630- text = label,
631- color = textColor,
632- fontSize = CHIP_FONT_SP .sp,
633- fontWeight = FontWeight .Medium ,
634- letterSpacing = CHIP_LETTER_SPACING_SP .sp,
635- )
582+ }
636583 }
637584}
638585
@@ -690,6 +637,13 @@ private fun SearchInput(
690637 onQueryChange : (String ) -> Unit ,
691638 modifier : Modifier = Modifier ,
692639) {
640+ // The placeholder Text inside `decorationBox` is a visual hint, not an
641+ // accessible name — without `contentDescription` on the field, focusing
642+ // the empty field would announce as "edit box" with no context.
643+ // `clearAndSetSemantics {}` on the placeholder hides its own `text`
644+ // semantic so TalkBack reads the field's `contentDescription` once
645+ // ("Search blocks, edit box") instead of duplicating from the placeholder.
646+ val label = stringResource(R .string.gbk_block_inserter_search)
693647 BasicTextField (
694648 value = query,
695649 onValueChange = onQueryChange,
@@ -699,13 +653,14 @@ private fun SearchInput(
699653 color = MaterialTheme .colorScheme.onSurface,
700654 ),
701655 cursorBrush = SolidColor (MaterialTheme .colorScheme.primary),
702- modifier = modifier,
656+ modifier = modifier.semantics { contentDescription = label } ,
703657 decorationBox = { inner ->
704658 if (query.isEmpty()) {
705659 Text (
706- text = stringResource( R .string.gbk_block_inserter_search) ,
660+ text = label ,
707661 color = MaterialTheme .colorScheme.onSurfaceVariant,
708662 fontSize = SEARCH_FONT_SP .sp,
663+ modifier = Modifier .clearAndSetSemantics {},
709664 )
710665 }
711666 inner()
@@ -798,7 +753,11 @@ private fun BlockTile(
798753 modifier = Modifier
799754 .fillMaxWidth()
800755 .clip(RoundedCornerShape (BLOCK_TILE_BUTTON_CORNER_DP .dp))
801- .clickable(enabled = ! block.isDisabled, onClick = onClick)
756+ .clickable(
757+ enabled = ! block.isDisabled,
758+ role = Role .Button ,
759+ onClick = onClick,
760+ )
802761 .padding(
803762 horizontal = BLOCK_TILE_HORIZONTAL_PAD_DP .dp,
804763 )
@@ -908,25 +867,40 @@ private fun AutoShrinkTileLabel(
908867
909868@Composable
910869private fun EmptyState (query : String , modifier : Modifier = Modifier ) {
911- val text = if (query.isNotBlank()) {
870+ val visibleText = if (query.isNotBlank()) {
912871 stringResource(R .string.gbk_block_inserter_no_results_for, query)
913872 } else {
914873 stringResource(R .string.gbk_block_inserter_no_results)
915874 }
875+ val announcedText = stringResource(R .string.gbk_block_inserter_no_results)
876+ // `liveRegion` so TalkBack announces the change when search filters the
877+ // grid down to no results — otherwise the composition swap is silent.
878+ // The visible Text would change on every keystroke that produces no
879+ // results, which would queue a fresh announcement each time. Keeping the
880+ // box's `contentDescription` constant — and clearing the inner Text's
881+ // own semantics — means TalkBack announces "No results" once on the
882+ // empty -> empty-results transition. The visible string still shows the
883+ // queried term to sighted users; blind users can re-focus the field to
884+ // hear the query back via `editableText`.
916885 Box (
917886 contentAlignment = Alignment .Center ,
918887 modifier = modifier
919888 .fillMaxWidth()
920889 .padding(
921890 horizontal = EMPTY_STATE_HORIZONTAL_PAD_DP .dp,
922891 vertical = EMPTY_STATE_VERTICAL_PAD_DP .dp,
923- ),
892+ )
893+ .semantics {
894+ liveRegion = LiveRegionMode .Polite
895+ contentDescription = announcedText
896+ },
924897 ) {
925898 Text (
926- text = text ,
899+ text = visibleText ,
927900 color = MaterialTheme .colorScheme.onSurfaceVariant,
928901 fontSize = EMPTY_STATE_FONT_SP .sp,
929902 textAlign = TextAlign .Center ,
903+ modifier = Modifier .clearAndSetSemantics {},
930904 )
931905 }
932906}
0 commit comments