Skip to content

Commit 8834cd8

Browse files
[JEWEL-1184] Add DefaultSplitButton Without Jewel's Dropdown
1 parent 74b45d7 commit 8834cd8

4 files changed

Lines changed: 249 additions & 6 deletions

File tree

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
package org.jetbrains.jewel.samples.showcase.components
44

5+
import androidx.compose.foundation.background
6+
import androidx.compose.foundation.border
57
import androidx.compose.foundation.layout.Arrangement
68
import androidx.compose.foundation.layout.Box
79
import androidx.compose.foundation.layout.Column
@@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.height
1315
import androidx.compose.foundation.layout.padding
1416
import androidx.compose.foundation.layout.size
1517
import androidx.compose.foundation.layout.width
18+
import androidx.compose.foundation.shape.RoundedCornerShape
1619
import androidx.compose.runtime.Composable
1720
import androidx.compose.runtime.getValue
1821
import androidx.compose.runtime.mutableIntStateOf
@@ -22,8 +25,16 @@ import androidx.compose.runtime.setValue
2225
import androidx.compose.ui.Alignment
2326
import androidx.compose.ui.Modifier
2427
import androidx.compose.ui.graphics.Color
28+
import androidx.compose.ui.layout.onGloballyPositioned
29+
import androidx.compose.ui.layout.positionInParent
30+
import androidx.compose.ui.platform.LocalDensity
2531
import androidx.compose.ui.text.style.TextOverflow
32+
import androidx.compose.ui.unit.IntOffset
33+
import androidx.compose.ui.unit.IntSize
2634
import androidx.compose.ui.unit.dp
35+
import androidx.compose.ui.unit.round
36+
import androidx.compose.ui.window.Popup
37+
import androidx.compose.ui.window.PopupProperties
2738
import org.jetbrains.jewel.foundation.theme.JewelTheme
2839
import org.jetbrains.jewel.foundation.util.JewelLogger
2940
import org.jetbrains.jewel.ui.component.ActionButton
@@ -52,6 +63,7 @@ import org.jetbrains.jewel.ui.painter.badge.DotBadgeShape
5263
import org.jetbrains.jewel.ui.painter.hints.Badge
5364
import org.jetbrains.jewel.ui.painter.hints.Selected
5465
import org.jetbrains.jewel.ui.painter.hints.Stroke
66+
import org.jetbrains.jewel.ui.theme.defaultSplitButtonStyle
5567
import org.jetbrains.jewel.ui.theme.outlinedSplitButtonStyle
5668
import org.jetbrains.jewel.ui.theme.transparentIconButtonStyle
5769

@@ -380,6 +392,16 @@ private fun SplitButtons() {
380392
},
381393
)
382394

395+
val defaultExpandedOutline =
396+
with(LocalDensity.current) {
397+
JewelTheme.defaultSplitButtonStyle.button.metrics.focusOutlineExpand.roundToPx()
398+
}
399+
SplitButtonWithComposePopup(defaultExpandedOutline) { mod, expanded, onChange ->
400+
DefaultSplitButton(modifier = mod, onClick = {}, expanded = expanded, onExpandedChange = onChange) {
401+
SingleLineText("Split button w/ Compose Popup")
402+
}
403+
}
404+
383405
Tooltip(
384406
tooltip = {
385407
Text("This button is intentionally too narrow, to check that it works when space-constrained.")
@@ -401,6 +423,16 @@ private fun SplitButtons() {
401423
menuContent = { blankNotice() },
402424
modifier = Modifier.height(JewelTheme.outlinedSplitButtonStyle.button.metrics.minSize.height * 1.25f),
403425
)
426+
427+
val outlinedExpandedOutline =
428+
with(LocalDensity.current) {
429+
JewelTheme.outlinedSplitButtonStyle.button.metrics.focusOutlineExpand.roundToPx()
430+
}
431+
SplitButtonWithComposePopup(outlinedExpandedOutline) { mod, expanded, onChange ->
432+
OutlinedSplitButton(modifier = mod, onClick = {}, expanded = expanded, onExpandedChange = onChange) {
433+
SingleLineText("Outlined Split button w/ Compose Popup")
434+
}
435+
}
404436
}
405437
}
406438
}
@@ -413,3 +445,43 @@ private fun MenuScope.blankNotice() {
413445
private fun SingleLineText(text: String) {
414446
Text(text, overflow = TextOverflow.Ellipsis, maxLines = 1)
415447
}
448+
449+
@Composable
450+
private fun SplitButtonWithComposePopup(
451+
expandedOutlinePx: Int,
452+
button: @Composable (modifier: Modifier, expanded: Boolean, onExpandedChange: (Boolean) -> Unit) -> Unit,
453+
) {
454+
var showPopup by remember { mutableStateOf(false) }
455+
var size by remember { mutableStateOf(IntSize.Zero) }
456+
var position by remember { mutableStateOf(IntOffset.Zero) }
457+
Box {
458+
button(
459+
Modifier.onGloballyPositioned {
460+
size = it.size
461+
position = it.positionInParent().round()
462+
},
463+
showPopup,
464+
{
465+
println("Expanded state changed to $it")
466+
showPopup = it
467+
},
468+
)
469+
if (showPopup) {
470+
Popup(
471+
offset = IntOffset(position.x, position.y + size.height + expandedOutlinePx),
472+
onDismissRequest = { showPopup = false },
473+
properties = PopupProperties(focusable = true),
474+
) {
475+
Box(
476+
modifier =
477+
Modifier.background(JewelTheme.globalColors.panelBackground, RoundedCornerShape(8.dp))
478+
.border(3.dp, JewelTheme.globalColors.outlines.warning, RoundedCornerShape(8.dp))
479+
.padding(16.dp),
480+
contentAlignment = Alignment.Center,
481+
) {
482+
Text("Compose Popup")
483+
}
484+
}
485+
}
486+
}
487+
}

platform/jewel/ui/api-dump.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ f:org.jetbrains.jewel.ui.component.BannerKt
132132
- sf:WarningDefaultBanner(java.lang.String,androidx.compose.ui.Modifier,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function3,org.jetbrains.jewel.ui.component.styling.DefaultBannerStyle,androidx.compose.ui.text.TextStyle,androidx.compose.runtime.Composer,I,I):V
133133
f:org.jetbrains.jewel.ui.component.ButtonKt
134134
- sf:DefaultButton(kotlin.jvm.functions.Function0,androidx.compose.ui.Modifier,Z,androidx.compose.foundation.interaction.MutableInteractionSource,org.jetbrains.jewel.ui.component.styling.ButtonStyle,androidx.compose.ui.text.TextStyle,kotlin.jvm.functions.Function2,androidx.compose.runtime.Composer,I,I):V
135+
- sf:DefaultSplitButton(kotlin.jvm.functions.Function0,androidx.compose.ui.Modifier,Z,androidx.compose.foundation.interaction.MutableInteractionSource,org.jetbrains.jewel.ui.component.styling.SplitButtonStyle,androidx.compose.ui.text.TextStyle,kotlin.jvm.functions.Function0,kotlin.jvm.functions.Function2,androidx.compose.runtime.Composer,I,I):V
135136
- bsf:DefaultSplitButton(kotlin.jvm.functions.Function0,kotlin.jvm.functions.Function0,androidx.compose.ui.Modifier,Z,androidx.compose.foundation.interaction.MutableInteractionSource,org.jetbrains.jewel.ui.component.styling.SplitButtonStyle,androidx.compose.ui.text.TextStyle,org.jetbrains.jewel.ui.component.styling.MenuStyle,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I):V
136137
- bsf:DefaultSplitButton(kotlin.jvm.functions.Function0,kotlin.jvm.functions.Function0,androidx.compose.ui.Modifier,Z,androidx.compose.foundation.interaction.MutableInteractionSource,org.jetbrains.jewel.ui.component.styling.SplitButtonStyle,androidx.compose.ui.text.TextStyle,org.jetbrains.jewel.ui.component.styling.MenuStyle,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function2,androidx.compose.runtime.Composer,I,I):V
137138
- sf:DefaultSplitButton-zTVvCW4(kotlin.jvm.functions.Function0,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,androidx.compose.ui.Modifier,F,F,Z,androidx.compose.foundation.interaction.MutableInteractionSource,org.jetbrains.jewel.ui.component.styling.SplitButtonStyle,androidx.compose.ui.text.TextStyle,org.jetbrains.jewel.ui.component.styling.MenuStyle,kotlin.jvm.functions.Function0,kotlin.jvm.functions.Function2,androidx.compose.runtime.Composer,I,I,I):V

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

Lines changed: 147 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,70 @@ public fun OutlinedSplitButton(
231231
)
232232
}
233233

234+
/**
235+
* A split button combining a primary action **without** a dropdown menu, using an outlined visual style.
236+
*
237+
* Similar to [DefaultSplitButton] but with an outlined visual treatment. Provides two interactive areas: the main
238+
* button area for the primary action and a chevron section whose expanded state is fully controlled by the caller.
239+
*
240+
* **IMPORTANT:** This overload does NOT manage any popup/dropdown. You are responsible for handling the lifecycle,
241+
* positioning, and disposal of any UI based on [expanded].
242+
*
243+
* **Popup toggle behavior:** When using a Compose `Popup`, prefer `PopupProperties(focusable = true)`. Non-focusable
244+
* popups dismiss on pointer down, which fires before the chevron click handler and causes the popup to immediately
245+
* re-open when clicking the chevron to close it. Focusable popups dismiss via focus loss, which fires after the click
246+
* is processed, allowing the chevron to correctly toggle the state.
247+
*
248+
* **Guidelines:** [on IJP SDK webhelp](https://plugins.jetbrains.com/docs/intellij/split-button.html)
249+
*
250+
* **Usage example:**
251+
* [`Buttons.kt`](https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Buttons.kt)
252+
*
253+
* **Swing equivalent:**
254+
* [`JBOptionButton`](https://github.com/JetBrains/intellij-community/tree/idea/243.22562.145/platform/platform-api/src/com/intellij/ui/components/JBOptionButton.kt)
255+
*
256+
* @param onClick Will be called when the user clicks the main button area
257+
* @param expanded Controls the visual expanded (active) state of the chevron area. Typically reflects whether a popup
258+
* or secondary UI is currently visible
259+
* @param onExpandedChange Called when the chevron is clicked or the down arrow key is pressed. The caller should update
260+
* [expanded] in response
261+
* @param modifier Modifier to be applied to the button
262+
* @param enabled Controls the enabled state of the button. When false, the button will not be clickable
263+
* @param interactionSource An optional [MutableInteractionSource] for observing and emitting [Interaction]s for this
264+
* button
265+
* @param style The visual styling configuration for the split button including colors, metrics and layout parameters
266+
* @param textStyle The typography style to be applied to the button's text content
267+
* @param content The content to be displayed in the main button area
268+
* @see com.intellij.ui.components.JBOptionButton
269+
*/
270+
@Composable
271+
public fun OutlinedSplitButton(
272+
onClick: () -> Unit,
273+
expanded: Boolean,
274+
onExpandedChange: (Boolean) -> Unit,
275+
modifier: Modifier = Modifier,
276+
enabled: Boolean = true,
277+
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
278+
style: SplitButtonStyle = JewelTheme.outlinedSplitButtonStyle,
279+
textStyle: TextStyle = JewelTheme.defaultTextStyle,
280+
content: @Composable () -> Unit,
281+
) {
282+
SplitButtonImpl(
283+
onClick = onClick,
284+
secondaryOnClick = {},
285+
enabled = enabled,
286+
interactionSource = interactionSource,
287+
style = style,
288+
textStyle = textStyle,
289+
menuStyle = null,
290+
isDefault = false,
291+
modifier = modifier,
292+
expanded = expanded,
293+
onExpandedChange = onExpandedChange,
294+
content = content,
295+
)
296+
}
297+
234298
/**
235299
* A split button combining a primary action with a dropdown menu, using an outlined visual style.
236300
*
@@ -479,6 +543,70 @@ public fun DefaultSplitButton(
479543
)
480544
}
481545

546+
/**
547+
* A split button combining a primary action **without** a dropdown menu, using the default visual style.
548+
*
549+
* Provides two interactive areas: the main button area for the primary action and a chevron section whose expanded
550+
* state is fully controlled by the caller.
551+
*
552+
* **IMPORTANT:** This overload does NOT manage any popup/dropdown. You are responsible for handling the lifecycle,
553+
* positioning, and disposal of any UI based on [expanded].
554+
*
555+
* **Popup toggle behavior:** When using a Compose `Popup`, prefer `PopupProperties(focusable = true)`. Non-focusable
556+
* popups dismiss on pointer down, which fires before the chevron click handler and causes the popup to immediately
557+
* re-open when clicking the chevron to close it. Focusable popups dismiss via focus loss, which fires after the click
558+
* is processed, allowing the chevron to correctly toggle the state.
559+
*
560+
* **Guidelines:** [on IJP SDK webhelp](https://plugins.jetbrains.com/docs/intellij/split-button.html)
561+
*
562+
* **Usage example:**
563+
* [`Buttons.kt`](https://github.com/JetBrains/intellij-community/blob/master/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/Buttons.kt)
564+
*
565+
* **Swing equivalent:**
566+
* [`JBOptionButton`](https://github.com/JetBrains/intellij-community/tree/idea/243.22562.145/platform/platform-api/src/com/intellij/ui/components/JBOptionButton.kt)
567+
*
568+
* @param onClick Will be called when the user clicks the main button area
569+
* @param expanded Controls the visual expanded (active) state of the chevron area. Typically reflects whether a popup
570+
* or secondary UI is currently visible
571+
* @param onExpandedChange Called when the chevron is clicked or the down arrow key is pressed. The caller should update
572+
* [expanded] in response
573+
* @param modifier Modifier to be applied to the button
574+
* @param enabled Controls the enabled state of the button. When false, the button will not be clickable
575+
* @param interactionSource An optional [MutableInteractionSource] for observing and emitting [Interaction]s for this
576+
* button
577+
* @param style The visual styling configuration for the split button including colors, metrics and layout parameters
578+
* @param textStyle The typography style to be applied to the button's text content
579+
* @param content The content to be displayed in the main button area
580+
* @see com.intellij.ui.components.JBOptionButton
581+
*/
582+
@Composable
583+
public fun DefaultSplitButton(
584+
onClick: () -> Unit,
585+
expanded: Boolean,
586+
onExpandedChange: (Boolean) -> Unit,
587+
modifier: Modifier = Modifier,
588+
enabled: Boolean = true,
589+
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
590+
style: SplitButtonStyle = JewelTheme.defaultSplitButtonStyle,
591+
textStyle: TextStyle = JewelTheme.defaultTextStyle,
592+
content: @Composable () -> Unit,
593+
) {
594+
SplitButtonImpl(
595+
onClick = onClick,
596+
secondaryOnClick = {},
597+
enabled = enabled,
598+
interactionSource = interactionSource,
599+
style = style,
600+
textStyle = textStyle,
601+
menuStyle = null,
602+
isDefault = true,
603+
modifier = modifier,
604+
expanded = expanded,
605+
onExpandedChange = onExpandedChange,
606+
content = content,
607+
)
608+
}
609+
482610
/**
483611
* A split button combining a primary action with a dropdown menu, using the default visual style.
484612
*
@@ -672,14 +800,16 @@ private fun SplitButtonImpl(
672800
interactionSource: MutableInteractionSource,
673801
style: SplitButtonStyle,
674802
textStyle: TextStyle,
675-
menuStyle: MenuStyle,
803+
menuStyle: MenuStyle?,
676804
isDefault: Boolean,
677805
modifier: Modifier = Modifier,
678806
popupModifier: Modifier = Modifier,
679807
maxPopupHeight: Dp = Dp.Unspecified,
680808
maxPopupWidth: Dp = Dp.Unspecified,
681809
secondaryContent: @Composable (() -> Unit)? = null,
682810
secondaryContentMenu: (MenuScope.() -> Unit)? = null,
811+
expanded: Boolean = false, // Used only by split buttons that don't use Jewel's popup
812+
onExpandedChange: ((Boolean) -> Unit)? = null, // Used only by split buttons that don't use Jewel's popup
683813
content: @Composable () -> Unit,
684814
) {
685815
val density = LocalDensity.current
@@ -699,14 +829,19 @@ private fun SplitButtonImpl(
699829
.onFocusChanged {
700830
if (!it.isFocused) {
701831
popupVisible = false
832+
onExpandedChange?.invoke(false)
702833
}
703834
}
704835
.thenIf(enabled) {
705836
onPreviewKeyEvent { keyEvent ->
706837
if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
707838
when {
708839
keyEvent.key == Key.DirectionDown -> {
709-
popupVisible = true
840+
if (menuStyle != null) {
841+
popupVisible = true
842+
} else {
843+
onExpandedChange?.invoke(true)
844+
}
710845
true
711846
}
712847

@@ -716,7 +851,7 @@ private fun SplitButtonImpl(
716851
}
717852
.focusRequester(focusRequester),
718853
enabled = enabled,
719-
forceFocused = popupVisible,
854+
forceFocused = if (menuStyle != null) popupVisible else expanded,
720855
onStateChange = { state -> buttonState = state },
721856
interactionSource = interactionSource,
722857
style = style.button,
@@ -728,16 +863,21 @@ private fun SplitButtonImpl(
728863
enabled = enabled,
729864
isDefault = isDefault,
730865
onChevronClick = {
731-
secondaryOnClick()
732-
popupVisible = !popupVisible
866+
println("popupvisible: $popupVisible")
867+
if (menuStyle != null) {
868+
secondaryOnClick()
869+
popupVisible = !popupVisible
870+
} else {
871+
onExpandedChange?.invoke(!expanded)
872+
}
733873
if (!buttonState.isFocused) focusRequester.requestFocus()
734874
},
735875
modifier = Modifier.testTag("Jewel.SplitButton.SecondaryAction"),
736876
)
737877
},
738878
)
739879

740-
if (popupVisible && enabled) {
880+
if (popupVisible && enabled && menuStyle != null) {
741881
val splitButtonPopupModifier =
742882
Modifier.heightIn(max = maxPopupHeight)
743883
.widthIn(min = buttonWidth, max = maxPopupWidth.coerceAtLeast(buttonWidth))
@@ -758,6 +898,7 @@ private fun SplitButtonImpl(
758898
content = secondaryContentMenu,
759899
)
760900
}
901+
761902
secondaryContent != null -> {
762903
PopupContainer(
763904
modifier = splitButtonPopupModifier,

0 commit comments

Comments
 (0)