Skip to content

Commit d7a4c15

Browse files
authored
Allow specifying AWT AccessibleRole directly (#2577)
1 parent 82c2abc commit d7a4c15

File tree

3 files changed

+82
-10
lines changed

3 files changed

+82
-10
lines changed

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/a11y/ComposeAccessible.kt

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Rect
2121
import androidx.compose.ui.node.Nodes
2222
import androidx.compose.ui.node.requireCoordinator
2323
import androidx.compose.ui.semantics.AccessibilityAction
24+
import androidx.compose.ui.semantics.DesktopSemanticsProperties
2425
import androidx.compose.ui.semantics.ProgressBarRangeInfo
2526
import androidx.compose.ui.semantics.Role
2627
import androidx.compose.ui.semantics.SemanticsActions
@@ -311,12 +312,11 @@ internal class ComposeAccessible(
311312
}
312313

313314
override fun getAccessibleValue(): AccessibleValue? {
314-
val role = semanticsConfig.getOrNull(SemanticsProperties.Role)
315315
// On macOS/VoiceOver, the a11y system appears to inspect the value we return here for
316316
// checkboxes and radio buttons. On Windows, it looks at getAccessibleStateSet instead.
317317
return when {
318318
toggleableState != null -> ToggleableAccessibleValue(this)
319-
role == Role.RadioButton -> RadioButtonAccessibleValue(this)
319+
computeAccessibleRole() == AccessibleRole.RADIO_BUTTON -> RadioButtonAccessibleValue(this)
320320
progressBarRangeInfo != null -> ProgressBarAccessibleValue(this)
321321
else -> null
322322
}
@@ -420,19 +420,26 @@ internal class ComposeAccessible(
420420

421421
// -----------------------------------
422422

423-
override fun getAccessibleRole(): AccessibleRole {
424-
AccessibilityController.AccessibilityUsage.notifyInUse()
425-
val fromSemanticRole = when (semanticsConfig.getOrNull(SemanticsProperties.Role)) {
423+
private fun computeAccessibleRole(): AccessibleRole {
424+
// AWT role takes precedence
425+
semanticsConfig.getOrNull(DesktopSemanticsProperties.AwtRole)?.let {
426+
return it
427+
}
428+
429+
// Check semantics role
430+
val role = semanticsConfig.getOrNull(SemanticsProperties.Role)
431+
val accessibleRole = when (role) {
426432
Role.Button -> AccessibleRole.PUSH_BUTTON
427433
Role.Checkbox, Role.Switch -> AccessibleRole.CHECK_BOX
428434
Role.RadioButton -> AccessibleRole.RADIO_BUTTON
429435
Role.Tab -> AccessibleRole.PAGE_TAB
430436
Role.DropdownList -> AccessibleRole.COMBO_BOX
431437
else -> null
432438
}
439+
if (accessibleRole != null) return accessibleRole
433440

441+
// Guess role from other semantics properties
434442
return when {
435-
fromSemanticRole != null -> fromSemanticRole
436443
isPassword -> AccessibleRole.PASSWORD_TEXT
437444
setText != null -> AccessibleRole.TEXT
438445
scrollBy != null -> AccessibleRole.SCROLL_PANE
@@ -449,6 +456,11 @@ internal class ComposeAccessible(
449456
}
450457
}
451458

459+
override fun getAccessibleRole(): AccessibleRole {
460+
AccessibilityController.AccessibilityUsage.notifyInUse()
461+
return computeAccessibleRole()
462+
}
463+
452464
override fun getAccessibleStateSet(): AccessibleStateSet {
453465
return AccessibleStateSet().apply {
454466
// can we support these
@@ -480,12 +492,12 @@ internal class ComposeAccessible(
480492
if (canCollapse)
481493
add(AccessibleState.EXPANDED)
482494

483-
when (semanticsConfig.getOrNull(SemanticsProperties.Role)) {
495+
when (computeAccessibleRole()) {
484496
// Note that this is not executed on macOS (for checkboxes or radio buttons),
485497
// where the system inspects the value returned by getAccessibleValue instead.
486-
Role.Checkbox, Role.Switch -> addCheckedStateForCheckboxOrSwitch()
487-
Role.RadioButton -> addCheckedStateForRadioButton()
488-
else -> { // Default case, for other (possibly null) roles
498+
AccessibleRole.CHECK_BOX -> addCheckedStateForCheckboxOrSwitch()
499+
AccessibleRole.RADIO_BUTTON -> addCheckedStateForRadioButton()
500+
else -> { // Default case for other roles
489501
addDefaultStateForToggleableState()
490502

491503
if (selected != null)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.semantics
18+
19+
import androidx.compose.ui.ExperimentalComposeUiApi
20+
import javax.accessibility.AccessibleRole
21+
22+
23+
/**
24+
* Extra semantics properties specific to the desktop.
25+
*/
26+
internal object DesktopSemanticsProperties {
27+
28+
/** @see SemanticsPropertyReceiver.awtRole */
29+
val AwtRole = AccessibilityKey<AccessibleRole>("AwtRole") { parentValue, _ -> parentValue }
30+
31+
}
32+
33+
/**
34+
* Specifies directly the [AccessibleRole] reported to AWT for the element.
35+
*
36+
* This should only be used for roles that are not supported by Compose's [Role].
37+
* Note that this overrides the role specified by [SemanticsPropertyReceiver.role], if any.
38+
*
39+
* @see SemanticsPropertyReceiver.role
40+
*/
41+
@ExperimentalComposeUiApi
42+
var SemanticsPropertyReceiver.awtRole by DesktopSemanticsProperties.AwtRole

compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/AccessibilityTest.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import androidx.compose.ui.platform.a11y.ComposeSceneAccessible
4040
import androidx.compose.ui.semantics.Role
4141
import androidx.compose.ui.semantics.SemanticsNode
4242
import androidx.compose.ui.semantics.SemanticsOwner
43+
import androidx.compose.ui.semantics.awtRole
4344
import androidx.compose.ui.semantics.contentDescription
4445
import androidx.compose.ui.semantics.hideFromAccessibility
4546
import androidx.compose.ui.semantics.isContainer
@@ -466,6 +467,23 @@ class AccessibilityTest {
466467
assertNodeWithTagIndexInParentIs("item2", 2)
467468
assertNodeWithTagIndexInParentIs("item3", 1)
468469
}
470+
471+
@Test
472+
fun awtRoleIsCorrect() = runDesktopA11yTest {
473+
test.setContent {
474+
Box(
475+
Modifier
476+
.testTag("button")
477+
.size(100.dp)
478+
.semantics {
479+
awtRole = AccessibleRole.PUSH_BUTTON
480+
}
481+
)
482+
}
483+
484+
assertThat(test.onNodeWithTag("button").fetchAccessible().accessibleContext?.accessibleRole)
485+
.isEqualTo(AccessibleRole.PUSH_BUTTON)
486+
}
469487
}
470488

471489

0 commit comments

Comments
 (0)