diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt index 1561e861010b3..19c21612c242f 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt @@ -32,13 +32,30 @@ import org.w3c.dom.HTMLElement internal fun setSizeAndPosition( element: HTMLElement, left: Float, top: Float, width: Float, height: Float ) { + // Note: the position must be set via left/top (not via a CSS transform). + // Transforms don't participate in the DOM layout: they break the layout-based geometry + // (offsetTop/offsetLeft), the scrollable overflow of the a11y scroll containers and + // the browser scroll anchoring, which ATs and browsers rely on. // language=javascript js( """ - element.style.left = "" + left + "px"; - element.style.top = "" + top + "px"; - element.style.width = "" + width + "px"; - element.style.height = "" + height + "px"; + const leftValue = "" + left + "px"; + const topValue = "" + top + "px"; + const widthValue = "" + width + "px"; + const heightValue = "" + height + "px"; + + if (element.style.left !== leftValue) { + element.style.left = leftValue; + } + if (element.style.top !== topValue) { + element.style.top = topValue; + } + if (element.style.width !== widthValue) { + element.style.width = widthValue; + } + if (element.style.height !== heightValue) { + element.style.height = heightValue; + } """ ) } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt index d7ee09dfa0077..7a0c8fc4b6af4 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt @@ -16,8 +16,9 @@ package androidx.compose.ui.platform.accessibility +import androidx.collection.MutableIntLongMap +import androidx.collection.MutableIntObjectMap import androidx.collection.MutableIntSet -import androidx.collection.MutableScatterMap import androidx.collection.ScatterMap import androidx.compose.ui.geometry.Offset import androidx.compose.ui.semantics.ScrollAxisRange @@ -44,22 +45,22 @@ import org.w3c.dom.events.Event * manipulate the scroll offset. */ internal class A11YScrollController( - private val idToA11YNode: ScatterMap, + private val idToA11YNode: MutableIntObjectMap, private val a11yNodeToSemanticsNode: ScatterMap, ) { // When a browser (or AT) updates the scroll offset of the node in A11Y tree, we apply the // new offset to SemanticsNode and save the applied offset value here. - private val appliedScrollOffsets = MutableScatterMap() + private val appliedScrollOffsets = ScrollOffsetsByIdMap() // When we process the Semantics updates, we record the new scroll offsets here. // After the new offsets get applied to A11Y tree, the map is cleared. - private val pendingScrollOffsets = MutableScatterMap() + private val pendingScrollOffsets = ScrollOffsetsByIdMap() // The non-semantic elements inserted into A11Y scrollable nodes. // To make the scrollable a11y node aware of the possible scroll ranges, they include this "sizer" // element, which width/height equal to the scrollable viewport size + max scroll distance. - private val domScrollSizers = MutableScatterMap() + private val domScrollSizers = MutableIntObjectMap() // Tracking the nodes with a scroll listener: private val scrollListenersAttached = MutableIntSet() @@ -68,7 +69,7 @@ internal class A11YScrollController( private val onScroll: (Event) -> Unit = onScroll@ { event -> val element = event.target as? HTMLElement ?: return@onScroll val semanticsNode = a11yNodeToSemanticsNode[element] ?: return@onScroll - val applied = appliedScrollOffsets[semanticsNode.id] ?: return@onScroll + val applied = appliedScrollOffsets.getOffset(semanticsNode.id) ?: return@onScroll val actual = Offset(element.scrollLeft.toFloat(), element.scrollTop.toFloat()) val deltaCssPx = actual - applied @@ -101,8 +102,8 @@ internal class A11YScrollController( } fun getScrollOffset(semanticsNode: SemanticsNode): Offset { - return pendingScrollOffsets[semanticsNode.id] - ?: appliedScrollOffsets[semanticsNode.id] + return pendingScrollOffsets.getOffset(semanticsNode.id) + ?: appliedScrollOffsets.getOffset(semanticsNode.id) ?: Offset.Zero } @@ -191,7 +192,7 @@ internal class A11YScrollController( // Applies Compose scroll offsets to DOM fun applyScrollOffsets() { - pendingScrollOffsets.forEach { id, offset -> + pendingScrollOffsets.forEach { id, offset : Offset -> val element = idToA11YNode[id] ?: return@forEach val sizer = domScrollSizers[id] ?: return@forEach if (sizer.parentElement !== element || element.firstElementChild !== sizer) { @@ -201,7 +202,7 @@ internal class A11YScrollController( } val actual = Offset(element.scrollLeft.toFloat(), element.scrollTop.toFloat()) - val lastApplied = appliedScrollOffsets[id] + val lastApplied = appliedScrollOffsets.getOffset(id) if (lastApplied != null && offset.isCloseTo(lastApplied) && !actual.isCloseTo(lastApplied)) { // Preserve a browser/AT offset until its asynchronous scroll event is handled. return@forEach @@ -238,6 +239,27 @@ internal class A11YScrollController( } } +private typealias ScrollOffsetsByIdMap = MutableIntLongMap + +private inline fun ScrollOffsetsByIdMap.forEach(action: (id: Int, offset: Offset) -> Unit) = forEach { id, longValue -> + action(id, Offset(longValue)) +} + +@Suppress("NOTHING_TO_INLINE") +private inline operator fun ScrollOffsetsByIdMap.set(id: Int, offset: Offset) { + this[id] = offset.packedValue +} + +@Suppress("INVISIBLE_REFERENCE", "NOTHING_TO_INLINE") +private inline fun ScrollOffsetsByIdMap.getOffset(key: Int): Offset? { + val offset = this.getOrElse(key) { -1L } + return if (offset == -1L) { + null + } else { + Offset(offset) + } +} + // We don't expect such huge layouts in Compose (it's likely too expensive), so // keep synthetic scroll range well below known browser layout limits (>10kk). internal const val MAX_SUPPORTED_SCROLL_CSS_PX = 4_000_000f diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt index 99018c853e3ff..5b6d6a10188fc 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt @@ -16,7 +16,10 @@ package androidx.compose.ui.platform.accessibility +import androidx.collection.MutableIntObjectMap import androidx.collection.MutableScatterMap +import androidx.collection.mutableIntSetOf +import androidx.collection.mutableObjectListOf import androidx.compose.ui.currentTimeMillis import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.PlatformContext @@ -143,7 +146,7 @@ internal class ComposeWebSemanticsListener( webSemanticsRoot.addEventListener("keydown", onKeyDown) } - private val semanticsOwners = mutableListOf() + private val semanticsOwners = mutableObjectListOf() override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) { if (semanticsOwners.contains(semanticsOwner)) return @@ -172,7 +175,7 @@ internal class ComposeWebSemanticsListener( private val dfsA11YParents = ArrayDeque() // Lookup maps between semantics nodes and corresponding A11Y DOM elements: - private val idToA11YNode = MutableScatterMap() + private val idToA11YNode = MutableIntObjectMap() private val a11yNodeToSemanticsNode = MutableScatterMap() // An intermediate tree representation which is applied to the actual DOM after every sync: @@ -224,7 +227,7 @@ internal class ComposeWebSemanticsListener( targetParentToChildren.clear() targetChildToParent.clear() - semanticsOwners.fastForEach { + semanticsOwners.forEach { syncSemanticsWithWebA11Y(it) } @@ -234,7 +237,7 @@ internal class ComposeWebSemanticsListener( placeA11YChildrenInOrder(parent, targetChildren) } - val removedIds = mutableSetOf() + val removedIds = mutableIntSetOf() idToA11YNode.forEach { id, htmlNode -> if (!targetChildToParent.containsKey(htmlNode)) { @@ -406,8 +409,8 @@ internal class ComposeWebSemanticsListener( text: String?, justCreated: Boolean = false, ) { - if (text != null && htmlNode.innerText != text) { - htmlNode.innerText = text + if (text != null && htmlNode.textContent != text) { + htmlNode.textContent = text } val ariaLabel = config.getAriaLabel() @@ -422,32 +425,60 @@ internal class ComposeWebSemanticsListener( htmlNode.id = testTag } + val disabled = SemanticsProperties.Disabled in config + if (config.contains(SemanticsProperties.EditableText)) { val editableText = config[SemanticsProperties.EditableText].text + val isObfuscatedPassword = config.isObfuscatedPassword() val exposedEditableText = if (isObfuscatedPassword) { obfuscatedPassword(editableText) } else { editableText } - if (htmlNode.innerText != exposedEditableText) { - htmlNode.innerText = exposedEditableText + if (htmlNode.textContent != exposedEditableText) { + htmlNode.textContent = exposedEditableText + } + + val editable = config.getOrNull(SemanticsProperties.IsEditable) ?: false + htmlNode.setAttribute("contenteditable", editable.toString()) + + val readOnly = !editable && !disabled + if (readOnly) { + htmlNode.setAttribute("aria-readonly", readOnly.toString()) + } else { + htmlNode.removeAttribute("aria-readonly") } if (justCreated) { - htmlNode.setAttribute("contenteditable", "true") - htmlNode.addEventListener("focus", { + htmlNode.addEventListener("focus") { htmlNode.click() - }) + } } } - if (config.contains(SemanticsProperties.Disabled)) { + if (SemanticsProperties.MaxTextLength in config) { + val maxTextLength = config[SemanticsProperties.MaxTextLength] + if(maxTextLength > 0) { + htmlNode.setAttribute("maxlength", maxTextLength.toString()) + } + } else { + htmlNode.removeAttribute("maxlength") + } + + if (disabled) { htmlNode.setAttribute("aria-disabled", "true") } else { htmlNode.removeAttribute("aria-disabled") } + if (SemanticsProperties.Selected in config) { + val selected = config[SemanticsProperties.Selected] + htmlNode.setAttribute("aria-selected", selected.toString()) + } else { + htmlNode.removeAttribute("aria-selected") + } + val roleId = config.getRoleId() setA11YAriaRole(element = htmlNode, roleId) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt index 5701c70e0a17d..a6f6945220909 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt @@ -135,8 +135,11 @@ class A11yScrollTest : OnCanvasTests { "scrollHeight=${element.scrollHeight}, clientHeight=${element.clientHeight}" ) - // Content: 10 items x 50dp in a 100dp viewport => 500dp total content extent - val expectedContentHeightCssPx = 500 + // Content: 10 items x 50dp in a 100dp viewport => ~500dp total content extent. + // Compose rounds each item to whole physical pixels, so on fractional-density screens + // the reported extent differs from 500 (e.g. 504 css px at density 1.25). Derive the + // expected extent from the scroll state instead of hardcoding it. + val expectedContentHeightCssPx = scrollState.maxValue / density + element.clientHeight assertTrue( abs(element.scrollHeight - expectedContentHeightCssPx) <= 2, "Scrollable extent must match the content size reported by Compose, " + @@ -175,8 +178,10 @@ class A11yScrollTest : OnCanvasTests { element.scrollTop = 50.0 val expectedComposePx = (50f * density).toInt() + // Also await the settled state: scrolling again while the ScrollBy-initiated animation + // is still in flight would interrupt it, losing its remaining delta. awaitCondition("Compose scroll state must follow the DOM scroll offset") { - abs(scrollState.value - expectedComposePx) <= 1 + !scrollState.isScrollInProgress && abs(scrollState.value - expectedComposePx) <= 1 } // Scroll further: the second delta must be computed against the new offset (not doubled)