diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index 87c20f516..bb461be21 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -36,6 +36,7 @@ import org.hisp.dhis.common.screens.FormsComponentsScreen import org.hisp.dhis.common.screens.IconButtonScreen import org.hisp.dhis.common.screens.InputAgeScreen import org.hisp.dhis.common.screens.InputCheckBoxScreen +import org.hisp.dhis.common.screens.InputDropDownScreen import org.hisp.dhis.common.screens.InputEmailScreen import org.hisp.dhis.common.screens.InputIntegerScreen import org.hisp.dhis.common.screens.InputLetterScreen @@ -171,6 +172,7 @@ fun Main() { Components.INPUT_EMAIL -> InputEmailScreen() Components.CAROUSEL_BUTTONS -> ButtonCarouselScreen() Components.INPUT_ORG_UNIT -> InputOrgUnitScreen() + Components.INPUT_DROPDOWN -> InputDropDownScreen() } } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index 7ef94940b..6ce84c24e 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -46,4 +46,5 @@ enum class Components(val label: String) { INPUT_EMAIL("Input Email"), CAROUSEL_BUTTONS("Carousel buttons"), INPUT_ORG_UNIT("Input Org. Unit"), + INPUT_DROPDOWN("Input Dropdown"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDropDownScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDropDownScreen.kt new file mode 100644 index 000000000..15e02910d --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputDropDownScreen.kt @@ -0,0 +1,102 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle +import org.hisp.dhis.mobile.ui.designsystem.component.Title +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +fun InputDropDownScreen() { + ColumnComponentContainer { + val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5", "Option 6", "Option 7") + var expanded by rememberSaveable { mutableStateOf(false) } + + Title("Input Dropdown", textColor = TextColor.OnSurfaceVariant) + + SubTitle("Basic Input Dropdown ", textColor = TextColor.OnSurfaceVariant) + var selectedItem by rememberSaveable { mutableStateOf(null) } + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + selectedItem = selectedItem, + onResetButtonClicked = { + selectedItem = null + }, + onArrowDropDownButtonClicked = { + expanded = !expanded + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + selectedItem = it + expanded = false + }, + ) + } + } + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Basic Input Dropdown with content ", textColor = TextColor.OnSurfaceVariant) + var selectedItem1 by rememberSaveable { mutableStateOf(options[0]) } + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + selectedItem = selectedItem1, + onResetButtonClicked = { + selectedItem1 = null + }, + onArrowDropDownButtonClicked = { + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Error Input Dropdown ", textColor = TextColor.OnSurfaceVariant) + var selectedItem2 by rememberSaveable { mutableStateOf(null) } + InputDropDown( + title = "Label", + state = InputShellState.ERROR, + selectedItem = selectedItem2, + onResetButtonClicked = { + selectedItem2 = null + }, + onArrowDropDownButtonClicked = { + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + + SubTitle("Disabled Input Dropdown with content ", textColor = TextColor.OnSurfaceVariant) + var selectedItem3 by rememberSaveable { mutableStateOf(options[1]) } + InputDropDown( + title = "Label", + state = InputShellState.DISABLED, + selectedItem = selectedItem3, + onResetButtonClicked = { + selectedItem3 = null + }, + onArrowDropDownButtonClicked = { + expanded = !expanded + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt new file mode 100644 index 000000000..41e5a7c6b --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt @@ -0,0 +1,121 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.testTag +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +/** + * DHIS2 Input dropdown. Wraps DHIS ยท [InputShell]. + * @param title controls the text to be shown for the title + * @param state Manages the InputShell state + * @param selectedItem manages the value of the selected item + * @param supportingTextData is a list of SupportingTextData that + * manages all the messages to be shown + * @param legendData manages the legendComponent + * @param isRequiredField controls whether the field is mandatory or not + * @param onFocusChanged gives access to the onFocusChanged returns true if + * item is focused + * @param modifier allows a modifier to be passed externally + * @param onResetButtonClicked callback to when reset button is clicked + * @param onArrowDropDownButtonClicked callback to when arrow drop down button is clicked + */ +@Composable +fun InputDropDown( + title: String, + state: InputShellState, + selectedItem: String? = null, + supportingTextData: List? = null, + legendData: LegendData? = null, + isRequiredField: Boolean = false, + modifier: Modifier = Modifier, + onFocusChanged: ((Boolean) -> Unit)? = null, + onResetButtonClicked: () -> Unit, + onArrowDropDownButtonClicked: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + InputShell( + modifier = modifier + .testTag("INPUT_DROPDOWN") + .focusRequester(focusRequester), + title = title, + state = state, + isRequiredField = isRequiredField, + onFocusChanged = onFocusChanged, + supportingText = { + supportingTextData?.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = modifier.testTag("INPUT_DROPDOWN_SUPPORTING_TEXT"), + ) + } + }, + legend = { + legendData?.let { + Legend(legendData, modifier.testTag("INPUT_DROPDOWN_LEGEND")) + } + }, + inputField = { + Text( + modifier = Modifier.testTag("INPUT_DROPDOWN_TEXT"), + text = selectedItem ?: "", + style = MaterialTheme.typography.bodyLarge.copy( + color = if (state != InputShellState.DISABLED) { + TextColor.OnSurface + } else { + TextColor.OnDisabledSurface + }, + ), + ) + }, + primaryButton = { + IconButton( + modifier = Modifier.testTag("INPUT_DROPDOWN_ARROW_BUTTON").onFocusChanged { + onFocusChanged?.invoke(it.isFocused) + }, + enabled = state != InputShellState.DISABLED, + icon = { + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = "Dropdown Button", + ) + }, + onClick = { + focusRequester.requestFocus() + onArrowDropDownButtonClicked.invoke() + }, + ) + }, + secondaryButton = + if (!selectedItem.isNullOrEmpty() && state != InputShellState.DISABLED) { + { + IconButton( + modifier = Modifier.testTag("INPUT_DROPDOWN_RESET_BUTTON"), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Reset Button", + ) + }, + onClick = { + focusRequester.requestFocus() + onResetButtonClicked.invoke() + }, + ) + } + } else { + null + }, + ) +} diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt new file mode 100644 index 000000000..720f80021 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt @@ -0,0 +1,162 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.junit.Rule +import org.junit.Test + +class InputDropDownTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayInputDropDownCorrectly() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("INPUT_DROPDOWN_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowDropDownSelectionWhenEnabled() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON").assertIsEnabled() + rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON") + } + + @Test + fun shouldNotAllowDropDownSelectionWhenDisabled() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.DISABLED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON").assertIsNotEnabled() + } + + @Test + fun shouldShowResetButtonWhenItemIsSelected() { + rule.setContent { + InputDropDown( + title = "Label", + selectedItem = "Input", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertExists() + } + + @Test + fun shouldHideResetButtonWhenNoItemIsSelected() { + rule.setContent { + InputDropDown( + title = "Label", + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldHideResetButtonWhenDisabled() { + rule.setContent { + InputDropDown( + title = "Label", + selectedItem = "Option 1", + state = InputShellState.DISABLED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldRemoveSelectedItemWhenResetButtonIsClickedAndHideResetButton() { + rule.setContent { + var itemSelected by rememberSaveable { mutableStateOf("Option 1") } + + InputDropDown( + title = "Label", + selectedItem = itemSelected, + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = { + itemSelected = null + }, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").performClick() + rule.onNodeWithTag("INPUT_DROPDOWN_TEXT").assertTextEquals("") + rule.onNodeWithTag("INPUT_DROPDOWN_RESET_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + InputDropDown( + title = "Label", + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_LEGEND").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + InputDropDown( + title = "Label", + supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + state = InputShellState.UNFOCUSED, + onArrowDropDownButtonClicked = {}, + onResetButtonClicked = {}, + ) + } + rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_SUPPORTING_TEXT").assertExists() + } +}