diff --git a/.gitignore b/.gitignore
index b904214..0e9511a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,11 +8,14 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
-/build
+build/
/captures
.externalNativeBuild
.cxx
local.properties
/.idea/markdown.xml
/.idea/gradle.xml
-/.idea/misc.xml
\ No newline at end of file
+/.idea/misc.xml
+.claude/
+.idea/
+.kotlin/errors/
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 11a0f92..1395e19 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,8 +1,6 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
- alias(libs.plugins.hilt)
- alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
}
@@ -41,10 +39,14 @@ android {
}
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+}
+
dependencies {
implementation(project(":scanner-ui"))
- implementation(libs.hilt.android)
- ksp(libs.hilt.android.compiler)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
diff --git a/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt b/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt
index 1fd4188..9c272a9 100644
--- a/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt
+++ b/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt
@@ -1,7 +1,5 @@
package com.composea11yscanner
import android.app.Application
-import dagger.hilt.android.HiltAndroidApp
-@HiltAndroidApp
class ComposeA11yScannerApplication : Application()
diff --git a/build.gradle.kts b/build.gradle.kts
index 51cd901..36d69e1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -4,8 +4,6 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
- alias(libs.plugins.hilt) apply false
- alias(libs.plugins.ksp) apply false
alias(libs.plugins.detekt)
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0f29b7e..af9f524 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,8 +9,6 @@ activityCompose = "1.9.3"
appStartup = "1.2.0"
kotlin = "2.3.21"
composeBom = "2024.12.01"
-hilt = "2.54"
-ksp = "2.1.0-1.0.29"
coroutines = "1.10.1"
mockk = "1.13.12"
turbine = "1.2.0"
@@ -38,8 +36,6 @@ androidx-compose-animation = { group = "androidx.compose.animation", name = "ani
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
-hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
@@ -52,7 +48,5 @@ android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts
index b5e1330..43930f0 100644
--- a/sample/build.gradle.kts
+++ b/sample/build.gradle.kts
@@ -1,8 +1,6 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
- alias(libs.plugins.hilt)
- alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
}
@@ -33,6 +31,12 @@ android {
buildFeatures { compose = true }
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+}
+
dependencies {
implementation(project(":scanner-ui"))
implementation(project(":scanner-core"))
@@ -45,8 +49,6 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
- implementation(libs.hilt.android)
- ksp(libs.hilt.android.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt
new file mode 100644
index 0000000..3876358
--- /dev/null
+++ b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt
@@ -0,0 +1,63 @@
+package com.composea11yscanner.sample
+
+import androidx.compose.material3.MaterialTheme
+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.ui.A11yScanner
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+
+class FixedScreenAccessibilityTest {
+
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun fixedLoginScreen_hasNoScannerIssues() {
+ assertNoScannerIssues {
+ FixedLoginScreen()
+ }
+ }
+
+ @Test
+ fun fixedFeedScreen_hasNoScannerIssues() {
+ assertNoScannerIssues {
+ FixedFeedScreen()
+ }
+ }
+
+ @Test
+ fun fixedFormScreen_hasNoScannerIssues() {
+ assertNoScannerIssues {
+ FixedFormScreen()
+ }
+ }
+
+ private fun assertNoScannerIssues(content: @Composable () -> Unit) {
+ composeRule.setContent {
+ MaterialTheme {
+ content()
+ }
+ }
+
+ composeRule.waitForIdle()
+
+ 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())
+
+ assertEquals(
+ "Expected fixed screen to have zero scanner issues. Issues: ${result.issues}",
+ 0,
+ result.issues.size,
+ )
+ }
+}
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index 017cbce..757cbe8 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -20,6 +20,15 @@
android:name="a11y_scanner_auto_scan"
android:value="false" />
+
+
+
+
- Column(
- modifier = Modifier
- .padding(innerPadding)
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(20.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Text(
- text = "Scan Trigger Demo",
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.SemiBold,
- )
+ LaunchedEffect(selectedScreen, viewingFixed) {
+ scanScrollY = scrollState.value
+ scannerController.clearState()
+ }
+
+ scanOnShake(onScanRequested = { startSampleScan() })
- BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
- val useWideLayout = maxWidth >= 720.dp
- if (useWideLayout) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
+ A11yScannerScaffold(
+ scannerController = scannerController,
+ config = scannerConfig,
+ modifier = modifier.fillMaxSize(),
+ issueOffsetY = issueOffsetY,
+ ) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ text = "Compose A11y Scanner",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ },
+ actions = {
+ IconButton(onClick = { scannerController.clearState() }) {
+ Icon(
+ imageVector = Icons.Filled.Clear,
+ contentDescription = "Clear scan results",
+ )
+ }
+ IconButton(onClick = { startSampleScan() }) {
+ Icon(
+ imageVector = Icons.Filled.Search,
+ contentDescription = "Scan selected sample",
+ )
+ }
+ },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ bottomBar = {
+ NavigationBar {
+ screens.forEachIndexed { index, label ->
+ NavigationBarItem(
+ selected = selectedScreen == index,
+ onClick = {
+ selectedScreen = index
+ viewingFixed = false
+ },
+ icon = {
+ Icon(
+ imageVector = if (selectedScreen == index) {
+ Icons.Filled.CheckCircle
+ } else {
+ Icons.Filled.Search
+ },
+ contentDescription = null,
+ )
+ },
+ label = { Text(label) },
+ )
+ }
+ }
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainerLowest)
+ .verticalScroll(scrollState)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- TriggerOptionCard(
- title = "Manual",
- body = "Button tap",
- modifier = Modifier.weight(1f),
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
) {
- Button(
- onClick = { ComposeA11yScanner.triggerScan() },
- contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
) {
- Text("Run Scan")
+ Text(
+ text = screens[selectedScreen],
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = screenSubtitles[selectedScreen],
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
}
- TriggerOptionCard(
- title = "Long Press",
- body = "Hold this panel",
- modifier = Modifier
- .weight(1f)
- .scanOnLongPress(),
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
- TriggerTarget("Hold")
+ FilterChip(
+ selected = !viewingFixed,
+ onClick = { viewingFixed = false },
+ label = { Text("Broken") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Filled.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ )
+ FilterChip(
+ selected = viewingFixed,
+ onClick = { viewingFixed = true },
+ label = { Text("Fixed") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ )
}
- ShakeTriggerCard(
- enabled = shakeEnabled,
- onEnabledChange = { shakeEnabled = it },
- modifier = Modifier.weight(1f),
- )
}
- } else {
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
- TriggerOptionCard(title = "Manual", body = "Button tap") {
- Button(onClick = { ComposeA11yScanner.triggerScan() }) {
- Text("Run Scan")
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(8.dp)
+ .semantics { testTag = BrokenSampleContentTag },
+ )
+ {
+ if (viewingFixed) {
+ when (selectedScreen) {
+ 0 -> FixedLoginScreen()
+ 1 -> FixedFeedScreen()
+ else -> FixedFormScreen()
+ }
+ } else {
+ when (selectedScreen) {
+ 0 -> BrokenLoginScreen()
+ 1 -> BrokenFeedScreen()
+ else -> BrokenFormScreen()
}
}
- TriggerOptionCard(
- title = "Long Press",
- body = "Hold this panel",
- modifier = Modifier.scanOnLongPress(),
- ) {
- TriggerTarget("Hold")
- }
- ShakeTriggerCard(
- enabled = shakeEnabled,
- onEnabledChange = { shakeEnabled = it },
- )
}
}
}
+ }
+ }
+}
+
+@Composable
+private fun BrokenLoginScreen(onViewFixed: (() -> Unit)? = null) {
+ BrokenScreenCard(
+ title = "Screen 1: Broken Login",
+ subtitle = "Missing content descriptions and small touch targets",
+ ) {
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ TinyClickableLabel("?", Color(0xFF006D77))
+ TinyClickableLabel("!", Color(0xFFE29578))
+ TinyClickableLabel("x", Color(0xFF8D99AE))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(Color(0xFF2A9D8F))
+ .clickable { },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("Go", color = Color.White)
+ }
+ Text(
+ text = "Forgot password",
+ modifier = Modifier
+ .size(width = 92.dp, height = 30.dp)
+ .clickable { },
+ color = Color(0xFF1565C0),
+ )
+ }
+ onViewFixed?.let {
+ Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("View Fixed Version")
+ }
+ }
+ }
+}
- DemoScanContent()
+@Composable
+private fun BrokenFeedScreen(onViewFixed: (() -> Unit)? = null) {
+ BrokenScreenCard(
+ title = "Screen 2: Broken Feed",
+ subtitle = "Poor contrast text over images and duplicate descriptions",
+ ) {
+ FeedHero(
+ imageColor = Color(0xFF9E9E9E),
+ title = "Morning trail update",
+ description = "Open story",
+ )
+ FeedHero(
+ imageColor = Color(0xFF78909C),
+ title = "City lights tonight",
+ description = "Open story",
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ DuplicateAction("Save", "Save item")
+ DuplicateAction("Bookmark", "Save item")
+ }
+ onViewFixed?.let {
+ Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("View Fixed Version")
+ }
}
}
}
@Composable
-private fun TriggerOptionCard(
- title: String,
- body: String,
- modifier: Modifier = Modifier,
- content: @Composable () -> Unit,
-) {
- Card(
- modifier = modifier.height(164.dp),
- shape = RoundedCornerShape(8.dp),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+private fun BrokenFormScreen(onViewFixed: (() -> Unit)? = null) {
+ BrokenScreenCard(
+ title = "Screen 3: Broken Form",
+ subtitle = "Clickable elements lack roles and source order breaks focus flow",
) {
- Column(
+ Text("Preference form", style = MaterialTheme.typography.titleMedium)
+
+ Box(
modifier = Modifier
- .fillMaxSize()
- .padding(16.dp),
- verticalArrangement = Arrangement.SpaceBetween,
+ .fillMaxWidth()
+ .height(180.dp)
+ .background(Color(0xFFECEFF1), RoundedCornerShape(8.dp)),
) {
- Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
- Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- Text(body, style = MaterialTheme.typography.bodyMedium)
+ FormAction(
+ text = "Submit preferences",
+ color = Color(0xFF00796B),
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(16.dp),
+ )
+ FormAction(
+ text = "Choose plan",
+ color = Color(0xFF5E35B1),
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(16.dp),
+ )
+ FormAction(
+ text = "Reset fields",
+ color = Color(0xFFC62828),
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(16.dp),
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ FormAction("Monthly", Color(0xFF455A64), Modifier.weight(1f))
+ FormAction("Yearly", Color(0xFF455A64), Modifier.weight(1f))
+ }
+ onViewFixed?.let {
+ Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("View Fixed Version")
}
- content()
}
}
}
@Composable
-private fun ShakeTriggerCard(
- enabled: Boolean,
- onEnabledChange: (Boolean) -> Unit,
- modifier: Modifier = Modifier,
-) {
- TriggerOptionCard(
- title = "Shake",
- body = if (enabled) "Sensor armed" else "Sensor off",
- modifier = modifier,
+fun FixedLoginScreen(onViewBroken: (() -> Unit)? = null) {
+ BrokenScreenCard(
+ title = "Fixed Login",
+ subtitle = "Described controls with touch targets at least 48dp",
) {
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ FixedIconAction("?", "Open login help", Color(0xFF006D77))
+ FixedIconAction("!", "Show login requirements", Color(0xFFE29578))
+ FixedIconAction("x", "Dismiss login form", Color(0xFF8D99AE))
+ }
+
Row(
modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
) {
- Text("Enabled", style = MaterialTheme.typography.labelLarge)
- Switch(checked = enabled, onCheckedChange = onEnabledChange)
+ Box(
+ modifier = Modifier
+ .size(width = 56.dp, height = 48.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(Color(0xFF2A9D8F))
+ .semantics { contentDescription = "Sign in" }
+ .clickable(
+ role = Role.Button,
+ onClick = {},
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("Go", color = Color.White)
+ }
+ Box(
+ modifier = Modifier
+ .size(width = 144.dp, height = 48.dp)
+ .semantics { contentDescription = "Reset password" }
+ .clickable(
+ role = Role.Button,
+ onClick = {},
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("Forgot password", color = Color(0xFF1565C0))
+ }
+ }
+ onViewBroken?.let {
+ Button(
+ onClick = it,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp),
+ ) {
+ Text("View Broken Version")
+ }
}
}
}
@Composable
-private fun TriggerTarget(label: String) {
- Box(
- modifier = Modifier
- .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
- .padding(horizontal = 16.dp, vertical = 10.dp),
- contentAlignment = Alignment.Center,
+fun FixedFeedScreen(onViewBroken: (() -> Unit)? = null) {
+ BrokenScreenCard(
+ title = "Fixed Feed",
+ subtitle = "Readable captions and unique descriptions",
) {
- Text(label, color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.SemiBold)
+ FixedFeedItem(
+ imageColor = Color(0xFF607D8B),
+ title = "Morning trail update",
+ description = "Open morning trail story",
+ )
+ FixedFeedItem(
+ imageColor = Color(0xFF546E7A),
+ title = "City lights tonight",
+ description = "Open city lights story",
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ FixedLabeledAction("Save", "Save feed item")
+ FixedLabeledAction("Bookmark", "Bookmark feed item")
+ }
+ onViewBroken?.let {
+ Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("View Broken Version")
+ }
+ }
}
}
@Composable
-private fun DemoScanContent() {
+fun FixedFormScreen(onViewBroken: (() -> Unit)? = null) {
+ BrokenScreenCard(
+ title = "Fixed Form",
+ subtitle = "Button roles and top-to-bottom focus order",
+ ) {
+ Text("Preference form", style = MaterialTheme.typography.titleMedium)
+ Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("Choose plan")
+ }
+ Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("Reset fields")
+ }
+ Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("Submit preferences")
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(onClick = {}, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) {
+ Text("Monthly")
+ }
+ Button(onClick = {}, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) {
+ Text("Yearly")
+ }
+ }
+ onViewBroken?.let {
+ Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
+ Text("View Broken Version")
+ }
+ }
+ }
+}
+
+@Composable
+private fun BrokenScreenCard(
+ title: String,
+ subtitle: String,
+ content: @Composable ColumnScope.() -> Unit,
+) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
@@ -211,33 +587,205 @@ private fun DemoScanContent() {
) {
Column(
modifier = Modifier.padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
) {
- Text("Inspectable Content", style = MaterialTheme.typography.titleMedium)
- Text("These controls intentionally include a few accessibility issues for the scanner.")
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = Modifier
- .size(28.dp)
- .background(Color(0xFF1976D2), RoundedCornerShape(4.dp))
- .clickable { },
- )
- Spacer(modifier = Modifier.width(12.dp))
- Text("Small clickable swatch")
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
+ Text(subtitle, style = MaterialTheme.typography.bodyMedium)
}
- Text(
- text = "Low contrast text sample",
- color = Color(0xFFBDBDBD),
- modifier = Modifier.background(Color.White),
- )
+ content()
}
}
}
+@Composable
+private fun TinyClickableLabel(text: String, color: Color) {
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .background(color, RoundedCornerShape(6.dp))
+ .clickable { },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text, color = Color.White, fontWeight = FontWeight.Bold)
+ }
+}
+
+@Composable
+private fun FeedHero(
+ imageColor: Color,
+ title: String,
+ description: String,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .semantics { contentDescription = description }
+ .clickable { },
+ ) {
+ Image(
+ painter = ColorPainter(imageColor),
+ contentDescription = description,
+ modifier = Modifier.fillMaxSize(),
+ )
+ Text(
+ text = title,
+ color = Color(0xFFBDBDBD),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(14.dp),
+ )
+ }
+}
+
+@Composable
+private fun DuplicateAction(label: String, description: String) {
+ Box(
+ modifier = Modifier
+ .size(width = 132.dp, height = 52.dp)
+ .background(Color(0xFF283593), RoundedCornerShape(8.dp))
+ .semantics { contentDescription = description }
+ .clickable { },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, color = Color.White)
+ }
+}
+
+@Composable
+private fun FormAction(
+ text: String,
+ color: Color,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .height(56.dp)
+ .background(color, RoundedCornerShape(8.dp))
+ .clickable { }
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text, color = Color.White, fontWeight = FontWeight.SemiBold)
+ }
+}
+
+@Composable
+private fun FixedIconAction(
+ label: String,
+ description: String,
+ color: Color,
+) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(color, RoundedCornerShape(8.dp))
+ .semantics { contentDescription = description }
+ .clickable(
+ role = Role.Button,
+ onClick = {},
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, color = Color.White, fontWeight = FontWeight.Bold)
+ }
+}
+
+@Composable
+private fun FixedFeedItem(
+ imageColor: Color,
+ title: String,
+ description: String,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = description }
+ .clickable(
+ role = Role.Button,
+ onClick = {},
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Image(
+ painter = ColorPainter(imageColor),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(120.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ )
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ }
+}
+
+@Composable
+private fun FixedLabeledAction(label: String, description: String) {
+ Box(
+ modifier = Modifier
+ .size(width = 132.dp, height = 56.dp)
+ .background(Color(0xFF283593), RoundedCornerShape(8.dp))
+ .semantics { contentDescription = description }
+ .clickable(
+ role = Role.Button,
+ onClick = {},
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, color = Color.White)
+ }
+}
+
@Preview(showBackground = true)
@Composable
-fun ScanTriggerDemoPreview() {
+fun BrokenAccessibilitySampleAppPreview() {
MaterialTheme {
- ScanTriggerDemo()
+ BrokenAccessibilitySampleApp()
}
}
+
+private fun ComponentActivity.extractBrokenSampleNodes(): List =
+ runCatching {
+ val hostView = (window.decorView as? ViewGroup)
+ ?.findFirstAbstractComposeView()
+ ?: return emptyList()
+ val semanticsOwner = hostView.findSemanticsOwner() ?: return emptyList()
+ val sampleRoot = semanticsOwner.unmergedRootSemanticsNode
+ .findNodeByTestTag(BrokenSampleContentTag)
+ ?: return emptyList()
+ A11yNodeExtractor(Density(this)).extract(sampleRoot)
+ }.getOrDefault(emptyList())
+
+private const val BrokenSampleContentTag = "broken-sample-content"
+
+private fun SemanticsNode.findNodeByTestTag(tag: String): SemanticsNode? {
+ if (config.getOrNull(SemanticsProperties.TestTag) == tag) return this
+ children.forEach { child ->
+ child.findNodeByTestTag(tag)?.let { return it }
+ }
+ return null
+}
+
+private fun ViewGroup.findFirstAbstractComposeView(): AbstractComposeView? {
+ for (i in 0 until childCount) {
+ val child = getChildAt(i)
+ if (child is AbstractComposeView) return child
+ if (child is ViewGroup) {
+ child.findFirstAbstractComposeView()?.let { return it }
+ }
+ }
+ return null
+}
+
+private fun AbstractComposeView.findSemanticsOwner(): SemanticsOwner? {
+ val composeOwnerView: View = getChildAt(0) ?: return null
+ return runCatching {
+ composeOwnerView.javaClass
+ .getMethod("getSemanticsOwner")
+ .invoke(composeOwnerView) as? SemanticsOwner
+ }.getOrNull()
+}
diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt
index d8f21dc..786c9d0 100644
--- a/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt
+++ b/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt
@@ -1,7 +1,5 @@
package com.composea11yscanner.sample
import android.app.Application
-import dagger.hilt.android.HiltAndroidApp
-@HiltAndroidApp
class SampleApplication : Application()
diff --git a/scanner-core/build.gradle.kts b/scanner-core/build.gradle.kts
index c87c248..3548419 100644
--- a/scanner-core/build.gradle.kts
+++ b/scanner-core/build.gradle.kts
@@ -1,7 +1,5 @@
plugins {
alias(libs.plugins.android.library)
- alias(libs.plugins.hilt)
- alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
}
@@ -30,10 +28,14 @@ android {
}
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+}
+
dependencies {
implementation(libs.androidx.core.ktx)
- implementation(libs.hilt.android)
- ksp(libs.hilt.android.compiler)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
implementation(platform(libs.androidx.compose.bom))
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt
index 0fbb925..45c6221 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt
@@ -12,4 +12,5 @@ enum class A11yRole {
RadioButton,
Switch,
Tab,
+ TextField,
}
diff --git a/scanner-rules/build.gradle.kts b/scanner-rules/build.gradle.kts
index 746373c..b5e1a72 100644
--- a/scanner-rules/build.gradle.kts
+++ b/scanner-rules/build.gradle.kts
@@ -1,7 +1,5 @@
plugins {
alias(libs.plugins.android.library)
- alias(libs.plugins.hilt)
- alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
}
@@ -30,11 +28,15 @@ android {
}
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+}
+
dependencies {
implementation(project(":scanner-core"))
implementation(libs.androidx.core.ktx)
- implementation(libs.hilt.android)
- ksp(libs.hilt.android.compiler)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt
index a5e8ec0..d5b764b 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt
@@ -14,6 +14,7 @@ class ClickableRoleRule : BaseA11yRule() {
override val wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)"
override fun check(node: A11yNode): A11yIssue? {
+ if (node.isMergedDescendant) return null
if (!node.isTouchTarget) return null
val missingRole = node.role == null
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
index e932848..729dd96 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
@@ -24,8 +24,7 @@ class FocusOrderRule(
private val jumpThresholdPx: Int = (jumpThresholdDp * screenDensity).roundToInt()
override fun evaluateAll(nodes: List): List =
- nodes
- .filter { it.isFocusable }
+ effectiveFocusNodes(nodes)
.zipWithNext { prev, curr ->
val jumpedUpward = curr.bounds.top < prev.bounds.top - jumpThresholdPx
if (!jumpedUpward) return@zipWithNext null
@@ -43,4 +42,25 @@ class FocusOrderRule(
)
}
.filterNotNull()
+
+ private fun effectiveFocusNodes(nodes: List): List {
+ val result = mutableListOf()
+
+ nodes
+ .filter { it.isFocusable && !it.isMergedDescendant }
+ .forEach { node ->
+ val hasFocusableAncestor = result.any { ancestor ->
+ ancestor.depth < node.depth && ancestor.bounds.contains(node.bounds)
+ }
+ if (!hasFocusableAncestor) result += node
+ }
+
+ return result
+ }
+
+ private fun com.composea11yscanner.core.model.Rect.contains(other: com.composea11yscanner.core.model.Rect): Boolean =
+ left <= other.left &&
+ top <= other.top &&
+ right >= other.right &&
+ bottom >= other.bottom
}
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt
index cc13759..64c256a 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt
@@ -22,9 +22,13 @@ class TextScalingRule(
override fun evaluateAll(nodes: List): List =
nodes
- .filter { it.composableName.contains("Text", ignoreCase = true) }
+ .filter {
+ it.composableName.contains("Text", ignoreCase = true) &&
+ !it.isMergedDescendant
+ }
.mapNotNull { textNode ->
val parent = findParent(textNode, nodes) ?: return@mapNotNull null
+ if (!parent.isTouchTarget) return@mapNotNull null
val scaledHeight = textNode.bounds.height * scaleFactor
val overflowPx = (textNode.bounds.top + scaledHeight) - parent.bounds.bottom
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt
index d3a3ca0..aba89d9 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt
@@ -14,6 +14,7 @@ class TouchTargetRule(
override val wcagReference = "WCAG 2.5.5 Target Size (Level AA)"
override fun check(node: A11yNode): A11yIssue? {
+ if (node.isMergedDescendant) return null
if (!node.isTouchTarget) return null
val w = node.touchTargetSize.width
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt
index e9a77a1..9d5bfcc 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt
@@ -33,6 +33,11 @@ class ClickableRoleRuleTest {
assertNull(rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Switch)))
}
+ @Test
+ fun `touch target with TextField role passes`() {
+ assertNull(rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.TextField)))
+ }
+
@Test
fun `touch target with Image role and non-empty description passes`() {
assertNull(
@@ -73,9 +78,8 @@ class ClickableRoleRuleTest {
// --- edge cases ---
@Test
- fun `merged descendant that is a touch target with no role is still flagged`() {
- // ClickableRoleRule does not exempt merged descendants — role is a per-node concern
- assertNotNull(
+ fun `merged descendant that is a touch target with no role is skipped`() {
+ assertNull(
rule.evaluate(createNode(isTouchTarget = true, role = null, isMergedDescendant = true))
)
}
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt
index 0bcb514..ab8cbcf 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt
@@ -2,6 +2,7 @@ package com.composea11yscanner.rules
import com.composea11yscanner.core.model.A11ySeverity
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt
index d85e0b3..0048acb 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt
@@ -52,6 +52,28 @@ class FocusOrderRuleTest {
assertTrue(rule.evaluateAll(nodes).isEmpty())
}
+ @Test
+ fun `merged descendant focusable nodes are skipped`() {
+ val nodes = listOf(
+ createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)),
+ createNode(
+ isFocusable = true,
+ bounds = Rect(0, 0, 100, 50),
+ isMergedDescendant = true,
+ ),
+ )
+ assertTrue(rule.evaluateAll(nodes).isEmpty())
+ }
+
+ @Test
+ fun `focusable child inside focusable parent is skipped`() {
+ val nodes = listOf(
+ createNode(isFocusable = true, depth = 0, bounds = Rect(0, 100, 200, 160)),
+ createNode(isFocusable = true, depth = 1, bounds = Rect(0, 110, 100, 140)),
+ )
+ assertTrue(rule.evaluateAll(nodes).isEmpty())
+ }
+
// --- failing cases ---
@Test
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt
index 2dcf735..7ef26f7 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt
@@ -30,7 +30,7 @@ class TextScalingRuleTest {
@Test
fun `text node with sufficient room in parent passes`() {
// scaledHeight = 100 * 1.3 = 130 < parent.bottom 200 → no overflow
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 200))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 200), isTouchTarget = true)
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 100))
assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty())
}
@@ -38,31 +38,43 @@ class TextScalingRuleTest {
@Test
fun `scaled height landing exactly on parent bottom passes`() {
// 80 * 1.3 = 104.0; scaledBottom = 0 + 104 = 104 == parent.bottom 104 → overflowPx = 0
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 104))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 104), isTouchTarget = true)
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80))
assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty())
}
@Test
fun `text node with zero height never overflows`() {
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 0))
assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty())
}
+ @Test
+ fun `merged descendant text node is skipped`() {
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
+ val text = createNode(
+ composableName = "Text",
+ depth = 1,
+ bounds = Rect(0, 0, 200, 80),
+ isMergedDescendant = true,
+ )
+ assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty())
+ }
+
// --- failing cases ---
@Test
fun `text node that overflows parent at 1_3x scale fails`() {
// scaledHeight = 80 * 1.3 = 104; scaledBottom = 104 > parent.bottom 100 → overflow 4px
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80))
assertEquals(1, rule.evaluateAll(listOf(parent, text)).size)
}
@Test
fun `BasicText composable name is recognised`() {
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
val text = createNode(composableName = "BasicText", depth = 1, bounds = Rect(0, 0, 200, 80))
assertEquals(1, rule.evaluateAll(listOf(parent, text)).size)
}
@@ -73,7 +85,7 @@ class TextScalingRuleTest {
fun `tightest parent is selected when multiple candidates at depth minus 1`() {
// tightParent area=20000 vs looseParent area=160000; tightParent is chosen
// tightParent.bottom=100 < scaledBottom=104 → overflow flagged
- val tightParent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100))
+ val tightParent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
val looseParent = createNode(depth = 0, bounds = Rect(0, 0, 400, 400))
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80))
assertEquals(1, rule.evaluateAll(listOf(tightParent, looseParent, text)).size)
@@ -83,14 +95,14 @@ class TextScalingRuleTest {
fun `custom scale factor changes overflow threshold`() {
// At 2.0x: scaledHeight = 60 * 2 = 120 > parent.bottom 100 → overflow
val rule2x = TextScalingRule(screenDensity = 1f, scaleFactor = 2.0f)
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 60))
assertEquals(1, rule2x.evaluateAll(listOf(parent, text)).size)
}
@Test
fun `issue message contains original and scaled height in dp`() {
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80))
val issue = rule.evaluateAll(listOf(parent, text)).first()
assertTrue(issue.message.contains("80dp")) // original
@@ -104,7 +116,7 @@ class TextScalingRuleTest {
@Test
fun `issue carries correct rule metadata`() {
- val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100))
+ val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true)
val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80))
val issue = rule.evaluateAll(listOf(parent, text)).first()
assertEquals("text-scaling", issue.ruleId)
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt
index f8ec116..ecd0ec9 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt
@@ -29,6 +29,19 @@ class TouchTargetRuleTest {
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
diff --git a/scanner-ui/build.gradle.kts b/scanner-ui/build.gradle.kts
index 237b79a..e81d4f1 100644
--- a/scanner-ui/build.gradle.kts
+++ b/scanner-ui/build.gradle.kts
@@ -1,8 +1,6 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
- alias(libs.plugins.hilt)
- alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.paparazzi)
}
@@ -33,6 +31,12 @@ android {
buildFeatures { compose = true }
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+}
+
dependencies {
implementation(project(":scanner-core"))
implementation(project(":scanner-rules"))
@@ -52,8 +56,6 @@ dependencies {
implementation(libs.androidx.compose.ui.unit)
implementation(libs.androidx.compose.ui.util)
- implementation(libs.hilt.android)
- ksp(libs.hilt.android.compiler)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
diff --git a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt
new file mode 100644
index 0000000..63083a0
--- /dev/null
+++ b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt
@@ -0,0 +1,52 @@
+package com.composea11yscanner.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.Text
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.unit.Density
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+class A11yNodeExtractorTest {
+
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun clickableContainerWithDescendantText_resolvesAsClickableText() {
+ composeRule.setContent {
+ Box(modifier = Modifier.clickable { }) {
+ Text("Go")
+ }
+ }
+
+ composeRule.waitForIdle()
+
+ val nodes = A11yNodeExtractor(Density(1f))
+ .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode())
+ val clickableNode = nodes.single { it.isTouchTarget && it.contentDescription == "Go" }
+
+ assertEquals("ClickableText", clickableNode.composableName)
+ }
+
+ @Test
+ fun unlabeledClickable_resolvesAsClickable() {
+ composeRule.setContent {
+ Box(modifier = Modifier.clickable { })
+ }
+
+ composeRule.waitForIdle()
+
+ val nodes = A11yNodeExtractor(Density(1f))
+ .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode())
+ val clickableNode = nodes.single { it.isTouchTarget }
+
+ assertEquals("Clickable", clickableNode.composableName)
+ assertTrue(clickableNode.contentDescription.isNullOrBlank())
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
index 4df94bb..76aab1d 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
@@ -264,14 +264,14 @@ private fun ScannerOverlayContent(
config: ScannerConfig,
) {
var scannerState by remember { mutableStateOf(ScannerState.Idle) }
- var selectedIssue by remember { mutableStateOf(null) }
+ var selectedIssues by remember { mutableStateOf(emptyList()) }
DisposableEffect(Unit) { onDispose { controller.stopScan() } }
LaunchedEffect(Unit) {
controller.stateFlow.collect { state ->
scannerState = state
- if (state is ScannerState.Scanning) selectedIssue = null
+ if (state is ScannerState.Scanning) selectedIssues = emptyList()
}
}
@@ -289,7 +289,7 @@ private fun ScannerOverlayContent(
Box(modifier = Modifier.fillMaxSize()) {
A11yIssueOverlay(
scanResult = scanResult,
- onIssueSelected = { selectedIssue = it },
+ onIssuesSelected = { selectedIssues = it },
modifier = Modifier.fillMaxSize(),
)
@@ -308,8 +308,8 @@ private fun ScannerOverlayContent(
}
IssueDetailPanel(
- issue = selectedIssue,
- onDismiss = { selectedIssue = null },
+ issues = selectedIssues,
+ onDismiss = { selectedIssues = emptyList() },
modifier = Modifier.align(Alignment.BottomCenter),
)
}
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 04bce69..1dd294d 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
@@ -60,35 +60,56 @@ class A11yNodeExtractor(private val density: Density) {
private fun SemanticsNode.toA11yNode(depth: Int, isMergedDescendant: Boolean): A11yNode {
val composeRole = config.getOrNull(SemanticsProperties.Role)
- val bounds = boundsInRoot.toCoreRect()
+ val isTextInput = config.contains(SemanticsActions.SetText)
+ val isTouchTarget = config.contains(SemanticsActions.OnClick)
+ val textLabel = if (isTouchTarget || composeRole != null || isTextInput) {
+ collectTextLabel()
+ } else {
+ null
+ }
+ val visualBounds = boundsInRoot
+ val touchTargetBounds = layoutBoundsInRoot()
+ val bounds = visualBounds.toCoreRect()
return A11yNode(
nodeId = id.toString(),
- composableName = resolveComposableName(composeRole),
+ composableName = resolveComposableName(
+ composeRole = composeRole,
+ isTouchTarget = isTouchTarget,
+ textLabel = textLabel,
+ ),
bounds = bounds,
contentDescription = config
.getOrNull(SemanticsProperties.ContentDescription)
- ?.joinToString(separator = ", "),
- isTouchTarget = config.contains(SemanticsActions.OnClick),
- touchTargetSize = bounds.toDpSize(),
+ ?.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)
|| config.contains(SemanticsActions.RequestFocus)
- || composeRole != null,
+ || composeRole != null
+ || isTextInput,
isMergedDescendant = isMergedDescendant,
depth = depth,
- role = composeRole?.toA11yRole(),
+ role = if (isTextInput) A11yRole.TextField else composeRole?.toA11yRole(),
)
}
/**
* Resolves a human-readable composable name.
- * Priority: explicit TestTag → inferred from Role → inferred from Text property → "Unknown".
+ * Priority: explicit TestTag -> inferred from Role -> inferred from Text/click semantics -> "Unknown".
*/
- private fun SemanticsNode.resolveComposableName(composeRole: Role?): String =
+ private fun SemanticsNode.resolveComposableName(
+ composeRole: Role?,
+ isTouchTarget: Boolean,
+ textLabel: String?,
+ ): String =
config.getOrNull(SemanticsProperties.TestTag)
- ?: when (composeRole) {
+ ?: if (config.contains(SemanticsActions.SetText)) {
+ "TextField"
+ } else when (composeRole) {
Role.Button -> "Button"
Role.Checkbox -> "Checkbox"
Role.Switch -> "Switch"
@@ -98,10 +119,43 @@ class A11yNodeExtractor(private val density: Density) {
Role.DropdownList -> "DropdownList"
else -> {
val text = config.getOrNull(SemanticsProperties.Text)
- if (!text.isNullOrEmpty()) "Text" else "Unknown"
+ when {
+ !text.isNullOrEmpty() -> "Text"
+ isTouchTarget && !textLabel.isNullOrBlank() -> "ClickableText"
+ isTouchTarget -> "Clickable"
+ else -> "Unknown"
+ }
}
}
+ private fun SemanticsNode.collectTextLabel(): String? {
+ val labels = mutableListOf()
+ collectTextLabelsInto(labels)
+ return labels
+ .joinToString(separator = " ")
+ .takeIf { it.isNotBlank() }
+ }
+
+ private fun SemanticsNode.collectTextLabelsInto(labels: MutableList) {
+ config.getOrNull(SemanticsProperties.Text)
+ ?.mapNotNull { it.text.takeIf(String::isNotBlank) }
+ ?.let(labels::addAll)
+
+ children.forEach { child ->
+ child.collectTextLabelsInto(labels)
+ }
+ }
+
+ 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(),
@@ -109,7 +163,7 @@ class A11yNodeExtractor(private val density: Density) {
bottom = bottom.roundToInt(),
)
- private fun Rect.toDpSize(): DpSize = with(density) {
+ private fun androidx.compose.ui.geometry.Rect.toDpSize(): DpSize = with(density) {
DpSize(
width = width.toDp().value,
height = height.toDp().value,
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 fc95830..415276c 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
/**
* Public SDK entry point for running accessibility scans.
@@ -116,12 +117,20 @@ class A11yScannerController(
)
scanJob = scope.launch {
- engine.scan(nodeProvider()).collect { _state.emit(it) }
+ val nodes = withContext(Dispatchers.Main.immediate) {
+ nodeProvider()
+ }
+ engine.scan(nodes).collect { _state.emit(it) }
}
return _state.asSharedFlow()
}
+ fun clearState() {
+ stopScan()
+ _state.tryEmit(ScannerState.Idle)
+ }
+
/** Cancels the current scan if one is running. The [Flow] returned by [startScan] stops emitting. */
fun stopScan() {
scanJob?.cancel()
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 edcd376..1a43e2b 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
@@ -54,10 +54,11 @@ fun A11yScannerScaffold(
scannerController: A11yScannerController,
config: ScannerConfig,
modifier: Modifier = Modifier,
+ issueOffsetY: Int = 0,
content: @Composable () -> Unit,
) {
var scannerState by remember { mutableStateOf(ScannerState.Idle) }
- var selectedIssue by remember { mutableStateOf(null) }
+ var selectedIssues by remember { mutableStateOf(emptyList()) }
// Cancel any in-flight scan when the scaffold leaves composition.
DisposableEffect(Unit) {
@@ -67,7 +68,7 @@ fun A11yScannerScaffold(
LaunchedEffect(Unit) {
scannerController.stateFlow.collect { state ->
scannerState = state
- if (state is ScannerState.Scanning) selectedIssue = null
+ if (state is ScannerState.Scanning) selectedIssues = emptyList()
}
}
@@ -77,7 +78,7 @@ fun A11yScannerScaffold(
if (config.autoScan) {
scannerController.startScan()
} else {
- scannerController.stopScan()
+ scannerController.clearState()
}
}
@@ -90,8 +91,9 @@ fun A11yScannerScaffold(
// ── 2. Issue highlight overlay ───────────────────────────────────────
A11yIssueOverlay(
scanResult = scanResult,
- onIssueSelected = { selectedIssue = it },
+ onIssuesSelected = { selectedIssues = it },
modifier = Modifier.fillMaxSize(),
+ issueOffsetY = issueOffsetY,
)
// ── 3. Summary bar — slides down from the top once scanning starts ───
@@ -111,8 +113,8 @@ fun A11yScannerScaffold(
// ── 4. Issue detail panel — slides up when an overlay box is tapped ──
IssueDetailPanel(
- issue = selectedIssue,
- onDismiss = { selectedIssue = null },
+ issues = selectedIssues,
+ onDismiss = { selectedIssues = emptyList() },
modifier = Modifier.align(Alignment.BottomCenter),
)
}
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 87b2c45..ce53ea4 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt
@@ -12,8 +12,11 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -52,6 +55,7 @@ import java.util.Locale
private val ErrorChipColor = Color(0xFFD32F2F)
private val WarningChipColor = Color(0xFFFFA000)
private val InfoChipColor = Color(0xFF1976D2)
+private val IssueListMaxHeight = 480.dp
private fun A11ySeverity.toChipColor(): Color = when (this) {
A11ySeverity.Error -> ErrorChipColor
@@ -90,29 +94,57 @@ private fun String.toWcagUrl(): String {
@Composable
fun IssueDetailPanel(
issue: A11yIssue?,
+ issues: List = issue?.let(::listOf).orEmpty(),
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
+ val activeIssues = issues.ifEmpty { issue?.let(::listOf).orEmpty() }
+ val isVisible = activeIssues.isNotEmpty()
// Keep the last non-null issue so the content stays visible during the exit slide.
- var panelIssue by remember { mutableStateOf(issue) }
- LaunchedEffect(issue) { if (issue != null) panelIssue = issue }
+ var panelIssues by remember { mutableStateOf(emptyList()) }
+ LaunchedEffect(activeIssues) {
+ if (activeIssues.isNotEmpty()) panelIssues = activeIssues
+ }
+ val displayIssues = activeIssues.ifEmpty { panelIssues }
AnimatedVisibility(
- visible = issue != null,
+ visible = isVisible,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier = modifier,
) {
- panelIssue?.let { PanelContent(issue = it, onDismiss = onDismiss) }
+ if (displayIssues.isNotEmpty()) {
+ PanelContent(issues = displayIssues, onDismiss = onDismiss)
+ }
}
}
+@Composable
+fun IssueDetailPanel(
+ issues: List,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ IssueDetailPanel(
+ issue = issues.firstOrNull(),
+ issues = issues,
+ onDismiss = onDismiss,
+ modifier = modifier,
+ )
+}
+
@Composable
private fun PanelContent(
- issue: A11yIssue,
+ issues: List,
onDismiss: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
+ val primaryIssue = issues.first()
+ val panelTitle = if (issues.size == 1) {
+ primaryIssue.ruleName
+ } else {
+ "${issues.size} issues on ${primaryIssue.affectedNode.composableName}"
+ }
Surface(
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
@@ -129,7 +161,7 @@ private fun PanelContent(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- text = issue.ruleName,
+ text = panelTitle,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
@@ -141,37 +173,66 @@ private fun PanelContent(
}
}
- SeverityChip(
- severity = issue.severity,
+ LazyColumn(
modifier = Modifier
- .padding(horizontal = 16.dp)
- .padding(top = 4.dp, bottom = 16.dp),
- )
-
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ .fillMaxWidth()
+ .heightIn(max = IssueListMaxHeight),
+ ) {
+ itemsIndexed(
+ items = issues,
+ key = { index, issue -> "${issue.issueId}-$index" },
+ ) { index, issue ->
+ if (index > 0) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ IssueBlock(
+ issue = issue,
+ onWcagClick = { ref -> uriHandler.openUri(ref.toWcagUrl()) },
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ )
+ }
+ }
+ }
+ }
+}
- SectionBlock(
- label = "Issue",
- body = issue.message,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+@Composable
+private fun IssueBlock(
+ issue: A11yIssue,
+ onWcagClick: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = issue.ruleName,
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.weight(1f),
)
+ SeverityChip(severity = issue.severity)
+ }
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ SectionBlock(
+ label = "Issue",
+ body = issue.message,
+ )
- SectionBlock(
- label = "How to fix",
- body = issue.howToFix,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
- )
+ SectionBlock(
+ label = "How to fix",
+ body = issue.howToFix,
+ )
- issue.wcagReference?.let { ref ->
- HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
- WcagLink(
- reference = ref,
- onClick = { uriHandler.openUri(ref.toWcagUrl()) },
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
- )
- }
+ issue.wcagReference?.let { ref ->
+ WcagLink(
+ reference = ref,
+ onClick = { onWcagClick(ref) },
+ )
}
}
}
@@ -310,6 +371,39 @@ private fun IssueDetailPanelNoWcagPreview() {
}
}
+@Preview(showBackground = true)
+@Composable
+private fun IssueDetailPanelMultipleIssuesPreview() {
+ MaterialTheme {
+ IssueDetailPanel(
+ issues = listOf(
+ previewIssue(
+ severity = A11ySeverity.Error,
+ ruleName = "Clickable Role",
+ message = "Clickable element does not expose a button role.",
+ howToFix = "Apply Modifier.clickable(role = Role.Button) for action controls.",
+ 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)",
+ ),
+ previewIssue(
+ severity = A11ySeverity.Warning,
+ ruleName = "Missing Content Description",
+ message = "Interactive element has no content description.",
+ howToFix = "Add a meaningful contentDescription via semantics.",
+ wcagReference = "WCAG 1.1.1 Non-text Content (Level A)",
+ ),
+ ),
+ onDismiss = {},
+ )
+ }
+}
+
private fun previewIssue(
severity: A11ySeverity,
ruleName: String,
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 2f8cec7..e3f0d8f 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt
@@ -5,8 +5,11 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.tooling.preview.Preview
import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
@@ -15,20 +18,15 @@ import com.composea11yscanner.core.model.DpSize
import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.core.model.ScanResult
-object ScannerUi {
- const val VERSION = "0.1.0"
-}
-
-@Composable
-fun ScannerOverlay(modifier: Modifier = Modifier) {
- // TODO: Implement scanner overlay
-}
-
@Composable
fun A11yIssueOverlay(
scanResult: ScanResult?,
onIssueSelected: (A11yIssue) -> Unit = {},
+ onIssuesSelected: (List) -> Unit = { issues ->
+ issues.firstOrNull()?.let(onIssueSelected)
+ },
modifier: Modifier = Modifier,
+ issueOffsetY: Int = 0,
) {
Box(modifier = modifier.fillMaxSize()) {
AnimatedVisibility(
@@ -36,7 +34,30 @@ fun A11yIssueOverlay(
enter = fadeIn(),
exit = fadeOut(),
) {
- // TODO: Render issue markers over host content
+ Box(modifier = Modifier.fillMaxSize()) {
+ scanResult?.issues
+ .orEmpty()
+ .groupBy { it.affectedNode.nodeId }
+ .values
+ .forEach { issues ->
+ val primaryIssue = issues.minWith(
+ compareBy { it.severity.sortOrder }
+ .thenBy { it.ruleId },
+ )
+ IssueHighlightBox(
+ issue = primaryIssue,
+ onIssueSelected = { onIssuesSelected(issues) },
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset {
+ IntOffset(
+ x = primaryIssue.affectedNode.bounds.left,
+ y = primaryIssue.affectedNode.bounds.top + issueOffsetY,
+ )
+ },
+ )
+ }
+ }
}
}
}
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 bd92b63..7b8e2f3 100644
--- a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt
+++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt
@@ -67,4 +67,41 @@ class IssueDetailPanelSnapshotTest {
}
}
}
+
+ @Test
+ fun multipleIssues_showScrollableList() {
+ paparazzi.snapshot {
+ MaterialTheme {
+ IssueDetailPanel(
+ issues = listOf(
+ issueFixture(
+ severity = A11ySeverity.Error,
+ issueId = "clickable-role",
+ ruleName = "Clickable Role",
+ message = "Clickable element does not expose a button role.",
+ howToFix = "Apply Modifier.clickable(role = Role.Button) for action controls.",
+ 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)",
+ ),
+ issueFixture(
+ severity = A11ySeverity.Warning,
+ issueId = "missing-content-description",
+ ruleName = "Missing Content Description",
+ message = "Interactive element has no content description.",
+ howToFix = "Add a meaningful contentDescription via semantics.",
+ wcagReference = "WCAG 1.1.1 Non-text Content (Level A)",
+ ),
+ ),
+ onDismiss = {},
+ )
+ }
+ }
+ }
}