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 @@ -63,6 +63,13 @@ internal abstract class WebTextInputService : PlatformTextInputService, InputAwa
*/
abstract val backingDomInputContainer: HTMLElement

/**
* The element to focus when the backing input is disposed.
* This ensures keyboard events are still received when focus moves
* from a TextField to a non-TextField focusable element.
*/
abstract val focusFallbackElement: HTMLElement

override fun startInput(
value: TextFieldValue,
imeOptions: ImeOptions,
Expand Down Expand Up @@ -101,6 +108,10 @@ internal abstract class WebTextInputService : PlatformTextInputService, InputAwa
override fun stopInput() {
backingDomInput?.dispose()
backingDomInput = null
// Focus the canvas so that keyboard events continue to work when focus moves
// from a TextField to a non-TextField focusable element (or read-only TextField).
// See https://youtrack.jetbrains.com/issue/CMP-9388
focusFallbackElement.focus()
}

override fun showSoftwareKeyboard() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ internal class ComposeWindow(
override val backingDomInputContainer: HTMLElement
get() = interopContainerElement

override val focusFallbackElement: HTMLElement
get() = canvas

override fun getNewGeometryForBackingInput(rect: Rect): DpRect {
val dpRect = rect.toDpRect(density)
val left = dpRect.left.value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,113 @@ class TextFieldFocusTest : OnCanvasTests {
assertTrue((lastKeydownEventOnRoot as KeyboardEvent).shiftKey)
assertTrue(lastKeydownEventOnRoot!!.defaultPrevented)
}

/**
* Regression test for https://youtrack.jetbrains.com/issue/CMP-9388
* Tests that Tab navigation works correctly with read-only TextFields.
*
* Read-only TextFields don't create a backing HTML input element, so when focus
* moves TO a read-only TextField, subsequent Tab presses must come from the canvas.
*
* Note: The actual bug (focus not working after leaving a TextField) is caused by
* the browser not knowing where to send key events when no element has DOM focus.
* This can't be fully simulated in tests since programmatic dispatchEvent() bypasses
* browser focus routing. The fix ensures focusFallbackElement.focus() is called in
* stopInput() to give the canvas DOM focus.
*/
@Test
fun canTabThroughReadOnlyTextField() = runApplicationTest {
val focusRequester = FocusRequester()

suspend fun waitForSingleLineHtmlInput(): HTMLInputElement {
while (true) {
val element = getShadowRoot().querySelector("input")
if (element is HTMLInputElement) {
return element
}
yield()
}
}

var field1FocusState: FocusState? = null
var field2FocusState: FocusState? = null
var field3FocusState: FocusState? = null // read-only
var field4FocusState: FocusState? = null

createComposeWindow {
Column {
TextField(
state = rememberTextFieldState(initialText = "Field 1"),
modifier = Modifier
.focusRequester(focusRequester)
.onFocusChanged { field1FocusState = it },
lineLimits = TextFieldLineLimits.SingleLine
)

TextField(
state = rememberTextFieldState(initialText = "Field 2"),
modifier = Modifier.onFocusChanged { field2FocusState = it },
lineLimits = TextFieldLineLimits.SingleLine
)

TextField(
state = rememberTextFieldState(initialText = "Field 3 (read-only)"),
modifier = Modifier.onFocusChanged { field3FocusState = it },
lineLimits = TextFieldLineLimits.SingleLine,
readOnly = true
)

TextField(
state = rememberTextFieldState(initialText = "Field 4"),
modifier = Modifier.onFocusChanged { field4FocusState = it },
lineLimits = TextFieldLineLimits.SingleLine
)
}
}

focusRequester.requestFocus()
awaitAnimationFrame()

// Verify initial focus on Field 1
assertNotNull(field1FocusState)
assertEquals(true, field1FocusState!!.isFocused)

val tabKeyDown = keyEvent(
key = "Tab",
type = "keydown",
keyCode = Key.Tab.keyCode.toInt(),
code = "Tab"
)

// Tab from Field 1 to Field 2
var htmlInput = waitForSingleLineHtmlInput()
htmlInput.dispatchEvent(tabKeyDown)
awaitAnimationFrame()

assertEquals(false, field1FocusState!!.isFocused)
assertEquals(true, field2FocusState!!.isFocused)

// Tab from Field 2 to Field 3 (read-only)
// This removes the backing HTML input because read-only fields don't create one
htmlInput = waitForSingleLineHtmlInput()
htmlInput.dispatchEvent(tabKeyDown)
awaitAnimationFrame()

assertEquals(false, field2FocusState!!.isFocused)
assertEquals(true, field3FocusState!!.isFocused, "Read-only Field 3 should be focused")

// Verify no backing input exists (read-only TextField doesn't create one)
val inputAfterReadOnlyFocus = getShadowRoot().querySelector("input")
assertEquals(null, inputAfterReadOnlyFocus, "Read-only TextField should not have backing input")

// Tab from read-only Field 3 to Field 4
// Since there's no backing input, we dispatch Tab to the canvas
// This is where the bug manifests without the fix - the canvas wouldn't have DOM focus
val canvas = getCanvas()
canvas.dispatchEvent(tabKeyDown)
awaitAnimationFrame()

assertEquals(false, field3FocusState!!.isFocused, "Read-only Field 3 should lose focus")
assertEquals(true, field4FocusState!!.isFocused, "Field 4 should be focused (not back to Field 1)")
}
}