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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ class TouchTargetRule(

val w = node.touchTargetSize.width
val h = node.touchTargetSize.height
if (w >= minTouchTargetDp && h >= minTouchTargetDp) return null
// 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,
Expand All @@ -29,4 +34,8 @@ class TouchTargetRule(
"composable reaches at least ${minTouchTargetDp}dp in both dimensions.",
)
}

private companion object {
const val MeasurementToleranceDp = 0.01f
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ class TouchTargetRuleTest {
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))))
Expand All @@ -49,6 +61,18 @@ class TouchTargetRuleTest {
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))))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package com.composea11yscanner.ui

import androidx.compose.foundation.clickable
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
import org.junit.Rule
Expand Down Expand Up @@ -49,4 +52,26 @@ class A11yNodeExtractorTest {
assertEquals("Clickable", clickableNode.composableName)
assertTrue(clickableNode.contentDescription.isNullOrBlank())
}

@Test
fun compactClickable_usesExpandedTouchBounds() {
lateinit var density: Density
composeRule.setContent {
density = LocalDensity.current
Box(
modifier = Modifier
.size(width = 40.dp, height = 48.dp)
.clickable { },
)
}

composeRule.waitForIdle()

val nodes = A11yNodeExtractor(density)
.extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode())
val clickableNode = nodes.single { it.isTouchTarget }

assertTrue(clickableNode.touchTargetSize.width >= 48f)
assertTrue(clickableNode.touchTargetSize.height >= 48f)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ class A11yNodeExtractor(private val density: Density) {
null
}
val visualBounds = boundsInRoot
val touchTargetBounds = layoutBoundsInRoot()
// 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(
Expand Down Expand Up @@ -146,16 +149,6 @@ class A11yNodeExtractor(private val density: Density) {
}
}

private fun SemanticsNode.layoutBoundsInRoot(): androidx.compose.ui.geometry.Rect {
val position = positionInRoot
return androidx.compose.ui.geometry.Rect(
left = position.x,
top = position.y,
right = position.x + layoutInfo.width,
bottom = position.y + layoutInfo.height,
)
}

private fun androidx.compose.ui.geometry.Rect.toCoreRect(): Rect = Rect(
left = left.roundToInt(),
top = top.roundToInt(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
Expand Down Expand Up @@ -101,6 +104,8 @@ fun ScanSummaryBar(
Box(
modifier = modifier
.fillMaxWidth()
// topOffset clears the host toolbar; the inset additionally clears the system bar.
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = topOffset, start = 16.dp, end = 16.dp),
contentAlignment = Alignment.TopCenter,
) {
Expand Down
Loading