Browse Source
* 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
5 changed files with 480 additions and 1 deletions
@ -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 |
||||
} |
@ -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) |
||||
} |
||||
} |
||||
} |
@ -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" } |
||||
} |
||||
} |
||||
} |
||||
} |
@ -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…
Reference in new issue