Skip to content

Commit 13c6336

Browse files
committed
[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
1 parent 281c946 commit 13c6336

File tree

9 files changed

+597
-23
lines changed

9 files changed

+597
-23
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: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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.Test
27+
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
28+
import org.jetbrains.jewel.ui.component.SpeedSearchArea
29+
import org.jetbrains.jewel.ui.component.assertCursorAtPosition
30+
import org.jetbrains.jewel.ui.component.interactions.performKeyPress
31+
import org.junit.Rule
32+
33+
@Suppress("ImplicitUnitReturnType")
34+
class SpeedSearchableComboBoxTest {
35+
@get:Rule val rule = createComposeRule()
36+
37+
private val comboBox: SemanticsNodeInteraction
38+
get() = rule.onNodeWithTag("ComboBox")
39+
40+
private val ComposeContentTestRule.onSpeedSearchAreaInput
41+
get() = onNodeWithTag("SpeedSearchArea.Input")
42+
43+
private fun ComposeContentTestRule.onComboBoxItem(text: String) =
44+
onNode(hasAnyAncestor(hasTestTag("Jewel.ComboBox.List")) and hasText(text))
45+
46+
@Test
47+
fun `should show on type text`() = runComposeTest {
48+
comboBox.performClick()
49+
comboBox.performKeyPress("Item", rule = this)
50+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
51+
}
52+
53+
@Test
54+
fun `should not show on type text before opening the popup`() = runComposeTest {
55+
comboBox.performKeyPress("Item", rule = this)
56+
onSpeedSearchAreaInput.assertDoesNotExist()
57+
}
58+
59+
@Test
60+
fun `should hide on esc press`() = runComposeTest {
61+
comboBox.performClick()
62+
63+
comboBox.performKeyPress("Item", rule = this)
64+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
65+
66+
comboBox.performKeyPress(Key.Escape, rule = this)
67+
onSpeedSearchAreaInput.assertDoesNotExist()
68+
}
69+
70+
@Test
71+
fun `on option navigation, move input cursor`() = runComposeTest {
72+
comboBox.performClick()
73+
74+
comboBox.performKeyPress("Item 42", rule = this)
75+
76+
comboBox.performKeyPress(Key.DirectionLeft, alt = true, rule = this)
77+
onSpeedSearchAreaInput.assertCursorAtPosition(0)
78+
79+
comboBox.performKeyPress(Key.DirectionRight, alt = true, rule = this)
80+
onSpeedSearchAreaInput.assertCursorAtPosition(7)
81+
}
82+
83+
@Test
84+
fun `on type, select first occurrence`() = runComposeTest {
85+
comboBox.performClick()
86+
comboBox.performKeyPress("Item 2", rule = this)
87+
onComboBoxItem("Item 2").assertIsDisplayed().assertIsSelected()
88+
}
89+
90+
@Test
91+
fun `on type continue typing, continue selecting first occurrence`() = runComposeTest {
92+
comboBox.performClick()
93+
94+
comboBox.performKeyPress("Item 2", rule = this)
95+
onComboBoxItem("Item 2").assertIsDisplayed().assertIsSelected()
96+
97+
onSpeedSearchAreaInput.performKeyPress("4", rule = this)
98+
onComboBoxItem("Item 24").assertIsDisplayed().assertIsSelected()
99+
100+
onSpeedSearchAreaInput.performKeyPress("5", rule = this)
101+
onComboBoxItem("Item 245").assertIsDisplayed().assertIsSelected()
102+
}
103+
104+
@Test
105+
fun `select closest match if after the current item`() = runComposeTest {
106+
comboBox.performClick()
107+
comboBox.performKeyPress("Item 245", rule = this)
108+
onComboBoxItem("Item 245").assertIsDisplayed().assertIsSelected()
109+
110+
// Delete the number
111+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
112+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
113+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
114+
115+
// Add `99` and jumps to next reference matching "99"
116+
onSpeedSearchAreaInput.performKeyPress("99", rule = this)
117+
onComboBoxItem("Item 299").assertIsDisplayed().assertIsSelected()
118+
}
119+
120+
@Test
121+
fun `on arrow up or down, navigate to the next and previous occurrences`() = runComposeTest {
122+
comboBox.performClick()
123+
comboBox.performKeyPress("Item 9", rule = this)
124+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
125+
126+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
127+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
128+
129+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
130+
onComboBoxItem("Item 29").assertIsDisplayed().assertIsSelected()
131+
132+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
133+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
134+
135+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
136+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
137+
}
138+
139+
@Test
140+
fun `deleting last char should keep current state`() = runComposeTest {
141+
comboBox.performClick()
142+
comboBox.performKeyPress("Item 42", rule = this)
143+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
144+
onComboBoxItem("Item 42").assertIsDisplayed().assertIsSelected()
145+
146+
// Remove "2" from "Item 42" to make "Item 4", but keep 42 selected as it matches the search query
147+
onSpeedSearchAreaInput.performKeyPress(Key.Backspace, rule = this)
148+
onComboBoxItem("Item 42").assertIsDisplayed().assertIsSelected()
149+
}
150+
151+
@Test
152+
fun `should handle partial text matching`() = runComposeTest {
153+
comboBox.performClick()
154+
155+
comboBox.performKeyPress("em 1", rule = this)
156+
onComboBoxItem("Item 1").assertIsDisplayed().assertIsSelected()
157+
}
158+
159+
@Test
160+
fun `should keep text when navigating through matches`() = runComposeTest {
161+
comboBox.performClick()
162+
comboBox.performKeyPress("Item 9", rule = this)
163+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
164+
165+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
166+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
167+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
168+
169+
comboBox.performKeyPress(Key.DirectionDown, rule = this)
170+
onComboBoxItem("Item 29").assertIsDisplayed().assertIsSelected()
171+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
172+
173+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
174+
onComboBoxItem("Item 19").assertIsDisplayed().assertIsSelected()
175+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
176+
177+
comboBox.performKeyPress(Key.DirectionUp, rule = this)
178+
onComboBoxItem("Item 9").assertIsDisplayed().assertIsSelected()
179+
onSpeedSearchAreaInput.assertExists().assertIsDisplayed()
180+
}
181+
182+
private fun runComposeTest(
183+
entries: List<String> = List(500) { "Item ${it + 1}" },
184+
block: ComposeContentTestRule.() -> Unit,
185+
) {
186+
rule.setContent {
187+
val focusRequester = remember { FocusRequester() }
188+
189+
IntUiTheme {
190+
var selectedIndex by remember { mutableIntStateOf(0) }
191+
SpeedSearchArea(
192+
Modifier.widthIn(max = 200.dp).focusRequester(focusRequester).testTag("SpeedSearchArea")
193+
) {
194+
SpeedSearchableComboBox(
195+
items = entries,
196+
selectedIndex = selectedIndex,
197+
onSelectedItemChange = { index -> selectedIndex = index },
198+
modifier = Modifier.testTag("ComboBox"),
199+
)
200+
}
201+
}
202+
203+
LaunchedEffect(Unit) { focusRequester.requestFocus() }
204+
}
205+
206+
rule.block()
207+
}
208+
}

platform/jewel/ui/api-dump-experimental.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ f:org.jetbrains.jewel.ui.component.SpeedSearchAreaKt
3131
- a:clearSearch():Z
3232
- a:getHasMatches():Z
3333
- a:getMatchingIndexes():java.util.List
34+
- a:getPosition():androidx.compose.ui.Alignment$Vertical
3435
- a:getSearchText():java.lang.String
3536
- a:hideSearch():Z
3637
- a:isVisible():Z
3738
- a:matchResultForText(java.lang.String):org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult
39+
- a:setPosition(androidx.compose.ui.Alignment$Vertical):V
3840
f:org.jetbrains.jewel.ui.component.TabStripKt
3941
- *sf:TabStrip(java.util.List,org.jetbrains.jewel.ui.component.styling.TabStyle,androidx.compose.ui.Modifier,Z,androidx.compose.runtime.Composer,I,I):V
4042
f:org.jetbrains.jewel.ui.component.TextAreaKt
@@ -48,6 +50,9 @@ f:org.jetbrains.jewel.ui.component.TypographyKt
4850
f:org.jetbrains.jewel.ui.component.search.HighlightKt
4951
- *sf:highlightSpeedSearchMatches(androidx.compose.ui.Modifier,androidx.compose.ui.text.TextLayoutResult,org.jetbrains.jewel.ui.component.NodeSearchMatchState,androidx.compose.runtime.Composer,I,I):androidx.compose.ui.Modifier
5052
- *sf:highlightTextSearch(java.lang.CharSequence,org.jetbrains.jewel.ui.component.NodeSearchMatchState,androidx.compose.runtime.Composer,I,I):androidx.compose.ui.text.AnnotatedString
53+
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBoxKt
54+
- *sf:SpeedSearchableComboBox-C3xArm8(org.jetbrains.jewel.ui.component.SpeedSearchScope,java.util.List,I,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function2,androidx.compose.ui.Modifier,androidx.compose.ui.Modifier,Z,org.jetbrains.jewel.ui.Outline,F,F,org.jetbrains.jewel.ui.component.styling.ComboBoxStyle,kotlin.jvm.functions.Function1,org.jetbrains.jewel.foundation.lazy.SelectableLazyListState,kotlinx.coroutines.CoroutineDispatcher,kotlin.jvm.functions.Function5,androidx.compose.runtime.Composer,I,I,I):V
55+
- *sf:SpeedSearchableComboBox-MCfSvGQ(org.jetbrains.jewel.ui.component.SpeedSearchScope,java.util.List,I,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.ui.Modifier,kotlin.jvm.functions.Function2,Z,org.jetbrains.jewel.ui.Outline,F,F,org.jetbrains.jewel.ui.component.styling.ComboBoxStyle,androidx.compose.ui.text.TextStyle,kotlin.jvm.functions.Function1,org.jetbrains.jewel.foundation.lazy.SelectableLazyListState,kotlinx.coroutines.CoroutineDispatcher,androidx.compose.runtime.Composer,I,I,I):V
5156
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableLazyColumnKt
5257
- *sf:SpeedSearchableLazyColumn(org.jetbrains.jewel.ui.component.SpeedSearchScope,androidx.compose.ui.Modifier,org.jetbrains.jewel.foundation.lazy.SelectionMode,org.jetbrains.jewel.foundation.lazy.SelectableLazyListState,androidx.compose.foundation.layout.PaddingValues,Z,kotlin.jvm.functions.Function1,androidx.compose.foundation.layout.Arrangement$Vertical,androidx.compose.ui.Alignment$Horizontal,androidx.compose.foundation.gestures.FlingBehavior,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.foundation.lazy.tree.PointerEventActions,kotlinx.coroutines.CoroutineDispatcher,kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I,I):V
5358
*:org.jetbrains.jewel.ui.component.search.SpeedSearchableLazyColumnScope

platform/jewel/ui/api-dump.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,7 @@ org.jetbrains.jewel.ui.component.banner.BannerIconActionScope
983983
org.jetbrains.jewel.ui.component.banner.BannerLinkActionScope
984984
- a:action(java.lang.String,kotlin.jvm.functions.Function0):V
985985
f:org.jetbrains.jewel.ui.component.search.HighlightKt
986+
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBoxKt
986987
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableLazyColumnKt
987988
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableTreeKt
988989
f:org.jetbrains.jewel.ui.component.styling.BannerColors

platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/ComboBox.kt

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.style.TextOverflow
4545
import androidx.compose.ui.unit.Dp
4646
import androidx.compose.ui.unit.coerceAtLeast
4747
import androidx.compose.ui.unit.takeOrElse
48+
import androidx.compose.ui.window.PopupPositionProvider
4849
import androidx.compose.ui.window.PopupProperties
4950
import org.jetbrains.annotations.ApiStatus
5051
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
@@ -56,9 +57,11 @@ import org.jetbrains.jewel.foundation.theme.JewelTheme
5657
import org.jetbrains.jewel.foundation.theme.LocalContentColor
5758
import org.jetbrains.jewel.ui.Outline
5859
import org.jetbrains.jewel.ui.component.styling.ComboBoxStyle
60+
import org.jetbrains.jewel.ui.component.styling.PopupContainerStyle
5961
import org.jetbrains.jewel.ui.focusOutline
6062
import org.jetbrains.jewel.ui.outline
6163
import org.jetbrains.jewel.ui.theme.comboBoxStyle
64+
import org.jetbrains.jewel.ui.theme.popupContainerStyle
6265

6366
/**
6467
* A dropdown component that displays a text label and a popup with custom content.
@@ -215,6 +218,48 @@ public fun ComboBox(
215218
onArrowDownPress: () -> Unit = {},
216219
onArrowUpPress: () -> Unit = {},
217220
popupManager: PopupManager = remember { PopupManager() },
221+
) {
222+
ComboBoxImpl(
223+
labelContent = labelContent,
224+
popupContent = popupContent,
225+
modifier = modifier,
226+
popupModifier = popupModifier,
227+
enabled = enabled,
228+
outline = outline,
229+
maxPopupHeight = maxPopupHeight,
230+
maxPopupWidth = maxPopupWidth,
231+
interactionSource = interactionSource,
232+
style = style,
233+
onArrowDownPress = onArrowDownPress,
234+
onArrowUpPress = onArrowUpPress,
235+
popupManager = popupManager,
236+
)
237+
}
238+
239+
@Composable
240+
internal fun ComboBoxImpl(
241+
labelContent: @Composable (() -> Unit),
242+
popupContent: @Composable (() -> Unit),
243+
modifier: Modifier = Modifier,
244+
popupModifier: Modifier = Modifier,
245+
enabled: Boolean = true,
246+
outline: Outline = Outline.None,
247+
maxPopupHeight: Dp = Dp.Unspecified,
248+
maxPopupWidth: Dp = Dp.Unspecified,
249+
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
250+
style: ComboBoxStyle = JewelTheme.comboBoxStyle,
251+
onArrowDownPress: () -> Unit = {},
252+
onArrowUpPress: () -> Unit = {},
253+
popupManager: PopupManager = remember { PopupManager() },
254+
horizontalPopupAlignment: Alignment.Horizontal = Alignment.Start,
255+
popupStyle: PopupContainerStyle = JewelTheme.popupContainerStyle,
256+
popupPositionProvider: PopupPositionProvider =
257+
AnchorVerticalMenuPositionProvider(
258+
contentOffset = popupStyle.metrics.offset,
259+
contentMargin = popupStyle.metrics.menuMargin,
260+
alignment = horizontalPopupAlignment,
261+
density = LocalDensity.current,
262+
),
218263
) {
219264
var chevronHovered by remember { mutableStateOf(false) }
220265

@@ -348,8 +393,10 @@ public fun ComboBox(
348393
.widthIn(min = comboBoxWidth, max = maxPopupWidth.coerceAtLeast(comboBoxWidth))
349394
.then(popupModifier)
350395
.onClick { popupManager.setPopupVisible(false) },
351-
horizontalAlignment = Alignment.Start,
396+
horizontalAlignment = horizontalPopupAlignment,
352397
popupProperties = PopupProperties(focusable = false),
398+
style = popupStyle,
399+
popupPositionProvider = popupPositionProvider,
353400
content = popupContent,
354401
)
355402
}

0 commit comments

Comments
 (0)