Split button
In reviewLive · web
A default action welded to a menu of alternatives. Click the label, or the caret
The label fires the default directly. The ▾ caret opens the menu: Enter / ↓ opens, ↑ ↓ move, Enter runs (and that choice becomes the new default), Esc closes.
Disabled: the WHOLE control, always (a split never half-disables)
Send (split button)
Commit (split button)
Last action: none yet · defaults: send send, commit commit · pointer + keyboard both work; the island is live.
// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform. Spec + live demo,// not yet exported from @dizyx/nockerl-react; promotion is tracked.)// The primary segment fires `onAction` directly; only the caret opens the menu.
export function ComposeBar() { const [defaultId, setDefaultId] = useState('send'); return ( <SplitButton variant="primary" leadingIcon="send" defaultActionId={defaultId} onAction={(id) => runSend(id)} // primary OR a picked menu item onDefaultChange={setDefaultId} // a picked item becomes the new default actions={[ { id: 'send', label: 'Send now', icon: 'send' }, { id: 'later', label: 'Send later', icon: 'clock' }, { id: 'archive', label: 'Send & archive', icon: 'archive' }, { id: 'draft', label: 'Save as draft', icon: 'edit', keepDefault: true }, ]} /> );}// Android (Jetpack Compose). Material 3 ships SplitButtonLayout (experimental):// a leadingButton that fires the default + a trailingButton that toggles a// DropdownMenu of alternatives. The shipped app today hand-rolls this with a// Row(NockerlButton + NockerlIconButton) + DropdownMenu (see SessionCreationDropdowns.kt);// SplitButtonLayout is the intended canonical; see the drift note.var expanded by remember { mutableStateOf(false) }var default by remember { mutableStateOf("send") }
SplitButtonLayout( leadingButton = { SplitButtonDefaults.LeadingButton(onClick = { runSend(default) }) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) Spacer(Modifier.size(SplitButtonDefaults.LeadingIconSpacing)) Text(labelFor(default)) } }, trailingButton = { SplitButtonDefaults.TrailingButton( checked = expanded, onCheckedChange = { expanded = it }, ) { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = "More send actions") } },)DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenuItem( text = { Text("Send later") }, leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, onClick = { expanded = false; default = "later"; runSend("later") }, ) DropdownMenuItem( text = { Text("Send & archive") }, leadingIcon = { Icon(Icons.Outlined.Archive, contentDescription = null) }, onClick = { expanded = false; default = "archive"; runSend("archive") }, )}// macOS (SwiftUI). Menu(primaryAction:) IS the split-button idiom: the label fires// primaryAction directly, the attached caret opens the menu. Voice ships plain// Menu { … } today (UI/AppSettingsView.swift); primaryAction is the intended form.// A leading checkmark marks the current default (Voice's checked-row idiom).@State private var current = "send"
Menu { Button { run("later") } label: { Label("Send later", systemImage: "clock") } Button { run("archive") } label: { Label("Send & archive", systemImage: "archivebox") } Divider() Button { run("draft") } label: { Label("Save as draft", systemImage: "pencil") }} label: { Label(label(for: current), systemImage: "paperplane.fill")} primaryAction: { run(current) // the primary segment's default action}.menuStyle(.button).buttonStyle(.nockerlPrimary)
func run(_ id: String) { current = id; send(id) } // a picked item becomes the new defaultParameters
Section titled “Parameters”| Prop | Type | Default | Description |
|---|---|---|---|
actions * | SplitAction[] | Ordered list of { id, label, icon?, danger?, keepDefault? }. The first is the initial default; the caret menu lists them all. | |
onAction * | (id: string) => void | Fired by the primary segment (current default) AND by a picked menu item. Ignored while disabled. | |
defaultActionId | string | Controlled id of the action the primary segment fires. Uncontrolled if omitted (tracks the last pick). | |
onDefaultChange | (id: string) => void | Called when a picked item promotes itself to the new default (skipped for keepDefault items). | |
variant | 'primary' | 'secondary' | 'tertiary' | 'destructive' | 'primary' | Shared fill across both segments, following the Button emphasis ladder. One primary per view. |
size | 'sm' | 'md' | 'md' | Control height + padding; the caret is a square of the same height. |
leadingIcon | string | Optional glyph before the default label on the primary segment. | |
disabled | boolean | false | The WHOLE control inert and clearly seen (never faded to invisible). A split never half-disables: both segments always share one state. |
| Parameter | Type | Default | Description |
|---|---|---|---|
leadingButton * | @Composable -> Unit | The default-action segment. Wrap SplitButtonDefaults.LeadingButton(onClick = …). Rounds the leading corners. | |
trailingButton * | @Composable -> Unit | The caret segment: SplitButtonDefaults.TrailingButton(checked, onCheckedChange) toggling a DropdownMenu. Rounds the trailing corners. | |
modifier | Modifier | Modifier | External modifier applied to the two-segment layout. |
spacing | Dp | SplitButtonDefaults.Spacing | The hairline gap between the two segments (the divider seam). |
expanded | Boolean | false | State you hold for the menu; bind to the trailing button’s checked and the DropdownMenu’s expanded. |
enabled | Boolean | true | Passed to BOTH inner buttons together, because a split never half-disables (the whole control shares one state). |
Menu(primaryAction:) is the native split button: the label fires primaryAction,
the attached caret opens the menu. State (isEnabled, pressed) comes from the
SwiftUI environment; styling is the shared .nockerl* ButtonStyle.
| Style | Type | Default | Description |
|---|---|---|---|
content | @ViewBuilder | The menu body: Button/Label rows, Section, Divider(); a leading systemImage: "checkmark" marks the current default. | |
label | @ViewBuilder | The primary segment’s content (the default action’s Label). | |
primaryAction | () -> Void | Fired by the primary segment directly, which is the split-button behaviour. Without it, the whole control is a plain Menu (no default action). | |
.menuStyle(.button) | MenuStyle | Renders the trigger as a button with the attached caret (the welded two-segment look). | |
.buttonStyle(.nockerlPrimary) | ButtonStyle | Shared fill. Swap for .nockerlSecondary / .nockerlDestructive to match the variant ladder. |