Fluid 1.3.0
Material Design for QtQuick and Qml
Loading...
Searching...
No Matches
ModalNavigationRail.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
10import "../internal/MotionAnimation.js" as MotionAnimation
11
50T.Control {
51 id: control
52
54 default property alias destinations: rail.contentData
55
61 property bool expanded: false
62
69 property bool hideOnCollapse: false
70
72 property alias currentIndex: rail.currentIndex
73
75 property alias arrangement: rail.arrangement
76
78 property alias header: rail.header
79
81 property color containerColor: control.MD.Style.surfaceContainerColor
82
84 property color scrimColor: control.MD.Style.scrimColor
85
87 readonly property alias count: rail.count
88
90 property Item _restoreFocusItem: null
91
93 property real _dragOffset: 0
94
96 property real _dragStartOffset: 0
97
102 function itemAt(index) {
103 return rail.itemAt(index);
104 }
105
107 function expand() {
108 control.expanded = true;
109 }
110
112 function collapse() {
113 control.expanded = false;
114 }
115
117 function toggle() {
118 control.expanded = !control.expanded;
119 }
120
127 function _focusRail() {
128 let index = control.currentIndex;
129 if (index >= 0) {
130 const selectedItem = rail.itemAt(index);
131 if (selectedItem && selectedItem.enabled) {
132 selectedItem.forceActiveFocus(Qt.PopupFocusReason);
133 return;
134 }
135 }
136 for (index = 0; index < rail.count; ++index) {
137 const item = rail.itemAt(index);
138 if (item && item.enabled) {
139 item.forceActiveFocus(Qt.PopupFocusReason);
140 return;
141 }
142 }
143 rail.forceActiveFocus(Qt.PopupFocusReason);
144 }
145
147 function _focusDestination() {
148 const selected = rail.itemAt(rail._tabStopIndex);
149 if (selected) {
150 selected.forceActiveFocus(Qt.TabFocusReason);
151 return true;
152 }
153 return false;
154 }
155
161 function _moveModalTab(backward) {
162 const headerItem = rail._headerFocusItem();
163 const headerFocusable = headerItem !== null;
164 if (headerFocusable && !headerItem.activeFocus) {
165 headerItem.forceActiveFocus(backward ? Qt.BacktabFocusReason : Qt.TabFocusReason);
166 return;
167 }
168 if (!control._focusDestination() && headerFocusable)
169 headerItem.forceActiveFocus(backward ? Qt.BacktabFocusReason : Qt.TabFocusReason);
170 }
171
172 implicitWidth: rail.implicitWidth
173 implicitHeight: rail.implicitHeight
174 padding: 0
175 focusPolicy: Qt.StrongFocus
176 LayoutMirroring.childrenInherit: true
177
178 // The page-sized host exists for input, focus trapping, and the scrim. Keep
179 // it out of the accessibility tree and expose the contained tab-list instead.
180 Accessible.ignored: true
181
182 onExpandedChanged: {
183 if (expanded) {
184 // Capture the invoking control before moving focus into the modal.
185 _restoreFocusItem = Window.window ? Window.window.activeFocusItem : null;
186 Qt.callLater(control._focusRail);
187 } else {
188 _dragOffset = 0;
189 // Return keyboard focus after every dismissal path: Escape, scrim,
190 // drag, or an external expanded-property change.
191 if (_restoreFocusItem)
192 _restoreFocusItem.forceActiveFocus(Qt.PopupFocusReason);
193 _restoreFocusItem = null;
194 }
195 }
196
197 // Only consume modal-navigation keys while expanded. When collapsed, allow
198 // ancestors and neighboring controls to handle normal application traversal.
199 Keys.onEscapePressed: event => {
200 if (control.expanded)
201 control.collapse();
202 else
203 event.accepted = false;
204 }
205 Keys.onTabPressed: event => {
206 if (control.expanded) {
207 control._moveModalTab(false);
208 event.accepted = true;
209 } else {
210 event.accepted = false;
211 }
212 }
213 Keys.onBacktabPressed: event => {
214 if (control.expanded) {
215 control._moveModalTab(true);
216 event.accepted = true;
217 } else {
218 event.accepted = false;
219 }
220 }
221
222 contentItem: Item {
223 clip: false
224
225 Rectangle {
226 id: scrim
227 objectName: "modalNavigationRailScrim"
228
229 anchors.fill: parent
230 z: 0
231 color: control.scrimColor
232 opacity: control.expanded ? MD.Tokens.navigationRail.modalScrimOpacity : 0
233 visible: opacity > 0
234
235 Behavior on opacity {
236 NumberAnimation {
237 duration: MotionAnimation.expressiveFastEffectsDuration
238 easing.type: Easing.BezierSpline
239 easing.bezierCurve: MotionAnimation.expressiveFastEffectsCurve
240 }
241 }
242
243 TapHandler {
244 onTapped: control.collapse()
245 }
246 }
247
249 id: rail
250 objectName: "modalNavigationRailSurface"
251
252 z: 1
253 x: {
254 const shownX = control.mirrored ? control.width - rail.width : 0;
255 const hiddenX = control.mirrored ? control.width : -rail.width;
256 return (control.expanded || !control.hideOnCollapse ? shownX : hiddenX)
257 + control._dragOffset;
258 }
259 y: 0
260 width: Math.min(rail.implicitWidth, control.width)
261 height: control.height
262 expanded: control.hideOnCollapse || control.expanded
263 containerColor: control.containerColor
264 _modal: true
265
266 // Forward the caller-provided name to the actual PageTabList node;
267 // the ignored overlay host must not become a duplicate announcement.
268 Accessible.name: control.Accessible.name
269 LayoutMirroring.enabled: control.mirrored
270 LayoutMirroring.childrenInherit: true
271
272 Behavior on x {
273 enabled: !dragHandler.active && !dragSettleAnimation.running
274
275 NumberAnimation {
276 duration: MotionAnimation.expressiveFastSpatialDuration
277 easing.type: Easing.BezierSpline
278 easing.bezierCurve: MotionAnimation.expressiveFastSpatialCurve
279 }
280 }
281
282 DragHandler {
283 id: dragHandler
284 objectName: "modalNavigationRailDragHandler"
285
286 enabled: control.expanded
287 target: null
288 dragThreshold: 0
289 acceptedDevices: PointerDevice.TouchScreen | PointerDevice.TouchPad | PointerDevice.Mouse
290
291 onActiveChanged: {
292 if (active) {
293 dragSettleAnimation.stop();
294 control._dragStartOffset = control._dragOffset;
295 } else {
296 const dismissalDistance = control.mirrored
297 ? control._dragOffset
298 : -control._dragOffset;
299 if (dismissalDistance >= rail.width * 0.5) {
300 control._dragOffset = 0;
301 control.collapse();
302 } else {
303 dragSettleAnimation.restart();
304 }
305 }
306 }
307
308 onTranslationChanged: {
309 if (!active)
310 return;
311 const logicalDistance = control.mirrored ? translation.x : -translation.x;
312 const clampedDistance = Math.max(0, Math.min(rail.width, logicalDistance));
313 control._dragOffset = control._dragStartOffset
314 + (control.mirrored ? clampedDistance : -clampedDistance);
315 }
316 }
317 }
318 }
319
320 NumberAnimation {
321 id: dragSettleAnimation
322 target: control
323 property: "_dragOffset"
324 to: 0
325 duration: MotionAnimation.expressiveFastSpatialDuration
326 easing.type: Easing.BezierSpline
327 easing.bezierCurve: MotionAnimation.expressiveFastSpatialCurve
328 }
329}
Material Design 3 Expressive navigation rail tokens.