Skip to content

Commit df03ff8

Browse files
authored
refactor(android): replace pill chips with M3 scrollable tabs in inserter (#512)
* refactor(android): replace pill chips with M3 scrollable tabs in inserter Rebuilds the category strip on `SecondaryScrollableTabRow` + `Tab`. The platform component provides selectable-group semantics, horizontal scroll, auto-scroll-to-selected, ripple, `Role.Tab` a11y, the underline indicator + divider, and a 48dp touch target — all for free. Deletes the custom `CategoryChip`, its shared `MutableInteractionSource` ripple- clip trick, and the chip-design constants. `edgePadding` is set to `SEARCH_HORIZONTAL_PAD_DP - TAB_INTERNAL_TEXT_PAD_DP` so the first tab label's leading edge lines up with the search bar's outer left edge. M3 1.3.1 hardcodes `ScrollableTabRowMinimumTabWidth = 90.dp`. The `minTabWidth` parameter that would let us override it ships in 1.4.0 and isn't worth a project-wide Compose BOM bump for this PR. Also folds in a few targeted TalkBack semantics fixes elsewhere in the sheet, all introduced by #461: - Header title gets `heading()` for heading navigation. - Search field's `BasicTextField` gets a `contentDescription` matching its visible placeholder; the placeholder Text uses `clearAndSetSemantics {}` so it doesn't double-announce alongside the field. - Block tiles get `role = Role.Button` on their `clickable`. - Empty state gets `liveRegion = Polite` with a constant `contentDescription` ("No results") and `clearAndSetSemantics {}` on the visible Text, so TalkBack announces once on the empty-results transition rather than re-announcing on every keystroke. Blind users recover the query by focusing the search field (TalkBack reads back `editableText`). * fix(js): set aria-label on native-inserter "Add block" trigger button The button was using `title={ __( 'Add block' ) }` on the `@wordpress/components` `<Button>`, which only sets the HTML `title` attribute (a hover tooltip on desktop). For an icon-only button, TalkBack on Android either skips the node or announces something unhelpful — the canonical accessible name comes from `aria-label`, which `@wordpress/components` sets via the `label` prop, not `title`. Switch to `label={ __( 'Add block' ) }` so the rendered HTML carries `aria-label="Add block"` and TalkBack announces "Add block, button" when focused. Empirically verified on a Pixel 9 Pro XL.
1 parent c81904f commit df03ff8

2 files changed

Lines changed: 70 additions & 96 deletions

File tree

android/Gutenberg/src/main/java/org/wordpress/gutenberg/inserter/BlockPickerDialog.kt

Lines changed: 69 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,7 @@ import androidx.activity.result.PickVisualMediaRequest
1414
import androidx.activity.result.contract.ActivityResultContracts
1515
import androidx.compose.foundation.Image
1616
import androidx.compose.foundation.background
17-
import androidx.compose.foundation.border
1817
import 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
2318
import androidx.compose.foundation.isSystemInDarkTheme
2419
import androidx.compose.foundation.layout.Arrangement
2520
import androidx.compose.foundation.layout.Box
@@ -37,7 +32,6 @@ import androidx.compose.foundation.layout.size
3732
import androidx.compose.foundation.layout.width
3833
import androidx.compose.foundation.lazy.grid.GridCells
3934
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
40-
import androidx.compose.foundation.rememberScrollState
4135
import androidx.compose.foundation.shape.RoundedCornerShape
4236
import androidx.compose.foundation.text.BasicTextField
4337
import androidx.compose.material.icons.Icons
@@ -46,9 +40,12 @@ import androidx.compose.material.icons.filled.PhotoCamera
4640
import androidx.compose.material.icons.filled.PhotoLibrary
4741
import androidx.compose.material.icons.filled.Search
4842
import androidx.compose.material3.ColorScheme
43+
import androidx.compose.material3.ExperimentalMaterial3Api
4944
import androidx.compose.material3.Icon
5045
import androidx.compose.material3.IconButton
5146
import androidx.compose.material3.MaterialTheme
47+
import androidx.compose.material3.SecondaryScrollableTabRow
48+
import androidx.compose.material3.Tab
5249
import androidx.compose.material3.Text
5350
import androidx.compose.material3.darkColorScheme
5451
import androidx.compose.material3.dynamicDarkColorScheme
@@ -80,7 +77,12 @@ import androidx.compose.ui.platform.LocalFocusManager
8077
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
8178
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
8279
import androidx.compose.ui.res.stringResource
80+
import androidx.compose.ui.semantics.LiveRegionMode
8381
import 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
8486
import androidx.compose.ui.semantics.role
8587
import androidx.compose.ui.semantics.semantics
8688
import androidx.compose.ui.text.TextStyle
@@ -140,19 +142,12 @@ private const val MEDIA_STACK_CORNER_DP = 18
140142
private const val MEDIA_STACK_ICON_SIZE_DP = 28
141143
private 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
156151
private const val SEARCH_HORIZONTAL_PAD_DP = 20
157152
private const val SEARCH_BOTTOM_PAD_DP = 10
158153
private 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
562560
private 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
910869
private 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
}

src/components/native-inserter/index.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ export default function NativeBlockInserterButton( { open, onToggle } ) {
421421
return (
422422
<Button
423423
ref={ buttonRef }
424-
title={ __( 'Add block' ) }
424+
label={ __( 'Add block' ) }
425425
icon={ plus }
426426
onClick={ () => {
427427
// Skip the redux toggle and present the native inserter

0 commit comments

Comments
 (0)