Skip to content
Open
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 @@ -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;
}
"""
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,22 +45,22 @@ import org.w3c.dom.events.Event
* manipulate the scroll offset.
*/
internal class A11YScrollController(
private val idToA11YNode: ScatterMap<Int, HTMLElement>,
private val idToA11YNode: MutableIntObjectMap<HTMLElement>,
private val a11yNodeToSemanticsNode: ScatterMap<HTMLElement, SemanticsNode>,
) {

// 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<Int, Offset>()
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<Int, Offset>()
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<Int, HTMLElement>()
private val domScrollSizers = MutableIntObjectMap<HTMLElement>()

// Tracking the nodes with a scroll listener:
private val scrollListenersAttached = MutableIntSet()
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,7 +146,7 @@ internal class ComposeWebSemanticsListener(
webSemanticsRoot.addEventListener("keydown", onKeyDown)
}

private val semanticsOwners = mutableListOf<SemanticsOwner>()
private val semanticsOwners = mutableObjectListOf<SemanticsOwner>()

override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) {
if (semanticsOwners.contains(semanticsOwner)) return
Expand Down Expand Up @@ -172,7 +175,7 @@ internal class ComposeWebSemanticsListener(
private val dfsA11YParents = ArrayDeque<HTMLElement>()

// Lookup maps between semantics nodes and corresponding A11Y DOM elements:
private val idToA11YNode = MutableScatterMap<Int, HTMLElement>()
private val idToA11YNode = MutableIntObjectMap<HTMLElement>()
private val a11yNodeToSemanticsNode = MutableScatterMap<HTMLElement, SemanticsNode>()

// An intermediate tree representation which is applied to the actual DOM after every sync:
Expand Down Expand Up @@ -224,7 +227,7 @@ internal class ComposeWebSemanticsListener(
targetParentToChildren.clear()
targetChildToParent.clear()

semanticsOwners.fastForEach {
semanticsOwners.forEach {
syncSemanticsWithWebA11Y(it)
}

Expand All @@ -234,7 +237,7 @@ internal class ComposeWebSemanticsListener(
placeA11YChildrenInOrder(parent, targetChildren)
}

val removedIds = mutableSetOf<Int>()
val removedIds = mutableIntSetOf()

idToA11YNode.forEach { id, htmlNode ->
if (!targetChildToParent.containsKey(htmlNode)) {
Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, " +
Expand Down Expand Up @@ -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)
Expand Down