[JEWEL-1184] Add DefaultSplitButton Without Jewel's Dropdown#3408
[JEWEL-1184] Add DefaultSplitButton Without Jewel's Dropdown#3408DanielSouzaBertoldi wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Idk man Swing is hard. Any other alternative to properly position a Swing popup under a Composable is too complex imo. This example is not ideal for production purposes but it gets the point across
827595d to
2f1217d
Compare
2f1217d to
6d66d2f
Compare
rock3r
left a comment
There was a problem hiding this comment.
Maybe a silly question, but why was it not done for the outlined one too? But also, I see a problem with this approach.
The Problem, Concretely
In the PR's current design, SplitButtonImpl always toggles its internal popupVisible state on chevron click, and uses it for forceFocused (the visual "expanded" look). When menuStyle == null, no popup renders, but popupVisible stays true — making the button look stuck in a focused/expanded state. The only reset paths are:
- Focus stolen — works with
JBPopupFactoryand focusablePopups - Click chevron again — user has to know to double-click
- Escape key
This breaks with:
Popup(properties = PopupProperties(focusable = false))- Overlays / tooltips
- Pure state changes with no popup at all
- Anything in the same composition tree that doesn't move focus
Recommended Fix: Hoisted expanded state
Follow the standard Compose pattern used by ExposedDropdownMenuBox, DropdownMenu, etc.:
@Composable
public fun DefaultSplitButton(
onClick: () -> Unit,
expanded: Boolean, // drives forceFocused visual
onExpandedChange: (Boolean) -> Unit, // called on chevron click / Escape / arrow keys
modifier: Modifier = Modifier,
enabled: Boolean = true,
style: SplitButtonStyle = JewelTheme.defaultSplitButtonStyle,
textStyle: TextStyle = JewelTheme.defaultTextStyle,
content: @Composable () -> Unit,
)User controls the lifecycle entirely. The button is a pure function of expanded:
// Compose Popup
var showPopup by remember { mutableStateOf(false) }
DefaultSplitButton(
onClick = { runAction() },
expanded = showPopup,
onExpandedChange = { showPopup = it },
) { Text("Run") }
if (showPopup) {
Popup(onDismissRequest = { showPopup = false }) { ... }
}// Bridge with JBPopupFactory
DefaultSplitButton(
onClick = { runAction() },
expanded = showPopup,
onExpandedChange = { showPopup = it },
) { Text("Run") }
LaunchedEffect(showPopup) {
if (showPopup) {
val popup = JBPopupFactory.getInstance().createMessage("Choose")
popup.addListener(object : JBPopupListener {
override fun onClosed(event: LightweightWindowEvent) {
showPopup = false // any dismiss path works
}
})
popup.show(...)
}
}Implementation change in SplitButtonImpl is small — when menuStyle == null, use the external expanded for forceFocused and call onExpandedChange instead of toggling internal state:
onChevronClick = {
if (menuStyle != null) {
secondaryOnClick()
popupVisible = !popupVisible
} else {
onExpandedChange?.invoke(!expanded)
}
}
...
forceFocused = if (menuStyle != null) popupVisible else expandedThis keeps existing popup-managing overloads completely untouched while giving the no-popup variant clean, idiomatic state management.
6d66d2f to
8834cd8
Compare
|
@rock3r great catch! While testing this new API I found some minor bugs that took me a bit to squash for good. I also updated the screen recordings in the PR description to show both Default and Outlined with the applied suggestion + other fixes 😎 |
e48eb77 to
c4586d7
Compare
| * @param onClick Will be called when the user clicks the main button area | ||
| * @param expanded Controls the visual expanded (active) state of the chevron area. Typically reflects whether a popup | ||
| * or secondary UI is currently visible | ||
| * @param onExpandedChange Called when the chevron is clicked or the down arrow key is pressed. The caller should update |
There was a problem hiding this comment.
The docs currently explain the PopupProperties(focusable = true) recommendation, but I think they should also call out that this overload drives onExpandedChange(false) on focus loss, since that is part of the actual behavior/contract here.
Also worth validating and documenting how this behaves with native popups when JewelFlags.useCustomPopupRenderer is enabled, especially for focusable vs non-focusable popups. The current note is Compose-Popup-specific, but users may reasonably assume the same guidance applies there too.
There was a problem hiding this comment.
We had a bug in our native implementation. After a good amount of time trying to find a fix, I finally found one. Updated the code + added a new comment explaining the reasoning behind the fix. Thanks!
c4586d7 to
c9fa788
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| fun shouldDismissPopup(event: WindowEvent, dialog: Window, currentProperties: PopupProperties): Boolean = | ||
| event.window == dialog && | ||
| currentProperties.focusable && | ||
| currentProperties.dismissOnClickOutside && | ||
| event.id == WindowEvent.WINDOW_LOST_FOCUS && | ||
| !dialog.isAncestorOf(event.oppositeWindow) | ||
| !dialog.isAncestorOf(event.oppositeWindow) && | ||
| event.oppositeWindow != window |
There was a problem hiding this comment.
Our native popup has two problems.
With the old code, we had a premature dismiss of the popup.
Every click in the parent window triggered WINDOW_LOST_FOCUS followed by onDismissRequest() during MOUSE_PRESSED, before Compose sees anything.
We can verify this by adding a log in both MouseEvent and WindowEvent in the listener below:
[COMPOSE] button expandedchange
WINDOW_LOST_FOCUS, opposite=javax.swing.JDialog[dialog6,0,33,1x1,layout=java.awt.BorderLayout,MODELESS,title=,defaultCloseOperation=HIDE_ON_CLOSE,rootPane=javax.swing.JRootPane[,0,0,1x1,layout=javax.swing.JRootPane$RootLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=16777665,maximumSize=,minimumSize=,preferredSize=],rootPaneCheckingEnabled=true]
// Popup is now open. Let's click the chevron button again:
WINDOW_LOST_FOCUS, opposite=androidx.compose.ui.awt.ComposeWindow[frame0,568,186,800x600,invalid,layout=java.awt.BorderLayout,title=Jewel standalone sample,resizable,normal,defaultCloseOperation=DO_NOTHING_ON_CLOSE,rootPane=javax.swing.JRootPane[,0,0,800x600,layout=javax.swing.JRootPane$RootLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=16777673,maximumSize=,minimumSize=,preferredSize=],rootPaneCheckingEnabled=true]
MOUSE event: 501 (PRESSED=501, RELEASED=502)
[COMPOSE] button expandedchange
// Uh oh, the popup was dismissed on `WINDOW_LOST_FOCUS` and the click reached the chevron click again. The popup now appears once again:
WINDOW_LOST_FOCUS, opposite=javax.swing.JDialog[dialog7,0,33,1x1,layout=java.awt.BorderLayout,MODELESS,title=,defaultCloseOperation=HIDE_ON_CLOSE,rootPane=javax.swing.JRootPane[,0,0,1x1,layout=javax.swing.JRootPane$RootLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=16777665,maximumSize=,minimumSize=,preferredSize=],rootPaneCheckingEnabled=true]
// Rinse and repeat.
This step basically says "when the user clicks back in the parent window, don't dismiss here yet"
| !dialog.bounds.contains(event.locationOnScreen) | ||
| ) { | ||
| SwingUtilities.invokeLater { currentOnDismissRequest?.invoke() } | ||
| } |
There was a problem hiding this comment.
This is the second part. The first problem creates another that this one solves, so they are complimentary (weird thing, I know, but due to the complexity of AWT/Compose boundaries it is what it is, at least I couldn't find any other solution)
With the first fix, now clicking anywhere else in the parent window does nothing at all. WINDOW_LOST_FOCUS is always dismissed.
This fix basically says: "if the user releases the mouse outside the popup bounds, then dismiss". We use invokeLater {} so that Compose can process the click first.
Demo
| Before | After |
|---|---|
Screen.Recording.2026-03-16.at.11.00.39.mov |
Screen.Recording.2026-03-16.at.10.57.59.mov |
With these changes, we can correctly "sort" the function calls:
[COMPOSE] button expandedchange
WINDOW_LOST_FOCUS, opposite=javax.swing.JDialog[dialog0,0,33,1x1,layout=java.awt.BorderLayout,MODELESS,title=,defaultCloseOperation=HIDE_ON_CLOSE,rootPane=javax.swing.JRootPane[,0,0,1x1,layout=javax.swing.JRootPane$RootLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=16777665,maximumSize=,minimumSize=,preferredSize=],rootPaneCheckingEnabled=true]
// The chevron button was clicked, the Jewel window lost focus and now Popup is showing up! Let's click in the chevron button again (which is inside the Jewel window, mind you!)
WINDOW_LOST_FOCUS, opposite=androidx.compose.ui.awt.ComposeWindow[frame0,48,81,800x600,invalid,layout=java.awt.BorderLayout,title=Jewel standalone sample,resizable,normal,defaultCloseOperation=DO_NOTHING_ON_CLOSE,rootPane=javax.swing.JRootPane[,0,0,800x600,layout=javax.swing.JRootPane$RootLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=16777673,maximumSize=,minimumSize=,preferredSize=],rootPaneCheckingEnabled=true]
MOUSE event: 501 (PRESSED=501, RELEASED=502)
MOUSE event: 502 (PRESSED=501, RELEASED=502)
[COMPOSE] button expandedchange
// WINDOW_LOST_FOCUS is now skipped (oppositeWindow == ComposeWindow). The click reaches Compose, which processes it after MOUSE_RELEASED — the toggle correctly sets the popup to closed. No new popup opens, so no new WINDOW_LOST_FOCUS is triggered.
c9fa788 to
a7a5b32
Compare
|
You have used all of your free Bugbot PR reviews. To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial. |
|
Come on Cursor, not now 😭 |
a7a5b32 to
4894ae3
Compare
|
You have used all of your free Bugbot PR reviews. To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial. |
9df6eae to
4dfd8f0
Compare
4dfd8f0 to
a795c75
Compare
|
Ready to merge |
a795c75 to
550eb20
Compare
closes #3408 GitOrigin-RevId: af9b15e3067051230c956e3028608f158e2ef755


Context
Users want a way to use
DefaultSplitButtonwithout relying on Jewel's Popup/Dropdown. So, we created a overload that doesn't show a popup when the secondary action is clicked. This way, users can show their own popup/dropdown menu.Changes
DefaultSplitButtonwithout parameters such asmenuContent,popupModifier,maxPopupHeight,maxPopupWidthandmenuStyleEvidences
Screen.Recording.2026-02-27.at.11.01.08.mov
Screen.Recording.2026-02-27.at.11.04.42.mov
Release notes
New features
DefaultSplitButtonandOutlinedSplitButtoncomponents that you can use to render your own UI component, instead of relying on Jewel's PopupNote
Medium Risk
Moderate risk: introduces new public split button overloads and tweaks popup dismissal event handling, which could affect focus/dismiss behavior in edge cases across platforms.
Overview
Adds new controlled
DefaultSplitButtonandOutlinedSplitButtonoverloads that do not open Jewel’s dropdown, exposingexpanded/onExpandedChangeso callers can drive their own popup/menu UI.Refactors
SplitButtonImplto acceptmenuStyleas nullable and to route chevron/keyboard (Down) behavior either to the built-in popup (when a menu is provided) or to the new externalexpandedstate, and updates the API dump accordingly.Adjusts
JDialogRendererdismissal logic for focusable popups to avoid closing/reopening when clicking back into the parent window (defers dismissal toMOUSE_RELEASEDviainvokeLater), and updates the Compose and DevKit showcase samples to demonstrate using the new overloads with ComposePopupandJBPopup.Written by Cursor Bugbot for commit c9fa788. This will update automatically on new commits. Configure here.