Fluid 1.3.0
Material Design for QtQuick and Qml
Loading...
Searching...
No Matches
FabMenu.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.Templates as T
8import QtQuick.Window
9import Fluid as MD
10
61T.Pane {
62 id: control
63
65 enum Variant {
66 Primary,
67 Secondary,
68 Tertiary
69 }
70
72 enum Direction {
74 Up,
76 Down
77 }
78
88 property bool expanded: false
89
91 property int variant: FabMenu.Variant.Primary
92
94 property int direction: FabMenu.Direction.Up
95
97 property int alignment: Qt.AlignRight
98
106 property bool scrim: true
107
109 property real margins: MD.Tokens.fabMenu.containerMargin
110
112 property string text
113
120 property string collapsedIconName: MD.SymbolNames.symbolAdd
121
128 property string expandedIconName: MD.SymbolNames.symbolClose
129
131 readonly property alias button: fabMenuButton
132
134 readonly property color itemContainerColor: {
135 switch (variant) {
136 case FabMenu.Variant.Primary:
137 return control.MD.Style.primaryContainerColor;
138 case FabMenu.Variant.Secondary:
139 return control.MD.Style.secondaryContainerColor;
140 case FabMenu.Variant.Tertiary:
141 return control.MD.Style.tertiaryContainerColor;
142 }
143 }
144
146 readonly property color itemContentColor: {
147 switch (variant) {
148 case FabMenu.Variant.Primary:
149 return control.MD.Style.onPrimaryContainerColor;
150 case FabMenu.Variant.Secondary:
151 return control.MD.Style.onSecondaryContainerColor;
152 case FabMenu.Variant.Tertiary:
153 return control.MD.Style.onTertiaryContainerColor;
154 }
155 }
156
158 readonly property bool _alignedRight: (alignment === Qt.AlignRight) !== mirrored
159
161 readonly property real _entranceDirection: direction === FabMenu.Direction.Up ? 1 : -1
162
164 readonly property real _buttonMargin: margins + MD.Tokens.fabMenu.closeButtonContainerHeight + MD.Tokens.fabMenu.closeButtonBetweenSpace
165
172 property Item _restoreFocusItem: null
173
175 function open() {
176 control.expanded = true;
177 }
178
180 function close() {
181 control.expanded = false;
182 }
183
185 function toggle() {
186 control.expanded = !control.expanded;
187 }
188
195 function _focusSequence() {
196 const items = [];
197 for (let index = 0; index < control.contentChildren.length; ++index) {
198 const item = control.contentChildren[index];
199 if (item && item.menu === control && item.enabled)
200 items.push(item);
201 }
202 if (control.direction === FabMenu.Direction.Down)
203 items.unshift(fabMenuButton);
204 else
205 items.push(fabMenuButton);
206 return items;
207 }
208
215 function _focusFirstAction() {
216 for (let index = 0; index < control.contentChildren.length; ++index) {
217 const item = control.contentChildren[index];
218 if (item && item.menu === control && item.enabled) {
219 item.forceActiveFocus(Qt.PopupFocusReason);
220 return;
221 }
222 }
223 fabMenuButton.forceActiveFocus(Qt.PopupFocusReason);
224 }
225
232 function _moveFocus(direction) {
233 const sequence = control._focusSequence();
234 if (sequence.length === 0)
235 return;
236 let index = sequence.indexOf(Window.window ? Window.window.activeFocusItem : null);
237 index = index < 0 ? (direction > 0 ? -1 : 0) : index;
238 sequence[(index + direction + sequence.length) % sequence.length]
239 .forceActiveFocus(Qt.TabFocusReason);
240 }
241
243 function _focusBoundary(last) {
244 const sequence = control._focusSequence();
245 if (sequence.length > 0)
246 sequence[last ? sequence.length - 1 : 0].forceActiveFocus(Qt.TabFocusReason);
247 }
248
257 function _bindItems() {
258 for (let index = 0; index < control.contentChildren.length; ++index) {
259 const item = control.contentChildren[index];
260 if (!item || item.menu === undefined || item.staggerIndex === undefined)
261 continue;
262 // Activating an item dismisses the menu, as the specification
263 // requires. The connection is made only when the item is first
264 // bound, so re-binding cannot connect it twice, and it lives here
265 // rather than in an onClicked handler inside FabMenuItem, which a
266 // caller's own onClicked would override.
267 if (item.menu !== control) {
268 item.menu = control;
269 item.clicked.connect(control.close);
270 }
271 item.staggerIndex = index;
272 item.x = Qt.binding(() => {
273 return control._alignedRight ? Math.max(0, column.width - item.width) : 0;
274 });
275 }
276 }
277
278 implicitWidth: Math.max(fabMenuButton.implicitWidth + margins * 2, implicitContentWidth + leftPadding + rightPadding)
279 implicitHeight: Math.max(fabMenuButton.implicitHeight + margins * 2, implicitContentHeight + topPadding + bottomPadding)
280
281 leftInset: 0
282 rightInset: 0
283 topInset: 0
284 bottomInset: 0
285 leftPadding: margins
286 rightPadding: margins
287 topPadding: direction === FabMenu.Direction.Up ? margins : _buttonMargin
288 bottomPadding: direction === FabMenu.Direction.Up ? _buttonMargin : margins
289
290 // Only the toggle participates in normal Tab traversal. Menu actions use
291 // programmatic focus while expanded, and the non-actionable pane remains
292 // transparent to both keyboard traversal and the accessibility tree.
293 focusPolicy: Qt.NoFocus
294 Accessible.ignored: true
295
296 onContentChildrenChanged: control._bindItems()
297
298 // Capture focus before entering the menu, then wait until the expansion
299 // state has made its animated actions visible before focusing one. Closing
300 // reverses that transfer so pointer, keyboard, and programmatic openings all
301 // return focus consistently.
302 onExpandedChanged: {
303 if (control.expanded) {
304 control._restoreFocusItem = Window.window ? Window.window.activeFocusItem : null;
305 Qt.callLater(control._focusFirstAction);
306 } else {
307 const restoreItem = control._restoreFocusItem || fabMenuButton;
308 if (restoreItem)
309 restoreItem.forceActiveFocus(Qt.PopupFocusReason);
310 control._restoreFocusItem = null;
311 }
312 }
313
314 Component.onCompleted: control._bindItems()
315
316 // Key events from the focused toggle or action bubble to the pane. Keeping
317 // the handlers here gives every item identical wrapping and dismissal rules
318 // without duplicating navigation logic in FabMenuItem.
319 Keys.onEscapePressed: event => {
320 if (control.expanded)
321 control.close();
322 else
323 event.accepted = false;
324 }
325 Keys.onUpPressed: control._moveFocus(-1)
326 Keys.onDownPressed: control._moveFocus(1)
327 Keys.onPressed: event => {
328 if (!control.expanded) {
329 event.accepted = false;
330 } else if (event.key === Qt.Key_Home) {
331 control._focusBoundary(false);
332 event.accepted = true;
333 } else if (event.key === Qt.Key_End) {
334 control._focusBoundary(true);
335 event.accepted = true;
336 } else {
337 event.accepted = false;
338 }
339 }
340
341 contentItem: Column {
342 id: column
343
344 z: 1
345 spacing: MD.Tokens.fabMenu.listItemBetweenSpace
346
347 // The pane stretches its content item over the available area, so the
348 // items are pushed down to sit next to a button placed at the bottom.
349 transform: Translate {
350 y: control.direction === FabMenu.Direction.Up ? Math.max(0, column.height - column.implicitHeight) : 0
351 }
352 }
353
354 /*
355 The scrim and the toggle button are declared as direct children of the
356 control rather than inside a background delegate: Qt Quick Controls does
357 not deliver mouse presses to items nested in a background, so a button
358 placed there would never register a press. Stacking is set explicitly so
359 the scrim stays behind the items and the button stays in front of them.
360 Every child keeps a non-negative z: a negative z would place it behind
361 the control itself, which then consumes the presses meant for it.
362 */
363 data: [
364 Rectangle {
365 objectName: "fabMenuScrim"
366
367 anchors.fill: parent
368 color: control.MD.Style.scrimColor
369 opacity: control.expanded && control.scrim ? MD.Tokens.fabMenu.scrimOpacity : 0
370 visible: opacity > 0
371
372 Behavior on opacity {
373 NumberAnimation {
374 duration: MD.Tokens.motion.duration.short4
375 }
376 }
377
378 TapHandler {
379 onTapped: control.close()
380 }
381 },
382
383 MD.FabMenuButton {
384 id: fabMenuButton
385 objectName: "fabMenuButton"
386
387 // Positioned explicitly rather than by swapping anchors: resetting an
388 // anchor to undefined does not restore the implicit size it overrode,
389 // which would stretch the button when the direction or alignment flips.
390 x: control._alignedRight ? control.width - width - control.margins : control.margins
391 y: control.direction === FabMenu.Direction.Up ? control.height - height - control.margins : control.margins
392 z: 2
393
394 expanded: control.expanded
395 containerColor: control.itemContainerColor
396 contentColor: control.itemContentColor
397 text: control.text
398 collapsedIconName: control.collapsedIconName
399 expandedIconName: control.expandedIconName
400
401 onClicked: control.toggle()
402 }
403 ]
404}
Material Design 3 design tokens.
Definition tokens.h:65
Material Design 3 Expressive FAB menu tokens.
Definition fabmenu.h:27
qreal listItemBetweenSpace
Definition fabmenu.h:52