Browse Source

web: add RadioGroup component to manage several Radio Inputs (#1116)

* web: add RadioGroup component to manage several Radio Inputs

* web: improve RadioGroup PR after discussions

Co-authored-by: Oleksandr Karpovich <oleksandr.karpovich@jetbrains.com>
pull/1187/head
Oleksandr Karpovich 3 years ago committed by GitHub
parent
commit
626cba019a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 122
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/RadioGroup.kt
  2. 191
      web/core/src/jsTest/kotlin/elements/RadioGroupTests.kt
  3. 4
      web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/Common.kt
  4. 77
      web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/RadioGroupTestCases.kt
  5. 87
      web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/RadioGroupTests.kt

122
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/RadioGroup.kt

@ -0,0 +1,122 @@
package org.jetbrains.compose.web.dom
import androidx.compose.runtime.*
import org.jetbrains.compose.web.ExperimentalComposeWebApi
import org.jetbrains.compose.web.attributes.InputType
import org.jetbrains.compose.web.attributes.builders.InputAttrsBuilder
import org.jetbrains.compose.web.attributes.name
typealias RadioInputAttrsBuilder = (InputAttrsBuilder<Boolean>.() -> Unit)
/**
* @param value - sets `value` attribute
* @param id - sets `id` attribute
* @param attrs - builder to set any eligible attribute
*/
@Composable
@NonRestartableComposable
@ExperimentalComposeWebApi
fun <T> RadioGroupScope<T>.RadioInput(
value: T,
id: String? = null,
attrs: RadioInputAttrsBuilder? = null
) {
val checkedValue = getCompositionLocalRadioGroupCheckedValue()
val radioGroupName = getCompositionLocalRadioGroupName()
Input(
type = InputType.Radio,
attrs = {
attrs?.invoke(this)
if (id != null) id(id)
name(radioGroupName)
val valueString = value.toString()
checked(checkedValue == valueString)
value(valueString)
}
)
}
/**
* @param checkedValue - value of a radio input that has to be checked
* @param name - radio group name. It has to be unique among all radio groups.
* If it's null during first composition, radio group will use a generated name.
* @param content - is a composable lambda that contains any number of [RadioInput]
*/
@Composable
@NonRestartableComposable
@ExperimentalComposeWebApi
fun <E, T : Enum<E>?> RadioGroup(
checkedValue: T,
name: String? = null,
content: @Composable RadioGroupScope<T>.() -> Unit
) {
val radioGroupName = remember { name ?: generateNextRadioGroupName() }
CompositionLocalProvider(
radioGroupCompositionLocalValue provides checkedValue.toString(),
radioGroupCompositionLocalName provides radioGroupName,
content = {
// normal cast would fail here!
// this is to specify the type of the values for radio inputs
content(radioGroupScopeImpl.unsafeCast<RadioGroupScope<T>>())
}
)
}
/**
* @param checkedValue - value of a radio input that has to be checked
* @param name - radio group name. It has to be unique among all radio groups.
* If it's null during first composition, radio group will use a generated name.
* @param content - is a composable lambda that contains any number of [RadioInput]
*/
@Composable
@NonRestartableComposable
@ExperimentalComposeWebApi
fun RadioGroup(
checkedValue: String?,
name: String? = null,
content: @Composable RadioGroupScope<String>.() -> Unit
) {
val radioGroupName = remember { name ?: generateNextRadioGroupName() }
CompositionLocalProvider(
radioGroupCompositionLocalValue provides checkedValue,
radioGroupCompositionLocalName provides radioGroupName,
content = {
// normal cast would fail here!
// this is to specify the type of the values for radio inputs
content(radioGroupScopeImpl.unsafeCast<RadioGroupScope<String>>())
}
)
}
@ExperimentalComposeWebApi
class RadioGroupScope<T> internal constructor()
@OptIn(ExperimentalComposeWebApi::class)
private val radioGroupScopeImpl = RadioGroupScope<Any>()
private var generatedRadioGroupNamesCounter = 0
private fun generateNextRadioGroupName(): String {
return "\$compose\$generated\$radio\$group-${generatedRadioGroupNamesCounter++}"
}
internal val radioGroupCompositionLocalValue = compositionLocalOf<String?> {
error("No radio group checked value provided")
}
internal val radioGroupCompositionLocalName = compositionLocalOf<String> {
error("No radio group name provided")
}
@Composable
internal fun getCompositionLocalRadioGroupCheckedValue(): String? {
return radioGroupCompositionLocalValue.current
}
@Composable
internal fun getCompositionLocalRadioGroupName(): String {
return radioGroupCompositionLocalName.current
}

191
web/core/src/jsTest/kotlin/elements/RadioGroupTests.kt

@ -0,0 +1,191 @@
package org.jetbrains.compose.web.core.tests.elements
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.jetbrains.compose.web.ExperimentalComposeWebApi
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.RadioGroup
import org.jetbrains.compose.web.dom.RadioInput
import org.jetbrains.compose.web.testutils.runTest
import org.w3c.dom.HTMLInputElement
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalComposeWebApi::class)
class RadioGroupTests {
@Test
fun canCreateRadioGroupsWithUniqueGeneratedGroupName() = runTest {
composition {
RadioGroup(checkedValue = null) {
RadioInput(value = "v1", id = "id1")
RadioInput(value = "v2", id = "id2")
}
RadioGroup(checkedValue = null) {
RadioInput(value = "v2_1", id = "id2_1")
RadioInput(value = "v2_2", id = "id2_2")
}
}
val expectedHtml = """
|<div>
|<input type="radio" id="id1" name="${'$'}compose${'$'}generated${'$'}radio${'$'}group-0" value="v1">
|<input type="radio" id="id2" name="${'$'}compose${'$'}generated${'$'}radio${'$'}group-0" value="v2">
|<input type="radio" id="id2_1" name="${'$'}compose${'$'}generated${'$'}radio${'$'}group-1" value="v2_1">
|<input type="radio" id="id2_2" name="${'$'}compose${'$'}generated${'$'}radio${'$'}group-1" value="v2_2">
|</div>
""".trimMargin().replace("\n", "")
assertEquals(expectedHtml, root.outerHTML)
repeat(4) {
assertEquals(false, (root.childNodes.item(it) as HTMLInputElement).checked)
}
}
@Test
fun canCreateRadioGroupWithGivenGroupName() = runTest {
composition {
RadioGroup(checkedValue = null, name = "g1") {
RadioInput(value = "v1", id = "id1")
RadioInput(value = "v2", id = "id2")
}
}
val expectedHtml = """
|<div>
|<input type="radio" id="id1" name="g1" value="v1">
|<input type="radio" id="id2" name="g1" value="v2">
|</div>
""".trimMargin().replace("\n", "")
assertEquals(expectedHtml, root.outerHTML)
repeat(2) {
assertEquals(false, (root.childNodes.item(it) as HTMLInputElement).checked)
}
}
private enum class Rg1 {
V1, V2, V3
}
@Test
fun canCreateRadioGroupWithEnumValues() = runTest {
val rgCheckedState by mutableStateOf<Rg1?>(null)
composition {
RadioGroup(checkedValue = rgCheckedState, name = "g1") {
RadioInput(value = Rg1.V1)
RadioInput(value = Rg1.V2)
RadioInput(value = Rg1.V3)
}
}
val expectedHtml = """
|<div>
|<input type="radio" name="g1" value="V1">
|<input type="radio" name="g1" value="V2">
|<input type="radio" name="g1" value="V3">
|</div>
""".trimMargin().replace("\n", "")
assertEquals(
expectedHtml,
root.outerHTML
)
repeat(3) {
assertEquals(false, (root.childNodes.item(it) as HTMLInputElement).checked)
}
}
@Test
fun radioGroupCheckedValueChanges() = runTest {
var rgCheckedState by mutableStateOf<Rg1?>(null)
composition {
RadioGroup(checkedValue = rgCheckedState, name = "g1") {
RadioInput(value = Rg1.V1)
RadioInput(value = Rg1.V2)
RadioInput(value = Rg1.V3)
}
}
val expectedHtml = """
|<div>
|<input type="radio" name="g1" value="V1">
|<input type="radio" name="g1" value="V2">
|<input type="radio" name="g1" value="V3">
|</div>
""".trimMargin().replace("\n", "")
assertEquals(expectedHtml, root.outerHTML)
repeat(3) {
assertEquals(false, (root.childNodes.item(it) as HTMLInputElement).checked)
}
Rg1.values().forEachIndexed { index, rg ->
rgCheckedState = rg
waitForRecompositionComplete()
assertEquals(expectedHtml, root.outerHTML)
repeat(3) {
assertEquals(it == index, (root.childNodes.item(it) as HTMLInputElement).checked)
}
}
rgCheckedState = null
waitForRecompositionComplete()
assertEquals(expectedHtml, root.outerHTML)
repeat(3) {
assertEquals(false, (root.childNodes.item(it) as HTMLInputElement).checked)
}
}
@Test
fun radioGroupWithNotDirectChildrenRadioInputsWorksCorrectly() = runTest {
var rgCheckedState by mutableStateOf<Rg1?>(null)
composition {
RadioGroup(checkedValue = rgCheckedState, name = "g1") {
Div { RadioInput(value = Rg1.V1) }
Div { RadioInput(value = Rg1.V2) }
Div { RadioInput(value = Rg1.V3) }
}
}
val expectedHtml = """
|<div>
|<div><input type="radio" name="g1" value="V1"></div>
|<div><input type="radio" name="g1" value="V2"></div>
|<div><input type="radio" name="g1" value="V3"></div>
|</div>
""".trimMargin().replace("\n", "")
assertEquals(expectedHtml, root.outerHTML)
repeat(3) {
assertEquals(false, (root.childNodes.item(it)!!.firstChild as HTMLInputElement).checked)
}
Rg1.values().forEachIndexed { index, rg ->
rgCheckedState = rg
waitForRecompositionComplete()
assertEquals(expectedHtml, root.outerHTML)
repeat(3) {
assertEquals(it == index, (root.childNodes.item(it)!!.firstChild as HTMLInputElement).checked)
}
}
rgCheckedState = null
waitForRecompositionComplete()
assertEquals(expectedHtml, root.outerHTML)
repeat(3) {
assertEquals(false, (root.childNodes.item(it)!!.firstChild as HTMLInputElement).checked)
}
}
}

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

@ -2,6 +2,7 @@ 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.ControlledInputsTests
import androidx.compose.web.sample.tests.RadioGroupTestCases
import androidx.compose.web.sample.tests.SelectElementTests import androidx.compose.web.sample.tests.SelectElementTests
import androidx.compose.web.sample.tests.UncontrolledInputsTests import androidx.compose.web.sample.tests.UncontrolledInputsTests
import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Span
@ -37,7 +38,8 @@ fun launchTestCase(testCaseId: String) {
// this makes test cases get initialised: // this makes test cases get initialised:
listOf<Any>( listOf<Any>(
TestCases1(), InputsTests(), EventsTests(), TestCases1(), InputsTests(), EventsTests(),
SelectElementTests(), ControlledInputsTests(), UncontrolledInputsTests() SelectElementTests(), ControlledInputsTests(), UncontrolledInputsTests(),
RadioGroupTestCases()
) )
if (testCaseId !in testCases) error("Test Case '$testCaseId' not found") if (testCaseId !in testCases) error("Test Case '$testCaseId' not found")

77
web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/RadioGroupTestCases.kt

@ -0,0 +1,77 @@
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.ExperimentalComposeWebApi
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.RadioGroup
import org.jetbrains.compose.web.dom.RadioInput
import org.jetbrains.compose.web.sample.tests.TestText
import org.jetbrains.compose.web.sample.tests.testCase
class RadioGroupTestCases {
@OptIn(ExperimentalComposeWebApi::class)
val radioGroupItemsCanBeChecked by testCase {
var checked by remember { mutableStateOf("None") }
TestText(value = checked)
RadioGroup(
checkedValue = checked
) {
RadioInput(value = "r1", id = "id1") {
onInput { checked = "r1" }
}
RadioInput(value = "r2", id = "id2") {
onInput { checked = "r2" }
}
RadioInput(value = "r3", id = "id3") {
onInput { checked = "r3" }
}
}
}
@OptIn(ExperimentalComposeWebApi::class)
val twoRadioGroupsChangedIndependently by testCase {
var checked1 by remember { mutableStateOf("None") }
var checked2 by remember { mutableStateOf("None") }
Div {
TestText(value = checked1)
}
Div {
TestText(value = checked2, id = "txt2")
}
RadioGroup(
checkedValue = checked1
) {
RadioInput(value = "r1", id = "id1") {
onInput { checked1 = "r1" }
}
RadioInput(value = "r2", id = "id2") {
onInput { checked1 = "r2" }
}
RadioInput(value = "r3", id = "id3") {
onInput { checked1 = "r3" }
}
}
RadioGroup(
checkedValue = checked2
) {
RadioInput(value = "ra", id = "ida") {
onInput { checked2 = "ra" }
}
RadioInput(value = "rb", id = "idb") {
onInput { checked2 = "rb" }
}
RadioInput(value = "rc", id = "idc") {
onInput { checked2 = "rc" }
}
}
}
}

87
web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/RadioGroupTests.kt

@ -0,0 +1,87 @@
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.remote.RemoteWebDriver
class RadioGroupTests : BaseIntegrationTests() {
@ResolveDrivers
fun radioGroupItemsCanBeChecked(driver: RemoteWebDriver) {
driver.openTestPage("radioGroupItemsCanBeChecked")
driver.waitTextToBe(value = "None")
val r1 = driver.findElement(By.id("id1"))
val r2 = driver.findElement(By.id("id2"))
val r3 = driver.findElement(By.id("id3"))
check(!r1.isSelected)
check(!r2.isSelected)
check(!r3.isSelected)
r1.click()
driver.waitTextToBe(value = "r1")
check(r1.isSelected)
check(!r2.isSelected)
check(!r3.isSelected)
r2.click()
driver.waitTextToBe(value = "r2")
check(!r1.isSelected)
check(r2.isSelected)
check(!r3.isSelected)
r3.click()
driver.waitTextToBe(value = "r3")
check(!r1.isSelected)
check(!r2.isSelected)
check(r3.isSelected)
}
@ResolveDrivers
fun twoRadioGroupsChangedIndependently(driver: RemoteWebDriver) {
driver.openTestPage("twoRadioGroupsChangedIndependently")
driver.waitTextToBe(textId = "txt", "None")
driver.waitTextToBe(textId = "txt2", "None")
val rg1Items = listOf(
driver.findElement(By.id("id1")),
driver.findElement(By.id("id2")),
driver.findElement(By.id("id3"))
)
val rg2Items = listOf(
driver.findElement(By.id("ida")),
driver.findElement(By.id("idb")),
driver.findElement(By.id("idc"))
)
check(rg1Items.all { !it.isSelected })
check(rg2Items.all { !it.isSelected })
rg1Items[1].click()
driver.waitTextToBe(textId = "txt", "r2")
driver.waitTextToBe(textId = "txt2", "None")
check(rg1Items[1].isSelected)
check(rg2Items.all { !it.isSelected })
rg2Items[2].click()
driver.waitTextToBe(textId = "txt", "r2")
driver.waitTextToBe(textId = "txt2", "rc")
check(rg2Items[2].isSelected)
check(rg2Items.filterIndexed { index, _ -> index != 2 }.all { !it.isSelected })
check(rg1Items[1].isSelected)
check(rg1Items.filterIndexed { index, _ -> index != 1 }.all { !it.isSelected })
}
}
Loading…
Cancel
Save