Browse Source

Web: fix jumping cursor of controlled inputs (#1287)

* web: fix cursor position for controlled inputs (wip)

* web: fix cursor position for controlled inputs (wip)

Co-authored-by: Oleksandr Karpovich <oleksandr.karpovich@jetbrains.com>
pull/1292/head
Oleksandr Karpovich 3 years ago committed by GitHub
parent
commit
354e48a542
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt
  2. 28
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/PredefinedAttrValues.kt
  3. 4
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/builders/InternalControlledInputUtils.kt
  4. 47
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Elements.kt
  5. 7
      web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/Common.kt
  6. 51
      web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/ControlledInputsCursorsPositionTests.kt
  7. 75
      web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/ControlledInputsCursorsPositionTests.kt
  8. 21
      web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/ControlledInputsTests.kt
  9. 9
      web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/common/BaseIntegrationTests.kt

4
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/Attrs.kt

@ -299,7 +299,9 @@ fun AttrsBuilder<HTMLImageElement>.alt(value: String): AttrsBuilder<HTMLImageEle
internal val setInputValue: (HTMLInputElement, String) -> Unit = { e, v -> internal val setInputValue: (HTMLInputElement, String) -> Unit = { e, v ->
e.value = v if (v != e.value) {
e.value = v
}
saveControlledInputState(e, v) saveControlledInputState(e, v)
} }

28
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/PredefinedAttrValues.kt

@ -50,6 +50,34 @@ sealed class InputType<T>(val typeStr: String) {
protected fun valueAsString(event: Event): String { protected fun valueAsString(event: Event): String {
return event.target?.asDynamic()?.value?.unsafeCast<String>() ?: "" return event.target?.asDynamic()?.value?.unsafeCast<String>() ?: ""
} }
companion object {
internal fun fromString(type: String): InputType<*> {
return when (type) {
"button" -> Button
"checkbox" -> Checkbox
"color" -> Color
"date" -> Date
"datetime-local" -> DateTimeLocal
"email" -> Email
"file" -> File
"hidden" -> Hidden
"month" -> Month
"number" -> Number
"password" -> Password
"radio" -> Radio
"range" -> Range
"search" -> Search
"submit" -> Submit
"tel" -> Tel
"text" -> Text
"time" -> Time
"url" -> Url
"week" -> Week
else -> error("fromString got unknown type - $type")
}
}
}
} }
sealed class DirType(val dirStr: String) { sealed class DirType(val dirStr: String) {

4
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/builders/InternalControlledInputUtils.kt

@ -16,7 +16,9 @@ import org.w3c.dom.HTMLTextAreaElement
private val controlledInputsValuesWeakMap: JsWeakMap = js("new WeakMap();").unsafeCast<JsWeakMap>() private val controlledInputsValuesWeakMap: JsWeakMap = js("new WeakMap();").unsafeCast<JsWeakMap>()
internal fun restoreControlledInputState(type: InputType<*>, inputElement: HTMLInputElement) { internal fun restoreControlledInputState(inputElement: HTMLInputElement) {
val type = InputType.fromString(inputElement.type)
if (controlledInputsValuesWeakMap.has(inputElement)) { if (controlledInputsValuesWeakMap.has(inputElement)) {
if (type == InputType.Radio) { if (type == InputType.Radio) {
controlledRadioGroups[inputElement.name]?.forEach { radio -> controlledRadioGroups[inputElement.name]?.forEach { radio ->

47
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Elements.kt

@ -1,8 +1,6 @@
package org.jetbrains.compose.web.dom package org.jetbrains.compose.web.dom
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.remember
import androidx.compose.web.attributes.SelectAttrsBuilder import androidx.compose.web.attributes.SelectAttrsBuilder
import kotlinx.browser.document import kotlinx.browser.document
import org.jetbrains.compose.web.attributes.* import org.jetbrains.compose.web.attributes.*
@ -678,27 +676,36 @@ fun TextArea(
// if firstProvidedValueWasNotNull then TextArea behaves as controlled input // if firstProvidedValueWasNotNull then TextArea behaves as controlled input
val firstProvidedValueWasNotNull = remember { value != null } val firstProvidedValueWasNotNull = remember { value != null }
// changes to this key trigger [textAreaRestoreControlledStateEffect]
val keyForRestoringControlledState: MutableState<Int> = remember { mutableStateOf(0) }
TagElement( TagElement(
elementBuilder = TextArea, elementBuilder = TextArea,
applyAttrs = { applyAttrs = {
val taab = TextAreaAttrsBuilder() val textAreaAttrsBuilder = TextAreaAttrsBuilder()
textAreaAttrsBuilder.onInput {
// controlled state needs to be restored after every input
keyForRestoringControlledState.value = keyForRestoringControlledState.value + 1
}
if (attrs != null) { if (attrs != null) {
taab.attrs() textAreaAttrsBuilder.attrs()
} }
if (firstProvidedValueWasNotNull) { if (firstProvidedValueWasNotNull) {
taab.value(value ?: "") textAreaAttrsBuilder.value(value ?: "")
} }
taab.onInput { this.copyFrom(textAreaAttrsBuilder)
restoreControlledTextAreaState(it.target)
}
this.copyFrom(taab)
}, },
content = null content = {
DomSideEffect(keyForRestoringControlledState.value, textAreaRestoreControlledStateEffect)
}
) )
} }
private val textAreaRestoreControlledStateEffect: DomEffectScope.(HTMLTextAreaElement) -> Unit = {
restoreControlledTextAreaState(element = it)
}
@Composable @Composable
fun Nav( fun Nav(
attrs: AttrBuilderContext<HTMLElement>? = null, attrs: AttrBuilderContext<HTMLElement>? = null,
@ -983,32 +990,42 @@ inline fun Style(
* } * }
* ``` * ```
*/ */
@OptIn(ComposeWebInternalApi::class)
@Composable @Composable
fun <K> Input( fun <K> Input(
type: InputType<K>, type: InputType<K>,
attrs: InputAttrsBuilder<K>.() -> Unit attrs: InputAttrsBuilder<K>.() -> Unit
) { ) {
// changes to this key trigger [inputRestoreControlledStateEffect]
val keyForRestoringControlledState: MutableState<Int> = remember { mutableStateOf(0) }
TagElement( TagElement(
elementBuilder = Input, elementBuilder = Input,
applyAttrs = { applyAttrs = {
val inputAttrsBuilder = InputAttrsBuilder(type) val inputAttrsBuilder = InputAttrsBuilder(type)
inputAttrsBuilder.type(type) inputAttrsBuilder.type(type)
inputAttrsBuilder.attrs()
inputAttrsBuilder.onInput { inputAttrsBuilder.onInput {
restoreControlledInputState(type = type, inputElement = it.target) // controlled state needs to be restored after every input
keyForRestoringControlledState.value = keyForRestoringControlledState.value + 1
} }
inputAttrsBuilder.attrs()
this.copyFrom(inputAttrsBuilder) this.copyFrom(inputAttrsBuilder)
}, },
content = { content = {
if (type == InputType.Radio) { if (type == InputType.Radio) {
DisposeRadioGroupEffect() DisposeRadioGroupEffect()
} }
DomSideEffect(keyForRestoringControlledState.value, inputRestoreControlledStateEffect)
} }
) )
} }
private val inputRestoreControlledStateEffect: DomEffectScope.(HTMLInputElement) -> Unit = {
restoreControlledInputState(inputElement = it)
}
@Composable @Composable
fun <K> Input(type: InputType<K>) { fun <K> Input(type: InputType<K>) {
Input(type) {} Input(type) {}

7
web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/Common.kt

@ -1,10 +1,7 @@
package org.jetbrains.compose.web.sample.tests package org.jetbrains.compose.web.sample.tests
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.web.sample.tests.ControlledInputsTests import androidx.compose.web.sample.tests.*
import androidx.compose.web.sample.tests.RadioGroupTestCases
import androidx.compose.web.sample.tests.SelectElementTests
import androidx.compose.web.sample.tests.UncontrolledInputsTests
import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text import org.jetbrains.compose.web.dom.Text
import org.jetbrains.compose.web.renderComposableInBody import org.jetbrains.compose.web.renderComposableInBody
@ -39,7 +36,7 @@ fun launchTestCase(testCaseId: String) {
listOf<Any>( listOf<Any>(
TestCases1(), InputsTests(), EventsTests(), TestCases1(), InputsTests(), EventsTests(),
SelectElementTests(), ControlledInputsTests(), UncontrolledInputsTests(), SelectElementTests(), ControlledInputsTests(), UncontrolledInputsTests(),
RadioGroupTestCases() RadioGroupTestCases(), ControlledInputsCursorsPositionTests()
) )
if (testCaseId !in testCases) error("Test Case '$testCaseId' not found") if (testCaseId !in testCases) error("Test Case '$testCaseId' not found")

51
web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/ControlledInputsCursorsPositionTests.kt

@ -0,0 +1,51 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package androidx.compose.web.sample.tests
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.sample.tests.TestText
import org.jetbrains.compose.web.sample.tests.testCase
class ControlledInputsCursorsPositionTests {
val textInputTypingIntoMiddle by testCase {
var onInputText by remember { mutableStateOf("None") }
var textValue by remember { mutableStateOf("") }
P { TestText(onInputText) }
Div {
TextInput(value = textValue, attrs = {
id("textInput")
onInput {
onInputText = it.value
textValue = it.value
}
})
}
}
val textAreaTypingIntoMiddle by testCase {
var onInputText by remember { mutableStateOf("None") }
var textValue by remember { mutableStateOf("") }
P { TestText(onInputText) }
Div {
TextArea(value = textValue, attrs = {
id("textArea")
onInput {
onInputText = it.value
textValue = it.value
}
})
}
}
}

75
web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/ControlledInputsCursorsPositionTests.kt

@ -0,0 +1,75 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.web.tests.integration
import org.jetbrains.compose.web.tests.integration.common.BaseIntegrationTests
import org.jetbrains.compose.web.tests.integration.common.ResolveDrivers
import org.jetbrains.compose.web.tests.integration.common.openTestPage
import org.jetbrains.compose.web.tests.integration.common.waitTextToBe
import org.openqa.selenium.By
import org.openqa.selenium.Keys
import org.openqa.selenium.WebDriver
class ControlledInputsCursorsPositionTests : BaseIntegrationTests() {
@ResolveDrivers
fun textInputTypingIntoMiddle(driver: WebDriver) {
driver.openTestPage("textInputTypingIntoMiddle")
driver.waitTextToBe(value = "None")
val controlledTextInput = driver.findElement(By.id("textInput"))
controlledTextInput.sendKeys("A")
driver.waitTextToBe(value = "A")
controlledTextInput.sendKeys("B")
driver.waitTextToBe(value = "AB")
controlledTextInput.sendKeys("C")
driver.waitTextToBe(value = "ABC")
controlledTextInput.sendKeys(Keys.ARROW_LEFT)
controlledTextInput.sendKeys(Keys.ARROW_LEFT)
assert(driver.cursorPosition("textInput") == 1)
controlledTextInput.sendKeys("1")
driver.waitTextToBe(value = "A1BC")
assert(driver.cursorPosition("textInput") == 2)
controlledTextInput.sendKeys("2")
driver.waitTextToBe(value = "A12BC")
assert(driver.cursorPosition("textInput") == 3)
}
@ResolveDrivers
fun textAreaTypingIntoMiddle(driver: WebDriver) {
driver.openTestPage("textAreaTypingIntoMiddle")
driver.waitTextToBe(value = "None")
val controlledTextInput = driver.findElement(By.id("textArea"))
controlledTextInput.sendKeys("A")
driver.waitTextToBe(value = "A")
controlledTextInput.sendKeys("B")
driver.waitTextToBe(value = "AB")
controlledTextInput.sendKeys("C")
driver.waitTextToBe(value = "ABC")
controlledTextInput.sendKeys(Keys.ARROW_LEFT)
controlledTextInput.sendKeys(Keys.ARROW_LEFT)
assert(driver.cursorPosition("textArea") == 1)
controlledTextInput.sendKeys("1")
driver.waitTextToBe(value = "A1BC")
assert(driver.cursorPosition("textArea") == 2)
controlledTextInput.sendKeys("2")
driver.waitTextToBe(value = "A12BC")
assert(driver.cursorPosition("textArea") == 3)
}
}

21
web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/ControlledInputsTests.kt

@ -472,12 +472,6 @@ class ControlledInputsTests : BaseIntegrationTests() {
@ResolveDrivers @ResolveDrivers
fun mutableTimeChanges(driver: WebDriver) { fun mutableTimeChanges(driver: WebDriver) {
// We skip chrome, since for some reason `sendKeys` doesn't work as expected when used for Controlled Input in Chrome
Assumptions.assumeTrue(
driver !is ChromeDriver,
"chrome driver doesn't work properly when using sendKeys on Controlled Input"
)
driver.openTestPage("mutableTimeChanges") driver.openTestPage("mutableTimeChanges")
driver.waitTextToBe(value = "") driver.waitTextToBe(value = "")
@ -489,19 +483,4 @@ class ControlledInputsTests : BaseIntegrationTests() {
driver.waitTextToBe(value = "18:31") driver.waitTextToBe(value = "18:31")
check(timeInput.getAttribute("value") == "18:31") check(timeInput.getAttribute("value") == "18:31")
} }
@ResolveDrivers
fun timeInputSendKeysOnChromeFailingTest(driver: WebDriver) {
Assumptions.assumeTrue(
driver is ChromeDriver,
"this a `failing test for Chrome only` to catch when issue with sendKeys is resolved"
)
driver.openTestPage("mutableTimeChanges")
driver.waitTextToBe(value = "")
val timeInput = driver.findElement(By.id("time"))
timeInput.sendKeys("18:31")
driver.waitTextToBe(value = "18:03") // it should be 18:31, but this is a failing test, so wrong value is expected
}
} }

9
web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/common/BaseIntegrationTests.kt

@ -95,6 +95,15 @@ abstract class BaseIntegrationTests() {
return (this as JavascriptExecutor).executeAsyncScript(script).toString() return (this as JavascriptExecutor).executeAsyncScript(script).toString()
} }
fun WebDriver.cursorPosition(id: String): Int {
val script = """
var callback = arguments[arguments.length - 1];
callback(document.getElementById("$id").selectionStart);
""".trimIndent()
return (this as JavascriptExecutor).executeAsyncScript(script).toString().toInt()
}
fun WebDriver.sendKeysForDateInput(input: WebElement, year: Int, month: Int, day: Int) { fun WebDriver.sendKeysForDateInput(input: WebElement, year: Int, month: Int, day: Int) {
val keys = when (this) { val keys = when (this) {
is ChromeDriver -> "${day}${month}${year}" is ChromeDriver -> "${day}${month}${year}"

Loading…
Cancel
Save