diff --git a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/ListComboBoxUiTest.kt b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/ListComboBoxUiTest.kt index f499ea45d93ae..0840221627703 100644 --- a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/ListComboBoxUiTest.kt +++ b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/ListComboBoxUiTest.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals @@ -1177,6 +1178,212 @@ class ListComboBoxUiTest { assertTrue(selectedItemChangeTriggered, "Item click should be detected for faster taps") } + @Test + fun `when ListComboBox selectedIndex is reset externally while popup is closed, selection updates correctly`() { + var selectedIndex by mutableIntStateOf(4) // Start at "Laughter" + val focusRequester = FocusRequester() + + composeRule.setContent { + IntUiTheme { + ListComboBox( + items = comboBoxItems, + selectedIndex = selectedIndex, + onSelectedItemChange = { index -> selectedIndex = index }, + modifier = Modifier.testTag("ComboBox").width(200.dp).focusRequester(focusRequester), + itemKeys = { _, item -> item }, + ) + } + } + + focusRequester.requestFocus() + + comboBox.assertTextEquals("Laughter", includeEditableText = false) + assertEquals(4, selectedIndex) + + // Reset selectedIndex externally while popup is closed + composeRule.runOnUiThread { selectedIndex = 0 } + + // Verify combo box label updated + comboBox.assertTextEquals("Item 1", includeEditableText = false) + + // Open popup and verify the correct item is selected + comboBox.performClick() + popupMenu.assertIsDisplayed() + + comboBoxPopupList.performScrollToIndex(0) + + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Item 1")) + .assertExists() + .assertIsDisplayed() + .assertIsSelected() + + // Previously selected item should not be selected anymore + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Laughter")) + .assertExists() + .assertIsDisplayed() + .assertIsNotSelected() + } + + @Test + fun `when ListComboBox selectedIndex is reset externally while popup is open, selection is not disrupted`() { + var selectedIndex by mutableIntStateOf(4) // Start at "Laughter" + val focusRequester = FocusRequester() + + composeRule.setContent { + IntUiTheme { + ListComboBox( + items = comboBoxItems, + selectedIndex = selectedIndex, + onSelectedItemChange = { index -> selectedIndex = index }, + modifier = Modifier.testTag("ComboBox").width(200.dp).focusRequester(focusRequester), + itemKeys = { _, item -> item }, + ) + } + } + + focusRequester.requestFocus() + + // Verify initial state + comboBox.assertTextEquals("Laughter", includeEditableText = false) + assertEquals(4, selectedIndex) + + // Open popup + comboBox.performClick() + popupMenu.assertIsDisplayed() + + // Verify "Laughter" is selected in the popup + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Laughter")) + .assertExists() + .assertIsDisplayed() + .assertIsSelected() + + // Reset selectedIndex externally while popup is open + composeRule.runOnUiThread { selectedIndex = 0 } + + // Verify popup still shows "Laughter" as selected (not disrupted by external change) + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Laughter")) + .assertExists() + .assertIsDisplayed() + .assertIsSelected() + + // Press Enter to commit current selection (should reconcile to internal listState) + comboBox.performKeyPress(Key.Enter, rule = composeRule) + popupMenu.assertDoesNotExist() + + // After closing, selectedIndex should be reconciled back to "Laughter" (index 4) + assertEquals(4, selectedIndex) + comboBox.assertTextEquals("Laughter", includeEditableText = false) + } + + @Test + fun `when EditableListComboBox selectedIndex is reset externally while popup is closed, selection updates correctly`() { + var selectedIndex by mutableIntStateOf(4) // Start at "Laughter" + val focusRequester = FocusRequester() + + composeRule.setContent { + IntUiTheme { + EditableListComboBox( + items = comboBoxItems, + selectedIndex = selectedIndex, + onSelectedItemChange = { index -> selectedIndex = index }, + modifier = Modifier.testTag("ComboBox").width(200.dp).focusRequester(focusRequester), + itemKeys = { _, item -> item }, + ) + } + } + + focusRequester.requestFocus() + composeRule.waitForIdle() + + textField.assertTextEquals("Laughter") + assertEquals(4, selectedIndex) + + // Reset selectedIndex externally while popup is closed + composeRule.runOnUiThread { selectedIndex = 0 } + composeRule.waitForIdle() + + // Verify text field updated + textField.assertTextEquals("Item 1") + + // Open popup and verify the correct item is selected + chevronContainer.performClick() + popupMenu.assertIsDisplayed() + + comboBoxPopupList.performScrollToIndex(0) + + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Item 1")) + .assertExists() + .assertIsDisplayed() + .assertIsSelected() + + // Previously selected item should not be selected anymore + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Laughter")) + .assertExists() + .assertIsDisplayed() + .assertIsNotSelected() + } + + @Test + fun `when EditableListComboBox selectedIndex is reset externally while popup is open, selection is not disrupted`() { + var selectedIndex by mutableIntStateOf(4) // Start at "Laughter" + val focusRequester = FocusRequester() + + composeRule.setContent { + IntUiTheme { + EditableListComboBox( + items = comboBoxItems, + selectedIndex = selectedIndex, + onSelectedItemChange = { index -> selectedIndex = index }, + modifier = Modifier.testTag("ComboBox").width(200.dp).focusRequester(focusRequester), + itemKeys = { _, item -> item }, + ) + } + } + + focusRequester.requestFocus() + composeRule.waitForIdle() + + // Verify initial state + textField.assertTextEquals("Laughter") + assertEquals(4, selectedIndex) + + // Open popup + chevronContainer.performClick() + popupMenu.assertIsDisplayed() + + // Verify "Laughter" is selected in the popup + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Laughter")) + .assertExists() + .assertIsDisplayed() + .assertIsSelected() + + // Reset selectedIndex externally while popup is open + composeRule.runOnUiThread { selectedIndex = 0 } + composeRule.waitForIdle() + + // Verify popup still shows "Laughter" as selected (not disrupted by external change) + composeRule + .onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.Popup")) and hasText("Laughter")) + .assertExists() + .assertIsDisplayed() + .assertIsSelected() + + // Press Enter to commit current selection (should reconcile to internal listState) + comboBox.performKeyPress(Key.Enter, rule = composeRule) + popupMenu.assertDoesNotExist() + + // After closing, selectedIndex should be reconciled back to "Laughter" (index 4) + assertEquals(4, selectedIndex) + textField.assertTextEquals("Laughter") + } + private fun editableListComboBox(): SemanticsNodeInteraction { val focusRequester = FocusRequester() composeRule.setContent { diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ListComboBox.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ListComboBox.kt index 87ab377467394..61954fca5fbfc 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ListComboBox.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ListComboBox.kt @@ -433,9 +433,22 @@ public fun EditableListComboBox( var hoveredItemIndex by remember { mutableIntStateOf(-1) } val scope = rememberCoroutineScope() - LaunchedEffect(itemKeys) { - // Select the first item in the list when creating - listState.selectedKeys = setOf(itemKeys(selectedIndex, items.getOrNull(selectedIndex).orEmpty())) + val popupManager = remember { + PopupManager( + onPopupVisibleChange = { + hoveredItemIndex = -1 + onPopupVisibleChange(it) + }, + name = "EditableListComboBoxPopup", + ) + } + + LaunchedEffect(itemKeys, selectedIndex, popupManager.isPopupVisible.value) { + if (!popupManager.isPopupVisible.value) { + val item = items.getOrNull(selectedIndex).orEmpty() + listState.selectedKeys = setOf(itemKeys(selectedIndex, item)) + textFieldState.edit { replace(0, length, item) } + } } fun setSelectedItem(index: Int) { @@ -506,16 +519,7 @@ public fun EditableListComboBox( setSelectedItem(indexOfSelected) } }, - popupManager = - remember { - PopupManager( - onPopupVisibleChange = { - hoveredItemIndex = -1 - onPopupVisibleChange(it) - }, - name = "EditableListComboBoxPopup", - ) - }, + popupManager = popupManager, popupContent = { PopupContent( items = items, @@ -676,15 +680,6 @@ internal fun ListComboBoxImpl( ), itemContent: @Composable (index: Int, item: T, isSelected: Boolean, isActive: Boolean) -> Unit, ) { - LaunchedEffect(itemKeys) { - val item = items.getOrNull(selectedIndex) - if (item != null) { - listState.selectedKeys = setOf(itemKeys(selectedIndex, item)) - } else { - listState.selectedKeys = emptySet() - } - } - val density = LocalDensity.current var comboBoxSize by remember { mutableStateOf(DpSize.Zero) } var currentComboBoxSize by remember { mutableStateOf(DpSize.Zero) } @@ -761,6 +756,19 @@ internal fun ListComboBoxImpl( ) } + // Sync external selectedIndex changes to listState, but only when popup is closed + // to avoid disrupting the user's active browsing session when popup is open + LaunchedEffect(itemKeys, selectedIndex, popupManager.isPopupVisible.value) { + if (!popupManager.isPopupVisible.value) { + val item = items.getOrNull(selectedIndex) + if (item != null) { + listState.selectedKeys = setOf(itemKeys(selectedIndex, item)) + } else { + listState.selectedKeys = emptySet() + } + } + } + fun commitSelectionFromHoverOrMapped() { val mappedIndex = listState.selectedItemIndex(items, itemKeys) val targetIndex =