Skip to content

Commit ea3537b

Browse files
Danialsamadiclaude
andauthored
fix(a11y): restore TalkBack tab states, native toggle/checkbox/radio roles, in-locale Connect strings (#45)
* docs(a11y): add 2026-05-19 accessibility fixes plan Captures the four TalkBack regressions reported by blind users and the plan to fix them using native Role.Tab/Role.Switch/Role.Checkbox semantics plus in-app-locale Connect-button strings. * chore(a11y): add Compose selection/Role/clearAndSetSemantics imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(a11y): use Role.Tab + selectable for bottom nav tabs Restores 'selected' state announcement and eliminates the triple read of each tab label by merging descendants and dropping the Icon's redundant contentDescription. * fix(a11y): use Role.Tab + selectable for ProfileTabSwitch Drops custom cd_profile_tab_selected/unselected strings in favor of the platform's native Tab role announcement. * fix(a11y): make ToggleRow a Role.Switch toggleable row Drops custom cd_toggle_row_on/off strings. The native Switch role makes TalkBack announce on/off naturally, fulfilling the user's 'use native controls' request. * fix(a11y): make ParallelTestConfigRow a Role.Checkbox toggleable row Single focus stop per config, native checked/not-checked announcement, honors the row's enabled parameter for the screen reader. * fix(a11y): localize Connect button screen-reader strings Migrates cd_connect_button_* into WhiteDnsStrings / WhiteDnsL10n so the button's accessibility description follows the in-app language picker, not the device system locale. Adds merged semantics so the button is a single focus stop. * chore(a11y): remove cd_* resources superseded by native semantics These keys are no longer referenced from Kotlin code now that bottom nav, profile tabs, ToggleRow, ParallelTestConfigRow, and the Connect button use native Role-based semantics or the in-app WhiteDnsL10n pipeline. * fix(a11y): use Role.RadioButton + selectable for segmented controls Language, Theme, and Connection mode segmented controls each had .clickable(enabled = !selected) on the option boxes, which: - gave the selected option no Role, so TalkBack never announced "selected" when the language (or theme/mode) changed - made the selected option non-focusable for the screen reader Switching to .selectable(selected, role = Role.RadioButton, onClick) + .semantics(mergeDescendants = true) {} fixes both: TalkBack now announces "<label>, radio button, selected/not selected" with a single focus stop per option. The ConnectionMode control keeps its outer enabled flag wired through to selectable.enabled. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent eee75ad commit ea3537b

6 files changed

Lines changed: 960 additions & 91 deletions

File tree

app/src/main/java/shop/whitedns/client/ui/WhiteDnsScreen.kt

Lines changed: 85 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ import androidx.compose.foundation.layout.statusBarsPadding
5757
import androidx.compose.foundation.layout.width
5858
import androidx.compose.foundation.layout.widthIn
5959
import androidx.compose.foundation.rememberScrollState
60+
import androidx.compose.foundation.selection.selectable
61+
import androidx.compose.foundation.selection.toggleable
6062
import androidx.compose.foundation.shape.CircleShape
6163
import androidx.compose.foundation.shape.RoundedCornerShape
6264
import androidx.compose.foundation.text.BasicTextField
@@ -140,7 +142,9 @@ import androidx.compose.ui.text.input.VisualTransformation
140142
import androidx.compose.ui.text.style.TextDirection
141143
import androidx.compose.ui.text.style.TextOverflow
142144
import androidx.compose.ui.res.stringResource
145+
import androidx.compose.ui.semantics.clearAndSetSemantics
143146
import androidx.compose.ui.semantics.contentDescription
147+
import androidx.compose.ui.semantics.Role
144148
import androidx.compose.ui.semantics.semantics
145149
import androidx.compose.ui.unit.LayoutDirection
146150
import androidx.compose.ui.unit.dp
@@ -1246,15 +1250,21 @@ private fun ParallelTestConfigRow(
12461250
modifier = Modifier
12471251
.fillMaxWidth()
12481252
.clip(RoundedCornerShape(11.dp))
1249-
.clickable(enabled = enabled, onClick = onToggle)
1253+
.toggleable(
1254+
value = checked,
1255+
enabled = enabled,
1256+
role = Role.Checkbox,
1257+
onValueChange = { onToggle() },
1258+
)
12501259
.padding(vertical = 7.dp, horizontal = 4.dp),
12511260
horizontalArrangement = Arrangement.spacedBy(8.dp),
12521261
verticalAlignment = Alignment.CenterVertically,
12531262
) {
12541263
Checkbox(
12551264
checked = checked,
12561265
enabled = enabled,
1257-
onCheckedChange = { onToggle() },
1266+
onCheckedChange = null,
1267+
modifier = Modifier.clearAndSetSemantics {},
12581268
colors = CheckboxDefaults.colors(
12591269
checkedColor = WhiteDnsPalette.Accent,
12601270
uncheckedColor = WhiteDnsPalette.ControlBorder,
@@ -2232,7 +2242,6 @@ private fun BottomNavigationBar(
22322242
selectedTab: WhiteDnsTab,
22332243
onTabSelected: (WhiteDnsTab) -> Unit,
22342244
) {
2235-
val context = LocalContext.current
22362245
val haptic = rememberHapticFeedback()
22372246

22382247
Column(
@@ -2266,22 +2275,22 @@ private fun BottomNavigationBar(
22662275
.weight(1f)
22672276
.clip(RoundedCornerShape(14.dp))
22682277
.background(background)
2269-
.semantics {
2270-
contentDescription = context.getString(
2271-
R.string.cd_navigate_to_tab, localizedLabel
2272-
)
2273-
}
2274-
.clickable {
2275-
haptic.performLight()
2276-
onTabSelected(tab)
2277-
}
2278+
.selectable(
2279+
selected = selected,
2280+
role = Role.Tab,
2281+
onClick = {
2282+
haptic.performLight()
2283+
onTabSelected(tab)
2284+
},
2285+
)
2286+
.semantics(mergeDescendants = true) {}
22782287
.padding(vertical = 8.dp),
22792288
horizontalAlignment = Alignment.CenterHorizontally,
22802289
verticalArrangement = Arrangement.spacedBy(4.dp),
22812290
) {
22822291
Icon(
22832292
imageVector = tab.icon,
2284-
contentDescription = localizedLabel,
2293+
contentDescription = null,
22852294
tint = color,
22862295
modifier = Modifier.size(20.dp),
22872296
)
@@ -2305,7 +2314,6 @@ private fun ProfileTabSwitch(
23052314
selectedTab: ProfileTab,
23062315
onTabSelected: (ProfileTab) -> Unit,
23072316
) {
2308-
val context = LocalContext.current
23092317
val haptic = rememberHapticFeedback()
23102318

23112319
Row(
@@ -2331,17 +2339,15 @@ private fun ProfileTabSwitch(
23312339
Color.Transparent
23322340
},
23332341
)
2334-
.semantics {
2335-
contentDescription = if (selected) {
2336-
context.getString(R.string.cd_profile_tab_selected, localizedProfileLabel)
2337-
} else {
2338-
context.getString(R.string.cd_profile_tab_unselected, localizedProfileLabel)
2339-
}
2340-
}
2341-
.clickable {
2342-
haptic.performLight()
2343-
onTabSelected(tab)
2344-
}
2342+
.selectable(
2343+
selected = selected,
2344+
role = Role.Tab,
2345+
onClick = {
2346+
haptic.performLight()
2347+
onTabSelected(tab)
2348+
},
2349+
)
2350+
.semantics(mergeDescendants = true) {}
23452351
.padding(horizontal = 8.dp, vertical = 11.dp),
23462352
contentAlignment = Alignment.Center,
23472353
) {
@@ -2445,10 +2451,16 @@ private fun ConnectionModeSegmentedControl(
24452451
.weight(1f)
24462452
.clip(RoundedCornerShape(9.dp))
24472453
.background(background)
2448-
.clickable(enabled = enabled && !selected) {
2449-
haptic.performLight()
2450-
onModeChange(modeValue)
2451-
}
2454+
.selectable(
2455+
selected = selected,
2456+
enabled = enabled,
2457+
role = Role.RadioButton,
2458+
onClick = {
2459+
haptic.performLight()
2460+
onModeChange(modeValue)
2461+
},
2462+
)
2463+
.semantics(mergeDescendants = true) {}
24522464
.padding(horizontal = 6.dp, vertical = 10.dp),
24532465
contentAlignment = Alignment.Center,
24542466
) {
@@ -2510,10 +2522,15 @@ private fun ThemeModeSegmentedControl(
25102522
.weight(1f)
25112523
.clip(RoundedCornerShape(9.dp))
25122524
.background(background)
2513-
.clickable(enabled = !selected) {
2514-
haptic.performLight()
2515-
onModeChange(modeValue)
2516-
}
2525+
.selectable(
2526+
selected = selected,
2527+
role = Role.RadioButton,
2528+
onClick = {
2529+
haptic.performLight()
2530+
onModeChange(modeValue)
2531+
},
2532+
)
2533+
.semantics(mergeDescendants = true) {}
25172534
.padding(horizontal = 6.dp, vertical = 9.dp),
25182535
contentAlignment = Alignment.Center,
25192536
) {
@@ -6573,10 +6590,15 @@ private fun LanguageModeSegmentedControl(
65736590
.weight(1f)
65746591
.clip(RoundedCornerShape(9.dp))
65756592
.background(background)
6576-
.clickable(enabled = !selected) {
6577-
haptic.performLight()
6578-
onCodeChange(code)
6579-
}
6593+
.selectable(
6594+
selected = selected,
6595+
role = Role.RadioButton,
6596+
onClick = {
6597+
haptic.performLight()
6598+
onCodeChange(code)
6599+
},
6600+
)
6601+
.semantics(mergeDescendants = true) {}
65806602
.padding(horizontal = 6.dp, vertical = 9.dp),
65816603
contentAlignment = Alignment.Center,
65826604
) {
@@ -7404,7 +7426,8 @@ private fun ConnectButton(
74047426
haptic.performMedium()
74057427
onClick()
74067428
},
7407-
),
7429+
)
7430+
.semantics(mergeDescendants = true) {},
74087431
contentAlignment = Alignment.Center,
74097432
) {
74107433
Column(
@@ -7417,13 +7440,11 @@ private fun ConnectButton(
74177440
} else {
74187441
Icons.Rounded.PowerSettingsNew
74197442
},
7420-
contentDescription = stringResource(
7421-
when (status) {
7422-
ConnectionStatus.DISCONNECTED -> R.string.cd_connect_button_disconnected
7423-
ConnectionStatus.CONNECTING -> R.string.cd_connect_button_connecting
7424-
ConnectionStatus.CONNECTED -> R.string.cd_connect_button_connected
7425-
}
7426-
),
7443+
contentDescription = when (status) {
7444+
ConnectionStatus.DISCONNECTED -> WhiteDnsL10n.cdConnectButtonDisconnected
7445+
ConnectionStatus.CONNECTING -> WhiteDnsL10n.cdConnectButtonConnecting
7446+
ConnectionStatus.CONNECTED -> WhiteDnsL10n.cdConnectButtonConnected
7447+
},
74277448
tint = iconColor,
74287449
modifier = Modifier.size(if (status == ConnectionStatus.CONNECTED) 30.dp else 34.dp),
74297450
)
@@ -9334,43 +9355,38 @@ private fun ToggleRow(
93349355
interactiveEnabled: Boolean = true,
93359356
onToggle: () -> Unit,
93369357
) {
9337-
val context = LocalContext.current
93389358
val haptic = rememberHapticFeedback()
93399359
val contentAlpha = if (interactiveEnabled) 1f else 0.46f
93409360

93419361
Row(
93429362
modifier = Modifier
93439363
.fillMaxWidth()
9344-
.semantics {
9345-
contentDescription = if (enabled) {
9346-
context.getString(R.string.cd_toggle_row_on, label)
9347-
} else {
9348-
context.getString(R.string.cd_toggle_row_off, label)
9349-
}
9350-
}
9351-
.clickable(enabled = interactiveEnabled) {
9352-
haptic.performLight()
9353-
onToggle()
9354-
}
9364+
.toggleable(
9365+
value = enabled,
9366+
enabled = interactiveEnabled,
9367+
role = Role.Switch,
9368+
onValueChange = {
9369+
haptic.performLight()
9370+
onToggle()
9371+
},
9372+
)
93559373
.padding(vertical = 10.dp),
93569374
horizontalArrangement = Arrangement.SpaceBetween,
93579375
verticalAlignment = Alignment.CenterVertically,
93589376
) {
9359-
Text(
9360-
text = label,
9361-
style = MaterialTheme.typography.bodyMedium.copy(
9362-
fontSize = 13.sp,
9363-
color = WhiteDnsPalette.FieldLabel.copy(alpha = contentAlpha),
9364-
fontWeight = FontWeight.Medium,
9365-
),
9366-
)
9377+
Text(
9378+
text = label,
9379+
style = MaterialTheme.typography.bodyMedium.copy(
9380+
fontSize = 13.sp,
9381+
color = WhiteDnsPalette.FieldLabel.copy(alpha = contentAlpha),
9382+
fontWeight = FontWeight.Medium,
9383+
),
9384+
)
93679385
Switch(
93689386
checked = enabled,
9387+
onCheckedChange = null,
93699388
enabled = interactiveEnabled,
9370-
onCheckedChange = {
9371-
haptic.performLight()
9372-
onToggle()
9373-
},
9389+
modifier = Modifier.clearAndSetSemantics {},
93749390
colors = SwitchDefaults.colors(
93759391
checkedThumbColor = WhiteDnsPalette.OnAccent,
93769392
checkedTrackColor = WhiteDnsPalette.Accent,

app/src/main/java/shop/whitedns/client/ui/WhiteDnsStrings.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ interface WhiteDnsStrings {
353353
val autoTuneMeasuringSpeed: String
354354
val cdParallelTestSpeed: String
355355
val cdParallelTestPing: String
356+
val cdConnectButtonDisconnected: String
357+
val cdConnectButtonConnecting: String
358+
val cdConnectButtonConnected: String
356359
val cdAutoTuneMtuFailed: String
357360
val cdAutoTuneMtuPassed: String
358361
val cdAutoTuneMtuTesting: String
@@ -1035,6 +1038,9 @@ object EnglishStrings : WhiteDnsStrings {
10351038
override val autoTuneMeasuringSpeed = "Measuring speed"
10361039
override val cdParallelTestSpeed = "Parallel Test speed"
10371040
override val cdParallelTestPing = "Parallel Test ping"
1041+
override val cdConnectButtonDisconnected = "Connect button - tap to start VPN"
1042+
override val cdConnectButtonConnecting = "Connecting - establishing VPN connection"
1043+
override val cdConnectButtonConnected = "Stop button - tap to disconnect VPN"
10381044
override val cdAutoTuneMtuFailed = "MTU failed"
10391045
override val cdAutoTuneMtuPassed = "MTU passed"
10401046
override val cdAutoTuneMtuTesting = "MTU testing"
@@ -1696,6 +1702,9 @@ object PersianStrings : WhiteDnsStrings {
16961702
override val autoTuneMeasuringSpeed = "در حال اندازه‌گیری سرعت"
16971703
override val cdParallelTestSpeed = "سرعت تست موازی"
16981704
override val cdParallelTestPing = "پینگ تست موازی"
1705+
override val cdConnectButtonDisconnected = "دکمه اتصال - ضربه بزنید برای شروع VPN"
1706+
override val cdConnectButtonConnecting = "در حال اتصال - برقراری اتصال VPN"
1707+
override val cdConnectButtonConnected = "دکمه قطع - ضربه بزنید برای قطع VPN"
16991708
override val cdAutoTuneMtuFailed = "MTU ناموفق"
17001709
override val cdAutoTuneMtuPassed = "MTU موفق"
17011710
override val cdAutoTuneMtuTesting = "در حال تست MTU"

app/src/main/java/shop/whitedns/client/ui/WhiteDnsTheme.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,9 @@ object WhiteDnsL10n {
485485
val autoTuneMeasuringSpeed: String @Composable get() = LocalWhiteDnsStrings.current.autoTuneMeasuringSpeed
486486
val cdParallelTestSpeed: String @Composable get() = LocalWhiteDnsStrings.current.cdParallelTestSpeed
487487
val cdParallelTestPing: String @Composable get() = LocalWhiteDnsStrings.current.cdParallelTestPing
488+
val cdConnectButtonDisconnected: String @Composable get() = LocalWhiteDnsStrings.current.cdConnectButtonDisconnected
489+
val cdConnectButtonConnecting: String @Composable get() = LocalWhiteDnsStrings.current.cdConnectButtonConnecting
490+
val cdConnectButtonConnected: String @Composable get() = LocalWhiteDnsStrings.current.cdConnectButtonConnected
488491
val cdAutoTuneMtuFailed: String @Composable get() = LocalWhiteDnsStrings.current.cdAutoTuneMtuFailed
489492
val cdAutoTuneMtuPassed: String @Composable get() = LocalWhiteDnsStrings.current.cdAutoTuneMtuPassed
490493
val cdAutoTuneMtuTesting: String @Composable get() = LocalWhiteDnsStrings.current.cdAutoTuneMtuTesting

app/src/main/res/values-fa/strings.xml

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,6 @@
99
<string name="cd_back_button">بازگشت به عقب</string>
1010
<string name="cd_close_dialog">بستن دیالوگ</string>
1111

12-
<!-- Connect Button States -->
13-
<string name="cd_connect_button_disconnected">دکمه اتصال - ضربه بزنید برای شروع VPN</string>
14-
<string name="cd_connect_button_connecting">در حال اتصال - برقراری اتصال VPN</string>
15-
<string name="cd_connect_button_connected">دکمه قطع - ضربه بزنید برای قطع VPN</string>
16-
<string name="cd_connect_button_disabled">دکمه اتصال - غیرفعال</string>
17-
1812
<!-- Profile Actions -->
1913
<string name="cd_profile_edit">ویرایش پروفایل</string>
2014
<string name="cd_profile_delete">حذف پروفایل</string>
@@ -222,9 +216,6 @@
222216
<!-- Accessibility — Special Labels -->
223217
<string name="cd_parallel_test_expand">گسترش تنظیمات تست موازی</string>
224218
<string name="cd_parallel_test_collapse">جمع کردن تنظیمات تست موازی</string>
225-
<string name="cd_navigate_to_tab">رفتن به %1$s</string>
226-
<string name="cd_profile_tab_selected">%1$s، تب انتخاب شده</string>
227-
<string name="cd_profile_tab_unselected">تب %1$s</string>
228219
<string name="cd_telegram_link">باز کردن انجمن تلگرام WhiteDNS، برنامه خارجی باز می‌شود</string>
229220
<string name="cd_select_profile_item">انتخاب پروفایل %1$s</string>
230221
<string name="cd_logo_telegram">لوگوی WhiteDNS، باز کردن انجمن تلگرام</string>
@@ -236,8 +227,6 @@
236227
<string name="cd_stat_card_detail">%1$s: %2$s، ضربه برای جزئیات</string>
237228
<string name="cd_section_expand">گسترش بخش %1$s</string>
238229
<string name="cd_section_collapse">جمع کردن بخش %1$s</string>
239-
<string name="cd_toggle_row_on">%1$s، فعال، ضربه برای غیرفعال کردن</string>
240-
<string name="cd_toggle_row_off">%1$s، غیرفعال، ضربه برای فعال کردن</string>
241230
<string name="cd_scan_autosave_enabled">ذخیره خودکار اسکن فعال</string>
242231
<string name="cd_worker_count_slider">اسلایدر تعداد ورکر، در حال حاضر %1$d ورکر</string>
243232
<string name="cd_mtu_parallelism_slider">اسلایدر موازی‌سازی MTU ریزالور، در حال حاضر %1$d تست موازی</string>

app/src/main/res/values/strings.xml

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,6 @@
99
<string name="cd_back_button">Navigate back</string>
1010
<string name="cd_close_dialog">Close dialog</string>
1111

12-
<!-- Connect Button States -->
13-
<string name="cd_connect_button_disconnected">Connect button - tap to start VPN</string>
14-
<string name="cd_connect_button_connecting">Connecting - establishing VPN connection</string>
15-
<string name="cd_connect_button_connected">Stop button - tap to disconnect VPN</string>
16-
<string name="cd_connect_button_disabled">Connect button - disabled</string>
17-
1812
<!-- Profile Actions -->
1913
<string name="cd_profile_edit">Edit profile</string>
2014
<string name="cd_profile_delete">Delete profile</string>
@@ -222,9 +216,6 @@
222216
<!-- Accessibility — Missing Labels -->
223217
<string name="cd_parallel_test_expand">Expand parallel test configurations</string>
224218
<string name="cd_parallel_test_collapse">Collapse parallel test configurations</string>
225-
<string name="cd_navigate_to_tab">Navigate to %1$s</string>
226-
<string name="cd_profile_tab_selected">%1$s, selected tab</string>
227-
<string name="cd_profile_tab_unselected">%1$s tab</string>
228219
<string name="cd_telegram_link">Open WhiteDNS Telegram community, opens external app</string>
229220
<string name="cd_select_profile_item">Select %1$s profile</string>
230221
<string name="cd_logo_telegram">WhiteDNS logo, opens Telegram community</string>
@@ -236,8 +227,6 @@
236227
<string name="cd_stat_card_detail">%1$s: %2$s, tap for details</string>
237228
<string name="cd_section_expand">Expand %1$s section</string>
238229
<string name="cd_section_collapse">Collapse %1$s section</string>
239-
<string name="cd_toggle_row_on">%1$s, enabled, tap to disable</string>
240-
<string name="cd_toggle_row_off">%1$s, disabled, tap to enable</string>
241230
<string name="cd_scan_autosave_enabled">Scan auto-save enabled</string>
242231
<string name="cd_worker_count_slider">Worker count slider, currently %1$d workers</string>
243232
<string name="cd_mtu_parallelism_slider">Resolver MTU parallelism slider, currently %1$d parallel tests</string>

0 commit comments

Comments
 (0)