Fluid 1.3.0
Material Design for QtQuick and Qml
Loading...
Searching...
No Matches
SplitButton.qml
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Pier Luigi Fiorini <pierluigi.fiorini@gmail.com>
2// SPDX-License-Identifier: MPL-2.0
3
4pragma ComponentBehavior: Bound
5
6import QtQuick
7import QtQuick.Window
8import QtQuick.Templates as T
9import Fluid as MD
10import "../internal"
11import "../core/UiMetrics.js" as UiMetrics
12
134T.Control {
135 id: control
136
137 enum Type { Filled, Tonal, Elevated, Outlined }
138 enum Size { ExtraSmall, Small, Medium, Large, ExtraLarge }
139
141 property int type: SplitButton.Filled
143 property int size: SplitButton.Small
145 property alias text: leading.text
147 property alias icon: leading.icon
149 property alias display: leading.display
151 property alias action: leading.action
153 property MD.typescale typescale: UiMetrics.buttonTypescale(control)
155 property bool primaryEnabled: true
157 property bool secondaryEnabled: true
159 property T.Menu menu: null
161 property bool expanded: false
163 property string secondaryText: qsTr("More options")
165 readonly property T.Button leadingButton: leading
167 readonly property T.ToolButton trailingButton: trailing
168
170 signal clicked()
172 signal secondaryClicked()
173
174 readonly property string _suffix: ["ExtraSmall", "Small", "Medium", "Large", "ExtraLarge"][Math.max(0, Math.min(4, size))]
175 readonly property real _surfaceHeight: MD.Tokens.splitButton["containerHeight" + _suffix]
176 readonly property bool _mirrored: mirrored || locale.textDirection === Qt.RightToLeft
177 readonly property color _containerColor: type === SplitButton.Filled ? control.MD.Style.primaryColor
178 : type === SplitButton.Tonal ? control.MD.Style.secondaryContainerColor
179 : type === SplitButton.Elevated ? control.MD.Style.surfaceContainerLowColor : "transparent"
180 readonly property color _contentColor: type === SplitButton.Filled ? control.MD.Style.onPrimaryColor
181 : type === SplitButton.Tonal ? control.MD.Style.onSecondaryContainerColor
182 : type === SplitButton.Elevated ? control.MD.Style.primaryColor : control.MD.Style.onSurfaceVariantColor
183 readonly property color _outlineColor: type === SplitButton.Outlined ? control.MD.Style.outlineVariantColor : "transparent"
184 readonly property real _outlineWidth: type === SplitButton.Outlined ? MD.Tokens.button["outlinedOutlineWidth" + _suffix] : 0
185 property T.Menu _managedMenu: null
186 property bool _hadManagedMenu: false
187 property bool _syncingExpanded: false
188 property bool _keyboardOpening: false
189 property bool _restoreMenuFocus: false
190 property bool _menuOwnedFocus: false
191 property bool _menuClosing: false
192 property bool _reopenMenuAfterClose: false
193 property Item _focusBeforeMenu: null
194 property Item _closingFocusItem: null
195 property Item _outsideMenuFocus: null
196 property Item _menuContentItem: null
197 property Item _menuPopupItem: null
198 property bool _menuOpenBeforePointerPress: false
199
200 implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
201 implicitContentWidth + leftPadding + rightPadding)
202 implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
203 implicitContentHeight + topPadding + bottomPadding)
204 padding: 0
205 focusPolicy: Qt.NoFocus
206 Accessible.ignored: true
207
208 // Overlay observes a pointer press before CloseOnPressOutside dismisses the menu.
209 T.Overlay.onPressed: {
210 _menuOpenBeforePointerPress = menu !== null && expanded;
211 Qt.callLater(() => control._menuOpenBeforePointerPress = false);
212 }
213
214 function _partContainerColor(partEnabled: bool): color {
215 return partEnabled ? _containerColor
216 : type === SplitButton.Outlined ? "transparent" : control.MD.Style.onSurfaceColor;
217 }
218
219 function _partElevation(partEnabled: bool, hovered: bool, down: bool): real {
220 if (!partEnabled)
221 return MD.Tokens.splitButton.disabledContainerElevation;
222 if (type === SplitButton.Outlined)
223 return MD.Tokens.splitButton.flatContainerElevation;
224 if (type === SplitButton.Elevated)
225 return hovered && !down ? MD.Tokens.splitButton.elevatedHoverContainerElevation
226 : MD.Tokens.splitButton.elevatedContainerElevation;
227 return hovered && !down ? MD.Tokens.splitButton.flatHoverContainerElevation
228 : MD.Tokens.splitButton.flatContainerElevation;
229 }
230
231 function _syncExpanded(value: bool) {
232 _syncingExpanded = true;
233 expanded = value;
234 _syncingExpanded = false;
235 }
236
237 function _insideMenu(item: Item): bool {
238 // Menu can already be marked deleted while focus teardown signals arrive.
239 // Read the Item references captured at opening instead of touching that Menu.
240 const content = control._menuContentItem;
241 const container = control._menuPopupItem;
242 if (!item || (!content && !container))
243 return false;
244 let cursor = item;
245 while (cursor) {
246 if (cursor === content || cursor === container)
247 return true;
248 cursor = cursor.parent;
249 }
250 return false;
251 }
252
253 function _placeMenu() {
254 if (!menu || !control.Window.window)
255 return;
256 const viewport = control.Window.window.contentItem;
257 const anchor = trailing.mapToItem(viewport, 0, 0);
258 const whole = control.mapToItem(viewport, 0, 0);
259 const menuWidth = menu.width > 0 ? menu.width : menu.implicitWidth;
260 const menuHeight = menu.height > 0 ? menu.height : menu.implicitHeight;
261 const left = Math.max(0, menu.leftMargin);
262 const right = viewport.width - Math.max(0, menu.rightMargin);
263 const top = Math.max(0, menu.topMargin);
264 const bottom = viewport.height - Math.max(0, menu.bottomMargin);
265 const candidates = _mirrored
266 ? [anchor.x + trailing.width - menuWidth, anchor.x,
267 whole.x + control.width - menuWidth, whole.x]
268 : [anchor.x, anchor.x + trailing.width - menuWidth,
269 whole.x, whole.x + control.width - menuWidth];
270 let chosenX = candidates[0];
271 for (let index = 0; index < candidates.length; ++index) {
272 if (candidates[index] >= left && candidates[index] + menuWidth <= right) {
273 chosenX = candidates[index];
274 break;
275 }
276 }
277 chosenX = Math.max(left, Math.min(Math.max(left, right - menuWidth), chosenX));
278 const surfaceTop = anchor.y + layout.surfaceY;
279 const below = surfaceTop + _surfaceHeight + MD.Tokens.splitButton.menuAnchorGap;
280 const above = surfaceTop - MD.Tokens.splitButton.menuAnchorGap - menuHeight;
281 const belowSpace = bottom - below;
282 const aboveSpace = surfaceTop - MD.Tokens.splitButton.menuAnchorGap - top;
283 const preferAbove = belowSpace < menuHeight && (above >= top || aboveSpace > belowSpace);
284 const chosenY = preferAbove ? above : below;
285 menu.parent = trailing;
286 menu.x = chosenX - anchor.x;
287 menu.y = chosenY - anchor.y;
288 }
289
291 function openMenu() {
292 if (!menu || !enabled || !visible || !secondaryEnabled)
293 return;
294 if (_menuClosing && menu.visible) {
295 _reopenMenuAfterClose = true;
296 _syncExpanded(true);
297 return;
298 }
299 _placeMenu();
300 menu.open();
301 }
302
304 function closeMenu() {
305 _reopenMenuAfterClose = false;
306 if (menu) {
307 _syncExpanded(false);
308 menu.close();
309 }
310 }
311
313 function toggleMenu() {
314 if (!menu)
315 return;
316 if (expanded)
317 closeMenu();
318 else
319 openMenu();
320 }
321
322 onExpandedChanged: {
323 if (menu && !_syncingExpanded) {
324 if (expanded) {
325 if (enabled && visible && secondaryEnabled)
326 openMenu();
327 else
328 _syncExpanded(false);
329 } else {
330 closeMenu();
331 }
332 }
333 }
334 onMenuChanged: {
335 const previous = _managedMenu;
336 const hadManagedMenu = _hadManagedMenu;
337 _managedMenu = menu;
338 _hadManagedMenu = menu !== null;
339 _restoreMenuFocus = false;
340 _menuOwnedFocus = false;
341 _menuClosing = false;
342 _reopenMenuAfterClose = false;
343 _focusBeforeMenu = null;
344 _closingFocusItem = null;
345 _outsideMenuFocus = null;
346 _menuContentItem = null;
347 _menuPopupItem = null;
348 if (previous && previous !== menu)
349 previous.close();
350 if (!hadManagedMenu && menu && expanded)
351 openMenu();
352 else if (hadManagedMenu || menu)
353 _syncExpanded(menu ? menu.visible : false);
354 }
355 onEnabledChanged: if (!enabled) closeMenu()
356 onVisibleChanged: if (!visible) closeMenu()
357 onSecondaryEnabledChanged: if (!secondaryEnabled) closeMenu()
358 Component.onDestruction: if (_managedMenu) _managedMenu.close()
359
360 Connections {
361 target: control.Window.window
362 function onActiveFocusItemChanged() {
363 const focused = control.Window.window.activeFocusItem;
364 if (control._insideMenu(focused)) {
365 control._menuOwnedFocus = true;
366 control._outsideMenuFocus = null;
367 } else if (control._menuClosing) {
368 // Qt restores the previous focus with PopupFocusReason or OtherFocusReason.
369 // A pointer/keyboard move during dismissal belongs to the user instead.
370 const focusedControl = focused as T.Control;
371 if (focusedControl && focusedControl.focusReason !== Qt.PopupFocusReason
372 && focusedControl.focusReason !== Qt.OtherFocusReason) {
373 control._restoreMenuFocus = false;
374 control._outsideMenuFocus = focused;
375 }
376 } else if (control.expanded) {
377 const focusedControl = focused as T.Control;
378 if (focusedControl && focusedControl.focusReason !== Qt.PopupFocusReason
379 && focusedControl.focusReason !== Qt.OtherFocusReason) {
380 control._menuOwnedFocus = false;
381 control._outsideMenuFocus = focused;
382 }
383 // Popup removes focus before aboutToHide, so defer relinquishing ownership.
384 Qt.callLater(() => {
385 if (control.Window.window && control.expanded && !control._menuClosing
386 && !control._insideMenu(control.Window.window.activeFocusItem))
387 control._menuOwnedFocus = false;
388 });
389 }
390 }
391 }
392
393 Connections {
394 target: control.menu
395 function onAboutToShow() {
396 control._menuContentItem = control.menu.contentItem;
397 control._menuPopupItem = control._menuContentItem ? control._menuContentItem.parent : null;
398 control._focusBeforeMenu = control.Window.window ? control.Window.window.activeFocusItem : null;
399 control._menuOwnedFocus = false;
400 control._menuClosing = false;
401 control._reopenMenuAfterClose = false;
402 control._restoreMenuFocus = false;
403 control._outsideMenuFocus = null;
404 control._placeMenu();
405 control._syncExpanded(true);
406 }
407 function onOpened() {
408 control._syncExpanded(true);
409 if (control._keyboardOpening && control.menu) {
410 for (let index = 0; index < control.menu.count; ++index) {
411 const item = control.menu.itemAt(index);
412 if (item && item.enabled && item.visible && item.focusPolicy !== Qt.NoFocus) {
413 control.menu.currentIndex = index;
414 item.forceActiveFocus(Qt.PopupFocusReason);
415 break;
416 }
417 }
418 }
419 control._keyboardOpening = false;
420 }
421 function onAboutToHide() {
422 control._closingFocusItem = control.Window.window ? control.Window.window.activeFocusItem : null;
423 control._restoreMenuFocus = control._menuOwnedFocus;
424 control._menuClosing = true;
425 control._syncExpanded(false);
426 }
427 function onClosed() {
428 if (control._reopenMenuAfterClose) {
429 control._reopenMenuAfterClose = false;
430 control._menuClosing = false;
431 control.openMenu();
432 return;
433 }
434 control._syncExpanded(false);
435 Qt.callLater(() => {
436 if (!control || (control.menu && control.menu.visible))
437 return;
438 const focused = control.Window.window ? control.Window.window.activeFocusItem : null;
439 const nativeFallback = !focused || focused === control.Window.window.contentItem
440 || focused === control._focusBeforeMenu
441 || focused === control._closingFocusItem || control._insideMenu(focused);
442 if (nativeFallback && control._outsideMenuFocus
443 && control._outsideMenuFocus.enabled && control._outsideMenuFocus.visible)
444 control._outsideMenuFocus.forceActiveFocus(Qt.PopupFocusReason);
445 else if (nativeFallback && control._restoreMenuFocus && trailing.enabled && control.visible)
446 trailing.forceActiveFocus(Qt.PopupFocusReason);
447 control._restoreMenuFocus = false;
448 control._menuOwnedFocus = false;
449 control._menuClosing = false;
450 control._focusBeforeMenu = null;
451 control._closingFocusItem = null;
452 control._outsideMenuFocus = null;
453 });
454 }
455 }
456
457 contentItem: SplitButtonLayout {
458 id: layout
459 objectName: "splitButtonLayout"
460 mirrored: control._mirrored
461 surfaceHeight: control._surfaceHeight
462 minimumTarget: MD.Tokens.measurement.minimumInteractiveSize
463 betweenSpace: MD.Tokens.splitButton["betweenSpace" + control._suffix]
464 leadingContentWidth: leading.implicitContentWidth
465 leadingStartPadding: MD.Tokens.splitButton["leadingButtonLeadingSpace" + control._suffix]
466 leadingEndPadding: MD.Tokens.splitButton["leadingButtonTrailingSpace" + control._suffix]
467 trailingIconSize: MD.Tokens.splitButton["trailingIconSize" + control._suffix]
468 trailingStartPadding: MD.Tokens.splitButton["trailingButtonLeadingSpace" + control._suffix]
469 trailingEndPadding: MD.Tokens.splitButton["trailingButtonTrailingSpace" + control._suffix]
470
471 T.Button {
472 id: leading
473 objectName: "splitButtonLeadingButton"
474 x: layout.leadingX
475 width: layout.leadingWidth
476 height: layout.height
477 enabled: control.enabled && control.primaryEnabled && (!action || action.enabled)
478 display: T.AbstractButton.TextBesideIcon
479 focusPolicy: Qt.StrongFocus
480 hoverEnabled: true
481 padding: 0
482 icon.width: MD.Tokens.button["iconSize" + control._suffix]
483 icon.height: MD.Tokens.button["iconSize" + control._suffix]
484 font.pixelSize: control.typescale.fontSize
485 font.weight: control.typescale.fontWeight
486 font.letterSpacing: control.typescale.tracking
487 LayoutMirroring.enabled: control._mirrored
488 Keys.onPressed: event => {
489 if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
490 event.accepted = true;
491 }
492 Keys.onReleased: event => {
493 if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
494 event.accepted = true;
495 if (!event.isAutoRepeat)
496 leading.click();
497 }
498 }
499 onClicked: control.clicked()
500
501 contentItem: SplitButtonContent {
502 objectName: "splitButtonLeadingContent"
503 trailing: false
504 mirrored: control._mirrored
505 expanded: false
506 text: leading.text
507 iconName: leading.icon.name
508 iconSource: leading.icon.source
509 iconWidth: leading.icon.width
510 iconHeight: leading.icon.height
511 display: leading.display
512 textFont: leading.font
513 lineHeight: control.typescale.lineHeight
514 contentColor: leading.enabled ? control._contentColor : control.MD.Style.onSurfaceVariantColor
515 iconColor: leading.enabled && leading.icon.color.a > 0 ? leading.icon.color : contentColor
516 contentOpacity: leading.enabled ? 1 : MD.Tokens.button.disabledLabelTextOpacity
517 spacing: MD.Tokens.button["iconLabelSpace" + control._suffix]
518 startPadding: layout.leadingStartPadding
519 endPadding: layout.leadingEndPadding
520 opticalOffset: leadingSurface.opticalOffset
521 }
522
523 background: SplitButtonBackground {
524 id: leadingSurface
525 objectName: "splitButtonLeadingBackground"
526 y: layout.surfaceY
527 width: leading.width
528 height: control._surfaceHeight
529 leading: true
530 mirrored: control._mirrored
531 partEnabled: leading.enabled
532 hovered: leading.hovered
533 focused: leading.visualFocus
534 pressed: leading.down
535 expanded: false
536 restingInnerRadius: MD.Tokens.splitButton["innerCornerCornerSize" + control._suffix]
537 hoveredInnerRadius: MD.Tokens.splitButton["innerHoveredCornerCornerSize" + control._suffix]
538 pressedInnerRadius: MD.Tokens.splitButton["innerPressedCornerCornerSize" + control._suffix]
539 outerShape: MD.Tokens.splitButton.outerCornerShape
540 expandedShape: MD.Tokens.splitButton.trailingExpandedInnerCornerShape
541 containerColor: control._partContainerColor(leading.enabled)
542 contentColor: control._contentColor
543 outlineColor: control._outlineColor
544 containerOpacity: leading.enabled ? 1 : MD.Tokens.button.disabledContainerOpacity
545 outlineWidth: control._outlineWidth
546 selectedOpacity: MD.Tokens.splitButton.trailingSelectedStateLayerOpacity
547 leadingPadding: layout.leadingStartPadding
548 trailingPadding: layout.leadingEndPadding
549 opticalCoefficient: MD.Tokens.splitButton.opticalCenteringCoefficient
550 elevation: control._partElevation(leading.enabled, leading.hovered, leading.down)
551 }
552 }
553
554 T.ToolButton {
555 id: trailing
556 property bool _closeMenuOnActivation: false
557 objectName: "splitButtonTrailingButton"
558 x: layout.trailingX
559 width: layout.trailingWidth
560 height: layout.height
561 enabled: control.enabled && control.secondaryEnabled
562 focusPolicy: Qt.StrongFocus
563 hoverEnabled: true
564 padding: 0
565 LayoutMirroring.enabled: control._mirrored
566 Accessible.name: control.secondaryText
567 Accessible.expandable: true
568 Accessible.expanded: control.expanded
569 onPressed: _closeMenuOnActivation = control.menu !== null
570 && (control._menuOpenBeforePointerPress || control.expanded)
571 onCanceled: _closeMenuOnActivation = false
572 Keys.onPressed: event => {
573 if (event.key === Qt.Key_Space || event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
574 control._keyboardOpening = true;
575 if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
576 event.accepted = true;
577 }
578 Keys.onReleased: event => {
579 if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
580 event.accepted = true;
581 if (!event.isAutoRepeat)
582 trailing.click();
583 }
584 }
585 onClicked: {
586 control._keyboardOpening = control._keyboardOpening && trailing.focusReason !== Qt.MouseFocusReason;
587 const closeMenu = _closeMenuOnActivation || (control.menu !== null && control.expanded);
588 _closeMenuOnActivation = false;
589 control.secondaryClicked();
590 if (closeMenu)
591 control.closeMenu();
592 else
593 control.openMenu();
594 }
595
596 contentItem: SplitButtonContent {
597 objectName: "splitButtonTrailingContent"
598 trailing: true
599 mirrored: control._mirrored
600 expanded: control.expanded
601 text: ""
602 iconName: ""
603 iconSource: ""
604 iconWidth: layout.trailingIconSize
605 iconHeight: layout.trailingIconSize
606 display: T.AbstractButton.IconOnly
607 textFont: leading.font
608 lineHeight: control.typescale.lineHeight
609 contentColor: trailing.enabled ? control._contentColor : control.MD.Style.onSurfaceVariantColor
610 iconColor: contentColor
611 contentOpacity: trailing.enabled ? 1 : MD.Tokens.button.disabledIconOpacity
612 spacing: 0
613 startPadding: layout.trailingStartPadding
614 endPadding: layout.trailingEndPadding
615 opticalOffset: trailingSurface.opticalOffset
616 }
617
618 background: SplitButtonBackground {
619 id: trailingSurface
620 objectName: "splitButtonTrailingBackground"
621 y: layout.surfaceY
622 width: trailing.width
623 height: control._surfaceHeight
624 leading: false
625 mirrored: control._mirrored
626 partEnabled: trailing.enabled
627 hovered: trailing.hovered
628 focused: trailing.visualFocus
629 pressed: trailing.down
630 expanded: control.expanded
631 restingInnerRadius: MD.Tokens.splitButton["innerCornerCornerSize" + control._suffix]
632 hoveredInnerRadius: MD.Tokens.splitButton["innerHoveredCornerCornerSize" + control._suffix]
633 pressedInnerRadius: MD.Tokens.splitButton["innerPressedCornerCornerSize" + control._suffix]
634 outerShape: MD.Tokens.splitButton.outerCornerShape
635 expandedShape: MD.Tokens.splitButton.trailingExpandedInnerCornerShape
636 containerColor: control._partContainerColor(trailing.enabled)
637 contentColor: control._contentColor
638 outlineColor: control._outlineColor
639 containerOpacity: trailing.enabled ? 1 : MD.Tokens.button.disabledContainerOpacity
640 outlineWidth: control._outlineWidth
641 selectedOpacity: MD.Tokens.splitButton.trailingSelectedStateLayerOpacity
642 leadingPadding: layout.trailingStartPadding
643 trailingPadding: layout.trailingEndPadding
644 opticalCoefficient: MD.Tokens.splitButton.opticalCenteringCoefficient
645 elevation: control._partElevation(trailing.enabled, trailing.hovered, trailing.down)
646 }
647 }
648 }
649}
A Material 3 Expressive push button.
Definition Button.qml:17
A Material Design 3 Expressive menu.
Definition Menu.qml:73