Fluid 1.3.0
Material Design for QtQuick and Qml
Loading...
Searching...
No Matches
NavigationRail.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 "../core/UiMetrics.js" as UiMetrics
11import "../internal/MotionAnimation.js" as MotionAnimation
12
65T.Container {
66 id: control
67
69 enum Arrangement {
71 Top,
73 Center,
75 Bottom
76 }
77
82 property bool expanded: false
83
85 property int arrangement: NavigationRail.Arrangement.Top
86
92 property Component header: null
93
95 property color containerColor: control.MD.Style.surfaceColor
96
98 property bool _modal: false
99
101 property bool _syncingSelection: false
102
104 property real _layoutProgress: expanded ? 1 : 0
105
107 readonly property real _naturalExpandedWidth: {
108 const headerItem = headerLoader.item as Item;
109 let naturalWidth = headerItem ? headerItem.implicitWidth : 0;
110 for (let index = 0; index < count; ++index) {
111 const item = itemAt(index) as MD.NavigationRailItem;
112 if (item)
113 naturalWidth = Math.max(naturalWidth,
114 item._expandedImplicitWidth
115 + MD.Tokens.navigationRail.itemHorizontalPadding);
116 }
117 return naturalWidth;
118 }
119
121 readonly property real _collapsedWidth: MD.Tokens.navigationRail.collapsedContainerWidth
122 + leftPadding + rightPadding
123
125 readonly property real _expandedWidth: Math.min(
126 MD.Tokens.navigationRail.expandedContainerWidthMaximum,
127 Math.max(MD.Tokens.navigationRail.expandedContainerWidthMinimum,
128 _naturalExpandedWidth + leftPadding + rightPadding))
129
131 readonly property real _itemSpacing: MD.Tokens.navigationRail.collapsedItemVerticalSpace
132 * (1 - _layoutProgress)
133
135 readonly property real _itemsHeight: {
136 let total = 0;
137 for (let index = 0; index < count; ++index) {
138 const item = itemAt(index);
139 if (item)
140 total += item.implicitHeight;
141 }
142 return total + Math.max(0, count - 1) * _itemSpacing;
143 }
144
151 readonly property int _tabStopIndex: {
152 const selected = itemAt(currentIndex);
153 if (selected && selected.enabled)
154 return currentIndex;
155 for (let index = 0; index < count; ++index) {
156 const item = itemAt(index);
157 if (item && item.enabled)
158 return index;
159 }
160 return -1;
161 }
162
164 readonly property Item _headerItem: headerLoader.item as Item
165
171 function _headerFocusItem(item) {
172 const candidate = item || control._headerItem;
173 if (!candidate || !candidate.visible || !candidate.enabled)
174 return null;
175 if (candidate.focusPolicy !== undefined && candidate.focusPolicy !== Qt.NoFocus)
176 return candidate;
177 for (let index = 0; index < candidate.children.length; ++index) {
178 const descendant = control._headerFocusItem(candidate.children[index]);
179 if (descendant)
180 return descendant;
181 }
182 return null;
183 }
184
186 readonly property real _headerOffset: {
187 const headerItem = headerLoader.item as Item;
188 return headerItem && headerItem.height > 0
189 ? headerItem.height + MD.Tokens.navigationRail.headerSpaceMinimum
190 : 0;
191 }
192
194 readonly property real _itemsOrigin: {
195 if (arrangement === NavigationRail.Arrangement.Center)
196 return Math.max(headerLoader.item ? (headerLoader.item as Item).height : 0,
197 (contentArea.height - _itemsHeight) / 2);
198 if (arrangement === NavigationRail.Arrangement.Bottom)
199 return Math.max(_headerOffset, contentArea.height - _itemsHeight);
200 return _headerOffset;
201 }
202
207 function expand() {
208 control.expanded = true;
209 }
210
215 function collapse() {
216 control.expanded = false;
217 }
218
220 function toggle() {
221 control.expanded = !control.expanded;
222 }
223
225 function _itemY(itemIndex) {
226 let itemY = control._itemsOrigin;
227 for (let index = 0; index < itemIndex; ++index) {
228 const previous = control.itemAt(index);
229 if (previous)
230 itemY += previous.implicitHeight + control._itemSpacing;
231 }
232 return itemY;
233 }
234
236 function _bindItems() {
237 for (let index = 0; index < control.count; ++index) {
238 const item = control.itemAt(index) as MD.NavigationRailItem;
239 if (!item)
240 continue;
241 item.rail = control;
242 item._railIndex = index;
243 item.x = Qt.binding(function() {
244 return MD.Tokens.navigationRail.itemHorizontalPadding
245 * control._layoutProgress / 2;
246 });
247 item.y = Qt.binding(function() { return control._itemY(index); });
248 item.width = Qt.binding(function() {
249 return Math.max(0, contentArea.width
250 - MD.Tokens.navigationRail.itemHorizontalPadding
251 * control._layoutProgress);
252 });
253 }
254 control._syncSelection();
255 }
256
258 function _syncSelection() {
259 if (control._syncingSelection)
260 return;
261 control._syncingSelection = true;
262 for (let index = 0; index < control.count; ++index) {
263 const item = control.itemAt(index) as MD.NavigationRailItem;
264 if (item)
265 item.checked = index === control.currentIndex;
266 }
267 control._syncingSelection = false;
268 }
269
271 function _activateItem(item) {
272 if (!item || !item.enabled || item._railIndex < 0)
273 return;
274 control.currentIndex = item._railIndex;
275 item.forceActiveFocus(Qt.TabFocusReason);
276 }
277
279 function _itemCheckedChanged(item) {
280 if (control._syncingSelection || !item || item._railIndex < 0)
281 return;
282 if (item.checked)
283 control.currentIndex = item._railIndex;
284 else if (control.currentIndex === item._railIndex)
285 control.currentIndex = -1;
286 }
287
289 function _selectIndex(index) {
290 const item = control.itemAt(index) as MD.NavigationRailItem;
291 if (!item || !item.enabled)
292 return false;
293 control.currentIndex = index;
294 item.forceActiveFocus(Qt.TabFocusReason);
295 return true;
296 }
297
303 function _moveSelection(direction) {
304 if (control.count <= 0)
305 return;
306 let index = control.currentIndex;
307 for (let attempt = 0; attempt < control.count; ++attempt) {
308 index = (index + direction + control.count) % control.count;
309 if (control._selectIndex(index))
310 return;
311 }
312 }
313
315 function _selectBoundary(last) {
316 let index = last ? control.count - 1 : 0;
317 const end = last ? -1 : control.count;
318 const direction = last ? -1 : 1;
319 for (; index !== end; index += direction) {
320 if (control._selectIndex(index))
321 return;
322 }
323 }
324
325 currentIndex: 0
326 implicitWidth: _collapsedWidth + (_expandedWidth - _collapsedWidth) * _layoutProgress
327 implicitHeight: topPadding + bottomPadding + Math.max(_headerOffset + _itemsHeight,
328 MD.Tokens.navigationRail.itemContainerHeight)
329
330 topPadding: SafeArea.margins.top + MD.Tokens.navigationRail.topSpace
331 bottomPadding: SafeArea.margins.bottom + MD.Tokens.navigationRail.topSpace
332 leftPadding: mirrored ? 0 : SafeArea.margins.left
333 rightPadding: mirrored ? SafeArea.margins.right : 0
334 leftInset: 0
335 rightInset: 0
336 topInset: 0
337 bottomInset: 0
338
339 // Destinations, rather than the container, own keyboard focus. This creates
340 // one Tab entry point while retaining a PageTabList node for screen readers.
341 focusPolicy: Qt.NoFocus
342 clip: true
343 LayoutMirroring.childrenInherit: true
344
345 Accessible.role: Accessible.PageTabList
346
347 Behavior on _layoutProgress {
348 NumberAnimation {
349 duration: MotionAnimation.expressiveDefaultSpatialDuration
350 easing.type: Easing.BezierSpline
351 easing.bezierCurve: MotionAnimation.expressiveDefaultSpatialCurve
352 }
353 }
354
355 onCountChanged: Qt.callLater(control._bindItems)
356 onCurrentIndexChanged: control._syncSelection()
357 onExpandedChanged: control._bindItems()
358 Component.onCompleted: control._bindItems()
359
360 // These handlers also cover the rare case where the modal wrapper focuses
361 // an empty rail; instantiated destinations provide the same key contract.
362 Keys.onUpPressed: control._moveSelection(-1)
363 Keys.onDownPressed: control._moveSelection(1)
364 Keys.onPressed: event => {
365 if (event.key === Qt.Key_Home) {
366 control._selectBoundary(false);
367 event.accepted = true;
368 } else if (event.key === Qt.Key_End) {
369 control._selectBoundary(true);
370 event.accepted = true;
371 } else {
372 event.accepted = false;
373 }
374 }
375
376 contentItem: Item {
377 id: contentArea
378 objectName: "navigationRailContent"
379 LayoutMirroring.enabled: control.mirrored
380 LayoutMirroring.childrenInherit: true
381 }
382
383 data: Loader {
384 id: headerLoader
385 objectName: "navigationRailHeader"
386
387 x: control.leftPadding
388 y: control.topPadding
389 width: Math.max(0, control.width - control.leftPadding - control.rightPadding)
390 sourceComponent: control.header
391 active: control.header !== null
392 LayoutMirroring.enabled: control.mirrored
393 LayoutMirroring.childrenInherit: true
394
395 // The loader is structural only. Its accessible header descendants stay
396 // available, while the wrapper itself does not add a redundant node.
397 Accessible.ignored: true
398 }
399
400 background: MD.ElevationRectangle {
401 objectName: "navigationRailBackground"
402
403 readonly property var containerShape: control._modal && control.expanded
404 ? MD.Tokens.navigationRail.modalContainerShape
405 : MD.Tokens.navigationRail.containerShape
406
407 color: control.containerColor
408 elevation: control._modal && control.expanded
409 ? MD.Tokens.navigationRail.modalContainerElevation
410 : MD.Tokens.navigationRail.containerElevation
411 topLeftRadius: UiMetrics.resolveShapeRadius(containerShape.topLeft, width, height)
412 topRightRadius: UiMetrics.resolveShapeRadius(containerShape.topRight, width, height)
413 bottomLeftRadius: UiMetrics.resolveShapeRadius(containerShape.bottomLeft, width, height)
414 bottomRightRadius: UiMetrics.resolveShapeRadius(containerShape.bottomRight, width, height)
415 }
416}
Material Design 3 design tokens.
Definition tokens.h:65
Tokens(QObject *parent=nullptr)
Definition tokens.cpp:8
Material Design 3 Expressive navigation rail tokens.