diff --git a/README.md b/README.md index e1ecc62..f081b56 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ See [RULES.md](RULES.md) for complete behavior, fixes, WCAG references, and exam | Rule ID | Name | Severity | Details | | --- | --- | --- | --- | +| `touch-target-overlap` | Touch Target Overlap | Warning | [RULES.md](RULES.md#touch-target-overlap---touch-target-overlap) | | `missing-content-description` | Missing Content Description | Error | [RULES.md](RULES.md#missing-content-description---missing-content-description) | | `duplicate-content-description` | Duplicate Content Description | Warning | [RULES.md](RULES.md#duplicate-content-description---duplicate-content-description) | | `focus-order` | Focus Order | Error | [RULES.md](RULES.md#focus-order---focus-order) | diff --git a/RULES.md b/RULES.md index 7882440..4133d87 100644 --- a/RULES.md +++ b/RULES.md @@ -8,11 +8,36 @@ Implementation note: the current codebase exposes 6 built-in rules. This documen | Rule ID | Name | Severity | What It Checks | How To Fix | WCAG Reference | | --- | --- | --- | --- | --- | --- | +| `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 | | `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) | | `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) | | `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) | | `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) | | `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) | +| `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) | + +## `touch-target-overlap` - Touch Target Overlap + +**Severity:** Warning + +**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. + +**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. + +**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. + +**Code example:** + +```kotlin +Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + IconButton(onClick = onPrevious) { + Icon(Icons.Default.ArrowBack, contentDescription = "Previous") + } + IconButton(onClick = onNext) { + Icon(Icons.Default.ArrowForward, contentDescription = "Next") + } +} +``` | `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) | ## `missing-content-description` - Missing Content Description diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt index 899b9ef..ac166b6 100644 --- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt +++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt @@ -8,6 +8,7 @@ package com.composea11yscanner.core.model * @property bounds Pixel bounds relative to the scanned root. * @property contentDescription Accessible label exposed by the node, if any. * @property isTouchTarget True when the node exposes a click action. + * @property effectiveTouchBounds Effective pointer target bounds in root pixels for clickable nodes. * @property textColor Foreground text color when it can be extracted. * @property backgroundColors Candidate background colors sampled behind the node. * @property isFocusable True when the node can participate in focus traversal. @@ -27,4 +28,5 @@ data class A11yNode( val isMergedDescendant: Boolean, val depth: Int, val role: A11yRole? = null, + val effectiveTouchBounds: Rect? = null, ) diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt index 1acd8a1..ee508e9 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt @@ -10,6 +10,7 @@ object ScannerRules { /** Returns every built-in rule id understood by [buildRules]. */ fun allRuleIds(): List = listOf( + "touch-target-overlap", "missing-content-description", "duplicate-content-description", "focus-order", @@ -26,6 +27,8 @@ object ScannerRules { * @return Rule instances that should run for the provided configuration. */ fun buildRules(config: ScannerConfig, screenDensity: Float): List = buildList { + if ("touch-target-overlap" in config.enabledRules) + add(TouchTargetOverlapRule()) if ("missing-content-description" in config.enabledRules) add(MissingContentDescriptionRule()) if ("duplicate-content-description" in config.enabledRules) diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt new file mode 100644 index 0000000..9a6bede --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt @@ -0,0 +1,53 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.Rect +import com.composea11yscanner.core.rule.BaseScanRule + +/** Reports interactive nodes whose effective pointer target overlaps another target. */ +class TouchTargetOverlapRule : BaseScanRule() { + + override val ruleId = "touch-target-overlap" + override val ruleName = "Touch Target Overlap" + override val severity = A11ySeverity.Warning + override val wcagReference: String? = null + + override fun evaluateAll(nodes: List): List { + val targets = nodes.filter { node -> + node.isTouchTarget && + !node.isMergedDescendant && + node.effectiveTouchBounds?.isEmpty() == false + } + val overlapsByNodeId = mutableMapOf>() + + targets.forEachIndexed { index, first -> + for (secondIndex in index + 1 until targets.size) { + val second = targets[secondIndex] + if (!first.effectiveTouchBounds!!.overlaps(second.effectiveTouchBounds!!)) continue + + overlapsByNodeId.getOrPut(first.nodeId) { mutableSetOf() }.add(second.nodeId) + overlapsByNodeId.getOrPut(second.nodeId) { mutableSetOf() }.add(first.nodeId) + } + } + + return targets.mapNotNull { node -> + val overlappingIds = overlapsByNodeId[node.nodeId] ?: return@mapNotNull null + val targetWord = if (overlappingIds.size == 1) "target" else "targets" + issue( + node = node, + message = "Effective touch target overlaps ${overlappingIds.size} other $targetWord. " + + "Overlapping hit regions can make the intended control ambiguous.", + howToFix = "Increase spacing between controls, enlarge their layout bounds, or " + + "restructure the layout so effective touch regions do not overlap.", + ) + } + } +} + +private fun Rect.overlaps(other: Rect): Boolean = + left < other.right && + right > other.left && + top < other.bottom && + bottom > other.top diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt index 227d4d5..e181aa5 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt @@ -13,6 +13,7 @@ fun createNode( bounds: Rect = Rect(0, 0, 100, 100), contentDescription: String? = null, isTouchTarget: Boolean = false, + effectiveTouchBounds: Rect? = null, textColor: Color? = null, backgroundColors: List = emptyList(), isFocusable: Boolean = false, @@ -32,4 +33,5 @@ fun createNode( isMergedDescendant = isMergedDescendant, depth = depth, role = role, + effectiveTouchBounds = effectiveTouchBounds, ) diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt new file mode 100644 index 0000000..2081986 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt @@ -0,0 +1,70 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TouchTargetOverlapRuleTest { + + private val rule = TouchTargetOverlapRule() + + @Test + fun `overlapping effective targets report each affected node once`() { + val first = target("first", Rect(0, 0, 48, 48)) + val second = target("second", Rect(40, 0, 88, 48)) + + val issues = rule.evaluateAll(listOf(first, second)) + + assertEquals(2, issues.size) + assertEquals(setOf("first", "second"), issues.map { it.affectedNode.nodeId }.toSet()) + } + + @Test + fun `adjacent targets that only share an edge pass`() { + val first = target("first", Rect(0, 0, 48, 48)) + val second = target("second", Rect(48, 0, 96, 48)) + + assertTrue(rule.evaluateAll(listOf(first, second)).isEmpty()) + } + + @Test + fun `non-interactive merged and missing bounds nodes are ignored`() { + val valid = target("valid", Rect(0, 0, 48, 48)) + val nonInteractive = createNode( + nodeId = "non-interactive", + isTouchTarget = false, + effectiveTouchBounds = Rect(0, 0, 48, 48), + ) + val merged = target("merged", Rect(0, 0, 48, 48), isMergedDescendant = true) + val missingBounds = createNode(nodeId = "missing", isTouchTarget = true) + + assertTrue(rule.evaluateAll(listOf(valid, nonInteractive, merged, missingBounds)).isEmpty()) + } + + @Test + fun `one node overlapping multiple targets produces one aggregated issue`() { + val center = target("center", Rect(20, 0, 68, 48)) + val left = target("left", Rect(0, 0, 40, 48)) + val right = target("right", Rect(60, 0, 108, 48)) + + val centerIssue = rule.evaluateAll(listOf(center, left, right)) + .single { it.affectedNode.nodeId == "center" } + + assertTrue(centerIssue.message.contains("2 other targets")) + assertEquals(A11ySeverity.Warning, centerIssue.severity) + assertEquals(null, centerIssue.wcagReference) + } + + private fun target( + id: String, + bounds: Rect, + isMergedDescendant: Boolean = false, + ) = createNode( + nodeId = id, + isTouchTarget = true, + effectiveTouchBounds = bounds, + isMergedDescendant = isMergedDescendant, + ) +} diff --git a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt index 88b41bc..5d8fc01 100644 --- a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt +++ b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.dp @@ -52,4 +51,24 @@ class A11yNodeExtractorTest { assertTrue(clickableNode.contentDescription.isNullOrBlank()) } + @Test + fun compactClickable_extractsExpandedEffectiveTouchBounds() { + composeRule.setContent { + Box( + modifier = Modifier + .size(width = 40.dp, height = 48.dp) + .clickable { }, + ) + } + + composeRule.waitForIdle() + + val nodes = A11yNodeExtractor() + .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) + val clickableNode = nodes.single { it.isTouchTarget } + + val effectiveBounds = clickableNode.effectiveTouchBounds!! + assertTrue(effectiveBounds.width >= clickableNode.bounds.width) + assertTrue(effectiveBounds.height >= clickableNode.bounds.height) + } } diff --git a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt index a546a55..45f4071 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt @@ -99,6 +99,8 @@ object ScanResultExporter { append(", ") appendJsonPair("isTouchTarget", isTouchTarget) append(", ") + appendJsonNullablePair("effectiveTouchBounds", effectiveTouchBounds) + append(", ") appendJsonPair("textColor", textColor) append(", ") append("\"backgroundColors\": [") @@ -178,6 +180,13 @@ object ScanResultExporter { append(value.toJson()) } + private fun StringBuilder.appendJsonNullablePair(name: String, value: Rect?) { + append("\"") + append(name.escapeJson()) + append("\": ") + append(value?.toJson() ?: "null") + } + private fun StringBuilder.appendJsonPair(name: String, value: Color?) { append("\"") append(name.escapeJson()) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt index 3e7c956..6fa3905 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt @@ -97,6 +97,9 @@ class A11yNodeExtractor { isMergedDescendant = isMergedDescendant, depth = depth, role = if (isTextInput) A11yRole.TextField else composeRole?.toA11yRole(), + effectiveTouchBounds = touchBoundsInRoot + .takeIf { isTouchTarget } + ?.toCoreRect(), ) } diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt index daf76ea..015d510 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt @@ -395,6 +395,13 @@ private fun IssueDetailPanelMultipleIssuesPreview() { howToFix = "Apply Modifier.clickable(role = Role.Button) for action controls.", wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)", ), + previewIssue( + severity = A11ySeverity.Warning, + ruleName = "Touch Target Overlap", + message = "Effective touch target overlaps another target.", + howToFix = "Increase spacing so effective touch regions do not overlap.", + wcagReference = null, + ), previewIssue( severity = A11ySeverity.Warning, ruleName = "Missing Content Description", diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt index 6223aab..507b488 100644 --- a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt @@ -82,6 +82,14 @@ class IssueDetailPanelSnapshotTest { howToFix = "Apply Modifier.clickable(role = Role.Button) for action controls.", wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)", ), + issueFixture( + severity = A11ySeverity.Warning, + issueId = "touch-target-overlap", + ruleName = "Touch Target Overlap", + message = "Effective touch target overlaps another target.", + howToFix = "Increase spacing so effective touch regions do not overlap.", + wcagReference = null, + ), issueFixture( severity = A11ySeverity.Warning, issueId = "missing-content-description",