Skip to content

Commit e189e68

Browse files
authored
Prevent focus interception by a browser when a backing html input is focused (#2452)
When handling Tab keydown event, we call `preventDefault()` to keep the focus in the Compose scene. Compose scene will process the keydown itself and move the focus within itself. Fixes https://youtrack.jetbrains.com/issue/CMP-9041 ## Testing - Added a test - This should be tested by QA ## Release Notes ### Fixes - Web - Fix focus with Tab behaviour in Text Fields
1 parent 3865692 commit e189e68

4 files changed

Lines changed: 147 additions & 2 deletions

File tree

compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/platform/DomInputStrategy.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package androidx.compose.ui.platform
22

3+
import androidx.compose.ui.input.key.Key
34
import androidx.compose.ui.text.input.ImeAction
45
import androidx.compose.ui.text.input.ImeOptions
56
import androidx.compose.ui.text.input.KeyboardType
@@ -44,13 +45,20 @@ internal class DomInputStrategy(
4445
lastMeaningfulUpdate = textFieldValue
4546
}
4647

48+
private val tabKeyCode = Key.Tab.keyCode.toInt()
49+
4750
private fun initEvents() {
4851
htmlInput.addEventListener("blur", { evt ->
4952
// TODO: any actions here?
5053
})
5154

5255
htmlInput.addEventListener("keydown", { evt ->
5356
nativeInputEventsProcessor.registerEvent(evt as KeyboardEvent)
57+
58+
if (evt.keyCode == tabKeyCode) {
59+
// Compose logic will handle the focus movement or insert Tabs if necessary
60+
evt.preventDefault()
61+
}
5462
})
5563

5664
htmlInput.addEventListener("keyup", { evt ->

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/OnCanvasTests.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ internal class WebApplicationScope(
179179
* awaitAnimationFrame is needed for text input tests,
180180
* due to DomInputStrategy implementation relying on animation frame events.
181181
*/
182-
private suspend fun awaitAnimationFrame() {
182+
internal suspend fun awaitAnimationFrame() {
183183
suspendCoroutine { continuation ->
184184
window.requestAnimationFrame { continuation.resumeWith(Result.success(Unit)) }
185185
}

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/events/synthethicEvents.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ internal fun keyEvent(
5555
shiftKey: Boolean = false,
5656
cancelable: Boolean = true,
5757
repeat: Boolean = false,
58-
isComposing: Boolean = false
58+
isComposing: Boolean = false,
59+
bubbles: Boolean = true,
5960
): KeyboardEvent {
6061
val keyboardEventInit = KeyboardEventInit(
6162
key = key,
@@ -67,6 +68,7 @@ internal fun keyEvent(
6768
cancelable = cancelable,
6869
repeat = repeat,
6970
isComposing = isComposing,
71+
bubbles = bubbles,
7072
) as KeyboardEventInitExtended
7173

7274
keyboardEventInit.keyCode = keyCode
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.input
18+
19+
import androidx.compose.foundation.layout.Column
20+
import androidx.compose.foundation.text.input.TextFieldLineLimits
21+
import androidx.compose.foundation.text.input.rememberTextFieldState
22+
import androidx.compose.material.TextField
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.OnCanvasTests
25+
import androidx.compose.ui.events.keyEvent
26+
import androidx.compose.ui.focus.FocusRequester
27+
import androidx.compose.ui.focus.FocusState
28+
import androidx.compose.ui.focus.focusRequester
29+
import androidx.compose.ui.focus.onFocusChanged
30+
import androidx.compose.ui.input.key.Key
31+
import kotlin.test.Test
32+
import kotlin.test.assertEquals
33+
import kotlin.test.assertFalse
34+
import kotlin.test.assertNotNull
35+
import kotlin.test.assertTrue
36+
import kotlinx.coroutines.yield
37+
import org.w3c.dom.HTMLInputElement
38+
import org.w3c.dom.events.Event
39+
import org.w3c.dom.events.KeyboardEvent
40+
41+
class TextFieldFocusTest : OnCanvasTests {
42+
43+
@Test
44+
fun canMoveFocusForwardAndBackUsingTab() = runApplicationTest {
45+
val focusRequester = FocusRequester()
46+
47+
suspend fun waitForSingleLineHtmlInput(): HTMLInputElement {
48+
while (true) {
49+
val element = getShadowRoot().querySelector("input")
50+
if (element is HTMLInputElement) {
51+
return element
52+
}
53+
yield()
54+
}
55+
}
56+
57+
var firstTextFieldFocusState: FocusState? = null
58+
var secondTextFieldFocusState: FocusState? = null
59+
60+
createComposeWindow {
61+
Column {
62+
TextField(
63+
state = rememberTextFieldState(initialText = "Hello"),
64+
modifier = Modifier
65+
.focusRequester(focusRequester)
66+
.onFocusChanged({
67+
firstTextFieldFocusState = it
68+
}),
69+
lineLimits = TextFieldLineLimits.SingleLine
70+
)
71+
72+
TextField(
73+
state = rememberTextFieldState(initialText = "World"),
74+
lineLimits = TextFieldLineLimits.SingleLine,
75+
modifier = Modifier.onFocusChanged({
76+
secondTextFieldFocusState = it
77+
})
78+
)
79+
}
80+
}
81+
82+
var lastKeydownEventOnRoot: Event? = null
83+
84+
focusRequester.requestFocus()
85+
86+
val htmlInput1 = waitForSingleLineHtmlInput()
87+
assertNotNull(firstTextFieldFocusState)
88+
assertNotNull(secondTextFieldFocusState)
89+
assertEquals(true, firstTextFieldFocusState.isFocused)
90+
assertEquals(false, secondTextFieldFocusState.isFocused)
91+
92+
getShadowRoot().addEventListener("keydown", {
93+
lastKeydownEventOnRoot = it
94+
})
95+
96+
val tabKeyDown = keyEvent(
97+
key = "Tab",
98+
type = "keydown",
99+
keyCode = Key.Tab.keyCode.toInt(),
100+
code = "Tab"
101+
)
102+
htmlInput1.dispatchEvent(tabKeyDown)
103+
awaitAnimationFrame()
104+
assertNotNull(lastKeydownEventOnRoot)
105+
assertEquals("Tab", (lastKeydownEventOnRoot as KeyboardEvent).key)
106+
assertFalse((lastKeydownEventOnRoot as KeyboardEvent).shiftKey)
107+
assertTrue(lastKeydownEventOnRoot!!.defaultPrevented)
108+
lastKeydownEventOnRoot = null
109+
110+
assertEquals(false, firstTextFieldFocusState.isFocused)
111+
assertEquals(true, secondTextFieldFocusState.isFocused)
112+
113+
/* Now move focus back using Tab+Shift */
114+
115+
val htmlInput2 = waitForSingleLineHtmlInput()
116+
val tabKeyDownWithShift = keyEvent(
117+
key = "Tab",
118+
type = "keydown",
119+
keyCode = Key.Tab.keyCode.toInt(),
120+
code = "Tab",
121+
shiftKey = true
122+
)
123+
124+
htmlInput2.dispatchEvent(tabKeyDownWithShift)
125+
awaitAnimationFrame()
126+
127+
assertEquals(true, firstTextFieldFocusState.isFocused)
128+
assertEquals(false, secondTextFieldFocusState.isFocused)
129+
130+
assertNotNull(lastKeydownEventOnRoot)
131+
assertEquals("Tab", (lastKeydownEventOnRoot as KeyboardEvent).key)
132+
assertTrue((lastKeydownEventOnRoot as KeyboardEvent).shiftKey)
133+
assertTrue(lastKeydownEventOnRoot!!.defaultPrevented)
134+
}
135+
}

0 commit comments

Comments
 (0)