From 291e7266fc20cad9f8076de75808ac85f93f402b Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 23 Jul 2026 07:05:36 +0530 Subject: [PATCH 1/2] Replace TouchTargetRule with TouchTargetOverlapRule - Remove `TouchTargetRule` and `DpSize` model in favor of `TouchTargetOverlapRule`. - Update `A11yNode` to store `effectiveTouchBounds` in pixels instead of `touchTargetSize` in dp. - Implement `TouchTargetOverlapRule` to detect and report overlapping interactive hit regions. - Remove density requirements from `A11yScanner` and `A11yNodeExtractor` as dp conversion is no longer needed for touch target evaluation. - Update `ScannerConfig` and Android manifest metadata to remove `minTouchTargetDp` configuration. - Update documentation and sample application to reflect the shift from target size to target overlap validation. --- README.md | 2 +- RULES.md | 27 ++-- .../sample/FixedScreenAccessibilityTest.kt | 2 - sample/src/main/AndroidManifest.xml | 3 - .../sample/SampleSemanticsExtractor.kt | 3 +- .../composea11yscanner/core/model/A11yNode.kt | 4 +- .../composea11yscanner/core/model/DpSize.kt | 18 --- .../core/model/ScannerConfig.kt | 2 - .../core/A11yScanEngineTest.kt | 2 - .../core/CoreModelAndRuleTest.kt | 2 - .../composea11yscanner/rules/ScannerRules.kt | 6 +- .../rules/TouchTargetOverlapRule.kt | 53 +++++++ .../rules/TouchTargetRule.kt | 54 -------- .../rules/FakeNodeBuilder.kt | 5 +- .../rules/TouchTargetOverlapRuleTest.kt | 70 ++++++++++ .../rules/TouchTargetRuleTest.kt | 131 ------------------ .../ui/A11yNodeExtractorTest.kt | 17 +-- .../A11yScannerInitializer.kt | 14 -- .../composea11yscanner/ComposeA11yScanner.kt | 3 +- .../export/ScanResultExporter.kt | 13 +- .../ui/A11yNodeExtractor.kt | 19 +-- .../com/composea11yscanner/ui/A11yScanner.kt | 7 +- .../ui/A11yScannerController.kt | 2 +- .../ui/A11yScannerScaffold.kt | 2 - .../composea11yscanner/ui/IssueDetailPanel.kt | 12 +- .../ui/IssueHighlightBox.kt | 2 - .../composea11yscanner/ui/ScanReportSheet.kt | 3 +- .../composea11yscanner/ui/ScanSummaryBar.kt | 3 +- .../com/composea11yscanner/ui/ScannerUi.kt | 2 - .../ui/IssueDetailPanelSnapshotTest.kt | 12 +- .../composea11yscanner/ui/SnapshotFixtures.kt | 2 - 31 files changed, 176 insertions(+), 321 deletions(-) delete mode 100644 scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt delete mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt delete mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt diff --git a/README.md b/README.md index 331d11b..f081b56 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ See [RULES.md](RULES.md) for complete behavior, fixes, WCAG references, and exam | Rule ID | Name | Severity | Details | | --- | --- | --- | --- | -| `touch-target-size` | Touch Target Size | Error | [RULES.md](RULES.md#touch-target-size---touch-target-size) | +| `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 251bba9..7bccccd 100644 --- a/RULES.md +++ b/RULES.md @@ -8,7 +8,7 @@ Implementation note: the current codebase exposes 7 built-in rules, not 8. This | Rule ID | Name | Severity | What It Checks | How To Fix | WCAG Reference | | --- | --- | --- | --- | --- | --- | -| `touch-target-size` | Touch Target Size | Error | Clickable/touch target nodes whose measured width or height is smaller than the configured minimum, 48dp by default. | Use `Modifier.minimumInteractiveComponentSize()` or add padding so the interactive area is at least the configured minimum in both dimensions. | WCAG 2.5.5 Target Size (Level AA) | +| `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) | @@ -16,27 +16,26 @@ Implementation note: the current codebase exposes 7 built-in rules, not 8. This | `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-size` - Touch Target Size +## `touch-target-overlap` - Touch Target Overlap -**Severity:** Error +**Severity:** Warning -**What it checks:** This rule evaluates clickable/touch target nodes that are not merged descendants. It reports a node when either its measured width or height is smaller than `ScannerConfig.minTouchTargetDp`, which defaults to 48dp. +**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:** Ensure the interactive area reaches the minimum size in both dimensions. Prefer Material's `Modifier.minimumInteractiveComponentSize()` for Material controls, or add padding around custom controls. +**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. -**WCAG reference:** WCAG 2.5.5 Target Size (Level AA) +**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 -IconButton( - onClick = onClose, - modifier = Modifier.minimumInteractiveComponentSize(), -) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close", - ) +Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + IconButton(onClick = onPrevious) { + Icon(Icons.Default.ArrowBack, contentDescription = "Previous") + } + IconButton(onClick = onNext) { + Icon(Icons.Default.ArrowForward, contentDescription = "Next") + } } ``` diff --git a/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt index dd43e96..959ee96 100644 --- a/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt +++ b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt @@ -3,7 +3,6 @@ package com.composea11yscanner.sample import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot -import androidx.compose.ui.unit.Density import com.composea11yscanner.core.model.ScannerConfig import com.composea11yscanner.rules.ScannerRules import com.composea11yscanner.sample.ui.theme.ScannerTheme @@ -50,7 +49,6 @@ class FixedScreenAccessibilityTest { val config = ScannerConfig(enabledRules = ScannerRules.allRuleIds().toSet()) val scanner = A11yScanner( rules = ScannerRules.buildRules(config, screenDensity = 1f), - density = Density(1f), ) val result = scanner.scan(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 757cbe8..fce926b 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -10,9 +10,6 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.ComposeA11yScanner"> - diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt index f68eb59..aa0a7d1 100644 --- a/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt +++ b/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.unit.Density import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.Rect import com.composea11yscanner.ui.A11yNodeExtractor @@ -33,7 +32,7 @@ internal fun ComponentActivity.extractBrokenSampleNodes(): List = ?: sampleRoot.boundsInRoot.let { Rect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) } - A11yNodeExtractor(Density(this)) + A11yNodeExtractor() .extract(sampleRoot) .filterVisibleIn(viewport) }.getOrDefault(emptyList()) 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 89a8d23..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,7 +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 touchTargetSize Size used by touch target rules, expressed in dp. + * @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. @@ -22,11 +22,11 @@ data class A11yNode( val bounds: Rect, val contentDescription: String?, val isTouchTarget: Boolean, - val touchTargetSize: DpSize, val textColor: Color?, val backgroundColors: List, val isFocusable: Boolean, val isMergedDescendant: Boolean, val depth: Int, val role: A11yRole? = null, + val effectiveTouchBounds: Rect? = null, ) diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt deleted file mode 100644 index 2602234..0000000 --- a/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.composea11yscanner.core.model - -/** - * Density-independent size in dp units. Mirrors the Compose DpSize API - * without pulling in the Compose dependency. - * - * @property width Width in dp. - * @property height Height in dp. - */ -data class DpSize(val width: Float, val height: Float) { - companion object { - /** A zero-width and zero-height size. */ - val Zero = DpSize(0f, 0f) - - /** Sentinel used when a size could not be measured. */ - val Unspecified = DpSize(Float.NaN, Float.NaN) - } -} diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt index 1f8bd51..be109d6 100644 --- a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt +++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt @@ -4,14 +4,12 @@ package com.composea11yscanner.core.model * Runtime configuration used by the scanner engine and UI integration. * * @property enabledRules Rule ids that should be evaluated. - * @property minTouchTargetDp Minimum touch target size in dp. * @property minContrastRatio Minimum text contrast ratio used by contrast-related rules. * @property debugOverlay Whether scanner UI should display issue overlays. * @property autoScan Whether scanning should start automatically when the scanner attaches. */ data class ScannerConfig( val enabledRules: Set, - val minTouchTargetDp: Int = 48, val minContrastRatio: Float = 4.5f, val debugOverlay: Boolean = true, val autoScan: Boolean = true, diff --git a/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt b/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt index 6c0288a..db95426 100644 --- a/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt +++ b/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt @@ -4,7 +4,6 @@ import app.cash.turbine.test import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScannerConfig import com.composea11yscanner.core.model.ScannerState @@ -29,7 +28,6 @@ class A11yScanEngineTest { bounds = Rect(0, 0, 100, 100), contentDescription = null, isTouchTarget = false, - touchTargetSize = DpSize(48f, 48f), textColor = null, backgroundColors = emptyList(), isFocusable = false, diff --git a/scanner-core/src/test/java/com/composea11yscanner/core/CoreModelAndRuleTest.kt b/scanner-core/src/test/java/com/composea11yscanner/core/CoreModelAndRuleTest.kt index 25cafce..5ea3e40 100644 --- a/scanner-core/src/test/java/com/composea11yscanner/core/CoreModelAndRuleTest.kt +++ b/scanner-core/src/test/java/com/composea11yscanner/core/CoreModelAndRuleTest.kt @@ -4,7 +4,6 @@ import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11yRole import com.composea11yscanner.core.model.A11ySeverity import com.composea11yscanner.core.model.Color -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult import com.composea11yscanner.core.rule.BaseA11yRule @@ -24,7 +23,6 @@ class CoreModelAndRuleTest { bounds = Rect(0, 0, 100, 48), contentDescription = "Submit", isTouchTarget = true, - touchTargetSize = DpSize(100f, 48f), textColor = Color(0xFF000000), backgroundColors = emptyList(), isFocusable = true, 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 ffb4250..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,7 +10,7 @@ object ScannerRules { /** Returns every built-in rule id understood by [buildRules]. */ fun allRuleIds(): List = listOf( - "touch-target-size", + "touch-target-overlap", "missing-content-description", "duplicate-content-description", "focus-order", @@ -27,8 +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-size" in config.enabledRules) - add(TouchTargetRule(config.minTouchTargetDp)) + 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/main/java/com/composea11yscanner/rules/TouchTargetRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt deleted file mode 100644 index 254f814..0000000 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt +++ /dev/null @@ -1,54 +0,0 @@ -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.rule.BaseA11yRule - -/** - * Flags clickable nodes whose measured touch target is smaller than the configured minimum. - * - * @param minTouchTargetDp Minimum accepted width and height in dp. - */ -class TouchTargetRule( - private val minTouchTargetDp: Int = 48, -) : BaseA11yRule() { - /** Stable id for the touch target size rule. */ - override val ruleId = "touch-target-size" - - /** Human-readable rule name. */ - override val ruleName = "Touch Target Size" - - /** Severity assigned to undersized touch targets. */ - override val severity = A11ySeverity.Error - - /** WCAG criterion associated with target size. */ - override val wcagReference = "WCAG 2.5.5 Target Size (Level AA)" - - /** Evaluates a single node for touch target dimensions. */ - override fun check(node: A11yNode): A11yIssue? { - if (node.isMergedDescendant) return null - if (!node.isTouchTarget) return null - - val w = node.touchTargetSize.width - val h = node.touchTargetSize.height - // Pixel bounds converted back to dp can land infinitesimally below an exact dp value - // (for example, a 48 dp target may be reported as 47.999996 dp). - if ( - w + MeasurementToleranceDp >= minTouchTargetDp && - h + MeasurementToleranceDp >= minTouchTargetDp - ) return null - - return issue( - node = node, - message = "Touch target is ${"%.0f".format(w)}x${"%.0f".format(h)}dp. " + - "Minimum required is ${minTouchTargetDp}x${minTouchTargetDp}dp.", - howToFix = "Apply Modifier.minimumInteractiveComponentSize() or add padding so the " + - "composable reaches at least ${minTouchTargetDp}dp in both dimensions.", - ) - } - - private companion object { - const val MeasurementToleranceDp = 0.01f - } -} 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 2b2bc40..e181aa5 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt @@ -3,7 +3,6 @@ package com.composea11yscanner.rules import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11yRole import com.composea11yscanner.core.model.Color -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import java.util.concurrent.atomic.AtomicInteger @@ -14,7 +13,7 @@ fun createNode( bounds: Rect = Rect(0, 0, 100, 100), contentDescription: String? = null, isTouchTarget: Boolean = false, - touchTargetSize: DpSize = DpSize(48f, 48f), + effectiveTouchBounds: Rect? = null, textColor: Color? = null, backgroundColors: List = emptyList(), isFocusable: Boolean = false, @@ -28,11 +27,11 @@ fun createNode( bounds = bounds, contentDescription = contentDescription, isTouchTarget = isTouchTarget, - touchTargetSize = touchTargetSize, textColor = textColor, backgroundColors = backgroundColors, isFocusable = isFocusable, 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-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt deleted file mode 100644 index bde8244..0000000 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.composea11yscanner.rules - -import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class TouchTargetRuleTest { - - private val rule = TouchTargetRule() - - // --- passing cases --- - - @Test - fun `non-interactive node is not evaluated`() { - assertNull(rule.evaluate(createNode(isTouchTarget = false, touchTargetSize = DpSize(40f, 40f)))) - } - - @Test - fun `touch target at exactly minimum size passes`() { - assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 48f)))) - } - - @Test - fun `floating point noise just below minimum size passes`() { - assertNull( - rule.evaluate( - createNode( - isTouchTarget = true, - touchTargetSize = DpSize(47.999996f, 47.999996f), - ) - ) - ) - } - - @Test - fun `touch target larger than minimum passes`() { - assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(56f, 64f)))) - } - - @Test - fun `merged descendant touch target is skipped`() { - assertNull( - rule.evaluate( - createNode( - isTouchTarget = true, - touchTargetSize = DpSize(32f, 32f), - isMergedDescendant = true, - ) - ) - ) - } - - // --- failing cases --- - - @Test - fun `width below minimum fails`() { - assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(44f, 48f)))) - } - - @Test - fun `meaningfully undersized target is not hidden by measurement tolerance`() { - assertNotNull( - rule.evaluate( - createNode( - isTouchTarget = true, - touchTargetSize = DpSize(47.98f, 48f), - ) - ) - ) - } - - @Test - fun `height below minimum fails`() { - assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 44f)))) - } - - @Test - fun `both dimensions below minimum fail`() { - assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(32f, 32f)))) - } - - // --- edge cases --- - - @Test - fun `zero-size touch target fails with correct dimensions in message`() { - val issue = rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(0f, 0f))) - assertNotNull(issue) - assertTrue(issue!!.message.contains("0x0dp")) - } - - @Test - fun `message contains actual dimensions`() { - val issue = rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(32f, 40f))) - assertNotNull(issue) - assertTrue(issue!!.message.contains("32x40dp")) - } - - @Test - fun `custom threshold accepts larger node`() { - val rule36 = TouchTargetRule(minTouchTargetDp = 36) - assertNull(rule36.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(40f, 40f)))) - } - - @Test - fun `custom threshold rejects node below it`() { - val rule36 = TouchTargetRule(minTouchTargetDp = 36) - assertNotNull(rule36.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(30f, 30f)))) - } - - @Test - fun `issue carries correct rule metadata`() { - val issue = rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(40f, 40f)))!! - assertEquals("touch-target-size", issue.ruleId) - assertEquals(A11ySeverity.Error, issue.severity) - assertEquals("WCAG 2.5.5 Target Size (Level AA)", issue.wcagReference) - } - - @Test - fun `evaluateAll aggregates per-node results`() { - val nodes = listOf( - createNode(isTouchTarget = false), - createNode(isTouchTarget = true, touchTargetSize = DpSize(40f, 40f)), - createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 48f)), - ) - assertEquals(1, rule.evaluateAll(nodes).size) - } -} 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 452c7bf..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,10 +5,8 @@ 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.Density import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -30,7 +28,7 @@ class A11yNodeExtractorTest { composeRule.waitForIdle() - val nodes = A11yNodeExtractor(Density(1f)) + val nodes = A11yNodeExtractor() .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) val clickableNode = nodes.single { it.isTouchTarget && it.contentDescription == "Go" } @@ -45,7 +43,7 @@ class A11yNodeExtractorTest { composeRule.waitForIdle() - val nodes = A11yNodeExtractor(Density(1f)) + val nodes = A11yNodeExtractor() .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) val clickableNode = nodes.single { it.isTouchTarget } @@ -54,10 +52,8 @@ class A11yNodeExtractorTest { } @Test - fun compactClickable_usesExpandedTouchBounds() { - lateinit var density: Density + fun compactClickable_extractsExpandedEffectiveTouchBounds() { composeRule.setContent { - density = LocalDensity.current Box( modifier = Modifier .size(width = 40.dp, height = 48.dp) @@ -67,11 +63,12 @@ class A11yNodeExtractorTest { composeRule.waitForIdle() - val nodes = A11yNodeExtractor(density) + val nodes = A11yNodeExtractor() .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) val clickableNode = nodes.single { it.isTouchTarget } - assertTrue(clickableNode.touchTargetSize.width >= 48f) - assertTrue(clickableNode.touchTargetSize.height >= 48f) + 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/A11yScannerInitializer.kt b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt index 419e21f..d50ace8 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt @@ -15,7 +15,6 @@ import com.composea11yscanner.rules.ScannerRules * AndroidX App Startup initializer for installing [ComposeA11yScanner] in debug builds. * * Configure the scanner from the app manifest with: - * - `a11y_scanner_min_touch_target` * - `a11y_scanner_min_contrast` * - `a11y_scanner_auto_scan` */ @@ -68,10 +67,6 @@ class A11yScannerInitializer : Initializer { val defaults = ScannerConfig(enabledRules = emptySet()) return ScannerConfig( enabledRules = ScannerRules.allRuleIds().toSet(), - minTouchTargetDp = metadata.intValue( - key = META_MIN_TOUCH_TARGET, - defaultValue = defaults.minTouchTargetDp, - ), minContrastRatio = metadata.floatValue( key = META_MIN_CONTRAST, defaultValue = defaults.minContrastRatio, @@ -88,14 +83,6 @@ class A11yScannerInitializer : Initializer { return appInfo.metaData ?: Bundle.EMPTY } - private fun Bundle.intValue(key: String, defaultValue: Int): Int = - when (val value = get(key)) { - is Int -> value - is Number -> value.toInt() - is String -> value.toIntOrNull() ?: defaultValue - else -> defaultValue - } - private fun Bundle.floatValue(key: String, defaultValue: Float): Float = when (val value = get(key)) { is Float -> value @@ -112,7 +99,6 @@ class A11yScannerInitializer : Initializer { } private companion object { - const val META_MIN_TOUCH_TARGET = "a11y_scanner_min_touch_target" const val META_MIN_CONTRAST = "a11y_scanner_min_contrast" const val META_AUTO_SCAN = "a11y_scanner_auto_scan" } diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt index d4cd275..680a691 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.semantics.SemanticsOwner -import androidx.compose.ui.unit.Density import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.composea11yscanner.core.model.A11yIssue @@ -204,7 +203,7 @@ object ComposeA11yScanner { ?.findFirstAbstractComposeView(excludeView = overlayView) ?: return emptyList() val semanticsOwner = hostView.findSemanticsOwner() ?: return emptyList() - return A11yNodeExtractor(Density(activity)).extract(semanticsOwner) + return A11yNodeExtractor().extract(semanticsOwner) } private fun AbstractComposeView.findSemanticsOwner(): SemanticsOwner? { 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 191025b..45f4071 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt @@ -3,7 +3,6 @@ package com.composea11yscanner.export import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity import com.composea11yscanner.core.model.Color -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult import java.util.Locale @@ -100,7 +99,7 @@ object ScanResultExporter { append(", ") appendJsonPair("isTouchTarget", isTouchTarget) append(", ") - appendJsonPair("touchTargetSize", touchTargetSize) + appendJsonNullablePair("effectiveTouchBounds", effectiveTouchBounds) append(", ") appendJsonPair("textColor", textColor) append(", ") @@ -181,11 +180,11 @@ object ScanResultExporter { append(value.toJson()) } - private fun StringBuilder.appendJsonPair(name: String, value: DpSize) { + private fun StringBuilder.appendJsonNullablePair(name: String, value: Rect?) { append("\"") append(name.escapeJson()) append("\": ") - append(value.toJson()) + append(value?.toJson() ?: "null") } private fun StringBuilder.appendJsonPair(name: String, value: Color?) { @@ -198,15 +197,9 @@ object ScanResultExporter { private fun Rect.toJson(): String = """{"left": $left, "top": $top, "right": $right, "bottom": $bottom, "width": $width, "height": $height}""" - private fun DpSize.toJson(): String = - """{"width": ${width.toJsonNumber()}, "height": ${height.toJsonNumber()}}""" - private fun Color.toJson(): String = """{"value": $value}""" - private fun Float.toJsonNumber(): String = - if (isFinite()) toString() else "null" - private fun Float.formatPercent(): String = String.format(Locale.US, "%.1f%%", this) 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 405b748..6fa3905 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt @@ -7,10 +7,8 @@ import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.unit.Density import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11yRole -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import kotlin.math.roundToInt @@ -21,9 +19,8 @@ import kotlin.math.roundToInt * - Test: `composeTestRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()` * - Production: `SemanticsOwner.rootSemanticsNode` via [extract(SemanticsOwner)] * - * @param density Density used to convert pixel bounds into dp sizes. */ -class A11yNodeExtractor(private val density: Density) { +class A11yNodeExtractor { /** * Recursively extracts all nodes from the tree rooted at [rootNode]. @@ -76,10 +73,6 @@ class A11yNodeExtractor(private val density: Density) { null } val visualBounds = boundsInRoot - // Compose expands pointer input for small clickables to the platform minimum touch - // target without changing their visual/layout bounds. Measure the region that actually - // accepts input so compact controls such as a short FilterChip are not false positives. - val touchTargetBounds = touchBoundsInRoot val bounds = visualBounds.toCoreRect() return A11yNode( @@ -95,7 +88,6 @@ class A11yNodeExtractor(private val density: Density) { ?.joinToString(separator = ", ") ?: textLabel, isTouchTarget = isTouchTarget, - touchTargetSize = touchTargetBounds.toDpSize(), textColor = null, // not available via semantics backgroundColors = emptyList(), // not available via semantics isFocusable = config.contains(SemanticsActions.OnClick) @@ -105,6 +97,9 @@ class A11yNodeExtractor(private val density: Density) { isMergedDescendant = isMergedDescendant, depth = depth, role = if (isTextInput) A11yRole.TextField else composeRole?.toA11yRole(), + effectiveTouchBounds = touchBoundsInRoot + .takeIf { isTouchTarget } + ?.toCoreRect(), ) } @@ -164,12 +159,6 @@ class A11yNodeExtractor(private val density: Density) { bottom = bottom.roundToInt(), ) - private fun androidx.compose.ui.geometry.Rect.toDpSize(): DpSize = with(density) { - DpSize( - width = width.toDp().value, - height = height.toDp().value, - ) - } } // --- Role mapping --- diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt index 5e5dea0..1e4a7a9 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt @@ -3,7 +3,6 @@ package com.composea11yscanner.ui import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner -import androidx.compose.ui.unit.Density import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.ScanResult import com.composea11yscanner.core.rule.A11yRule @@ -14,11 +13,9 @@ import java.util.UUID * * @param rules The rules to run. Build from [com.composea11yscanner.rules.ScannerRules.buildRules] * or supply a custom list. - * @param density Compose [Density] used by [A11yNodeExtractor] for pixel↔dp conversion. */ class A11yScanner( private val rules: List, - private val density: Density, ) { /** * Scans from a semantics node, typically in tests or manual tree walks. @@ -27,7 +24,7 @@ class A11yScanner( * @return Completed scan result. */ fun scan(rootNode: SemanticsNode): ScanResult = - buildResult(A11yNodeExtractor(density).extract(rootNode)) + buildResult(A11yNodeExtractor().extract(rootNode)) /** * Scans the live Compose semantics tree via [SemanticsOwner]. @@ -37,7 +34,7 @@ class A11yScanner( */ @OptIn(InternalComposeUiApi::class) fun scan(owner: SemanticsOwner): ScanResult = - buildResult(A11yNodeExtractor(density).extract(owner)) + buildResult(A11yNodeExtractor().extract(owner)) private fun buildResult(nodes: List): ScanResult { val issues = rules diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt index 4f74b7f..6444c5e 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.withContext * Typical usage: * ``` * val controller = A11yScannerController( - * nodeProvider = { A11yNodeExtractor(density).extract(semanticsOwner) }, + * nodeProvider = { A11yNodeExtractor().extract(semanticsOwner) }, * screenDensity = resources.displayMetrics.density, * ) * .configure(ScannerConfig(enabledRules = ScannerRules.allRuleIds().toSet())) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt index 6544080..0c49a90 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.unit.dp import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult import com.composea11yscanner.core.model.ScannerConfig @@ -194,7 +193,6 @@ private fun previewScanResult() = ScanResult( nodeId = "node-1", composableName = "Button", bounds = Rect(0, 0, 300, 120), contentDescription = null, isTouchTarget = true, - touchTargetSize = DpSize(100f, 40f), textColor = null, backgroundColors = emptyList(), isFocusable = true, isMergedDescendant = false, depth = 1, ), 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 78d54ad..015d510 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.unit.dp import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import java.util.Locale @@ -397,11 +396,11 @@ private fun IssueDetailPanelMultipleIssuesPreview() { wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)", ), previewIssue( - severity = A11ySeverity.Error, - ruleName = "Touch Target Size", - message = "Touch target is 28x28dp. Minimum required is 48x48dp.", - howToFix = "Apply Modifier.minimumInteractiveComponentSize() or add padding.", - wcagReference = "WCAG 2.5.5 Target Size (Level AA)", + 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, @@ -433,7 +432,6 @@ private fun previewIssue( bounds = Rect(0, 0, 300, 120), contentDescription = null, isTouchTarget = true, - touchTargetSize = DpSize(100f, 40f), textColor = null, backgroundColors = emptyList(), isFocusable = true, diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt index 10c03c6..8d9225d 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.unit.dp import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect private val ErrorBorderColor = Color(0xFFD32F2F) @@ -148,7 +147,6 @@ private fun previewIssue(severity: A11ySeverity) = A11yIssue( bounds = Rect(0, 0, 300, 150), contentDescription = null, isTouchTarget = true, - touchTargetSize = DpSize(100f, 50f), textColor = null, backgroundColors = emptyList(), isFocusable = true, diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt index a7c6664..2d3b47a 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.unit.dp import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult import com.composea11yscanner.export.ScanResultExporter @@ -443,7 +442,7 @@ private fun previewResult(errors: Int, warnings: Int, info: Int): ScanResult { val node = A11yNode( nodeId = "node-1", composableName = "Button", bounds = Rect(0, 0, 300, 120), contentDescription = null, - isTouchTarget = true, touchTargetSize = DpSize(100f, 40f), + isTouchTarget = true, textColor = null, backgroundColors = emptyList(), isFocusable = true, isMergedDescendant = false, depth = 1, ) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt index 0f58fd6..3b5c964 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult import com.composea11yscanner.core.model.ScannerState @@ -339,7 +338,7 @@ private fun previewResult( val node = A11yNode( nodeId = "node-1", composableName = "Button", bounds = Rect(0, 0, 300, 120), contentDescription = null, - isTouchTarget = true, touchTargetSize = DpSize(100f, 40f), + isTouchTarget = true, textColor = null, backgroundColors = emptyList(), isFocusable = true, isMergedDescendant = false, depth = 1, ) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt index df02bd7..0092f5b 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.tooling.preview.Preview import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult @@ -83,7 +82,6 @@ private fun A11yIssueOverlayPreview() { bounds = Rect(0, 0, 120, 48), contentDescription = null, isTouchTarget = true, - touchTargetSize = DpSize(120f, 48f), textColor = null, backgroundColors = emptyList(), isFocusable = true, 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 7b8e2f3..507b488 100644 --- a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt @@ -83,12 +83,12 @@ class IssueDetailPanelSnapshotTest { wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)", ), issueFixture( - severity = A11ySeverity.Error, - issueId = "touch-target-size", - ruleName = "Touch Target Size", - message = "Touch target is 28x28dp. Minimum required is 48x48dp.", - howToFix = "Apply Modifier.minimumInteractiveComponentSize() or add padding.", - wcagReference = "WCAG 2.5.5 Target Size (Level AA)", + 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, diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/SnapshotFixtures.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/SnapshotFixtures.kt index 5218ac8..18719c1 100644 --- a/scanner-ui/src/test/java/com/composea11yscanner/ui/SnapshotFixtures.kt +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/SnapshotFixtures.kt @@ -3,7 +3,6 @@ package com.composea11yscanner.ui import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.A11ySeverity -import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult @@ -31,7 +30,6 @@ internal fun nodeFixture() = A11yNode( bounds = Rect(left = 0, top = 0, right = 300, bottom = 120), contentDescription = null, isTouchTarget = true, - touchTargetSize = DpSize(width = 100f, height = 40f), textColor = null, backgroundColors = emptyList(), isFocusable = true, From 01bfcfb6d0ab9f120eed55167a7e370ab73ea035 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Thu, 23 Jul 2026 07:05:36 +0530 Subject: [PATCH 2/2] Replace TouchTargetRule with TouchTargetOverlapRule - Remove `TouchTargetRule` and `DpSize` model in favor of `TouchTargetOverlapRule`. - Update `A11yNode` to store `effectiveTouchBounds` in pixels instead of `touchTargetSize` in dp. - Implement `TouchTargetOverlapRule` to detect and report overlapping interactive hit regions. - Remove density requirements from `A11yScanner` and `A11yNodeExtractor` as dp conversion is no longer needed for touch target evaluation. - Update `ScannerConfig` and Android manifest metadata to remove `minTouchTargetDp` configuration. - Update documentation and sample application to reflect the shift from target size to target overlap validation. --- README.md | 1 + RULES.md | 25 +++++++ .../composea11yscanner/core/model/A11yNode.kt | 2 + .../composea11yscanner/rules/ScannerRules.kt | 3 + .../rules/TouchTargetOverlapRule.kt | 53 ++++++++++++++ .../rules/FakeNodeBuilder.kt | 2 + .../rules/TouchTargetOverlapRuleTest.kt | 70 +++++++++++++++++++ .../ui/A11yNodeExtractorTest.kt | 21 +++++- .../export/ScanResultExporter.kt | 9 +++ .../ui/A11yNodeExtractor.kt | 3 + .../composea11yscanner/ui/IssueDetailPanel.kt | 7 ++ .../ui/IssueDetailPanelSnapshotTest.kt | 8 +++ 12 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetOverlapRule.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetOverlapRuleTest.kt 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",