From 9e3fbb11a3aad2d07050224d8da45607599b447e Mon Sep 17 00:00:00 2001 From: Shemar DaCosta Date: Thu, 7 May 2026 21:26:42 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Fix=20a11y=20root=20element=20having=200?= =?UTF-8?q?=C3=970=20dimensions=20on=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `cmp_a11y_root` container was created with `position: absolute` but never given width or height, making it invisible to hit-test-based accessibility tools (Accessibility Inspector, Appium). VoiceOver still worked because it traverses the DOM tree sequentially. Sync the a11y container's CSS dimensions with the canvas in `resize()`, which runs on both initial layout and every subsequent resize. Add A11yContainerSizingTest as a regression test, asserting the a11y container has non-zero CSS dimensions after init and that they match the canvas's CSS dimensions. Verified passing on both wasmJs and js browser test targets. Fixes https://youtrack.jetbrains.com/issue/CMP-10172 --- .../ui/window/ComposeWindowInternal.web.kt | 5 ++ .../platform/a11y/A11yContainerSizingTest.kt | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 8e6679c5bf89f..6d83623717fc4 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -501,6 +501,11 @@ internal class ComposeWindow( canvas.style.width = "${boxSize.width.value}px" canvas.style.height = "${boxSize.height.value}px" + a11yContainerElement?.let { + it.style.width = "${boxSize.width.value}px" + it.style.height = "${boxSize.height.value}px" + } + _windowInfo.containerSize = sizeInPx _windowInfo.containerDpSize = boxSize diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt new file mode 100644 index 0000000000000..1342981afc65c --- /dev/null +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform.a11y + +import androidx.compose.material.Text +import androidx.compose.ui.OnCanvasTests +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +/** + * Regression tests for https://youtrack.jetbrains.com/issue/CMP-10172. + * + * The a11y root container (`cmp_a11y_root`) was created with `position: absolute` but never + * given a width or height, leaving it as a 0×0 element in the DOM. Because hit-test-based + * accessibility tools (Apple Accessibility Inspector, Appium) walk down from the parent's + * bounding rect, they could not reach any Compose semantic node. VoiceOver was unaffected + * because it traverses the DOM tree sequentially, which masked the bug. + * + * These tests guard the contract that the a11y container's CSS dimensions stay in sync with + * the underlying ``. + */ +class A11yContainerSizingTest : OnCanvasTests { + + @Test + fun a11yContainerHasNonZeroDimensionsAfterInit() = runTest { + createComposeWindow { + Text("a11y sizing regression") + } + + val a11yContainer = assertNotNull( + getA11YContainer(), + "A11Y container must exist when isA11YEnabled is true (default)" + ) + + val width = a11yContainer.style.width + val height = a11yContainer.style.height + + assertTrue( + width.isNotEmpty() && width != "0px", + "a11y container width must be set to a non-zero pixel value, was '$width'" + ) + assertTrue( + height.isNotEmpty() && height != "0px", + "a11y container height must be set to a non-zero pixel value, was '$height'" + ) + } + + @Test + fun a11yContainerMatchesCanvasCssDimensions() = runTest { + createComposeWindow { + Text("a11y sizing regression") + } + + val canvas = getCanvas() + val a11yContainer = assertNotNull( + getA11YContainer(), + "A11Y container must exist when isA11YEnabled is true (default)" + ) + + assertEquals( + canvas.style.width, + a11yContainer.style.width, + "a11y container width must match the canvas's CSS width so hit-test-based " + + "tools can reach Compose semantic nodes" + ) + assertEquals( + canvas.style.height, + a11yContainer.style.height, + "a11y container height must match the canvas's CSS height so hit-test-based " + + "tools can reach Compose semantic nodes" + ) + } +} From 0ba6c61dcea4652677318fa3212b5d738fd397c1 Mon Sep 17 00:00:00 2001 From: shemar Date: Fri, 8 May 2026 16:11:57 -0400 Subject: [PATCH 2/2] Size a11y container via CSS instead of mirroring px on every resize Address review feedback: setting `width: 100%; height: 100%` once at construction lets the a11y container track the canvas via CSS layout, avoiding a per-resize call across the wasm2js boundary. Update A11yContainerSizingTest to assert against the rendered `getBoundingClientRect()` instead of the inline style strings, since the a11y container's `style.width` is now `"100%"` rather than a px value. Verified passing on the wasmJs browser test target. Assisted-by: cursor --- .../compose/ui/window/ComposeWindow.web.kt | 2 + .../ui/window/ComposeWindowInternal.web.kt | 7 ++- .../platform/a11y/A11yContainerSizingTest.kt | 45 +++---------------- 3 files changed, 12 insertions(+), 42 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt index 4ab4933efa02c..b5a85c5b4d7e3 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt @@ -139,6 +139,8 @@ fun ComposeViewport( position = "absolute" top = "0" left = "0" + width = "100%" + height = "100%" } appContainer.appendChild(a11yContainer) } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 6d83623717fc4..b66052bcc1ef8 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -501,10 +501,9 @@ internal class ComposeWindow( canvas.style.width = "${boxSize.width.value}px" canvas.style.height = "${boxSize.height.value}px" - a11yContainerElement?.let { - it.style.width = "${boxSize.width.value}px" - it.style.height = "${boxSize.height.value}px" - } + // The a11y container is sized via CSS (`width: 100%; height: 100%`) at construction + // time, so it tracks the canvas automatically without a per-resize bridge call across + // the wasm2js boundary. See ComposeViewport for the setup. _windowInfo.containerSize = sizeInPx _windowInfo.containerDpSize = boxSize diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt index 1342981afc65c..ae2a4b82ac5de 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yContainerSizingTest.kt @@ -19,27 +19,23 @@ package androidx.compose.ui.platform.a11y import androidx.compose.material.Text import androidx.compose.ui.OnCanvasTests import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest /** - * Regression tests for https://youtrack.jetbrains.com/issue/CMP-10172. + * Regression test for https://youtrack.jetbrains.com/issue/CMP-10172. * * The a11y root container (`cmp_a11y_root`) was created with `position: absolute` but never * given a width or height, leaving it as a 0×0 element in the DOM. Because hit-test-based * accessibility tools (Apple Accessibility Inspector, Appium) walk down from the parent's * bounding rect, they could not reach any Compose semantic node. VoiceOver was unaffected * because it traverses the DOM tree sequentially, which masked the bug. - * - * These tests guard the contract that the a11y container's CSS dimensions stay in sync with - * the underlying ``. */ class A11yContainerSizingTest : OnCanvasTests { @Test - fun a11yContainerHasNonZeroDimensionsAfterInit() = runTest { + fun a11yContainerHasNonZeroRenderedSizeAfterInit() = runTest { createComposeWindow { Text("a11y sizing regression") } @@ -49,42 +45,15 @@ class A11yContainerSizingTest : OnCanvasTests { "A11Y container must exist when isA11YEnabled is true (default)" ) - val width = a11yContainer.style.width - val height = a11yContainer.style.height + val rect = a11yContainer.getBoundingClientRect() assertTrue( - width.isNotEmpty() && width != "0px", - "a11y container width must be set to a non-zero pixel value, was '$width'" + rect.width > 0.0, + "a11y container rendered width must be non-zero, was ${rect.width}" ) assertTrue( - height.isNotEmpty() && height != "0px", - "a11y container height must be set to a non-zero pixel value, was '$height'" - ) - } - - @Test - fun a11yContainerMatchesCanvasCssDimensions() = runTest { - createComposeWindow { - Text("a11y sizing regression") - } - - val canvas = getCanvas() - val a11yContainer = assertNotNull( - getA11YContainer(), - "A11Y container must exist when isA11YEnabled is true (default)" - ) - - assertEquals( - canvas.style.width, - a11yContainer.style.width, - "a11y container width must match the canvas's CSS width so hit-test-based " + - "tools can reach Compose semantic nodes" - ) - assertEquals( - canvas.style.height, - a11yContainer.style.height, - "a11y container height must match the canvas's CSS height so hit-test-based " + - "tools can reach Compose semantic nodes" + rect.height > 0.0, + "a11y container rendered height must be non-zero, was ${rect.height}" ) } }