Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
25 changes: 25 additions & 0 deletions RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,4 +28,5 @@ data class A11yNode(
val isMergedDescendant: Boolean,
val depth: Int,
val role: A11yRole? = null,
val effectiveTouchBounds: Rect? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ object ScannerRules {

/** Returns every built-in rule id understood by [buildRules]. */
fun allRuleIds(): List<String> = listOf(
"touch-target-overlap",
"missing-content-description",
"duplicate-content-description",
"focus-order",
Expand All @@ -26,6 +27,8 @@ object ScannerRules {
* @return Rule instances that should run for the provided configuration.
*/
fun buildRules(config: ScannerConfig, screenDensity: Float): List<A11yRule> = 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<A11yNode>): List<A11yIssue> {
val targets = nodes.filter { node ->
node.isTouchTarget &&
!node.isMergedDescendant &&
node.effectiveTouchBounds?.isEmpty() == false
}
val overlapsByNodeId = mutableMapOf<String, MutableSet<String>>()

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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Color> = emptyList(),
isFocusable: Boolean = false,
Expand All @@ -32,4 +33,5 @@ fun createNode(
isMergedDescendant = isMergedDescendant,
depth = depth,
role = role,
effectiveTouchBounds = effectiveTouchBounds,
)
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ object ScanResultExporter {
append(", ")
appendJsonPair("isTouchTarget", isTouchTarget)
append(", ")
appendJsonNullablePair("effectiveTouchBounds", effectiveTouchBounds)
append(", ")
appendJsonPair("textColor", textColor)
append(", ")
append("\"backgroundColors\": [")
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ class A11yNodeExtractor {
isMergedDescendant = isMergedDescendant,
depth = depth,
role = if (isTextInput) A11yRole.TextField else composeRole?.toA11yRole(),
effectiveTouchBounds = touchBoundsInRoot
.takeIf { isTouchTarget }
?.toCoreRect(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading