Skip to content

[JEWEL-1184] Add DefaultSplitButton Without Jewel's Dropdown#3408

Closed
DanielSouzaBertoldi wants to merge 1 commit into
JetBrains:masterfrom
DanielSouzaBertoldi:dsb/JEWEL-1184
Closed

[JEWEL-1184] Add DefaultSplitButton Without Jewel's Dropdown#3408
DanielSouzaBertoldi wants to merge 1 commit into
JetBrains:masterfrom
DanielSouzaBertoldi:dsb/JEWEL-1184

Conversation

@DanielSouzaBertoldi
Copy link
Copy Markdown
Collaborator

@DanielSouzaBertoldi DanielSouzaBertoldi commented Feb 5, 2026

Context

Users want a way to use DefaultSplitButton without 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

  • Added a new DefaultSplitButton without parameters such as menuContent, popupModifier, maxPopupHeight, maxPopupWidth and menuStyle
  • Added examples in both Bridge showcase and Compose showcase so users can have an idea on how to use it

Evidences

Compose Bridge
Screen.Recording.2026-02-27.at.11.01.08.mov
Screen.Recording.2026-02-27.at.11.04.42.mov

Release notes

New features

  • Added a variation of both DefaultSplitButton and OutlinedSplitButton components that you can use to render your own UI component, instead of relying on Jewel's Popup

Note

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 DefaultSplitButton and OutlinedSplitButton overloads that do not open Jewel’s dropdown, exposing expanded/onExpandedChange so callers can drive their own popup/menu UI.

Refactors SplitButtonImpl to accept menuStyle as nullable and to route chevron/keyboard (Down) behavior either to the built-in popup (when a menu is provided) or to the new external expanded state, and updates the API dump accordingly.

Adjusts JDialogRenderer dismissal logic for focusable popups to avoid closing/reopening when clicking back into the parent window (defers dismissal to MOUSE_RELEASED via invokeLater), and updates the Compose and DevKit showcase samples to demonstrate using the new overloads with Compose Popup and JBPopup.

Written by Cursor Bugbot for commit c9fa788. This will update automatically on new commits. Configure here.

Comment on lines 170 to 175
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@DanielSouzaBertoldi DanielSouzaBertoldi force-pushed the dsb/JEWEL-1184 branch 4 times, most recently from 827595d to 2f1217d Compare February 6, 2026 16:36
Copy link
Copy Markdown
Collaborator

@rock3r rock3r left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Focus stolen — works with JBPopupFactory and focusable Popups
  2. Click chevron again — user has to know to double-click
  3. 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 expanded

This keeps existing popup-managing overloads completely untouched while giving the no-popup variant clean, idiomatic state management.

@DanielSouzaBertoldi
Copy link
Copy Markdown
Collaborator Author

@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 😎

@DanielSouzaBertoldi DanielSouzaBertoldi force-pushed the dsb/JEWEL-1184 branch 2 times, most recently from e48eb77 to c4586d7 Compare February 27, 2026 16:14
* @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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Button.kt Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

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
Copy link
Copy Markdown
Collaborator Author

@DanielSouzaBertoldi DanielSouzaBertoldi Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() }
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. 

@cursor
Copy link
Copy Markdown

cursor Bot commented Mar 16, 2026

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.

@DanielSouzaBertoldi
Copy link
Copy Markdown
Collaborator Author

Come on Cursor, not now 😭

@cursor
Copy link
Copy Markdown

cursor Bot commented Mar 16, 2026

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.

@DanielSouzaBertoldi DanielSouzaBertoldi force-pushed the dsb/JEWEL-1184 branch 2 times, most recently from 9df6eae to 4dfd8f0 Compare March 19, 2026 17:41
@DanielSouzaBertoldi
Copy link
Copy Markdown
Collaborator Author

The CI code check now failed because of a 502 😮‍💨

image

Will rebase with master after some time

@rock3r
Copy link
Copy Markdown
Collaborator

rock3r commented May 7, 2026

Ready to merge

intellij-monorepo-bot pushed a commit that referenced this pull request May 11, 2026
closes #3408

GitOrigin-RevId: af9b15e3067051230c956e3028608f158e2ef755
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants