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