From 626cba019a87c4a6d85627765e1fdfd565d0210f Mon Sep 17 00:00:00 2001 From: Oleksandr Karpovich Date: Thu, 16 Sep 2021 10:39:11 +0200 Subject: [PATCH] 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 --- .../compose/web/elements/RadioGroup.kt | 122 +++++++++++ .../jsTest/kotlin/elements/RadioGroupTests.kt | 191 ++++++++++++++++++ .../compose/web/sample/tests/Common.kt | 4 +- .../web/sample/tests/RadioGroupTestCases.kt | 77 +++++++ .../web/tests/integration/RadioGroupTests.kt | 87 ++++++++ 5 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/RadioGroup.kt create mode 100644 web/core/src/jsTest/kotlin/elements/RadioGroupTests.kt create mode 100644 web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/RadioGroupTestCases.kt create mode 100644 web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/RadioGroupTests.kt diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/RadioGroup.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/RadioGroup.kt new file mode 100644 index 0000000000..35408514ef --- /dev/null +++ b/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.() -> Unit) + +/** + * @param value - sets `value` attribute + * @param id - sets `id` attribute + * @param attrs - builder to set any eligible attribute + */ +@Composable +@NonRestartableComposable +@ExperimentalComposeWebApi +fun RadioGroupScope.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 ?> RadioGroup( + checkedValue: T, + name: String? = null, + content: @Composable RadioGroupScope.() -> 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>()) + } + ) +} + +/** + * @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.() -> 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>()) + } + ) +} + +@ExperimentalComposeWebApi +class RadioGroupScope internal constructor() + +@OptIn(ExperimentalComposeWebApi::class) +private val radioGroupScopeImpl = RadioGroupScope() + +private var generatedRadioGroupNamesCounter = 0 + +private fun generateNextRadioGroupName(): String { + return "\$compose\$generated\$radio\$group-${generatedRadioGroupNamesCounter++}" +} + +internal val radioGroupCompositionLocalValue = compositionLocalOf { + error("No radio group checked value provided") +} +internal val radioGroupCompositionLocalName = compositionLocalOf { + error("No radio group name provided") +} + +@Composable +internal fun getCompositionLocalRadioGroupCheckedValue(): String? { + return radioGroupCompositionLocalValue.current +} + +@Composable +internal fun getCompositionLocalRadioGroupName(): String { + return radioGroupCompositionLocalName.current +} diff --git a/web/core/src/jsTest/kotlin/elements/RadioGroupTests.kt b/web/core/src/jsTest/kotlin/elements/RadioGroupTests.kt new file mode 100644 index 0000000000..db448ece18 --- /dev/null +++ b/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 = """ + |
+ | + | + | + | + |
+ """.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 = """ + |
+ | + | + |
+ """.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(null) + composition { + RadioGroup(checkedValue = rgCheckedState, name = "g1") { + RadioInput(value = Rg1.V1) + RadioInput(value = Rg1.V2) + RadioInput(value = Rg1.V3) + } + } + + val expectedHtml = """ + |
+ | + | + | + |
+ """.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(null) + + composition { + RadioGroup(checkedValue = rgCheckedState, name = "g1") { + RadioInput(value = Rg1.V1) + RadioInput(value = Rg1.V2) + RadioInput(value = Rg1.V3) + } + } + + val expectedHtml = """ + |
+ | + | + | + |
+ """.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(null) + + composition { + RadioGroup(checkedValue = rgCheckedState, name = "g1") { + Div { RadioInput(value = Rg1.V1) } + Div { RadioInput(value = Rg1.V2) } + Div { RadioInput(value = Rg1.V3) } + } + } + + val expectedHtml = """ + |
+ |
+ |
+ |
+ |
+ """.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) + } + } +} diff --git a/web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/Common.kt b/web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/Common.kt index 98e05f20b5..2e2b7dd80f 100644 --- a/web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/Common.kt +++ b/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.web.sample.tests.ControlledInputsTests +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 @@ -37,7 +38,8 @@ fun launchTestCase(testCaseId: String) { // this makes test cases get initialised: listOf( TestCases1(), InputsTests(), EventsTests(), - SelectElementTests(), ControlledInputsTests(), UncontrolledInputsTests() + SelectElementTests(), ControlledInputsTests(), UncontrolledInputsTests(), + RadioGroupTestCases() ) if (testCaseId !in testCases) error("Test Case '$testCaseId' not found") diff --git a/web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/RadioGroupTestCases.kt b/web/integration-core/src/jsMain/kotlin/androidx/compose/web/sample/tests/RadioGroupTestCases.kt new file mode 100644 index 0000000000..b356aad7a0 --- /dev/null +++ b/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" } + } + } + } +} diff --git a/web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/RadioGroupTests.kt b/web/integration-core/src/jvmTest/kotlin/org/jetbrains/compose/web/tests/integration/RadioGroupTests.kt new file mode 100644 index 0000000000..b850ce0986 --- /dev/null +++ b/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 }) + } +}