[jewel] JEWEL-774 Introduce new stateless *ListComboBox

fix typo in ListComboBoxUiTest

update ListComboBox tests and add new ones

update UI API file

introduce the new stateless EditableListComboBox

introduce the new stateless ListComboBox

closes https://github.com/JetBrains/intellij-community/pull/2955


(cherry picked from commit 2470c5e8f0ee56eb7157f0f3e28586adba305e22)

IJ-MR-155570

GitOrigin-RevId: 88ef0a6984383e282977f75e4971710599e2c79b
This commit is contained in:
Ivan Morgillo
2025-02-21 15:07:55 +01:00
committed by intellij-monorepo-bot
parent 6ba0fa2f89
commit 049d01cd7a
4 changed files with 424 additions and 15 deletions

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -216,21 +217,20 @@ private fun ListComboBoxes() {
)
}
var selectedComboBox1: String? by remember { mutableStateOf(comboBoxItems.first()) }
var selectedComboBox2: String? by remember { mutableStateOf(comboBoxItems.first()) }
var selectedComboBox3: String? by remember { mutableStateOf(comboBoxItems.first()) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(Modifier.weight(1f).padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Enabled and Editable")
Text(text = "Selected item: $selectedComboBox1", maxLines = 1, overflow = TextOverflow.Ellipsis)
var selectedIndex by remember { mutableIntStateOf(2) }
val selectedItemText = if (selectedIndex >= 0) comboBoxItems[selectedIndex] else ""
Text(text = "Selected item: $selectedItemText", maxLines = 1, overflow = TextOverflow.Ellipsis)
EditableListComboBox(
items = comboBoxItems,
selectedIndex = selectedIndex,
onItemSelected = { index, text -> selectedIndex = index },
modifier = Modifier.width(200.dp),
maxPopupHeight = 150.dp,
onSelectedItemChange = { _, text -> selectedComboBox1 = text },
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,
@@ -244,14 +244,16 @@ private fun ListComboBoxes() {
Column(Modifier.weight(1f).padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Enabled")
Text(text = "Selected item: $selectedComboBox2", maxLines = 1, overflow = TextOverflow.Ellipsis)
var selectedIndex by remember { mutableIntStateOf(2) }
val selectedItemText = if (selectedIndex >= 0) comboBoxItems[selectedIndex] else ""
Text(text = "Selected item: $selectedItemText", maxLines = 1, overflow = TextOverflow.Ellipsis)
ListComboBox(
items = comboBoxItems,
selectedIndex = selectedIndex,
modifier = Modifier.width(200.dp),
maxPopupHeight = 150.dp,
onSelectedItemChange = { _, text -> selectedComboBox2 = text },
onItemSelected = { index, text -> selectedIndex = index },
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,
@@ -265,14 +267,17 @@ private fun ListComboBoxes() {
Column(Modifier.weight(1f).padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Disabled")
Text(text = "Selected item: $selectedComboBox3", maxLines = 1, overflow = TextOverflow.Ellipsis)
var selectedIndex by remember { mutableIntStateOf(2) }
val selectedItemText = if (selectedIndex >= 0) comboBoxItems[selectedIndex] else ""
Text(text = "Selected item: $selectedItemText", maxLines = 1, overflow = TextOverflow.Ellipsis)
ListComboBox(
items = comboBoxItems,
modifier = Modifier.width(200.dp),
isEnabled = false,
onSelectedItemChange = { _, text -> selectedComboBox3 = text },
items = comboBoxItems,
selectedIndex = selectedIndex,
modifier = Modifier.width(200.dp),
maxPopupHeight = 150.dp,
onItemSelected = { index, text -> selectedIndex = index },
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,

View File

@@ -5,6 +5,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -422,6 +426,149 @@ class ListComboBoxUiTest {
comboBox.assertTextEquals("Item 2", includeEditableText = false)
}
@Test
fun `stateless ListComboBox displays and selects initial selectedIndex item`() {
var selectedIdx = 0
var selectedText = ""
composeRule.setContent {
IntUiTheme {
ListComboBox(
items = comboBoxItems,
selectedIndex = 2, // Start with "Item 3" selected
onItemSelected = { index, text ->
selectedIdx = index
selectedText = text
},
modifier = Modifier.testTag("ComboBox").width(200.dp),
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,
isSelected = isSelected,
isActive = isActive,
modifier = Modifier.testTag(item),
iconContentDescription = item,
)
},
)
}
}
composeRule.onNode(hasTestTag("ComboBox")).assertTextEquals("Item 3", includeEditableText = false)
composeRule.onNodeWithTag("Jewel.ComboBox.ChevronContainer", useUnmergedTree = true).performClick()
composeRule.onNodeWithTag("Item 1").performClick()
assert(selectedIdx == 0) { "Expected selectedIdx to be 0, but was $selectedIdx" }
assert(selectedText == "Item 1") { "Expected selectedText to be 'Item 1', but was $selectedText" }
}
@Test
fun `when selectedIndex changes externally ListComboBox updates`() {
var selectedIndex by mutableStateOf(0)
composeRule.setContent {
IntUiTheme {
ListComboBox(
items = comboBoxItems,
selectedIndex = selectedIndex,
onItemSelected = { index, _ -> selectedIndex = index },
modifier = Modifier.testTag("ComboBox").width(200.dp),
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,
isSelected = isSelected,
isActive = isActive,
modifier = Modifier.testTag(item),
iconContentDescription = item,
)
},
)
}
}
composeRule.onNode(hasTestTag("ComboBox")).assertTextEquals("Item 1", includeEditableText = false)
selectedIndex = 3
composeRule.waitForIdle()
composeRule.onNodeWithTag("Jewel.ComboBox.ChevronContainer", useUnmergedTree = true).performClick()
composeRule.onNodeWithTag("Book").assertIsSelected()
}
@Test
fun `when editable ListComboBox text is edited then selectedIndex remains unchanged`() {
var selectedIdx = 1
composeRule.setContent {
val textState = rememberTextFieldState("Item 2")
IntUiTheme {
EditableListComboBox(
items = comboBoxItems,
selectedIndex = selectedIdx,
onItemSelected = { index, _ -> selectedIdx = index },
textFieldState = textState,
modifier = Modifier.testTag("ComboBox").width(200.dp),
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,
isSelected = isSelected,
isActive = isActive,
modifier = Modifier.testTag(item),
iconContentDescription = item,
)
},
)
}
}
textField.assertTextEquals("Item 2")
textField.performTextClearance()
textField.performTextInput("Custom text")
assert(selectedIdx == 1) { "Expected selectedIdx to remain 1, but was $selectedIdx" }
chevronContainer.performClick()
composeRule.onNodeWithTag("Item 2").assertIsSelected()
}
@Test
fun `when editable ListComboBox selectedIndex changes then text field updates`() {
var selectedIndex by mutableStateOf(0)
composeRule.setContent {
val textState = rememberTextFieldState(comboBoxItems[selectedIndex])
// Update text state when selectedIndex changes
LaunchedEffect(selectedIndex) { textState.edit { replace(0, length, comboBoxItems[selectedIndex]) } }
IntUiTheme {
EditableListComboBox(
items = comboBoxItems,
selectedIndex = selectedIndex,
onItemSelected = { index, _ -> selectedIndex = index },
textFieldState = textState, // Pass the explicitly managed text state
modifier = Modifier.testTag("ComboBox").width(200.dp),
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,
isSelected = isSelected,
isActive = isActive,
modifier = Modifier.testTag(item),
iconContentDescription = item,
)
},
)
}
}
textField.assertTextEquals("Item 1")
selectedIndex = 3
composeRule.waitForIdle()
textField.assertTextEquals("Book")
chevronContainer.performClick()
composeRule.onNodeWithTag("Book").assertIsSelected()
}
private fun editableListComboBox(): SemanticsNodeInteraction {
val focusRequester = FocusRequester()
injectEditableListComboBox(focusRequester, isEnabled = true)
@@ -459,6 +606,8 @@ class ListComboBoxUiTest {
IntUiTheme {
ListComboBox(
items = comboBoxItems,
selectedIndex = 0,
onItemSelected = { _, _ -> },
modifier = Modifier.testTag("ComboBox").width(200.dp).focusRequester(focusRequester),
isEnabled = isEnabled,
itemContent = { item, isSelected, isActive ->

View File

@@ -554,7 +554,10 @@ public final class org/jetbrains/jewel/ui/component/LinkState$Companion {
public final class org/jetbrains/jewel/ui/component/ListComboBoxKt {
public static final fun EditableListComboBox-lYrZsNM (Ljava/util/List;Landroidx/compose/ui/Modifier;ZILorg/jetbrains/jewel/ui/Outline;FLandroidx/compose/foundation/interaction/MutableInteractionSource;Lorg/jetbrains/jewel/ui/component/styling/ComboBoxStyle;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V
public static final fun EditableListComboBox-xKBSf-U (Ljava/util/List;ILkotlin/jvm/functions/Function2;Landroidx/compose/foundation/text/input/TextFieldState;Landroidx/compose/ui/Modifier;ZLorg/jetbrains/jewel/ui/Outline;FLandroidx/compose/foundation/interaction/MutableInteractionSource;Lorg/jetbrains/jewel/ui/component/styling/ComboBoxStyle;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V
public static final fun ListComboBox-8u0NR3k (Ljava/util/List;ILkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;ZLorg/jetbrains/jewel/ui/Outline;FLandroidx/compose/foundation/interaction/MutableInteractionSource;Lorg/jetbrains/jewel/ui/component/styling/ComboBoxStyle;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V
public static final fun ListComboBox-lYrZsNM (Ljava/util/List;Landroidx/compose/ui/Modifier;ZILorg/jetbrains/jewel/ui/Outline;FLandroidx/compose/foundation/interaction/MutableInteractionSource;Lorg/jetbrains/jewel/ui/component/styling/ComboBoxStyle;Landroidx/compose/ui/text/TextStyle;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V
public static final fun selectedItemIndex (Lorg/jetbrains/jewel/foundation/lazy/SelectableLazyListState;)I
}
public final class org/jetbrains/jewel/ui/component/ListItemState {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -24,7 +25,9 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import kotlin.collections.indexOf
import kotlinx.coroutines.launch
import org.jetbrains.annotations.ApiStatus.ScheduledForRemoval
import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn
import org.jetbrains.jewel.foundation.lazy.SelectableLazyListState
import org.jetbrains.jewel.foundation.lazy.SelectionMode
@@ -39,6 +42,127 @@ import org.jetbrains.jewel.ui.Outline
import org.jetbrains.jewel.ui.component.styling.ComboBoxStyle
import org.jetbrains.jewel.ui.theme.comboBoxStyle
@Composable
public fun ListComboBox(
items: List<String>,
selectedIndex: Int,
onItemSelected: (Int, String) -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
outline: Outline = Outline.None,
maxPopupHeight: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
style: ComboBoxStyle = JewelTheme.comboBoxStyle,
textStyle: TextStyle = JewelTheme.defaultTextStyle,
onPopupVisibleChange: (visible: Boolean) -> Unit = {},
itemContent: @Composable (text: String, isSelected: Boolean, isActive: Boolean) -> Unit,
) {
val listState = rememberSelectableLazyListState()
listState.selectedKeys = setOf(selectedIndex)
var labelText by remember { mutableStateOf(items[selectedIndex]) }
var previewSelectedIndex by remember { mutableIntStateOf(selectedIndex) }
val scope = rememberCoroutineScope()
fun setSelectedItem(index: Int) {
if (index >= 0 && index <= items.lastIndex) {
listState.selectedKeys = setOf(index)
labelText = items[index]
onItemSelected(index, items[index])
scope.launch { listState.lazyListState.scrollToIndex(index) }
} else {
JewelLogger.getInstance("ListComboBox").trace("Ignoring item index $index as it's invalid")
}
}
fun resetPreviewSelectedIndex() {
previewSelectedIndex = -1
}
val contentPadding = JewelTheme.comboBoxStyle.metrics.popupContentPadding
val popupMaxHeight =
if (maxPopupHeight == Dp.Unspecified) {
JewelTheme.comboBoxStyle.metrics.maxPopupHeight
} else {
maxPopupHeight
}
val popupManager = remember {
PopupManager(
onPopupVisibleChange = { visible ->
resetPreviewSelectedIndex()
onPopupVisibleChange(visible)
},
name = "ListComboBoxPopup",
)
}
ComboBox(
modifier =
modifier.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
if (it.key == Key.Enter || it.key == Key.NumPadEnter) {
if (popupManager.isPopupVisible.value && previewSelectedIndex >= 0) {
setSelectedItem(previewSelectedIndex)
resetPreviewSelectedIndex()
}
popupManager.setPopupVisible(false)
true
} else {
false
}
},
isEnabled = isEnabled,
labelText = labelText,
maxPopupHeight = popupMaxHeight,
onArrowDownPress = {
var currentSelectedIndex = listState.selectedItemIndex()
// When there is a preview-selected item, pressing down will actually change the
// selected value to the one underneath it (unless it's the last one)
if (previewSelectedIndex >= 0 && previewSelectedIndex < items.lastIndex) {
currentSelectedIndex = previewSelectedIndex
resetPreviewSelectedIndex()
}
setSelectedItem((currentSelectedIndex + 1).coerceAtMost(items.lastIndex))
},
onArrowUpPress = {
var currentSelectedIndex = listState.selectedItemIndex()
// When there is a preview-selected item, pressing up will actually change the
// selected value to the one above it (unless it's the first one)
if (previewSelectedIndex > 0) {
currentSelectedIndex = previewSelectedIndex
resetPreviewSelectedIndex()
}
setSelectedItem((currentSelectedIndex - 1).coerceAtLeast(0))
},
style = style,
textStyle = textStyle,
interactionSource = interactionSource,
outline = outline,
popupManager = popupManager,
) {
PopupContent(
items = items,
previewSelectedItemIndex = previewSelectedIndex,
listState = listState,
popupMaxHeight = popupMaxHeight,
contentPadding = contentPadding,
onPreviewSelectedItemChange = {
if (it >= 0 && previewSelectedIndex != it) {
previewSelectedIndex = it
}
},
onSelectedItemChange = ::setSelectedItem,
itemContent = itemContent,
)
}
}
/**
* A non-editable dropdown list component that follows the standard visual styling.
*
@@ -69,6 +193,129 @@ import org.jetbrains.jewel.ui.theme.comboBoxStyle
* @see com.intellij.openapi.ui.ComboBox
*/
@Composable
public fun EditableListComboBox(
items: List<String>,
selectedIndex: Int,
onItemSelected: (Int, String) -> Unit,
textFieldState: TextFieldState = rememberTextFieldState(items[selectedIndex]),
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
outline: Outline = Outline.None,
maxPopupHeight: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
style: ComboBoxStyle = JewelTheme.comboBoxStyle,
textStyle: TextStyle = JewelTheme.defaultTextStyle,
onPopupVisibleChange: (visible: Boolean) -> Unit = {},
itemContent: @Composable (text: String, isSelected: Boolean, isActive: Boolean) -> Unit,
) {
val listState = rememberSelectableLazyListState()
listState.selectedKeys = setOf(selectedIndex)
var previewSelectedIndex by remember { mutableIntStateOf(selectedIndex) }
val scope = rememberCoroutineScope()
fun setSelectedItem(index: Int) {
if (index >= 0 && index <= items.lastIndex) {
// Note: it's important to do the edit _before_ updating the list state,
// since updating the list state will cause another, asynchronous and
// potentially nested call to edit, which is not supported.
// This is because setting the selected keys on the SLC will eventually
// cause a call to this very function via SLC's onSelectedIndexesChange.
textFieldState.edit { replace(0, length, items[index]) }
if (listState.selectedKeys.size != 1 || listState.selectedItemIndex() != index) {
// This guard condition should also help avoid issues caused by side effects
// of setting new selected keys, as per the comment above.
listState.selectedKeys = setOf(index)
}
onItemSelected(index, items[index])
scope.launch { listState.lazyListState.scrollToIndex(index) }
} else {
JewelLogger.getInstance("EditableListComboBox").trace("Ignoring item index $index as it's invalid")
}
}
val contentPadding = JewelTheme.comboBoxStyle.metrics.popupContentPadding
val popupMaxHeight =
if (maxPopupHeight == Dp.Unspecified) {
JewelTheme.comboBoxStyle.metrics.maxPopupHeight
} else {
maxPopupHeight
}
EditableComboBox(
textFieldState = textFieldState,
modifier = modifier,
isEnabled = isEnabled,
outline = outline,
interactionSource = interactionSource,
style = style,
textStyle = textStyle,
onArrowDownPress = {
var currentSelectedIndex = listState.selectedItemIndex()
// When there is a preview-selected item, pressing down will actually change the
// selected value to the one underneath it (unless it's the last one)
if (previewSelectedIndex >= 0 && previewSelectedIndex < items.lastIndex) {
currentSelectedIndex = previewSelectedIndex
previewSelectedIndex = -1
}
setSelectedItem((currentSelectedIndex + 1).coerceAtMost(items.lastIndex))
},
onArrowUpPress = {
var currentSelectedIndex = listState.selectedItemIndex()
// When there is a preview-selected item, pressing up will actually change the
// selected value to the one above it (unless it's the first one)
if (previewSelectedIndex > 0) {
currentSelectedIndex = previewSelectedIndex
previewSelectedIndex = -1
}
setSelectedItem((currentSelectedIndex - 1).coerceAtLeast(0))
},
onEnterPress = {
val indexOfSelected = items.indexOf(textFieldState.text)
if (indexOfSelected != -1) {
setSelectedItem(indexOfSelected)
}
},
popupManager =
remember {
PopupManager(
onPopupVisibleChange = {
previewSelectedIndex = -1
onPopupVisibleChange(it)
},
name = "EditableListComboBoxPopup",
)
},
popupContent = {
PopupContent(
items = items,
previewSelectedItemIndex = previewSelectedIndex,
listState = listState,
popupMaxHeight = popupMaxHeight,
contentPadding = contentPadding,
onPreviewSelectedItemChange = {
if (it >= 0 && previewSelectedIndex != it) {
previewSelectedIndex = it
}
},
onSelectedItemChange = ::setSelectedItem,
itemContent = itemContent,
)
},
)
}
@Deprecated(
message = "Use the stateless ListComboBox with selectedIndex and onItemSelected parameters instead",
level = DeprecationLevel.WARNING,
)
@ScheduledForRemoval(inVersion = "Before 1.0")
@Composable
public fun ListComboBox(
items: List<String>,
modifier: Modifier = Modifier,
@@ -220,6 +467,11 @@ public fun ListComboBox(
* @param itemContent Composable content for rendering each item in the list
* @see com.intellij.openapi.ui.ComboBox
*/
@Deprecated(
"Use the stateless EditableListComboBox with selectedIndex and onItemSelected parameters instead",
level = DeprecationLevel.WARNING,
)
@ScheduledForRemoval(inVersion = "Before 1.0")
@Composable
public fun EditableListComboBox(
items: List<String>,
@@ -372,7 +624,7 @@ private suspend fun LazyListState.scrollToIndex(itemIndex: Int) {
}
/** Returns the index of the selected item in the list, returning -1 if there is no selected item. */
private fun SelectableLazyListState.selectedItemIndex(): Int = selectedKeys.firstOrNull() as Int? ?: -1
public fun SelectableLazyListState.selectedItemIndex(): Int = selectedKeys.firstOrNull() as Int? ?: -1
@Composable
private fun PopupContent(