Skip to content

Commit 2c29e6c

Browse files
faogustavointellij-monorepo-bot
authored andcommitted
[JEWEL-1018] Adding SpeedSearchableComboBox
- Created new component supporting String-based and Generic-based APIs - Added support to choose SpeedSearch input position to support showing in the opposite size of the drop down closes #3250 (cherry picked from commit 126323a67ee7d0e830c8b9d628ab104894c517cc) (cherry picked from commit 9fc61b0e91cd69c67028c8de8015089afd5bcec4) IJ-MR-179919 GitOrigin-RevId: 355cb2e028dad1ea8d05a74c5ff4dcf4e7987c20
1 parent d4feb74 commit 2c29e6c

File tree

11 files changed

+652
-25
lines changed

11 files changed

+652
-25
lines changed

platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/ComboBoxes.kt

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import org.jetbrains.jewel.ui.component.GroupHeader
3131
import org.jetbrains.jewel.ui.component.ListComboBox
3232
import org.jetbrains.jewel.ui.component.PopupManager
3333
import org.jetbrains.jewel.ui.component.SimpleListItem
34+
import org.jetbrains.jewel.ui.component.SpeedSearchArea
3435
import org.jetbrains.jewel.ui.component.Text
36+
import org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBox
3537
import org.jetbrains.jewel.ui.disabledAppearance
3638
import org.jetbrains.jewel.ui.icon.IconKey
3739
import org.jetbrains.jewel.ui.icons.AllIconsKeys
@@ -92,7 +94,7 @@ public fun ComboBoxes(modifier: Modifier = Modifier) {
9294
private fun ListComboBoxes() {
9395
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
9496
Column(Modifier.weight(1f).widthIn(min = 125.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
95-
Text("String-based API, enabled")
97+
Text("String-based API")
9698
var selectedIndex by remember { mutableIntStateOf(2) }
9799
val selectedItemText = if (selectedIndex >= 0) stringItems[selectedIndex] else "[none]"
98100
InfoText(text = "Selected item: $selectedItemText")
@@ -107,7 +109,7 @@ private fun ListComboBoxes() {
107109
}
108110

109111
Column(Modifier.weight(1f).widthIn(min = 125.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
110-
Text("Generics-based API, enabled")
112+
Text("Generics-based API")
111113
var selectedIndex by remember { mutableIntStateOf(2) }
112114
val selectedItemText = if (selectedIndex >= 0) languageOptions[selectedIndex].name else "[none]"
113115
InfoText(text = "Selected item: $selectedItemText")
@@ -132,22 +134,22 @@ private fun ListComboBoxes() {
132134
}
133135

134136
Column(Modifier.weight(1f).widthIn(min = 125.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
135-
Text("String-based API, disabled")
137+
Text("Speed Search API")
136138
var selectedIndex by remember { mutableIntStateOf(2) }
137139
val selectedItemText = if (selectedIndex >= 0) stringItems[selectedIndex] else "[none]"
138140
InfoText(text = "Selected item: $selectedItemText")
139141

140-
ListComboBox(
141-
items = stringItems,
142-
selectedIndex = selectedIndex,
143-
onSelectedItemChange = { index -> selectedIndex = index },
144-
modifier = Modifier.widthIn(max = 200.dp),
145-
enabled = false,
146-
)
142+
SpeedSearchArea(Modifier.widthIn(max = 200.dp)) {
143+
SpeedSearchableComboBox(
144+
items = stringItems,
145+
selectedIndex = selectedIndex,
146+
onSelectedItemChange = { index -> selectedIndex = index },
147+
)
148+
}
147149
}
148150

149151
Column(Modifier.weight(1f).widthIn(min = 125.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
150-
Text("Generics-based API, disabled")
152+
Text("Disabled")
151153
var selectedIndex by remember { mutableIntStateOf(2) }
152154
val selectedItemText = if (selectedIndex >= 0) languageOptions[selectedIndex].name else "[none]"
153155
InfoText(text = "Selected item: $selectedItemText")
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
2+
package org.jetbrains.jewel.ui.component.search
3+
4+
import androidx.compose.foundation.layout.widthIn
5+
import androidx.compose.runtime.LaunchedEffect
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableIntStateOf
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.setValue
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.focus.FocusRequester
12+
import androidx.compose.ui.focus.focusRequester
13+
import androidx.compose.ui.input.key.Key
14+
import androidx.compose.ui.platform.testTag
15+
import androidx.compose.ui.test.SemanticsNodeInteraction
16+
import androidx.compose.ui.test.assertIsDisplayed
17+
import androidx.compose.ui.test.assertIsSelected
18+
import androidx.compose.ui.test.hasAnyAncestor
19+
import androidx.compose.ui.test.hasTestTag
20+
import androidx.compose.ui.test.hasText
21+
import androidx.compose.ui.test.junit4.ComposeContentTestRule
22+
import androidx.compose.ui.test.junit4.createComposeRule
23+
import androidx.compose.ui.test.onNodeWithTag
24+
import androidx.compose.ui.test.performClick
25+
import androidx.compose.ui.unit.dp
26+
import kotlin.test.AfterTest
27+
import kotlin.test.BeforeTest
28+
import kotlin.test.Test
29+
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
31+
import kotlinx.coroutines.test.resetMain
32+
import kotlinx.coroutines.test.setMain
33+
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
34+
import org.jetbrains.jewel.ui.component.SpeedSearchArea
35+
import org.jetbrains.jewel.ui.component.assertCursorAtPosition
36+
import org.jetbrains.jewel.ui.component.interactions.performKeyPress
37+
import org.junit.Rule
38+
39+
@Suppress("ImplicitUnitReturnType")
40+
class SpeedSearchableComboBoxTest {
41+
@get:Rule val rule = createComposeRule()
42+
43+
private val testDispatcher = UnconfinedTestDispatcher()
44+
45+
private val comboBox: SemanticsNodeInteraction
46+
get() = rule.onNodeWithTag("ComboBox")
47+
48+
private val ComposeContentTestRule.onSpeedSearchAreaInput
49+
get() = onNodeWithTag("SpeedSearchArea.Input")
50+
51+
private fun ComposeContentTestRule.onComboBoxItem(text: String) =
52+
onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.List")) and hasText(text))
53+
54+
@BeforeTest
55+
fun setUp() {
56+
Dispatchers.setMain(testDispatcher)
57+
}
58+
59+
@AfterTest
60+
fun tearDown() {
61+
Dispatchers.resetMain()
62+
}
63+
64+
@Test
65+
fun `should show on type text`() = runComposeTest {
66+
comboBox.performClick()
67+
comboBox.performKeyPress("Item", rule = this)
68+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
69+
}
70+
71+
@Test
72+
fun `should not show on type text before opening the popup`() = runComposeTest {
73+
comboBox.performKeyPress("Item", rule = this)
74+
onSpeedSearchAreaInput.assertDoesNotExist()
75+
}
76+
77+
@Test
78+
fun `should hide on esc press`() = runComposeTest {
79+
comboBox.performClick()
80+
81+
comboBox.performKeyPress("Item", rule = this)
82+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
83+
84+
comboBox.performKeyPress(Key.Escape, rule = this)
85+
onSpeedSearchAreaInput.assertDoesNotExist()
86+
}
87+
88+
@Test
89+
fun `on option navigation, move input cursor`() = runComposeTest {
90+
comboBox.performClick()
91+
92+
comboBox.performKeyPress("Item 42", rule = this)
93+
94+
comboBox.performKeyPress(Key.DirectionLeft, alt = true, rule = this)
95+
onSpeedSearchAreaInput.assertCursorAtPosition(0)
96+
97+
comboBox.performKeyPress(Key.DirectionRight, alt = true, rule = this)
98+
onSpeedSearchAreaInput.assertCursorAtPosition(7)
99+
}
100+
101+
@Test
102+
fun `on type, select first occurrence`() = runComposeTest {
103+
comboBox.performClick()
104+
comboBox.performKeyPress("Item 2", rule = this)
105+
onComboBoxItem("Item 2").assertIsDisplayed().assertIsSelected()
106+
}
107+
108+
@Test
109+
fun `on type continue typing, continue selecting first occurrence`() = runComposeTest {
110+
comboBox.performClick()
111+
112+
comboBox.performKeyPress("Item 2", rule = this)
113+
onComboBoxItem("Item 2").assertIsDisplayed().assertIsSelected()
114+
115+
onSpeedSearchAreaInput.performKeyPress("4", rule = this)
116+
onComboBoxItem("Item 24").assertIsDisplayed().assertIsSelected()
117+
118+
onSpeedSearchAreaInput.performKeyPress("5", rule = this)
119+
onComboBoxItem("Item 245").assertIsDisplayed().assertIsSelected()
120+
}
121+
122+
@Test
123+
fun `select closest match if after the current item`() = runComposeTest {
124+
comboBox.performClick()
125+
comboBox.performKeyPress("Item 245", rule = this)
126+
onComboBoxItem("Item 245").assertIsDisplayed().assertIsSelected()
127+
128+
// Delete the number
129+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
130+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
131+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
132+
133+
// Add `99` and jumps to next reference matching "99"
134+
onSpeedSearchAreaInput.performKeyPress("99", rule = this)
135+
onComboBoxItem("Item 299").assertIsDisplayed().assertIsSelected()
136+
}
137+
138+
@Test
139+
fun `on arrow up or down, navigate to the next and previous occurrences`() = runComposeTest {
140+
comboBox.performClick()
141+
comboBox.performKeyPress("Item 9", rule = this)
142+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
143+
144+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
145+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
146+
147+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
148+
onComboBoxItem("Item 29").assertIsDisplayed().assertIsSelected()
149+
150+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
151+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
152+
153+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
154+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
155+
}
156+
157+
@Test
158+
fun `deleting last char should keep current state`() = runComposeTest {
159+
comboBox.performClick()
160+
comboBox.performKeyPress("Item 42", rule = this)
161+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
162+
onComboBoxItem("Item 42").assertIsDisplayed().assertIsSelected()
163+
164+
// Remove "2" from "Item 42" to make "Item 4", but keep 42 selected as it matches the search query
165+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
166+
onComboBoxItem("Item 42").assertIsDisplayed().assertIsSelected()
167+
}
168+
169+
@Test
170+
fun `should handle partial text matching`() = runComposeTest {
171+
comboBox.performClick()
172+
173+
comboBox.performKeyPress("em 1", rule = this)
174+
onComboBoxItem("Item 1").assertIsDisplayed().assertIsSelected()
175+
}
176+
177+
@Test
178+
fun `should keep text when navigating through matches`() = runComposeTest {
179+
comboBox.performClick()
180+
comboBox.performKeyPress("Item 9", rule = this)
181+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
182+
183+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
184+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
185+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
186+
187+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
188+
onComboBoxItem("Item 29").assertIsDisplayed().assertIsSelected()
189+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
190+
191+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
192+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
193+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
194+
195+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
196+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
197+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
198+
}
199+
200+
private fun runComposeTest(
201+
entries: List<String> = List(500) { "Item ${it + 1}" },
202+
block: ComposeContentTestRule.() -> Unit,
203+
) {
204+
rule.setContent {
205+
val focusRequester = remember { FocusRequester() }
206+
207+
IntUiTheme {
208+
var selectedIndex by remember { mutableIntStateOf(0) }
209+
SpeedSearchArea(
210+
modifier = Modifier.widthIn(max = 200.dp).focusRequester(focusRequester).testTag("SpeedSearchArea")
211+
) {
212+
SpeedSearchableComboBox(
213+
items = entries,
214+
selectedIndex = selectedIndex,
215+
onSelectedItemChange = { index -> selectedIndex = index },
216+
modifier = Modifier.testTag("ComboBox"),
217+
dispatcher = testDispatcher,
218+
)
219+
}
220+
}
221+
222+
LaunchedEffect(Unit) { focusRequester.requestFocus() }
223+
}
224+
225+
rule.block()
226+
}
227+
}

platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableLazyColumnTest.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@ import androidx.compose.ui.test.performClick
2727
import androidx.compose.ui.test.performScrollToIndex
2828
import androidx.compose.ui.text.TextLayoutResult
2929
import androidx.compose.ui.unit.dp
30+
import kotlin.test.AfterTest
31+
import kotlin.test.BeforeTest
3032
import kotlin.test.Test
33+
import kotlinx.coroutines.Dispatchers
3134
import kotlinx.coroutines.ExperimentalCoroutinesApi
3235
import kotlinx.coroutines.test.UnconfinedTestDispatcher
36+
import kotlinx.coroutines.test.resetMain
37+
import kotlinx.coroutines.test.setMain
3338
import org.jetbrains.jewel.foundation.util.JewelLogger
3439
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
3540
import org.jetbrains.jewel.ui.component.DefaultButton
@@ -45,6 +50,8 @@ import org.junit.Rule
4550
class SpeedSearchableLazyColumnTest {
4651
@get:Rule val rule = createComposeRule()
4752

53+
private val testDispatcher = UnconfinedTestDispatcher()
54+
4855
private val ComposeContentTestRule.onLazyColumn
4956
get() = onNodeWithTag("LazyColumn")
5057

@@ -54,6 +61,16 @@ class SpeedSearchableLazyColumnTest {
5461
private fun ComposeContentTestRule.onLazyColumnItem(text: String) =
5562
onNode(hasAnyAncestor(hasTestTag("LazyColumn")) and hasText(text))
5663

64+
@BeforeTest
65+
fun setUp() {
66+
Dispatchers.setMain(testDispatcher)
67+
}
68+
69+
@AfterTest
70+
fun tearDown() {
71+
Dispatchers.resetMain()
72+
}
73+
5774
@Test
5875
fun `should show on type text`() = runComposeTest {
5976
onLazyColumn.performKeyPress("Item 42", rule = this)
@@ -233,7 +250,7 @@ class SpeedSearchableLazyColumnTest {
233250
SpeedSearchArea(modifier = Modifier.testTag("SpeedSearchArea")) {
234251
SpeedSearchableLazyColumn(
235252
modifier = Modifier.size(200.dp).testTag("LazyColumn").focusRequester(focusRequester),
236-
dispatcher = UnconfinedTestDispatcher(),
253+
dispatcher = testDispatcher,
237254
) {
238255
items(listEntries, textContent = { it }) { item ->
239256
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }

platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/search/SpeedSearchableTreeTest.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,14 @@ import androidx.compose.ui.test.performMouseInput
3030
import androidx.compose.ui.test.performScrollToIndex
3131
import androidx.compose.ui.text.TextLayoutResult
3232
import androidx.compose.ui.unit.dp
33+
import kotlin.test.AfterTest
34+
import kotlin.test.BeforeTest
3335
import kotlin.test.Test
36+
import kotlinx.coroutines.Dispatchers
3437
import kotlinx.coroutines.ExperimentalCoroutinesApi
3538
import kotlinx.coroutines.test.UnconfinedTestDispatcher
39+
import kotlinx.coroutines.test.resetMain
40+
import kotlinx.coroutines.test.setMain
3641
import org.jetbrains.jewel.foundation.lazy.tree.buildTree
3742
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
3843
import org.jetbrains.jewel.ui.component.DefaultButton
@@ -47,6 +52,8 @@ import org.junit.Rule
4752
class SpeedSearchableTreeTest {
4853
@get:Rule val rule = createComposeRule()
4954

55+
private val testDispatcher = UnconfinedTestDispatcher()
56+
5057
private val ComposeContentTestRule.onLazyTree
5158
get() = onNodeWithTag("LazyTree")
5259

@@ -56,6 +63,16 @@ class SpeedSearchableTreeTest {
5663
private fun ComposeContentTestRule.onLazyTreeNode(text: String) =
5764
onNode(hasAnyAncestor(hasTestTag("LazyTree")) and hasText(text))
5865

66+
@BeforeTest
67+
fun setUp() {
68+
Dispatchers.setMain(testDispatcher)
69+
}
70+
71+
@AfterTest
72+
fun tearDown() {
73+
Dispatchers.resetMain()
74+
}
75+
5976
@Test
6077
fun `should show on type text`() = runComposeTest {
6178
onLazyTree.performKeyPress("Root 42", rule = this)
@@ -273,7 +290,7 @@ class SpeedSearchableTreeTest {
273290
tree = tree,
274291
modifier = Modifier.size(200.dp).testTag("LazyTree").focusRequester(focusRequester),
275292
nodeText = { it.data },
276-
dispatcher = UnconfinedTestDispatcher(),
293+
dispatcher = testDispatcher,
277294
) {
278295
Box(Modifier.fillMaxWidth()) {
279296
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }

0 commit comments

Comments
 (0)