Skip to content
Merged
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 @@ -63,6 +63,8 @@ <h2>Keyboard Event Order Tracker</h2>
scheduleRAF();
});
}

document.addEventListener('selectionchange', () => log(`selectionchange --- ${input.selectionStart}-${input.selectionEnd}`));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this after this'll end up in jb-main?

Copy link
Member Author

@eymar eymar Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is our internal html demo for tracking the input events in a pure setup.

yes, i'd rather keep it.

</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ internal class BackingDomInput(
}

fun dispose() {
inputStrategy.dispose()
backingElement.remove()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.SetSelectionCommand
import androidx.compose.ui.text.input.TextFieldValue
import kotlin.js.ExperimentalWasmJsInterop
import kotlin.js.JsAny
import kotlin.js.JsName
import kotlin.js.definedExternally
import kotlin.js.js
import kotlin.js.unsafeCast
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLElement
import org.w3c.dom.events.CompositionEvent
import org.w3c.dom.events.Event
import org.w3c.dom.events.InputEvent
import org.w3c.dom.events.KeyboardEvent

Expand All @@ -40,6 +44,10 @@ internal class DomInputStrategy(

private var lastMeaningfulUpdate = TextFieldValue("")

// To avoid the re-triggering of the selection change
private var pauseSelectionChangeListener = false
private var selectionChangeListener: ((Event) -> Unit)? = null

init {
initEvents()
}
Expand All @@ -55,14 +63,18 @@ internal class DomInputStrategy(
fun updateState(textFieldValue: TextFieldValue) {
htmlInput as HTMLElementWithValue

if (lastMeaningfulUpdate.text != textFieldValue.text) {
val needsTextUpdate = lastMeaningfulUpdate.text != textFieldValue.text
val needsSelectionUpdate = lastMeaningfulUpdate.selection != textFieldValue.selection
lastMeaningfulUpdate = textFieldValue

if (needsTextUpdate) {
htmlInput.value = textFieldValue.text
}
if (lastMeaningfulUpdate.selection != textFieldValue.selection) {
if (needsSelectionUpdate) {
pauseSelectionChangeListener = true
htmlInput.setSelectionRange(textFieldValue.selection.min, textFieldValue.selection.max)
pauseSelectionChangeListener = false
}

lastMeaningfulUpdate = textFieldValue
}

private val tabKeyCode = Key.Tab.keyCode.toInt()
Expand All @@ -79,6 +91,9 @@ internal class DomInputStrategy(
// Compose logic will handle the focus movement or insert Tabs if necessary
evt.preventDefault()
}

// Let Compose decide the selection right after a new key input
pauseSelectionChangeListener = true
})

htmlInput.addEventListener("keyup", { evt ->
Expand All @@ -104,7 +119,43 @@ internal class DomInputStrategy(
htmlInput.addEventListener("compositionend", { evt ->
nativeInputEventsProcessor.registerEvent(evt as CompositionEvent)
})

selectionChangeListener = listener@{ _ ->
if (pauseSelectionChangeListener || !isInputActive()) return@listener
htmlInput as HTMLElementWithValue
val start = htmlInput.selectionStart
val end = htmlInput.selectionEnd
val selection = lastMeaningfulUpdate.selection

if (start != selection.min || end != selection.max) {
val normalizedStart = minOf(start, end)
val normalizedEnd = maxOf(start, end)
composeSender.sendEditCommand(SetSelectionCommand(normalizedStart, normalizedEnd))
}
}
document.addEventListener("selectionchange", selectionChangeListener)
}

fun dispose() {
document.removeEventListener("selectionchange", selectionChangeListener)
selectionChangeListener = null
}

@OptIn(ExperimentalWasmJsInterop::class)
private fun isInputActive(): Boolean {
val root = htmlInput.unsafeCast<NodeWithRootNode>().getRootNode()
val rootActive = root?.activeElement
return rootActive == htmlInput
}
}

@OptIn(ExperimentalWasmJsInterop::class)
private external interface NodeWithRootNode : JsAny {
fun getRootNode(): DocumentOrShadowRootLike?
}

private external interface DocumentOrShadowRootLike : JsAny {
val activeElement: HTMLElement?
}

@JsName("InputEvent")
Expand Down Expand Up @@ -208,4 +259,4 @@ private external interface HTMLElementWithValue {
}

internal fun isTypedEvent(evt: KeyboardEvent): Boolean =
js("!evt.metaKey && !evt.ctrlKey && evt.key.charAt(0) === evt.key")
js("!evt.metaKey && !evt.ctrlKey && evt.key.charAt(0) === evt.key")
Original file line number Diff line number Diff line change
@@ -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.input

import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.OnCanvasTests
import androidx.compose.ui.WebApplicationScope
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.browser.document
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.w3c.dom.HTMLTextAreaElement
import org.w3c.dom.events.Event

class ExternalSelectionChangeListenerTest : OnCanvasTests {

@Test
fun selectionChangeInBackingInputUpdatesComposeSelection() = runApplicationTest {
val text = "hello world"
val textFieldValue = mutableStateOf(
TextFieldValue(text, selection = TextRange(text.length))
)
val focusRequester = FocusRequester()

createComposeWindow {
BasicTextField(
value = textFieldValue.value,
onValueChange = { value ->
textFieldValue.value = value
},
modifier = Modifier.focusRequester(focusRequester)
)
}

focusRequester.requestFocus()
val htmlInput = waitForHtmlInput()
htmlInput.focus()
awaitAnimationFrame()
awaitIdle()

assertEquals(TextRange(text.length), textFieldValue.value.selection)

htmlInput.setSelectionRange(1, 7)
document.dispatchEvent(Event("selectionchange"))
awaitAnimationFrame()
awaitIdle()

assertEquals(TextRange(1, 7), textFieldValue.value.selection)

htmlInput.setSelectionRange(8, 8)
document.dispatchEvent(Event("selectionchange"))
awaitAnimationFrame()
awaitIdle()

assertEquals(TextRange(8, 8), textFieldValue.value.selection)
}

private suspend fun WebApplicationScope.waitForHtmlInput(): HTMLTextAreaElement {
while (true) {
val element = getShadowRoot().querySelector("textarea")
if (element is HTMLTextAreaElement) {
return element
}
yield()
}
}
}