Skip to content

Commit 550eb20

Browse files
[JEWEL-1184] Add DefaultSplitButton Without Jewel's Dropdown
1 parent d73f672 commit 550eb20

6 files changed

Lines changed: 324 additions & 7 deletions

File tree

platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/popup/JDialogRenderer.kt

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,19 +296,53 @@ private fun JPopupImpl(
296296
// - The current properties allow for the popup to be dismissed on click outside;
297297
// - The event must be a "WINDOW_LOST_FOCUS" event;
298298
// - The window receiving the focus should not be a child of this dialog;
299+
// - The window receiving the focus must not be the parent window: when the user clicks back in the parent
300+
// window, WINDOW_LOST_FOCUS fires during MOUSE_PRESSED (before Compose processes the event). Dismissing
301+
// here would cause the popup to close and immediately reopen when a toggle button (e.g., chevron) is
302+
// clicked. Instead, we handle this case on MOUSE_RELEASED via invokeLater (see below), which ensures
303+
// Compose has already processed the click before the dismiss runs.
299304
fun shouldDismissPopup(event: WindowEvent, dialog: Window, currentProperties: PopupProperties): Boolean =
300305
event.window == dialog &&
301306
currentProperties.focusable &&
302307
currentProperties.dismissOnClickOutside &&
303308
event.id == WindowEvent.WINDOW_LOST_FOCUS &&
304-
!dialog.isAncestorOf(event.oppositeWindow)
309+
!dialog.isAncestorOf(event.oppositeWindow) &&
310+
event.oppositeWindow != window
311+
312+
// The following conditions should be considered for deferring the dismiss on MOUSE_RELEASED:
313+
// - The popup is focusable (non-focusable case is handled by shouldDismissPopup(MouseEvent) above);
314+
// - The current properties allow for the popup to be dismissed on click outside;
315+
// - The event must be a MOUSE_RELEASED event — we defer to after release so that Compose has already
316+
// processed the full click gesture (press + release) before the dismiss runs;
317+
// - The event is not triggered in a child component (like menus/submenus);
318+
// - The mouse position is outside the popup bounds;
319+
// - The click originates from the parent window: other windows are handled by shouldDismissPopup(WindowEvent)
320+
// via WINDOW_LOST_FOCUS, so we restrict this path to avoid double-dismissal.
321+
fun shouldDeferDismissOnMouseReleased(
322+
event: MouseEvent,
323+
dialog: Window,
324+
currentProperties: PopupProperties,
325+
): Boolean =
326+
currentProperties.focusable &&
327+
currentProperties.dismissOnClickOutside &&
328+
event.id == MouseEvent.MOUSE_RELEASED &&
329+
!dialog.isAncestorOf(event.component) &&
330+
!dialog.bounds.contains(event.locationOnScreen) &&
331+
SwingUtilities.getWindowAncestor(event.component) == window
305332

306333
val listener = AWTEventListener { event ->
307334
when (event) {
308335
is MouseEvent -> {
309336
if (shouldDismissPopup(event, dialog, currentProperties)) {
310337
currentOnDismissRequest?.invoke()
311338
}
339+
// For focusable popups, WINDOW_LOST_FOCUS is skipped when the user clicks in the parent window
340+
// (see shouldDismissPopup(WindowEvent) above). We handle dismissal on MOUSE_RELEASED instead.
341+
// invokeLater queues after the current event finishes dispatching, so Compose has already
342+
// processed the click (e.g., a toggle button's onClick) by the time the dismiss runs.
343+
if (shouldDeferDismissOnMouseReleased(event, dialog, currentProperties)) {
344+
SwingUtilities.invokeLater { currentOnDismissRequest?.invoke() }
345+
}
312346
}
313347
is WindowEvent -> {
314348
if (shouldDismissPopup(event, dialog, currentProperties)) {

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

Lines changed: 74 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,17 @@ 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.Dp
33+
import androidx.compose.ui.unit.DpOffset
34+
import androidx.compose.ui.unit.IntOffset
2635
import androidx.compose.ui.unit.dp
36+
import androidx.compose.ui.unit.round
37+
import androidx.compose.ui.window.PopupProperties
38+
import androidx.compose.ui.window.rememberComponentRectPositionProvider
2739
import org.jetbrains.jewel.foundation.theme.JewelTheme
2840
import org.jetbrains.jewel.foundation.util.JewelLogger
2941
import org.jetbrains.jewel.ui.component.ActionButton
@@ -39,6 +51,7 @@ import org.jetbrains.jewel.ui.component.MenuScope
3951
import org.jetbrains.jewel.ui.component.OutlinedButton
4052
import org.jetbrains.jewel.ui.component.OutlinedSlimButton
4153
import org.jetbrains.jewel.ui.component.OutlinedSplitButton
54+
import org.jetbrains.jewel.ui.component.Popup
4255
import org.jetbrains.jewel.ui.component.SelectableIconActionButton
4356
import org.jetbrains.jewel.ui.component.SelectableIconButton
4457
import org.jetbrains.jewel.ui.component.Text
@@ -54,6 +67,7 @@ import org.jetbrains.jewel.ui.painter.badge.DotBadgeShape
5467
import org.jetbrains.jewel.ui.painter.hints.Badge
5568
import org.jetbrains.jewel.ui.painter.hints.Selected
5669
import org.jetbrains.jewel.ui.painter.hints.Stroke
70+
import org.jetbrains.jewel.ui.theme.defaultSplitButtonStyle
5771
import org.jetbrains.jewel.ui.theme.outlinedSplitButtonStyle
5872
import org.jetbrains.jewel.ui.theme.transparentIconButtonStyle
5973

@@ -399,6 +413,15 @@ private fun SplitButtons() {
399413
},
400414
)
401415

416+
SplitButtonWithComposePopup(JewelTheme.defaultSplitButtonStyle.button.metrics.focusOutlineExpand) {
417+
mod,
418+
expanded,
419+
onChange ->
420+
DefaultSplitButton(modifier = mod, onClick = {}, expanded = expanded, onExpandedChange = onChange) {
421+
SingleLineText("Split button w/ Compose Popup")
422+
}
423+
}
424+
402425
Tooltip(
403426
tooltip = {
404427
Text("This button is intentionally too narrow, to check that it works when space-constrained.")
@@ -420,6 +443,15 @@ private fun SplitButtons() {
420443
menuContent = { blankNotice() },
421444
modifier = Modifier.height(JewelTheme.outlinedSplitButtonStyle.button.metrics.minSize.height * 1.25f),
422445
)
446+
447+
SplitButtonWithComposePopup(JewelTheme.outlinedSplitButtonStyle.button.metrics.focusOutlineExpand) {
448+
mod,
449+
expanded,
450+
onChange ->
451+
OutlinedSplitButton(modifier = mod, onClick = {}, expanded = expanded, onExpandedChange = onChange) {
452+
SingleLineText("Outlined Split button w/ Compose Popup")
453+
}
454+
}
423455
}
424456
}
425457
}
@@ -432,3 +464,45 @@ private fun MenuScope.blankNotice() {
432464
private fun SingleLineText(text: String) {
433465
Text(text, overflow = TextOverflow.Ellipsis, maxLines = 1)
434466
}
467+
468+
@Composable
469+
private fun SplitButtonWithComposePopup(
470+
expandedOutline: Dp,
471+
button: @Composable (modifier: Modifier, expanded: Boolean, onExpandedChange: (Boolean) -> Unit) -> Unit,
472+
) {
473+
var showPopup by remember { mutableStateOf(false) }
474+
var position by remember { mutableStateOf(IntOffset.Zero) }
475+
val density = LocalDensity.current
476+
477+
Box {
478+
button(
479+
Modifier.onGloballyPositioned { position = it.positionInParent().round() },
480+
showPopup,
481+
{ showPopup = it },
482+
)
483+
if (showPopup) {
484+
Popup(
485+
popupPositionProvider =
486+
rememberComponentRectPositionProvider(
487+
offset =
488+
DpOffset(
489+
with(density) { position.x.toDp() },
490+
with(density) { position.y.toDp() } + expandedOutline,
491+
)
492+
),
493+
onDismissRequest = { showPopup = false },
494+
properties = PopupProperties(focusable = true),
495+
) {
496+
Box(
497+
modifier =
498+
Modifier.background(JewelTheme.globalColors.panelBackground, RoundedCornerShape(8.dp))
499+
.border(3.dp, JewelTheme.globalColors.outlines.warning, RoundedCornerShape(8.dp))
500+
.padding(16.dp),
501+
contentAlignment = Alignment.Center,
502+
) {
503+
Text("Compose Popup")
504+
}
505+
}
506+
}
507+
}
508+
}

platform/jewel/ui/api-dump.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,14 @@ f:org.jetbrains.jewel.ui.component.ButtonKt
138138
- sf:DefaultSlimButton(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
139139
- 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
140140
- 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
141+
- sf:DefaultSplitButton(kotlin.jvm.functions.Function0,Z,kotlin.jvm.functions.Function1,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.Function2,androidx.compose.runtime.Composer,I,I):V
141142
- 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
142143
- sf:DefaultSplitButton-zTVvCW4(kotlin.jvm.functions.Function0,kotlin.jvm.functions.Function2,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
143144
- sf:OutlinedButton(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
144145
- sf:OutlinedSlimButton(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
145146
- bsf:OutlinedSplitButton(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
146147
- bsf:OutlinedSplitButton(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
148+
- sf:OutlinedSplitButton(kotlin.jvm.functions.Function0,Z,kotlin.jvm.functions.Function1,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.Function2,androidx.compose.runtime.Composer,I,I):V
147149
- sf:OutlinedSplitButton-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
148150
- sf:OutlinedSplitButton-zTVvCW4(kotlin.jvm.functions.Function0,kotlin.jvm.functions.Function2,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
149151
f:org.jetbrains.jewel.ui.component.ButtonState

0 commit comments

Comments
 (0)