diff --git a/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/lazy/LazyList.web.kt b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/lazy/LazyList.web.kt index d10fbc59e23a3..f086c50386298 100644 --- a/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/lazy/LazyList.web.kt +++ b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/lazy/LazyList.web.kt @@ -17,5 +17,21 @@ package androidx.compose.foundation.lazy import androidx.compose.runtime.Composable +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.platform.LocalPlatformScreenReader -@Composable internal actual fun defaultLazyListBeyondBoundsItemCount(): Int = 0 +/** + * A minimum number of preloaded elements to allow the Web accessibility engine to traverse the + * lazy list elements without delay for semantic tree reloads after scrolling. + */ +private const val SCREEN_READER_BEYOND_BOUNDS_ITEM_COUNT = 3 + +@OptIn(InternalComposeUiApi::class) +@Composable +internal actual fun defaultLazyListBeyondBoundsItemCount(): Int { + return if (LocalPlatformScreenReader.current.isActive) { + SCREEN_READER_BEYOND_BOUNDS_ITEM_COUNT + } else { + 0 + } +} diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/A11yExamplesMissingHtmlAriaAttributes.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/A11yExamplesMissingHtmlAriaAttributes.kt new file mode 100644 index 0000000000000..e14d60822effa --- /dev/null +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/A11yExamplesMissingHtmlAriaAttributes.kt @@ -0,0 +1,166 @@ +/* + * 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.mpp.demo.bugs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.font.FontWeight + +@Composable +fun A11yMissingAriaAttributesExample() { + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + val topSampleText1 = "This sentence is in " + val bottomSampleText1 = "the left column." + val topSampleText2 = "This sentence is" + val bottomSampleText2 = "on the right." + + Text( + "isTraversalGroup = true", fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { + heading() + contentDescription = "Heading content description - isTraversalGroup = true" + stateDescription = "Heading state description - isTraversalGroup = true" + testTag = "headingTestTag" + }) + Row { + CardBox( + topSampleText1, + bottomSampleText1, + Modifier.semantics { isTraversalGroup = true } + ) + CardBox( + topSampleText2, + bottomSampleText2, + Modifier.semantics { isTraversalGroup = true } + ) + } + + Text( + "isTraversalGroup = false", fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { + heading() + contentDescription = "Heading content description - isTraversalGroup = false" + stateDescription = "Heading state description - isTraversalGroup = false" + testTag = "headingTestTag" + }) + Row { + CardBox( + topSampleText1, + bottomSampleText1, + Modifier.semantics { isTraversalGroup = false } + ) + CardBox( + topSampleText2, + bottomSampleText2, + Modifier.semantics { isTraversalGroup = false } + ) + } + + Text( + "Inaccessible Text - invisible below", fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { + heading() + contentDescription = "Heading content description - Inaccessible Text" + stateDescription = "Heading state description - Inaccessible Text" + testTag = "headingTestTag" + }) + Text( + "This text is inaccessible", + modifier = Modifier.alpha(0f) + .semantics { + contentDescription = "Inaccessible text content description" + stateDescription = "Inaccessible text state description" + testTag = "inaccessibleTextFieldTestTag" + } + ) + + Text( + "mergeDescendants = true", fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { + heading() + contentDescription = "Heading content description - mergeDescendants = true" + stateDescription = "Heading state description - mergeDescendants = true" + testTag = "headingTestTag" + }) + Box { + Column( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "Common content description" + stateDescription = "Common state description" + testTag = "commonTestTag" + } + ) { + Text("Text 1") + Text("Text 2") + Text("Text 3") + } + } + + Text( + "mergeDescendants = false", fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { + heading() + contentDescription = "Heading content description - mergeDescendants = false" + stateDescription = "Heading state description - mergeDescendants = false" + testTag = "headingTestTag" + }) + Box { + Column( + modifier = Modifier.semantics(mergeDescendants = false) { + contentDescription = "Common content description" + stateDescription = "Common state description" + testTag = "commonTestTag" + } + ) { + Text("Text 1") + Text("Text 2") + Text("Text 3") + } + } + } +} + +@Composable +private fun CardBox( + topSampleText: String, + bottomSampleText: String, + modifier: Modifier = Modifier +) { + + Column(modifier = modifier.wrapContentSize()) { + Text(topSampleText) + Text(bottomSampleText) + } +} \ No newline at end of file diff --git a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/BugsScreen.kt b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/BugsScreen.kt index 4ef073f0aaed1..77840cf55f908 100644 --- a/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/BugsScreen.kt +++ b/compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/bugs/BugsScreen.kt @@ -28,6 +28,9 @@ val BugsScreen = Screen.Selection("Web Bug Reproducers", screens = listOf( }, Screen.Example("MagicMouse") { MagicMouse() + }, + Screen.Example("A11yMissingAttributes"){ + A11yMissingAriaAttributesExample() } )) 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 5040cea115128..9bc19f801bb39 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,411 +16,46 @@ package androidx.compose.ui.platform.accessibility -import androidx.collection.MutableScatterMap -import androidx.compose.ui.currentTimeMillis -import androidx.compose.ui.geometry.Offset +import androidx.collection.mutableObjectListOf import androidx.compose.ui.platform.PlatformContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.semantics.SemanticsConfiguration -import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.util.fastJoinToString -import kotlin.js.js -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.browser.document import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch import org.w3c.dom.HTMLElement internal class ComposeWebSemanticsListener( val webSemanticsRoot: HTMLElement, ) : PlatformContext.SemanticsOwnerListener { - private val invalidationChannel = - Channel(1, onBufferOverflow = BufferOverflow.DROP_LATEST) - private val syncTriggerChannel = - Channel(1, onBufferOverflow = BufferOverflow.DROP_LATEST) - - private companion object { - const val MAX_TIME_IN_DEBOUNCE_MS = 1000L - const val DEBOUNCE_MS = 100L - } - - - /** - * @param coroutineScope The [CoroutineScope] used to run this listener, - * typically the composition scope so the listener follows the composition lifecycle. - */ - internal fun start(coroutineScope: CoroutineScope) { - // Here we do the following: - // - Every invalidation doesn't trigger an a11y tree sync immediately, but only after the changes have settled (debounce 100ms). - // - We track the time spent in "debounce", so eventually it must sync the a11y tree despite no pause in invalidation events (the changes couldn't settle). - // So the a11y tree sync will happen either when the changes have settled or when the timeSpentInDebounce exceeds 1000 ms. - - /* - 1) --x-x-x-x------------------------------------------------- - |--- 100ms ---| -> sync after changes settle - - 2) ---x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-- - |-------- 1000ms -------| spent 1 second debouncing - |-> forced sync - - 3) ----------------------------x-x-x-x-x-x-x-x--------------- - |---------- 1200ms ---------| |--- 100 ms ---| -> sync after changes settle - | No forced sync here, because the debouncing has just started - */ - coroutineScope.launch { - var timeSpentDebouncing = 0L - var lastDebouncedTime = 0L - var lastSyncTime = currentTimeMillis() - - launch { - invalidationChannel.receiveAsFlow().collect { - val currentTime = currentTimeMillis() - - if (lastDebouncedTime == 0L) { - lastDebouncedTime = currentTime - timeSpentDebouncing = 0L - } else { - val delta = currentTime - lastDebouncedTime - timeSpentDebouncing += delta - lastDebouncedTime = currentTime - } - - if (timeSpentDebouncing >= MAX_TIME_IN_DEBOUNCE_MS) { - // we've been debouncing for too long, but must sync periodically, so force a sync - lastDebouncedTime = 0L - lastSyncTime = currentTime - syncSemanticsWithWebA11Y() - } else { - syncTriggerChannel.trySend(currentTime) - } - } - } - - @OptIn(FlowPreview::class) - launch { - // debounce until the Semantics changes settled for at least 100ms - syncTriggerChannel.receiveAsFlow().debounce(DEBOUNCE_MS.milliseconds).collect { - val currentTime = currentTimeMillis() - - // syncSemanticsWithWebA11Y could've been triggered from a "force sync" above, - // so we check the lastSyncTime here - if (currentTime - lastSyncTime >= DEBOUNCE_MS) { - lastDebouncedTime = 0L - lastSyncTime = currentTime - syncSemanticsWithWebA11Y() - } - } - } - } - } - - private var semanticsOwner: SemanticsOwner? = null - + private val semanticOwners = mutableObjectListOf() override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) { - this.semanticsOwner = semanticsOwner + val owner = ComposeWebSemanticsOwner(semanticsOwner, webSemanticsRoot) + semanticOwners.add(owner) + owner.initialize(coroutineScope) } override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) { - if (semanticsOwner == this.semanticsOwner) { - this.semanticsOwner = null + semanticOwners.removeIf { + (it.semanticsOwner == semanticsOwner).also { isNode -> + if (isNode) it.dispose() + } } } override fun onSemanticsChange(semanticsOwner: SemanticsOwner) { - invalidationChannel.trySend(Unit) - } - - override fun onLayoutChange( - semanticsOwner: SemanticsOwner, semanticsNodeId: Int - ) { - invalidationChannel.trySend(Unit) - } - - private val dfsDeque = ArrayDeque() - - private val nodes = MutableScatterMap() - private val nodeToParent = MutableScatterMap() - private val webNodes = MutableScatterMap() - - - private fun syncSemanticsWithWebA11Y() { - fun SemanticsNode.isValid() = layoutNode.let { it.isPlaced && it.isAttached } - - val root = semanticsOwner?.rootSemanticsNode ?: return - - if (root.isValid()) { - dfsDeque.addLast(root) - } - - - val allIds = mutableSetOf() - - val rootPosition = webSemanticsRoot.getBoundingClientRect().let { - Offset(it.left.toFloat(), it.top.toFloat()) - } - - while (!dfsDeque.isEmpty()) { - val node = dfsDeque.removeLast() - val currentId = node.id - allIds.add(currentId) - - val children = node.replacedChildren.asReversed() - dfsDeque.addAll(children) - children.fastForEach { it -> nodeToParent[it.id] = currentId } - - val htmlNode = if (nodes[currentId] != null) { - nodes[currentId] = node - val htmlNode = webNodes[currentId] ?: error("Node $currentId not found") - - if (children.isNotEmpty()) { - // To ensure the correct order of nested nodes, we remove all of them. - // I assume it's more efficient to remove and re-add them than to insert the nodes at specific positions. - // Also, the code is more simple with this approach. - // They are added below. - removeAllChildrenOf(htmlNode) - } - - syncNode(node, htmlNode, rootPosition) - htmlNode - } else { - nodes[currentId] = node - val htmlNode = document.createElement("div") as HTMLElement - htmlNode.style.apply { - position = "fixed" - whiteSpace = "pre" - } - - webNodes[currentId] = htmlNode - syncNode(node, htmlNode, rootPosition, true) - htmlNode + semanticOwners.forEach { webSemanticsOwner -> + if(webSemanticsOwner.semanticsOwner.rootSemanticsNode.id == semanticsOwner.rootSemanticsNode.id) { + webSemanticsOwner.sendInvalidation() } - - // find the parent node and attach to it - val parentId = nodeToParent[currentId] - val htmlParent = parentId?.let { webNodes[it] } ?: webSemanticsRoot - htmlParent.appendChild(htmlNode) - } - - val removedIds = mutableSetOf() - - webNodes.forEachKey { - if (it !in allIds) { - webNodes[it]?.remove() - removedIds.add(it) - } - } - - removedIds.forEach { - webNodes.remove(it) - nodes.remove(it) - nodeToParent.remove(it) } } - private fun syncNode( - sn: SemanticsNode, - htmlNode: HTMLElement, - rootOffset: Offset, - justCreated: Boolean = false, + override fun onLayoutChange( + semanticsOwner: SemanticsOwner, semanticsNodeId: Int ) { - val config = sn.config - - if (config.contains(SemanticsProperties.Text)) { - val text = config[SemanticsProperties.Text] - htmlNode.innerText = text.fastJoinToString("\n") { it.text } - } - - if (config.contains(SemanticsProperties.ContentDescription)) { - val contentDescription = config[SemanticsProperties.ContentDescription] - htmlNode.setAttribute("aria-label", contentDescription.fastJoinToString(", ")) - } - - if (config.contains(SemanticsActions.OnClick) && justCreated) { - val listener = config[SemanticsActions.OnClick].action!! - - // TODO: need to remove the click listener when the new config version doesn't have OnClick action - htmlNode.addEventListener("click", { - listener.invoke() - }) - } - - if (config.contains(SemanticsProperties.TestTag)) { - val testTag = config[SemanticsProperties.TestTag] - htmlNode.id = testTag - } - - if (config.contains(SemanticsProperties.EditableText)) { - val text = config[SemanticsProperties.EditableText].text - htmlNode.innerText = text - - if (justCreated) { - htmlNode.setAttribute("contenteditable", "true") - htmlNode.addEventListener("focus", { - htmlNode.click() - }) + semanticOwners.forEach { webSemanticsOwner -> + if(webSemanticsOwner.semanticsOwner.rootSemanticsNode.id == semanticsOwner.rootSemanticsNode.id) { + webSemanticsOwner.sendInvalidation() } } - - setA11YAriaRole(element = htmlNode, config.getRoleId()) - - val density = sn.layoutNode.density - sn.boundsInRoot.let { rect -> - val newPosition = rootOffset + rect.topLeft.div(density.density) - val width = rect.width.div(density.density) - val height = rect.height.div(density.density) - - setSizeAndPosition(htmlNode, newPosition.x, newPosition.y, width, height) - } - } -} - -private fun setSizeAndPosition( - element: HTMLElement, left: Float, top: Float, width: Float, height: Float -) { - // language=javascript - js( - """ - element.style.left = "" + left + "px"; - element.style.top = "" + top + "px"; - element.style.width = "" + width + "px"; - element.style.height = "" + height + "px"; - """ - ) -} - -internal object AriaRoleId { - const val Unknown = -1 - - // Mapped from [androidx.compose.ui.semantics.Role] values: - const val Button = 0 - const val Checkbox = 1 - const val Switch = 2 - const val RadioButton = 3 - const val Tab = 4 - const val Image = 5 - const val DropdownList = 6 - const val ValuePicker = Unknown // TODO: Any web alternative? - const val Carousel = Unknown // TODO: Any web alternative? - - // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles - // Other ARIA roles not specified explicitly by [androidx.compose.ui.semantics.Role]: - const val Heading = 7 - const val TextBox = 8 - const val List = 9 - const val Grid = 10 -} - -internal fun SemanticsConfiguration.getRoleId(): Int { - // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles - // Unfortunately, Role value is private, so we map it here: - fun Role.toIntId(): Int = when (this) { - Role.Button -> AriaRoleId.Button - Role.Checkbox -> AriaRoleId.Checkbox - Role.Switch -> AriaRoleId.Switch - Role.RadioButton -> AriaRoleId.RadioButton - Role.Tab -> AriaRoleId.Tab - Role.Image -> AriaRoleId.Image - Role.DropdownList -> AriaRoleId.DropdownList - Role.ValuePicker -> AriaRoleId.Unknown // TODO: Any web alternative? - Role.Carousel -> AriaRoleId.Unknown // TODO: Any web alternative? - else -> AriaRoleId.Unknown - } - - var roleId = -1 - - if (this.contains(SemanticsProperties.Role)) { - roleId = this[SemanticsProperties.Role].toIntId() - } - - if (this.contains(SemanticsActions.OnClick)) { - // TODO: Not everything with OnClick is a button!!! - roleId = Role.Button.toIntId() - } - - if (this.contains(SemanticsProperties.Heading)) { - roleId = AriaRoleId.Heading - } - - if (this.contains(SemanticsProperties.EditableText)) { - roleId = AriaRoleId.TextBox - } - - if (this.contains(SemanticsProperties.CollectionInfo)) { - val info = this.get(SemanticsProperties.CollectionInfo) - roleId = if (info.columnCount > 1 && info.rowCount > 1) { - AriaRoleId.Grid - } else { - AriaRoleId.List - } } - - return roleId -} - -// To avoid passing a Kotlin string to JS, we pass an int instead and map it to String on the JS side. -// See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles -internal fun setA11YAriaRole(element: HTMLElement, ariaRoleId: Int) { - // language=javascript - js( - """ - var roleValue = ""; - switch (ariaRoleId) { - case 0: // Role.Button - roleValue = "button"; - break; - case 1: // Role.Checkbox - roleValue = "checkbox"; - break; - case 2: // Role.Switch - roleValue = "switch"; - break; - case 3: // Role.RadioButton - roleValue = "radio"; - break; - case 4: // Role.Tab - roleValue = "tab"; - break; - case 5: // Role.Image - roleValue = "img"; - break; - case 6: // Role.DropdownList - roleValue = "menu"; - break; - case 7: // heading https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/heading_role - roleValue = "heading"; - break; - case 8: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/textbox_role - roleValue = "textbox"; - break; - case 9: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/list_role - roleValue = "list"; - break; - case 10: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/grid_role - roleValue = "grid"; - break; - default: - break; - } - if (roleValue.length > 0) { - element.setAttribute("role", roleValue); - } else { - element.removeAttribute("role"); - } - """ - ) -} - -private fun removeAllChildrenOf(element: HTMLElement) { - // language=javascript - js("element.replaceChildren()") -} +} \ No newline at end of file diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsOwner.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsOwner.kt new file mode 100644 index 0000000000000..a9f849a762d0a --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsOwner.kt @@ -0,0 +1,1242 @@ +/* + * 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.accessibility + +import androidx.collection.mutableIntObjectMapOf +import androidx.collection.mutableObjectListOf +import androidx.compose.ui.currentTimeMillis +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsOwner +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.findClosestParentNode +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.semantics.sortByGeometryGroupings +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastJoinToString +import kotlin.js.js +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.w3c.dom.HTMLElement +import org.w3c.dom.events.InputEvent + + +internal class ComposeWebSemanticsOwner( + val semanticsOwner: SemanticsOwner, + val webSemanticsRoot: HTMLElement, +) { + + private var job: Job? = null + private val invalidationChannel = + Channel(1, onBufferOverflow = BufferOverflow.DROP_LATEST) + private val syncTriggerChannel = + Channel(1, onBufferOverflow = BufferOverflow.DROP_LATEST) + + private companion object { + const val MAX_TIME_IN_DEBOUNCE_MS = 1000L + const val DEBOUNCE_MS = 100L + } + + fun initialize(scope: CoroutineScope) { + // Here we do the following: + // - Every invalidation doesn't trigger an a11y tree sync immediately, but only after the changes have settled (debounce 100ms). + // - We track the time spent in "debounce", so eventually it must sync the a11y tree despite no pause in invalidation events (the changes couldn't settle). + // So the a11y tree sync will happen either when the changes have settled or when the timeSpentInDebounce exceeds 1000 ms. + + /* + 1) --x-x-x-x------------------------------------------------- + |--- 100ms ---| -> sync after changes settle + + 2) ---x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-- + |-------- 1000ms -------| spent 1 second debouncing + |-> forced sync + + 3) ----------------------------x-x-x-x-x-x-x-x--------------- + |---------- 1200ms ---------| |--- 100 ms ---| -> sync after changes settle + | No forced sync here, because the debouncing has just started + */ + job = scope.launch { + var timeSpentDebouncing = 0L + var lastDebouncedTime = 0L + var lastSyncTime = currentTimeMillis() + + launch { + invalidationChannel.receiveAsFlow().collect { + val currentTime = currentTimeMillis() + + if (lastDebouncedTime == 0L) { + lastDebouncedTime = currentTime + timeSpentDebouncing = 0L + } else { + val delta = currentTime - lastDebouncedTime + timeSpentDebouncing += delta + lastDebouncedTime = currentTime + } + + if (timeSpentDebouncing >= MAX_TIME_IN_DEBOUNCE_MS) { + // we've been debouncing for too long, but must sync periodically, so force a sync + lastDebouncedTime = 0L + lastSyncTime = currentTime + syncSemanticsWithWebA11Y() + } else { + syncTriggerChannel.trySend(currentTime) + } + } + } + + @OptIn(FlowPreview::class) + launch { + // debounce until the Semantics changes settled for at least 100ms + syncTriggerChannel.receiveAsFlow().debounce(DEBOUNCE_MS.milliseconds).collect { + val currentTime = currentTimeMillis() + + // syncSemanticsWithWebA11Y could've been triggered from a "force sync" above, + // so we check the lastSyncTime here + if (currentTime - lastSyncTime >= DEBOUNCE_MS) { + lastDebouncedTime = 0L + lastSyncTime = currentTime + syncSemanticsWithWebA11Y() + } + } + } + } + } + + fun sendInvalidation() { + invalidationChannel.trySend(Unit) + } + + private val bfsDeque = ArrayDeque() + + //Necessary due to the Breadth-First traversal of the semantics tree, to keep track of the correct parent in the DOM tree + private val domParentQueue = ArrayDeque() + + /** + * Maps the [WebSemanticsNode]s we have created by the [SemanticsNode.id] for which they were + * created. + */ + private var accessibleByNodeId = mutableIntObjectMapOf() + + /** + * An auxiliary mapping of semantics node ids to [WebSemanticsNode]s that is swapped with + * [accessibleByNodeId] on each sync, to avoid allocating memory on each sync. + */ + private var auxAccessibleByNodeId = mutableIntObjectMapOf() + + private val pendingSyncWebSemanticsNodes = mutableObjectListOf() + + /** + * Syncs [accessibleByNodeId] with the semantics node tree. + */ + private fun syncSemanticsWithWebA11Y() { + fun SemanticsNode.isValid() = + layoutNode.let { it.isPlaced && it.isAttached } and !config.let { + @Suppress("DEPRECATION") + SemanticsProperties.InvisibleToUser in it || + SemanticsProperties.HideFromAccessibility in it + } + + val previous = accessibleByNodeId + val updated = auxAccessibleByNodeId + val rootSemanticNode = semanticsOwner.rootSemanticsNode + + if (!rootSemanticNode.isValid()) { + previous.forEach { _, node -> + node.dispose() + } + previous.clear() + return + } + + val htmlRootElementOffset = webSemanticsRoot.getBoundingClientRect().let { + Offset(it.left.toFloat(), it.top.toFloat()) + } + + // We use a Queue to naturally map Top-Down traversal keeping exact Parent mapping + bfsDeque.addLast(rootSemanticNode) + domParentQueue.addLast(webSemanticsRoot) + + while (!bfsDeque.isEmpty()) { + val node = bfsDeque.removeFirst() + val domParent = domParentQueue.removeFirst() + + val existingWebSemanticsNode = previous[node.id] + + val currentHtmlSemanticNode = if (existingWebSemanticsNode != null) { + val oldSemanticsConfig = existingWebSemanticsNode.configuration + existingWebSemanticsNode.semanticsNode = node + existingWebSemanticsNode.pendingOldSemanticsConfiguration = oldSemanticsConfig + + existingWebSemanticsNode + } else { + val htmlNode = createHtmlElementForSemanticNode() + WebSemanticsNode(htmlNode, node) + } + + pendingSyncWebSemanticsNodes.add(currentHtmlSemanticNode) + + updated[node.id] = currentHtmlSemanticNode + val currentWebSemanticsHtmlElement = currentHtmlSemanticNode.backingHtmlElement + + addChildToParentOrIgnore(currentWebSemanticsHtmlElement, domParent) + + var currentDomChildIndex = 0 + + node.sortFlattenChildren().fastForEach { childSemanticsNode -> + if (!childSemanticsNode.isValid()) return@fastForEach + val childWebSemanticsNode = + previous[childSemanticsNode.id] ?: updated[childSemanticsNode.id] + + if (childWebSemanticsNode != null) { + updateHtmlNodeIfRequired( + currentWebSemanticsHtmlElement, + childWebSemanticsNode.backingHtmlElement, + currentDomChildIndex + ) + currentDomChildIndex++ + } + + + bfsDeque.addLast(childSemanticsNode) + domParentQueue.addLast(currentWebSemanticsHtmlElement) + } + } + + //Remove all the nodes that are not in the new tree or are invalid + previous.forEach { id, node -> + if (id !in updated || !node.semanticsNode.isValid()) { + node.dispose() + } + } + + auxAccessibleByNodeId = previous.also { it.clear() } + accessibleByNodeId = updated + + pendingSyncWebSemanticsNodes.forEach { node -> + syncNode( + webSemanticsNode = node, + htmlRootElementOffset = htmlRootElementOffset, + ) + node.pendingOldSemanticsConfiguration = null + node.appended = true + } + + pendingSyncWebSemanticsNodes.clear() + bfsDeque.clear() + domParentQueue.clear() + } + + + /** + * Returns `true` when the value has changed compared to the old configuration, + * or this is the first sync (node just added). Avoids repeating `isAdded ||` everywhere. + */ + @Suppress("NOTHING_TO_INLINE") + private inline fun needsUpdate(isNewlyAdded: Boolean, value: T, oldValue: T): Boolean = + isNewlyAdded || value != oldValue + + @Suppress("NOTHING_TO_INLINE") + private inline fun needsRemoval(isNewlyAdded: Boolean, oldValueExists: Boolean): Boolean = + !isNewlyAdded && oldValueExists + + private fun syncNode( + webSemanticsNode: WebSemanticsNode, + htmlRootElementOffset: Offset, + ) { + val isNewlyCreated = !webSemanticsNode.appended + val config = webSemanticsNode.configuration + val oldConfig = webSemanticsNode.pendingOldSemanticsConfiguration + val htmlNode = webSemanticsNode.backingHtmlElement + + if (SemanticsProperties.Text in config) { + val text = config[SemanticsProperties.Text] + val oldText = oldConfig?.getOrNull(SemanticsProperties.Text) + if (needsUpdate(isNewlyCreated, text, oldText)) { + //Better than innerText since it does not cause layout reflows + htmlNode.textContent = text.fastJoinToString("\n") { it.text } + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.Text) == true + ) + ) { + //Better than innerText since it does not cause layout reflows + htmlNode.textContent = "" + } + } + + if (SemanticsProperties.ContentDescription in config) { + val contentDescription = config[SemanticsProperties.ContentDescription] + val oldContentDescription = + oldConfig?.getOrNull(SemanticsProperties.ContentDescription) + if (needsUpdate(isNewlyCreated, contentDescription, oldContentDescription)) { + setAriaLabel(htmlNode, contentDescription.fastJoinToString(", ")) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.ContentDescription) == true + ) + ) { + removeAriaLabel(htmlNode) + } + } + + if (SemanticsActions.OnClick in config) { + val listener = config[SemanticsActions.OnClick].action!! + val oldListener = oldConfig?.getOrNull(SemanticsActions.OnClick)?.action + + if (needsUpdate(isNewlyCreated, listener, oldListener)) { + webSemanticsNode.addOrReplaceEventListener("click") { + listener.invoke() + } + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsActions.OnClick) == true + ) + ) { + webSemanticsNode.removeEventListener("click") + } + } + + if (SemanticsProperties.TestTag in config) { + val testTag = config[SemanticsProperties.TestTag] + val oldTestTag = oldConfig?.getOrNull(SemanticsProperties.TestTag) + if (needsUpdate(isNewlyCreated, testTag, oldTestTag)) { + htmlNode.id = testTag + } + } else { + //Rather than removal, it is replacing it for the default id which is formatted as "a11y_${semanticsNode.id}" + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.TestTag) == true + ) + ) { + setElementId(htmlNode, webSemanticsNode.id) + } + } + + val disabled = SemanticsProperties.Disabled in config + val oldDisabled = + if (!isNewlyCreated) oldConfig?.contains(SemanticsProperties.Disabled) == true else false + if (disabled) { + setDisabled(htmlNode) + } else { + if (oldDisabled) { + removeDisabled(htmlNode) + } + } + + if (SemanticsProperties.EditableText in config) { + val text = config[SemanticsProperties.EditableText].text + val oldText = oldConfig?.getOrNull(SemanticsProperties.EditableText)?.text + if (needsUpdate(isNewlyCreated, text, oldText)) { + htmlNode.textContent = text + } + + val editable = config.getOrNull(SemanticsProperties.IsEditable) ?: false + val oldEditable = oldConfig?.getOrNull(SemanticsProperties.IsEditable) ?: false + if (needsUpdate(isNewlyCreated, editable, oldEditable)) { + setContentEditable(htmlNode, editable) + } + + val readOnly = !editable && !disabled + val oldReadOnly = !oldEditable && !oldDisabled + if (needsUpdate(isNewlyCreated, readOnly, oldReadOnly)) { + if (readOnly) { + setReadOnly(htmlNode) + } else { + removeReadOnly(htmlNode) + } + } + + if (isNewlyCreated) { + webSemanticsNode.addOrReplaceEventListener("focus") { + htmlNode.click() + } + } + } + if (SemanticsProperties.MaxTextLength in config) { + val maxTextLength = config[SemanticsProperties.MaxTextLength] + val oldMaxTextLength = oldConfig?.getOrNull(SemanticsProperties.MaxTextLength) + if (needsUpdate(isNewlyCreated, maxTextLength, oldMaxTextLength)) { + setMaxTextLength(htmlNode, maxTextLength) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.MaxTextLength) == true + ) + ) { + removeMaxTextLength(htmlNode) + } + } + + if (SemanticsProperties.Selected in config) { + val selected = config[SemanticsProperties.Selected] + val oldSelected = oldConfig?.getOrNull(SemanticsProperties.Selected) + + if (needsUpdate(isNewlyCreated, selected, oldSelected)) { + setSelected(htmlNode, selected) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.Selected) == true + ) + ) { + removeSelected(htmlNode) + } + } + + if (SemanticsProperties.Focused in config) { + if (isNewlyCreated) { + setFocusable(htmlNode) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.Focused) == true + ) + ) { + removeFocusable(htmlNode) + } + } + + if (SemanticsProperties.Error in config) { + //TODO: Implement aria-errormessage, which accepts an HTMLElement id that refers to the error message. + if (isNewlyCreated) { + setErrorState(htmlNode) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.Error) == true + ) + ) { + removeErrorState(htmlNode) + } + } + + if (SemanticsProperties.IsDialog in config) { + if (isNewlyCreated || oldConfig?.contains(SemanticsProperties.IsDialog) != true) { + setAriaModal(htmlNode) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.IsDialog) == true + ) + ) { + removeAriaModal(htmlNode) + } + } + + if (SemanticsProperties.HideFromAccessibility !in config && !isNewlyCreated) { + if (oldConfig?.contains(SemanticsProperties.HideFromAccessibility) == true) { + removeHidden(htmlNode) + } + } + + if (SemanticsProperties.LiveRegion in config) { + val liveRegion = config[SemanticsProperties.LiveRegion] + val oldLiveRegion = oldConfig?.getOrNull(SemanticsProperties.LiveRegion) + if (needsUpdate(isNewlyCreated, liveRegion, oldLiveRegion)) { + setLiveRegion( + htmlNode, when (liveRegion) { + LiveRegionMode.Polite -> 0 + LiveRegionMode.Assertive -> 1 + else -> 2 + } + ) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.LiveRegion) == true + ) + ) { + removeLiveRegionAttribute(htmlNode) + } + } + + if (SemanticsProperties.ProgressBarRangeInfo in config) { + val info = config[SemanticsProperties.ProgressBarRangeInfo] + val oldInfo = oldConfig?.getOrNull(SemanticsProperties.ProgressBarRangeInfo) + if (needsUpdate( + isNewlyCreated, + info, + oldInfo + ) && info != ProgressBarRangeInfo.Indeterminate + ) { + setProgressBarRangeInfo( + htmlNode, + info.range.start, + info.range.endInclusive, + info.current, + ) + } + if (SemanticsProperties.StateDescription in config && info != ProgressBarRangeInfo.Indeterminate) { + val stateDescription = config[SemanticsProperties.StateDescription] + val oldStateDescription = + oldConfig?.getOrNull(SemanticsProperties.StateDescription) + if (needsUpdate(isNewlyCreated, stateDescription, oldStateDescription)) { + setValueTextStateDescription(htmlNode, stateDescription) + } + } + + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.ProgressBarRangeInfo) == true + ) + ) { + removeRangeInfoAttributes(htmlNode) + } + } + + if (SemanticsProperties.ToggleableState in config) { + val state = config[SemanticsProperties.ToggleableState] + val oldState = oldConfig?.getOrNull(SemanticsProperties.ToggleableState) + if (needsUpdate(isNewlyCreated, state, oldState)) { + setCheckedState(htmlNode, state.ordinal) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.ToggleableState) == true + ) + ) { + removeCheckedState(htmlNode) + } + } + + if (SemanticsProperties.CollectionInfo in config) { + val info = config[SemanticsProperties.CollectionInfo] + val oldInfo = oldConfig?.getOrNull(SemanticsProperties.CollectionInfo) + if (needsUpdate(isNewlyCreated, info, oldInfo)) { + setCollectionInfo(htmlNode, info.rowCount, info.columnCount) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.CollectionInfo) == true + ) + ) { + removeCollectionInfoAttributes(htmlNode) + } + } + + if (SemanticsProperties.CollectionItemInfo in config) { + val info = config[SemanticsProperties.CollectionItemInfo] + val oldInfo = oldConfig?.getOrNull(SemanticsProperties.CollectionItemInfo) + if (needsUpdate(isNewlyCreated, info, oldInfo)) { + setItemCollectionInfo( + htmlNode, + info.rowIndex, + info.columnIndex, + info.rowSpan, + info.columnSpan + ) + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsProperties.CollectionItemInfo) == true + ) + ) { + removeCollectionItemInfoAttributes(htmlNode) + } + } + + val verticallyScrollable = SemanticsProperties.VerticalScrollAxisRange in config + val horizontallyScrollable = SemanticsProperties.HorizontalScrollAxisRange in config + val oldVerticallyScrollable = + oldConfig?.contains(SemanticsProperties.VerticalScrollAxisRange) == true + val oldHorizontallyScrollable = + oldConfig?.contains(SemanticsProperties.HorizontalScrollAxisRange) == true + + val scrollChanged = isNewlyCreated || + verticallyScrollable != oldVerticallyScrollable || + horizontallyScrollable != oldHorizontallyScrollable + + if (scrollChanged) { + if (verticallyScrollable xor horizontallyScrollable) { + setOrientation(htmlNode, verticallyScrollable) + } else { + removeOrientation(htmlNode) + } + } + + if (SemanticsActions.RequestFocus in config) { + val listener = config[SemanticsActions.RequestFocus].action!! + val oldListener = oldConfig?.getOrNull(SemanticsActions.RequestFocus)?.action + if (needsUpdate(isNewlyCreated, listener, oldListener)) { + webSemanticsNode.addOrReplaceEventListener("focus") { + listener.invoke() + } + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsActions.RequestFocus) == true + ) + ) { + webSemanticsNode.removeEventListener("focus") + } + } + + if (SemanticsActions.SetProgress in config) { + val listener = config[SemanticsActions.SetProgress].action!! + val oldListener = oldConfig?.getOrNull(SemanticsActions.SetProgress)?.action + if (needsUpdate(isNewlyCreated, listener, oldListener)) { + webSemanticsNode.addOrReplaceEventListener("input") { + it as InputEvent + val value = it.data.toFloatOrNull() ?: return@addOrReplaceEventListener + listener.invoke(value) + } + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsActions.SetProgress) == true + ) + ) { + webSemanticsNode.removeEventListener("input") + } + } + + if (SemanticsActions.ScrollBy in config) { + val listener = config[SemanticsActions.ScrollBy].action!! + val oldListener = oldConfig?.getOrNull(SemanticsActions.ScrollBy)?.action + if (needsUpdate(isNewlyCreated, listener, oldListener)) { + webSemanticsNode.addOrReplaceEventListener("scroll") { + val scrollLeft = htmlNode.scrollLeft.toFloat() + val scrollTop = htmlNode.scrollTop.toFloat() + listener.invoke(scrollLeft, scrollTop) + } + } + } else { + if (needsRemoval( + isNewlyCreated, + oldConfig?.contains(SemanticsActions.ScrollBy) == true + ) + ) { + webSemanticsNode.removeEventListener("scroll") + } + } + + val ariaRole = config.getRoleId() + val oldAriaRole = oldConfig?.getRoleId() ?: AriaRoleId.Unknown + + if (needsUpdate(isNewlyCreated, ariaRole, oldAriaRole)) { + setA11YAriaRole(element = htmlNode, ariaRole) + } + + webSemanticsNode.setBounds(htmlRootElementOffset) + } + + internal fun SemanticsNode.sortFlattenChildren(): List { + val sortedChildren = sortByGeometryGroupings( + replacedChildren, + { + it.config.contains(SemanticsProperties.Focused) + } + ) as MutableList + + //sortByGeometryGroupings uses clipped bounds for sorting, which is not accurate for nodes beyond visible bounds + // (as their clipped bounds are empty), so we need to fix the order of those nodes. Similar to IOS fix for this + val isRTL = layoutNode.layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl + sortedChildren.sortWith(BeyondBoundsComparator(isRTL)) + + // Fix the specifics of nodes sorting where a parent node may go after a child in the sorted list. + // Swapping them if the order is not specified by other criteria as TraversalIndex. + // In case of other sort issues, consider copy and re-implementing the `sortByGeometryGroupings` + // method to match TalkBack application traversal order. + repeat(sortedChildren.count() - 1) { index -> + val first = sortedChildren[index] + val second = sortedChildren[index + 1] + if (!first.config.contains(SemanticsProperties.TraversalIndex) && + !second.config.contains(SemanticsProperties.TraversalIndex) && + first.layoutNode.parent != second.layoutNode.parent && + first.layoutNode.findClosestParentNode({ it == second.layoutNode }) != null + ) { + sortedChildren[index] = second + sortedChildren[index + 1] = first + } + } + return sortedChildren + } + + /** + * Simplified version of [SemanticsNode.sortByGeometryGroupings] based on the + * [SemanticsNode.unclippedBoundsInWindow] because [SemanticsNode.boundsInWindow] is empty for + * nodes beyond visible bounds. + */ + private class BeyondBoundsComparator(private val isRTL: Boolean) : Comparator { + + val SemanticsNode.unclippedBoundsInWindow: Rect + get() = Rect(positionInWindow, size.toSize()) + override fun compare(a: SemanticsNode, b: SemanticsNode): Int { + var result = a.unmergedConfig + .getOrElse(SemanticsProperties.TraversalIndex) { 0f } + .compareTo(b.unmergedConfig.getOrElse(SemanticsProperties.TraversalIndex) { 0f }) + + if (result != 0) { + return result + } + val aCenter = a.unclippedBoundsInWindow.center + val bCenter = b.unclippedBoundsInWindow.center + + result = aCenter.y + .compareTo(bCenter.y) + + if (result != 0) { + return result + } + + result = aCenter.x + .compareTo(bCenter.x) + + if (result != 0) { + return if (isRTL) -result else result + } + + return result + } + } + + fun dispose() { + job?.cancel() + accessibleByNodeId.forEach { _, node -> + node.dispose() + } + accessibleByNodeId.clear() + syncTriggerChannel.close() + invalidationChannel.close() + } +} + + +private fun WebSemanticsNode.setBounds(htmlRootElementOffset: Offset) { + val density = this.semanticsNode.layoutNode.density + + // Fetch the attached coordinates directly for this node and its strict semantics parent, + // explicitly bypassing "isImportantForBounds" ancestral skipping logic. + //If we use boundsInParent, in some cases the bounds will not be really from the parent, + // but from the parent of the parent. + val layoutCoordinates = this.semanticsNode.findCoordinatorToGetBounds() + ?.takeIf { it.isAttached }?.coordinates + val parentLayoutCoordinates = this.semanticsNode.parent?.findCoordinatorToGetBounds() + ?.takeIf { it.isAttached }?.coordinates + + // Calculate strict local boundaries between the two nodes + val localBounds = if (layoutCoordinates != null && parentLayoutCoordinates != null) { + parentLayoutCoordinates.localBoundingBoxOf(layoutCoordinates, clipBounds = false) + } else if (layoutCoordinates != null) { + // Fallback for the root node + this.semanticsNode.boundsInRoot + } else { + // Unattached or invalid state + Rect.Zero + } + + val newPosition = + localBounds.topLeft / density.density + if (this.semanticsNode.isRoot) htmlRootElementOffset else Offset.Zero + + val newSize = localBounds.size.div(density.density) + + if (newPosition != this.topLeft) { + this.topLeft = newPosition + setPosition(this.backingHtmlElement, newPosition.x, newPosition.y) + } + + if (newSize != this.size) { + this.size = newSize + setSize(this.backingHtmlElement, newSize.width, newSize.height) + } +} + +private fun setPosition( + element: HTMLElement, left: Float, top: Float +) { + // language=javascript + js("element.style.transform = 'matrix(1, 0, 0, 1, ' + left + ', ' + top + ')'") +} + +private fun setSize( + element: HTMLElement, width: Float, height: Float +) { + // language=javascript + js( + """ + element.style.width = "" + width + "px"; + element.style.height = "" + height + "px"; + """ + ) +} + +internal object AriaRoleId { + const val Unknown = -1 + + // Mapped from [androidx.compose.ui.semantics.Role] values: + const val Button = 0 + const val Checkbox = 1 + const val Switch = 2 + const val RadioButton = 3 + const val Tab = 4 + const val Image = 5 + const val DropdownList = 6 + const val ValuePicker = Unknown // TODO: Any web alternative? + const val Carousel = Unknown // TODO: Any web alternative? + + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles + // Other ARIA roles not specified explicitly by [androidx.compose.ui.semantics.Role]: + const val Heading = 7 + const val TextBox = 8 + const val List = 9 + const val Grid = 10 + + const val Dialog = 11 + const val ProgressBar = 12 + const val Slider = 13 +} + +internal fun SemanticsConfiguration.getRoleId(): Int { + // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles + // Unfortunately, Role value is private, so we map it here: + fun Role.toIntId(): Int = when (this) { + Role.Button -> AriaRoleId.Button + Role.Checkbox -> AriaRoleId.Checkbox + Role.Switch -> AriaRoleId.Switch + Role.RadioButton -> AriaRoleId.RadioButton + Role.Tab -> AriaRoleId.Tab + Role.Image -> AriaRoleId.Image + Role.DropdownList -> AriaRoleId.DropdownList + Role.ValuePicker -> AriaRoleId.Unknown // TODO: Any web alternative? + Role.Carousel -> AriaRoleId.Unknown // TODO: Any web alternative? + else -> AriaRoleId.Unknown + } + + var roleId = -1 + + if (SemanticsProperties.Role in this) { + roleId = this[SemanticsProperties.Role].toIntId() + } + + if (SemanticsActions.OnClick in this && roleId == AriaRoleId.Unknown) { + // TODO: Not everything with OnClick is a button!!! + roleId = Role.Button.toIntId() + } + + if (SemanticsProperties.Heading in this) { + roleId = AriaRoleId.Heading + } + + if (SemanticsProperties.EditableText in this) { + roleId = AriaRoleId.TextBox + } + + if (SemanticsProperties.CollectionInfo in this) { + val info = this[SemanticsProperties.CollectionInfo] + roleId = if (info.columnCount > 1 && info.rowCount > 1) { + AriaRoleId.Grid + } else { + AriaRoleId.List + } + } + + if (SemanticsProperties.ProgressBarRangeInfo in this) { + val info = this[SemanticsProperties.ProgressBarRangeInfo] + roleId = + if (info.steps > 0 || SemanticsProperties.Disabled in this || SemanticsActions.SetProgress in this) { + AriaRoleId.Slider + } else { + AriaRoleId.ProgressBar + } + } + if (SemanticsProperties.IsDialog in this) { + roleId = AriaRoleId.Dialog + } + + return roleId +} + +// To avoid passing a Kotlin string to JS, we pass an int instead and map it to String on the JS side. +// See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles +internal fun setA11YAriaRole(element: HTMLElement, ariaRoleId: Int) { + // language=javascript + js( + """ + var roleValue = ""; + switch (ariaRoleId) { + case 0: // Role.Button + roleValue = "button"; + break; + case 1: // Role.Checkbox + roleValue = "checkbox"; + break; + case 2: // Role.Switch + roleValue = "switch"; + break; + case 3: // Role.RadioButton + roleValue = "radio"; + break; + case 4: // Role.Tab + roleValue = "tab"; + break; + case 5: // Role.Image + roleValue = "img"; + break; + case 6: // Role.DropdownList + roleValue = "combobox"; + break; + case 7: // heading https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/heading_role + roleValue = "heading"; + break; + case 8: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/textbox_role + roleValue = "textbox"; + break; + case 9: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/list_role + roleValue = "list"; + break; + case 10: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/grid_role + roleValue = "grid"; + break; + case 11: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/dialog_role + roleValue = "dialog"; + break; + case 12: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/progressbar_role/ + roleValue = "progressbar"; + break; + case 13: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/slider_role + roleValue = "slider"; + break; + default: + break; + } + if (roleValue.length > 0) { + element.setAttribute("role", roleValue); + } else { + element.removeAttribute("role"); + } + """ + ) +} + +private fun removeAllChildrenOf(element: HTMLElement) { + // language=javascript + js("element.replaceChildren()") +} + +private fun addChildToParentOrIgnore( + currentWebSemanticsHtmlNode: HTMLElement, + domParent: HTMLElement +) { + // language=javascript + js( + """ + if (currentWebSemanticsHtmlNode.parentElement !== domParent) { + domParent.appendChild(currentWebSemanticsHtmlNode) + } + """ + ) +} + +private fun updateHtmlNodeIfRequired( + currentWebSemanticsHtmlNode: HTMLElement, + childElement: HTMLElement, + currentDomChildIndex: Int +) { + // language=javascript + js( + """ + let existingNodeAtIndex = currentWebSemanticsHtmlNode.children.item(currentDomChildIndex); + // If the element sitting at DOM[index] isn't our expected child, move it there. + if (existingNodeAtIndex !== childElement) { + currentWebSemanticsHtmlNode.insertBefore(childElement, existingNodeAtIndex) + } + """ + ) +} + +private fun createHtmlElementForSemanticNode(): HTMLElement = js( +//language=javascript + """ + { + let htmlNode = document.createElement("div") + htmlNode.style.position = "fixed" + htmlNode.style.top = "0px" + htmlNode.style.left = "0px" + htmlNode.style.whiteSpace = "pre" + htmlNode.style.transformOrigin = "0px 0px 0px" + return htmlNode; + } + """ +) + + +private fun setContentEditable(element: HTMLElement, isEditable: Boolean): Unit = + // language=javascript + js("element.setAttribute('contenteditable', isEditable ? 'true' : 'false')") + +private fun setOrientation(element: HTMLElement, isVertical: Boolean): Unit = + // language=javascript + js("element.setAttribute('aria-orientation', isVertical ? 'vertical' : 'horizontal')") + +private fun removeOrientation(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-orientation')") + +private fun setReadOnly(element: HTMLElement): Unit = + // language=javascript + js("element.setAttribute('aria-readonly', 'true')") + +private fun removeReadOnly(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-readonly')") + +private fun setDisabled(element: HTMLElement): Unit = + // language=javascript + js("element.setAttribute('aria-disabled', 'true')") + +private fun removeDisabled(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-disabled')") + +private fun setSelected(element: HTMLElement, isSelected: Boolean): Unit = + // language=javascript + js("element.setAttribute('aria-selected', isSelected ? 'true' : 'false')") + +private fun removeSelected(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-selected')") + +private fun setCheckedState(element: HTMLElement, type: Int): Unit = + // language=javascript + js( + """ + { + switch (type) { + case 0: // ToggleableState.On + element.setAttribute('aria-checked', 'true'); + break; + case 1: // ToggleableState.Off + element.setAttribute('aria-checked', 'false'); + break; + case 2: // ToggleableState.Indeterminate + element.setAttribute('aria-checked', 'mixed'); + break; + } + } + """ + ) + +private fun removeCheckedState(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-checked')") + +private fun setProgressBarRangeInfo( + element: HTMLElement, + start: Float, + endIncl: Float, + current: Float +) { + // language=javascript + js( + """ + element.setAttribute('aria-valuemin', start); + element.setAttribute('aria-valuemax', endIncl); + element.setAttribute('aria-valuenow', current); + """ + ) +} + +private fun setValueTextStateDescription(element: HTMLElement, description: String): Unit = + // language=javascript + js("element.setAttribute('aria-valuetext', description)") + +private fun removeRangeInfoAttributes(element: HTMLElement) { + // language=javascript + js( + """ + element.removeAttribute('aria-valuemin'); + element.removeAttribute('aria-valuemax'); + element.removeAttribute('aria-valuenow'); + element.removeAttribute('aria-valuetext'); + """ + ) +} + +private fun removeCollectionItemInfoAttributes(element: HTMLElement) { + // language=javascript + js( + """ + element.removeAttribute('aria-rowindex'); + element.removeAttribute('aria-colindex'); + element.removeAttribute('aria-rowspan'); + element.removeAttribute('aria-colspan'); + """ + ) +} + +private fun removeCollectionInfoAttributes(element: HTMLElement) { + // language=javascript + js( + """ + element.removeAttribute('aria-rowcount'); + element.removeAttribute('aria-colcount'); + """ + ) +} + +private fun setLiveRegion( + element: HTMLElement, + liveRegionMode: Int +) { + // language=javascript + js( + """ + switch (liveRegionMode) { + case 0: // LiveRegionMode.None + element.setAttribute('aria-live', 'off'); + break; + case 1: // LiveRegionMode.Polite + element.setAttribute('aria-live', 'polite'); + break; + case 2: // LiveRegionMode.Assertive + element.setAttribute('aria-live', 'assertive'); + break; + } + """ + ) +} + +private fun removeLiveRegionAttribute(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-live')") + +private fun setMaxTextLength(element: HTMLElement, maxLength: Int): Unit = + // language=javascript + js("element.setAttribute('maxlength', maxLength)") + +private fun removeMaxTextLength(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('maxlength')") + +private fun setFocusable(element: HTMLElement): Unit = + // language=javascript + js("element.setAttribute('tabindex', '0')") + +private fun removeFocusable(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('tabindex')") + +private fun setErrorState(element: HTMLElement): Unit = + // language=javascript + js("element.setAttribute('aria-invalid', 'true')") + +private fun removeErrorState(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-invalid')") + +private fun setElementId(element: HTMLElement, id: Int): Unit = + // language=javascript + js("element.id = 'a11y_' + id") + +private fun setCollectionInfo(element: HTMLElement, rowCount: Int, columnCount: Int) { + // language=javascript + js( + """ + const isGrid = rowCount > 1 && columnCount > 1; + if (isGrid || rowCount > 1) { + element.setAttribute("aria-rowcount", rowCount); + } else { + element.removeAttribute("aria-rowcount"); + } + if (isGrid || columnCount > 1) { + element.setAttribute("aria-colcount", columnCount); + } else { + element.removeAttribute("aria-colcount"); + } + """ + ) +} + +private fun setItemCollectionInfo( + element: HTMLElement, + rowIndex: Int, + columnIndex: Int, + rowSpan: Int, + columnSpan: Int +) { + // language=javascript + js( + """ + element.setAttribute("aria-rowindex", rowIndex); + element.setAttribute("aria-colindex", columnIndex); + if (rowSpan > 1) { + element.setAttribute("aria-rowspan", rowSpan); + } else { + element.removeAttribute("aria-rowspan"); + } + if (columnSpan > 1) { + element.setAttribute("aria-colspan", columnSpan); + } else { + element.removeAttribute("aria-colspan"); + } + """ + ) +} + +private fun setAriaLabel(element: HTMLElement, label: String): Unit = + // language=javascript + js("element.setAttribute('aria-label', label)") + +private fun removeAriaLabel(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-label')") + +private fun removeHidden(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-hidden')") + + +private fun setAriaModal(element: HTMLElement): Unit = + // language=javascript + js("element.setAttribute('aria-modal', 'true')") + +private fun removeAriaModal(element: HTMLElement): Unit = + // language=javascript + js("element.removeAttribute('aria-modal')") \ No newline at end of file diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/WebSemanticsNode.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/WebSemanticsNode.kt new file mode 100644 index 0000000000000..530d235eb16ae --- /dev/null +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/WebSemanticsNode.kt @@ -0,0 +1,69 @@ +/* + * 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.accessibility + +import androidx.collection.MutableScatterMap +import androidx.collection.mutableScatterMapOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsNode +import org.w3c.dom.HTMLElement +import org.w3c.dom.events.Event + +internal data class WebSemanticsNode( + var backingHtmlElement: HTMLElement, + var semanticsNode: SemanticsNode, + val listeners: MutableScatterMap Unit> = mutableScatterMapOf Unit>() +) { + var topLeft: Offset = Offset.Zero + var size: Size = Size.Zero + var appended: Boolean = false + var pendingOldSemanticsConfiguration: SemanticsConfiguration? = null + + + val configuration: SemanticsConfiguration + get() = semanticsNode.config + + val id: Int + get() = semanticsNode.id + + fun addOrReplaceEventListener(type: String, listener: (Event) -> Unit) { + listeners[type]?.let { backingHtmlElement.removeEventListener(type, it) } + backingHtmlElement.addEventListener(type, listener) + listeners[type] = listener + } + + fun removeEventListener(type: String) { + listeners.remove(type)?.let { backingHtmlElement.removeEventListener(type, it) } + } + + fun clearAllEventListeners() { + listeners.forEach { type, listener -> + backingHtmlElement.removeEventListener( + type, + listener + ) + } + listeners.clear() + } + + fun dispose() { + backingHtmlElement.remove() + clearAllEventListeners() + } +} \ No newline at end of file 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 fc679a755b4e6..ae6276002c38d 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 @@ -134,6 +134,7 @@ fun ComposeViewport( val canvas = document.createElement("canvas") as HTMLCanvasElement canvas.setAttribute("tabindex", "0") canvas.setAttribute("role", "generic") + canvas.setAttribute("aria-hidden", "true") canvas.style.outline = "none" // Fixes https://youtrack.jetbrains.com/issue/CMP-9040 canvas.style.setProperty("touch-action", "none") //blocks default browser touch handling appContainer.appendChild(canvas) 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 0ce03f60fa392..353e058b031c3 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 @@ -55,6 +55,7 @@ import androidx.compose.ui.navigationevent.BackNavigationEventInput import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformDragAndDropManager +import androidx.compose.ui.platform.PlatformScreenReader import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.ViewConfiguration @@ -308,6 +309,11 @@ internal class ComposeWindow( get() = WebWakeLockManager.isWakeLockActive() set(value) = WebWakeLockManager.sendWakeLockRequest(this@ComposeWindow, value) + override val screenReader: PlatformScreenReader = object : PlatformScreenReader { + override val isActive: Boolean + get() = configuration.isA11YEnabled + } + override fun setPointerIcon(pointerIcon: PointerIcon) { if (pointerIcon is BrowserCursor) { canvas.style.cursor = pointerIcon.id