Skip to content

Commit ae7b57e

Browse files
authored
Merge pull request #15 from mohdaquib/feature/overlap-rule-impl
Replace TouchTargetRule with TouchTargetOverlapRule
2 parents 138b0f4 + d5f0041 commit ae7b57e

12 files changed

Lines changed: 203 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ See [RULES.md](RULES.md) for complete behavior, fixes, WCAG references, and exam
102102

103103
| Rule ID | Name | Severity | Details |
104104
| --- | --- | --- | --- |
105+
| `touch-target-overlap` | Touch Target Overlap | Warning | [RULES.md](RULES.md#touch-target-overlap---touch-target-overlap) |
105106
| `missing-content-description` | Missing Content Description | Error | [RULES.md](RULES.md#missing-content-description---missing-content-description) |
106107
| `duplicate-content-description` | Duplicate Content Description | Warning | [RULES.md](RULES.md#duplicate-content-description---duplicate-content-description) |
107108
| `focus-order` | Focus Order | Error | [RULES.md](RULES.md#focus-order---focus-order) |

RULES.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,36 @@ Implementation note: the current codebase exposes 6 built-in rules. This documen
88

99
| Rule ID | Name | Severity | What It Checks | How To Fix | WCAG Reference |
1010
| --- | --- | --- | --- | --- | --- |
11+
| `touch-target-overlap` | Touch Target Overlap | Warning | Interactive nodes whose effective Compose touch bounds overlap another effective target. | Increase spacing, enlarge layout bounds, or restructure controls so their effective hit regions do not overlap. | Android accessibility guidance |
1112
| `missing-content-description` | Missing Content Description | Error | Interactive nodes and image-like nodes that do not expose a non-empty content description. | Add a meaningful `contentDescription` through semantics, or pass one directly to image composables that support it. | WCAG 1.1.1 Non-text Content (Level A) |
1213
| `duplicate-content-description` | Duplicate Content Description | Warning | Non-merged nodes at the same semantics depth that reuse the same non-empty content description. | Give each control or item a label that identifies its specific action, state, or content. | WCAG 2.4.6 Headings and Labels (Level AA) |
1314
| `focus-order` | Focus Order | Error | Focusable nodes whose semantics traversal jumps upward compared with the previous focusable node's visual position. | Reorder composables so focus follows the visual reading order, or set explicit traversal order with semantics. | WCAG 2.4.3 Focus Order (Level A) |
1415
| `text-scaling` | Text Scaling | Warning | Text nodes that may overflow or clip inside their parent when simulated at a larger font scale. | Avoid fixed-height containers for text; use flexible height, wrapping, or scrolling so scaled text can reflow. | WCAG 1.4.4 Resize Text (Level AA) |
1516
| `image-text-overlay` | Image With Text Overlay | Warning | Text nodes that significantly overlap image nodes, creating a contrast risk across dynamic images. | Add a scrim or solid text background, or otherwise guarantee sufficient contrast for every image state. | WCAG 1.4.3 Contrast Minimum (Level AA) |
17+
| `clickable-role` | Clickable Role | Error | Clickable/touch target nodes that do not expose a semantic role, and clickable image roles without a content description. | Add the appropriate role, such as `Role.Button`, `Role.Checkbox`, or `Role.Image`; provide labels for clickable images. | WCAG 4.1.2 Name, Role, Value (Level A) |
18+
19+
## `touch-target-overlap` - Touch Target Overlap
20+
21+
**Severity:** Warning
22+
23+
**What it checks:** This scan-level rule compares `touchBoundsInRoot` for clickable nodes that are not merged descendants. It reports each affected node once when its effective pointer target intersects one or more other effective targets. Targets that only share an edge are not considered overlapping.
24+
25+
**How to fix:** Increase the layout spacing between controls, give controls layout bounds that accommodate their expanded hit regions, or restructure the layout so each action has an unambiguous pointer target.
26+
27+
**Reference:** Android accessibility touch-target guidance. This warning is not presented as a direct WCAG failure because WCAG target-size criteria include different thresholds and exceptions.
28+
29+
**Code example:**
30+
31+
```kotlin
32+
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
33+
IconButton(onClick = onPrevious) {
34+
Icon(Icons.Default.ArrowBack, contentDescription = "Previous")
35+
}
36+
IconButton(onClick = onNext) {
37+
Icon(Icons.Default.ArrowForward, contentDescription = "Next")
38+
}
39+
}
40+
```
1641
| `clickable-role` | Clickable Role | Error | Clickable nodes with role-specific semantic requirements, currently clickable images without a content description. | Provide a meaningful label for clickable images. A generic clickable does not require a role when no predefined Compose role accurately applies. | WCAG 4.1.2 Name, Role, Value (Level A) |
1742

1843
## `missing-content-description` - Missing Content Description

scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package com.composea11yscanner.core.model
88
* @property bounds Pixel bounds relative to the scanned root.
99
* @property contentDescription Accessible label exposed by the node, if any.
1010
* @property isTouchTarget True when the node exposes a click action.
11+
* @property effectiveTouchBounds Effective pointer target bounds in root pixels for clickable nodes.
1112
* @property textColor Foreground text color when it can be extracted.
1213
* @property backgroundColors Candidate background colors sampled behind the node.
1314
* @property isFocusable True when the node can participate in focus traversal.
@@ -27,4 +28,5 @@ data class A11yNode(
2728
val isMergedDescendant: Boolean,
2829
val depth: Int,
2930
val role: A11yRole? = null,
31+
val effectiveTouchBounds: Rect? = null,
3032
)

scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ object ScannerRules {
1010

1111
/** Returns every built-in rule id understood by [buildRules]. */
1212
fun allRuleIds(): List<String> = listOf(
13+
"touch-target-overlap",
1314
"missing-content-description",
1415
"duplicate-content-description",
1516
"focus-order",
@@ -26,6 +27,8 @@ object ScannerRules {
2627
* @return Rule instances that should run for the provided configuration.
2728
*/
2829
fun buildRules(config: ScannerConfig, screenDensity: Float): List<A11yRule> = buildList {
30+
if ("touch-target-overlap" in config.enabledRules)
31+
add(TouchTargetOverlapRule())
2932
if ("missing-content-description" in config.enabledRules)
3033
add(MissingContentDescriptionRule())
3134
if ("duplicate-content-description" in config.enabledRules)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.composea11yscanner.rules
2+
3+
import com.composea11yscanner.core.model.A11yIssue
4+
import com.composea11yscanner.core.model.A11yNode
5+
import com.composea11yscanner.core.model.A11ySeverity
6+
import com.composea11yscanner.core.model.Rect
7+
import com.composea11yscanner.core.rule.BaseScanRule
8+
9+
/** Reports interactive nodes whose effective pointer target overlaps another target. */
10+
class TouchTargetOverlapRule : BaseScanRule() {
11+
12+
override val ruleId = "touch-target-overlap"
13+
override val ruleName = "Touch Target Overlap"
14+
override val severity = A11ySeverity.Warning
15+
override val wcagReference: String? = null
16+
17+
override fun evaluateAll(nodes: List<A11yNode>): List<A11yIssue> {
18+
val targets = nodes.filter { node ->
19+
node.isTouchTarget &&
20+
!node.isMergedDescendant &&
21+
node.effectiveTouchBounds?.isEmpty() == false
22+
}
23+
val overlapsByNodeId = mutableMapOf<String, MutableSet<String>>()
24+
25+
targets.forEachIndexed { index, first ->
26+
for (secondIndex in index + 1 until targets.size) {
27+
val second = targets[secondIndex]
28+
if (!first.effectiveTouchBounds!!.overlaps(second.effectiveTouchBounds!!)) continue
29+
30+
overlapsByNodeId.getOrPut(first.nodeId) { mutableSetOf() }.add(second.nodeId)
31+
overlapsByNodeId.getOrPut(second.nodeId) { mutableSetOf() }.add(first.nodeId)
32+
}
33+
}
34+
35+
return targets.mapNotNull { node ->
36+
val overlappingIds = overlapsByNodeId[node.nodeId] ?: return@mapNotNull null
37+
val targetWord = if (overlappingIds.size == 1) "target" else "targets"
38+
issue(
39+
node = node,
40+
message = "Effective touch target overlaps ${overlappingIds.size} other $targetWord. " +
41+
"Overlapping hit regions can make the intended control ambiguous.",
42+
howToFix = "Increase spacing between controls, enlarge their layout bounds, or " +
43+
"restructure the layout so effective touch regions do not overlap.",
44+
)
45+
}
46+
}
47+
}
48+
49+
private fun Rect.overlaps(other: Rect): Boolean =
50+
left < other.right &&
51+
right > other.left &&
52+
top < other.bottom &&
53+
bottom > other.top

scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ fun createNode(
1313
bounds: Rect = Rect(0, 0, 100, 100),
1414
contentDescription: String? = null,
1515
isTouchTarget: Boolean = false,
16+
effectiveTouchBounds: Rect? = null,
1617
textColor: Color? = null,
1718
backgroundColors: List<Color> = emptyList(),
1819
isFocusable: Boolean = false,
@@ -32,4 +33,5 @@ fun createNode(
3233
isMergedDescendant = isMergedDescendant,
3334
depth = depth,
3435
role = role,
36+
effectiveTouchBounds = effectiveTouchBounds,
3537
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.composea11yscanner.rules
2+
3+
import com.composea11yscanner.core.model.A11ySeverity
4+
import com.composea11yscanner.core.model.Rect
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
class TouchTargetOverlapRuleTest {
10+
11+
private val rule = TouchTargetOverlapRule()
12+
13+
@Test
14+
fun `overlapping effective targets report each affected node once`() {
15+
val first = target("first", Rect(0, 0, 48, 48))
16+
val second = target("second", Rect(40, 0, 88, 48))
17+
18+
val issues = rule.evaluateAll(listOf(first, second))
19+
20+
assertEquals(2, issues.size)
21+
assertEquals(setOf("first", "second"), issues.map { it.affectedNode.nodeId }.toSet())
22+
}
23+
24+
@Test
25+
fun `adjacent targets that only share an edge pass`() {
26+
val first = target("first", Rect(0, 0, 48, 48))
27+
val second = target("second", Rect(48, 0, 96, 48))
28+
29+
assertTrue(rule.evaluateAll(listOf(first, second)).isEmpty())
30+
}
31+
32+
@Test
33+
fun `non-interactive merged and missing bounds nodes are ignored`() {
34+
val valid = target("valid", Rect(0, 0, 48, 48))
35+
val nonInteractive = createNode(
36+
nodeId = "non-interactive",
37+
isTouchTarget = false,
38+
effectiveTouchBounds = Rect(0, 0, 48, 48),
39+
)
40+
val merged = target("merged", Rect(0, 0, 48, 48), isMergedDescendant = true)
41+
val missingBounds = createNode(nodeId = "missing", isTouchTarget = true)
42+
43+
assertTrue(rule.evaluateAll(listOf(valid, nonInteractive, merged, missingBounds)).isEmpty())
44+
}
45+
46+
@Test
47+
fun `one node overlapping multiple targets produces one aggregated issue`() {
48+
val center = target("center", Rect(20, 0, 68, 48))
49+
val left = target("left", Rect(0, 0, 40, 48))
50+
val right = target("right", Rect(60, 0, 108, 48))
51+
52+
val centerIssue = rule.evaluateAll(listOf(center, left, right))
53+
.single { it.affectedNode.nodeId == "center" }
54+
55+
assertTrue(centerIssue.message.contains("2 other targets"))
56+
assertEquals(A11ySeverity.Warning, centerIssue.severity)
57+
assertEquals(null, centerIssue.wcagReference)
58+
}
59+
60+
private fun target(
61+
id: String,
62+
bounds: Rect,
63+
isMergedDescendant: Boolean = false,
64+
) = createNode(
65+
nodeId = id,
66+
isTouchTarget = true,
67+
effectiveTouchBounds = bounds,
68+
isMergedDescendant = isMergedDescendant,
69+
)
70+
}

scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Box
55
import androidx.compose.foundation.layout.size
66
import androidx.compose.material3.Text
77
import androidx.compose.ui.Modifier
8-
import androidx.compose.ui.platform.LocalDensity
98
import androidx.compose.ui.test.junit4.createComposeRule
109
import androidx.compose.ui.test.onRoot
1110
import androidx.compose.ui.unit.dp
@@ -52,4 +51,24 @@ class A11yNodeExtractorTest {
5251
assertTrue(clickableNode.contentDescription.isNullOrBlank())
5352
}
5453

54+
@Test
55+
fun compactClickable_extractsExpandedEffectiveTouchBounds() {
56+
composeRule.setContent {
57+
Box(
58+
modifier = Modifier
59+
.size(width = 40.dp, height = 48.dp)
60+
.clickable { },
61+
)
62+
}
63+
64+
composeRule.waitForIdle()
65+
66+
val nodes = A11yNodeExtractor()
67+
.extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode())
68+
val clickableNode = nodes.single { it.isTouchTarget }
69+
70+
val effectiveBounds = clickableNode.effectiveTouchBounds!!
71+
assertTrue(effectiveBounds.width >= clickableNode.bounds.width)
72+
assertTrue(effectiveBounds.height >= clickableNode.bounds.height)
73+
}
5574
}

scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ object ScanResultExporter {
9999
append(", ")
100100
appendJsonPair("isTouchTarget", isTouchTarget)
101101
append(", ")
102+
appendJsonNullablePair("effectiveTouchBounds", effectiveTouchBounds)
103+
append(", ")
102104
appendJsonPair("textColor", textColor)
103105
append(", ")
104106
append("\"backgroundColors\": [")
@@ -178,6 +180,13 @@ object ScanResultExporter {
178180
append(value.toJson())
179181
}
180182

183+
private fun StringBuilder.appendJsonNullablePair(name: String, value: Rect?) {
184+
append("\"")
185+
append(name.escapeJson())
186+
append("\": ")
187+
append(value?.toJson() ?: "null")
188+
}
189+
181190
private fun StringBuilder.appendJsonPair(name: String, value: Color?) {
182191
append("\"")
183192
append(name.escapeJson())

scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ class A11yNodeExtractor {
9797
isMergedDescendant = isMergedDescendant,
9898
depth = depth,
9999
role = if (isTextInput) A11yRole.TextField else composeRole?.toA11yRole(),
100+
effectiveTouchBounds = touchBoundsInRoot
101+
.takeIf { isTouchTarget }
102+
?.toCoreRect(),
100103
)
101104
}
102105

0 commit comments

Comments
 (0)