Skip to content

Commit 6e7f518

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 18fbb9b commit 6e7f518

File tree

9 files changed

+580
-23
lines changed

9 files changed

+580
-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
@@ -28,7 +28,9 @@ import org.jetbrains.jewel.ui.component.ListComboBox
2828
import org.jetbrains.jewel.ui.component.PopupContainer
2929
import org.jetbrains.jewel.ui.component.PopupManager
3030
import org.jetbrains.jewel.ui.component.SimpleListItem
31+
import org.jetbrains.jewel.ui.component.SpeedSearchArea
3132
import org.jetbrains.jewel.ui.component.Text
33+
import org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBox
3234
import org.jetbrains.jewel.ui.disabledAppearance
3335
import org.jetbrains.jewel.ui.icon.IconKey
3436
import org.jetbrains.jewel.ui.icons.AllIconsKeys
@@ -87,7 +89,7 @@ public fun ComboBoxes(modifier: Modifier = Modifier) {
8789
private fun ListComboBoxes() {
8890
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
8991
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
90-
Text("String-based API, enabled")
92+
Text("String-based API")
9193
var selectedIndex by remember { mutableIntStateOf(2) }
9294
val selectedItemText = if (selectedIndex >= 0) stringItems[selectedIndex] else "[none]"
9395
InfoText(text = "Selected item: $selectedItemText")
@@ -102,7 +104,7 @@ private fun ListComboBoxes() {
102104
}
103105

104106
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
105-
Text("Generics-based API, enabled")
107+
Text("Generics-based API")
106108
var selectedIndex by remember { mutableIntStateOf(2) }
107109
val selectedItemText = if (selectedIndex >= 0) languageOptions[selectedIndex].name else "[none]"
108110
InfoText(text = "Selected item: $selectedItemText")
@@ -127,22 +129,22 @@ private fun ListComboBoxes() {
127129
}
128130

129131
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
130-
Text("String-based API, disabled")
132+
Text("Speed Search API")
131133
var selectedIndex by remember { mutableIntStateOf(2) }
132134
val selectedItemText = if (selectedIndex >= 0) stringItems[selectedIndex] else "[none]"
133135
InfoText(text = "Selected item: $selectedItemText")
134136

135-
ListComboBox(
136-
items = stringItems,
137-
selectedIndex = selectedIndex,
138-
onSelectedItemChange = { index -> selectedIndex = index },
139-
modifier = Modifier.widthIn(max = 200.dp),
140-
enabled = false,
141-
)
137+
SpeedSearchArea(Modifier.widthIn(max = 200.dp)) {
138+
SpeedSearchableComboBox(
139+
items = stringItems,
140+
selectedIndex = selectedIndex,
141+
onSelectedItemChange = { index -> selectedIndex = index },
142+
)
143+
}
142144
}
143145

144146
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
145-
Text("Generics-based API, disabled")
147+
Text("Disabled")
146148
var selectedIndex by remember { mutableIntStateOf(2) }
147149
val selectedItemText = if (selectedIndex >= 0) languageOptions[selectedIndex].name else "[none]"
148150
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(modifier = Modifier.testTag("SpeedSearchArea")) {
192+
SpeedSearchArea(Modifier.widthIn(max = 200.dp).focusRequester(focusRequester)) {
193+
SpeedSearchableComboBox(
194+
items = entries,
195+
selectedIndex = selectedIndex,
196+
onSelectedItemChange = { index -> selectedIndex = index },
197+
modifier = Modifier.testTag("ComboBox"),
198+
)
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
@@ -49,10 +49,12 @@ f:org.jetbrains.jewel.ui.component.SpeedSearchAreaKt
4949
- a:clearSearch():Z
5050
- a:getHasMatches():Z
5151
- a:getMatchingIndexes():java.util.List
52+
- a:getPosition():androidx.compose.ui.Alignment$Vertical
5253
- a:getSearchText():java.lang.String
5354
- a:hideSearch():Z
5455
- a:isVisible():Z
5556
- a:matchResultForText(java.lang.String):org.jetbrains.jewel.foundation.search.SpeedSearchMatcher$MatchResult
57+
- a:setPosition(androidx.compose.ui.Alignment$Vertical):V
5658
f:org.jetbrains.jewel.ui.component.TabStripKt
5759
- *sf:TabStrip(java.util.List,org.jetbrains.jewel.ui.component.styling.TabStyle,androidx.compose.ui.Modifier,Z,androidx.compose.runtime.Composer,I,I):V
5860
f:org.jetbrains.jewel.ui.component.TextAreaKt
@@ -66,6 +68,9 @@ f:org.jetbrains.jewel.ui.component.TypographyKt
6668
f:org.jetbrains.jewel.ui.component.search.HighlightKt
6769
- *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
6870
- *sf:highlightTextSearch(java.lang.CharSequence,org.jetbrains.jewel.ui.component.NodeSearchMatchState,androidx.compose.runtime.Composer,I,I):androidx.compose.ui.text.AnnotatedString
71+
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBoxKt
72+
- *sf:SpeedSearchableComboBox-9UqVb8Q(org.jetbrains.jewel.ui.component.SpeedSearchScope,java.util.List,I,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,kotlin.jvm.functions.Function2,Z,org.jetbrains.jewel.ui.Outline,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
73+
- *sf:SpeedSearchableComboBox-_zuB-KE(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,Z,org.jetbrains.jewel.ui.Outline,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
6974
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableLazyColumnKt
7075
- *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
7176
*: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
@@ -956,6 +956,7 @@ org.jetbrains.jewel.ui.component.banner.BannerIconActionScope
956956
org.jetbrains.jewel.ui.component.banner.BannerLinkActionScope
957957
- a:action(java.lang.String,kotlin.jvm.functions.Function0):V
958958
f:org.jetbrains.jewel.ui.component.search.HighlightKt
959+
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBoxKt
959960
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableLazyColumnKt
960961
f:org.jetbrains.jewel.ui.component.search.SpeedSearchableTreeKt
961962
f:org.jetbrains.jewel.ui.component.styling.BannerColors

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.TextStyle
4545
import androidx.compose.ui.text.style.TextOverflow
4646
import androidx.compose.ui.unit.Dp
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.
@@ -151,6 +154,46 @@ public fun ComboBox(
151154
onArrowDownPress: () -> Unit = {},
152155
onArrowUpPress: () -> Unit = {},
153156
popupManager: PopupManager = remember { PopupManager() },
157+
) {
158+
ComboBoxImpl(
159+
labelContent = labelContent,
160+
popupContent = popupContent,
161+
modifier = modifier,
162+
popupModifier = popupModifier,
163+
enabled = enabled,
164+
outline = outline,
165+
maxPopupHeight = maxPopupHeight,
166+
interactionSource = interactionSource,
167+
style = style,
168+
onArrowDownPress = onArrowDownPress,
169+
onArrowUpPress = onArrowUpPress,
170+
popupManager = popupManager,
171+
)
172+
}
173+
174+
@Composable
175+
internal fun ComboBoxImpl(
176+
labelContent: @Composable (() -> Unit),
177+
popupContent: @Composable (() -> Unit),
178+
modifier: Modifier = Modifier,
179+
popupModifier: Modifier = Modifier,
180+
enabled: Boolean = true,
181+
outline: Outline = Outline.None,
182+
maxPopupHeight: Dp = Dp.Unspecified,
183+
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
184+
style: ComboBoxStyle = JewelTheme.comboBoxStyle,
185+
onArrowDownPress: () -> Unit = {},
186+
onArrowUpPress: () -> Unit = {},
187+
popupManager: PopupManager = remember { PopupManager() },
188+
horizontalPopupAlignment: Alignment.Horizontal = Alignment.Start,
189+
popupStyle: PopupContainerStyle = JewelTheme.popupContainerStyle,
190+
popupPositionProvider: PopupPositionProvider =
191+
AnchorVerticalMenuPositionProvider(
192+
contentOffset = popupStyle.metrics.offset,
193+
contentMargin = popupStyle.metrics.menuMargin,
194+
alignment = horizontalPopupAlignment,
195+
density = LocalDensity.current,
196+
),
154197
) {
155198
var chevronHovered by remember { mutableStateOf(false) }
156199

@@ -284,8 +327,10 @@ public fun ComboBox(
284327
.heightIn(max = maxHeight)
285328
.width(comboBoxWidth)
286329
.onClick { popupManager.setPopupVisible(false) },
287-
horizontalAlignment = Alignment.Start,
330+
horizontalAlignment = horizontalPopupAlignment,
288331
popupProperties = PopupProperties(focusable = false),
332+
style = popupStyle,
333+
popupPositionProvider = popupPositionProvider,
289334
content = popupContent,
290335
)
291336
}

0 commit comments

Comments
 (0)