Libplasma

ExpandableListItem.qml
1/*
2 SPDX-FileCopyrightText: 2020 Nate Graham <nate@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.0-or-later
5*/
6
7pragma ComponentBehavior: Bound
8
9import QtQuick
10import QtQuick.Layouts
11import QtQuick.Templates as T
12import org.kde.plasma.core as PlasmaCore
13import org.kde.ksvg as KSvg
14import org.kde.plasma.components as PlasmaComponents3
15import org.kde.plasma.extras as PlasmaExtras
16import org.kde.kirigami as Kirigami
17
18/**
19 * A list item that expands when clicked to show additional actions and/or a
20 * custom view.
21 * The list item has a standardized appearance, with an icon on the left badged
22 * with an optional emblem, a title and optional subtitle to the right, an
23 * optional default action button, and a button to expand and collapse the list
24 * item.
25 *
26 * When expanded, the list item shows a list of contextually-appropriate actions
27 * if contextualActions has been defined.
28 * If customExpandedViewContent has been defined, it will show a custom view.
29 * If both have been defined, it shows both, with the actions above the custom
30 * view.
31 *
32 * It is not valid to define neither; define one or both.
33 *
34 * Note: this component should only be used for lists where the maximum number
35 * of items is very low, ideally less than 10. For longer lists, consider using
36 * a different paradigm.
37 *
38 *
39 * Example usage:
40 *
41 * @code
42 * import QtQuick
43 * import QtQuick.Controls as QQC2
44 * import org.kde.kirigami as Kirigami
45 * import org.kde.plasma.extras as PlasmaExtras
46 * import org.kde.plasma.components as PlasmaComponents
47 *
48 * PlasmaComponents.ScrollView {
49 * ListView {
50 * anchors.fill: parent
51 * focus: true
52 * currentIndex: -1
53 * clip: true
54 * model: myModel
55 * highlight: PlasmaExtras.Highlight {}
56 * highlightMoveDuration: Kirigami.Units.shortDuration
57 * highlightResizeDuration: Kirigami.Units.shortDuration
58 * delegate: PlasmaExtras.ExpandableListItem {
59 * icon: model.iconName
60 * iconEmblem: model.isPaused ? "emblem-pause" : ""
61 * title: model.name
62 * subtitle: model.subtitle
63 * isDefault: model.isDefault
64 * defaultActionButtonAction: QQC2.Action {
65 * icon.name: model.isPaused ? "media-playback-start" : "media-playback-pause"
66 * text: model.isPaused ? "Resume" : "Pause"
67 * onTriggered: {
68 * if (model.isPaused) {
69 * model.resume(model.name);
70 * } else {
71 * model.pause(model.name);
72 * }
73 * }
74 * }
75 * contextualActions: [
76 * QQC2.Action {
77 * icon.name: "configure"
78 * text: "Configureā€¦"
79 * onTriggered: model.configure(model.name);
80 * }
81 * ]
82 * }
83 * }
84 * }
85 * @endcode
86 */
87Item {
88 id: listItem
89
90 /**
91 * icon: var
92 * The name of the icon used in the list item.
93 * @sa Kirigami.Icon::source
94 *
95 * Required.
96 */
97 property alias icon: listItemIcon.source
98
99 /**
100 * iconEmblem: var
101 * The name of the emblem to badge the icon with.
102 * @sa Kirigami.Icon::source
103 *
104 * Optional, defaults to nothing, in which case there is no emblem.
105 */
106 property alias iconEmblem: iconEmblem.source
107
108 /*
109 * title: string
110 * The name or title for this list item.
111 *
112 * Optional; if not defined, there will be no title and the subtitle will be
113 * vertically centered in the list item.
114 */
115 property alias title: listItemTitle.text
116
117 /*
118 * subtitle: string
119 * The subtitle for this list item, displayed under the title.
120 *
121 * Optional; if not defined, there will be no subtitle and the title will be
122 * vertically centered in the list item.
123 */
124 property alias subtitle: listItemSubtitle.text
125
126 /*
127 * subtitleCanWrap: bool
128 * Whether to allow the subtitle to become a multi-line string instead of
129 * eliding when the text is very long.
130 *
131 * Optional, defaults to false.
132 */
133 property bool subtitleCanWrap: false
134
135 /**
136 * subtitleMaximumLineCount: int
137 * The maximum number of lines the subtitle can have when subtitleCanWrap is true.
138 * @since 6.9
139 *
140 * Optional, defaults to -1, which means no limit.
141 */
142 property int subtitleMaximumLineCount: -1
143
144 /*
145 * subtitleColor: color
146 * The color of the subtitle text
147 *
148 * Optional; if not defined, the subtitle will use the default text color
149 */
150 property alias subtitleColor: listItemSubtitle.color
151
152 /*
153 * allowStyledText: bool
154 * Whether to allow the title, subtitle, and tooltip to contain styled text.
155 * For performance and security reasons, keep this off unless needed.
156 *
157 * Optional, defaults to false.
158 */
159 property bool allowStyledText: false
160
161 /*
162 * defaultActionButtonAction: T.Action
163 * The Action to execute when the default button is clicked.
164 *
165 * Optional; if not defined, no default action button will be displayed.
166 */
167 property alias defaultActionButtonAction: defaultActionButton.action
168
169 /*
170 * defaultActionButtonVisible: bool
171 * When/whether to show to default action button. Useful for making it
172 * conditionally appear or disappear.
173 *
174 * Optional; defaults to true
175 */
176 property bool defaultActionButtonVisible: true
177
178 /*
179 * showDefaultActionButtonWhenBusy : bool
180 * Whether to continue showing the default action button while the busy
181 * indicator is visible. Useful for cancelable actions that could take a few
182 * seconds and show a busy indicator while processing.
183 *
184 * Optional; defaults to false
185 */
186 property bool showDefaultActionButtonWhenBusy: false
187
188 /*
189 * contextualActions: list<T.Action>
190 * A list of standard QQC2.Action objects that describes additional actions
191 * that can be performed on this list item. For example:
192 *
193 * @code
194 * contextualActions: [
195 * Action {
196 * text: "Do something"
197 * icon.name: "document-edit"
198 * onTriggered: doSomething()
199 * },
200 * Action {
201 * text: "Do something else"
202 * icon.name: "draw-polygon"
203 * onTriggered: doSomethingElse()
204 * },
205 * Action {
206 * text: "Do something completely different"
207 * icon.name: "games-highscores"
208 * onTriggered: doSomethingCompletelyDifferent()
209 * }
210 * ]
211 * @endcode
212 *
213 * Optional; if not defined, no contextual actions will be displayed and
214 * you should instead assign a custom view to customExpandedViewContent,
215 * which will be shown when the user expands the list item.
216 */
217 property list<T.Action> contextualActions
218
219 readonly property list<T.Action> __enabledContextualActions: contextualActions.filter(action => action?.enabled ?? false)
220
221 /*
222 * A custom view to display when the user expands the list item.
223 *
224 * This component must define width and height properties. Width should be
225 * equal to the width of the list item itself, while height: will depend
226 * on the component itself.
227 *
228 * Optional; if not defined, no custom view actions will be displayed and
229 * you should instead define contextualActions, and then actions will
230 * be shown when the user expands the list item.
231 */
232 property Component customExpandedViewContent
233
234 /*
235 * The actual instance of the custom view content, if loaded.
236 * @since 5.72
237 */
238 property alias customExpandedViewContentItem: customContentLoader.item
239
240 /*
241 * isBusy: bool
242 * Whether or not to display a busy indicator on the list item. Set to true
243 * while the item should be non-interactive because things are processing.
245 * Optional; defaults to false.
246 */
247 property bool isBusy: false
248
249 /*
250 * isDefault: bool
251 * Whether or not this list item should be considered the "default" or
252 * "Current" item in the list. When set to true, and the list itself has
253 * more than one item in it, the list item's title and subtitle will be
254 * drawn in a bold style.
255 *
256 * Optional; defaults to false.
257 */
258 property bool isDefault: false
259
260 /**
261 * expanded: bool
262 * Whether the expanded view is visible.
263 *
264 * @since 5.98
265 */
266 readonly property alias expanded: expandedView.expanded
267
268 /*
269 * hasExpandableContent: bool (read-only)
270 * Whether or not this expandable list item is actually expandable. True if
271 * this item has either a custom view or else at least one enabled action.
272 * Otherwise false.
273 */
274 readonly property bool hasExpandableContent: customExpandedViewContent !== null || __enabledContextualActions.length > 0
275
276 /*
277 * expand()
278 * Show the expanded view, growing the list item to its taller size.
279 */
280 function expand() {
281 if (!listItem.hasExpandableContent) {
282 return;
283 }
284 expandedView.expanded = true
285 listItem.itemExpanded()
286 }
287
288 /*
289 * collapse()
290 * Hide the expanded view and collapse the list item to its shorter size.
291 */
292 function collapse() {
293 if (!listItem.hasExpandableContent) {
294 return;
295 }
296 expandedView.expanded = false
297 listItem.itemCollapsed()
298 }
299
300 /*
301 * toggleExpanded()
302 * Expand or collapse the list item depending on its current state.
303 */
304 function toggleExpanded() {
305 if (!listItem.hasExpandableContent) {
306 return;
307 }
308 expandedView.expanded ? listItem.collapse() : listItem.expand()
309 }
310
311 signal itemExpanded()
312 signal itemCollapsed()
313
314 width: parent ? parent.width : undefined // Assume that we will be used as a delegate, not placed in a layout
315 height: mainLayout.height
316
317 Behavior on height {
318 enabled: listItem.ListView.view.highlightResizeDuration > 0
319 SmoothedAnimation { // to match the highlight
320 id: heightAnimation
321 duration: listItem.ListView.view.highlightResizeDuration || -1
322 velocity: listItem.ListView.view.highlightResizeVelocity
323 easing.type: Easing.InOutCubic
324 }
325 }
326 clip: heightAnimation.running || expandedItemOpacityFade.running
327
328 onEnabledChanged: if (!listItem.enabled) { collapse() }
329
330 Keys.onPressed: event => {
331 if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
332 if (defaultActionButtonAction) {
333 defaultActionButtonAction.trigger()
334 } else {
335 toggleExpanded();
336 }
337 event.accepted = true;
338 } else if (event.key === Qt.Key_Escape) {
339 if (expandedView.expanded) {
340 collapse();
341 event.accepted = true;
342 }
343 // if not active, we'll let the Escape event pass through, so it can close the applet, etc.
344 } else if (event.key === Qt.Key_Space) {
345 toggleExpanded();
346 event.accepted = true;
347 }
348 }
349
350 KeyNavigation.tab: defaultActionButtonVisible ? defaultActionButton : expandToggleButton
351 KeyNavigation.right: defaultActionButtonVisible ? defaultActionButton : expandToggleButton
352 KeyNavigation.down: expandToggleButton.KeyNavigation.down
353 Keys.onDownPressed: event => {
354 if (!actionsListLoader.item || ListView.view.currentIndex < 0) {
355 ListView.view.incrementCurrentIndex();
356 const item = ListView.view.currentItem;
357 if (item) {
358 item.forceActiveFocus(Qt.TabFocusReason);
359 }
360 event.accepted = true;
361 return;
362 }
363 event.accepted = false; // Forward to KeyNavigation.down
364 }
365 Keys.onUpPressed: event => {
366 if (ListView.view.currentIndex === 0) {
367 event.accepted = false;
368 } else {
369 ListView.view.decrementCurrentIndex();
370 const item = ListView.view.currentItem;
371 if (item) {
372 item.forceActiveFocus(Qt.BacktabFocusReason);
373 }
374 event.accepted = true;
375 }
376 }
377
378 Accessible.role: Accessible.Button
379 Accessible.name: title
380 Accessible.description: subtitle
381
382 PlasmaComponents3.ToolTip {
383 text: listItem.title + (listItem.subtitle.length > 0 ? "\n" + listItem.subtitle : "")
384 visible: mouseArea.containsMouse && (listItemTitle.truncated || listItemSubtitle.truncated)
385 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText
386 }
387
388 // Handle left clicks and taps; don't accept stylus input or else it steals
389 // events from the buttons on the list item
390 TapHandler {
391 enabled: listItem.hasExpandableContent
392
393 acceptedPointerTypes: PointerDevice.Generic | PointerDevice.Finger
394
395 onSingleTapped: {
396 listItem.ListView.view.currentIndex = index
397 listItem.toggleExpanded()
398 }
399 }
400
401 MouseArea {
402 id: mouseArea
403 anchors.fill: parent
404
405 // This MouseArea used to intercept RightButton to open a context
406 // menu, but that has been removed, and now it's only used for hover
407 acceptedButtons: Qt.NoButton
408 hoverEnabled: true
409
410 // using onPositionChanged instead of onContainsMouseChanged so this doesn't trigger when the list reflows
411 onPositionChanged: {
412 // don't change currentIndex if it would make listview scroll
413 // see https://bugs.kde.org/show_bug.cgi?id=387797
414 // this is a workaround till https://bugreports.qt.io/browse/QTBUG-114574 gets fixed
415 // which would allow a proper solution
416 if (parent.y - listItem.ListView.view.contentY >= 0 && parent.y - listItem.ListView.view.contentY + parent.height + 1 /* border */ < listItem.ListView.view.height) {
417 listItem.ListView.view.currentIndex = (containsMouse ? index : -1)
418 }
419 }
420 onExited: if (listItem.ListView.view.currentIndex === index) {
421 listItem.ListView.view.currentIndex = -1;
422 }
423
424 ColumnLayout {
425 id: mainLayout
426
427 anchors.top: parent.top
428 anchors.left: parent.left
429 anchors.right: parent.right
430
431 spacing: 0
432
433 RowLayout {
434 id: mainRowLayout
435
436 Layout.fillWidth: true
437 Layout.margins: Kirigami.Units.smallSpacing
438 // Otherwise it becomes taller when the button appears
439 Layout.minimumHeight: defaultActionButton.height
440
441 // Icon and optional emblem
442 Kirigami.Icon {
443 id: listItemIcon
444
445 implicitWidth: Kirigami.Units.iconSizes.medium
446 implicitHeight: Kirigami.Units.iconSizes.medium
447
448 Kirigami.Icon {
449 id: iconEmblem
450
451 visible: valid
452
453 anchors.right: parent.right
454 anchors.bottom: parent.bottom
455
456 implicitWidth: Kirigami.Units.iconSizes.small
457 implicitHeight: Kirigami.Units.iconSizes.small
458 }
459 }
460
461 // Title and subtitle
462 ColumnLayout {
463 Layout.fillWidth: true
464 Layout.alignment: Qt.AlignVCenter
465
466 spacing: 0
467
468 Kirigami.Heading {
469 id: listItemTitle
470
471 visible: text.length > 0
472
473 Layout.fillWidth: true
474
475 level: 5
476
477 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText
478 elide: Text.ElideRight
479 maximumLineCount: 1
480
481 // Even if it's the default item, only make it bold when
482 // there's more than one item in the list, or else there's
483 // only one item and it's bold, which is a little bit weird
484 font.weight: listItem.isDefault && listItem.ListView.view.count > 1
485 ? Font.Bold
486 : Font.Normal
487 }
488
489 PlasmaComponents3.Label {
490 id: listItemSubtitle
491
492 visible: text.length > 0
493 font: Kirigami.Theme.smallFont
494
495 // Otherwise colored text can be hard to see
496 opacity: color === Kirigami.Theme.textColor ? 0.7 : 1.0
497
498 Layout.fillWidth: true
499
500 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText
501 elide: Text.ElideRight
502 maximumLineCount: subtitleCanWrap ? (subtitleMaximumLineCount === -1 ? undefined : subtitleMaximumLineCount) : 1
503 wrapMode: subtitleCanWrap ? Text.WordWrap : Text.NoWrap
504 }
505 }
506
507 // Busy indicator
508 PlasmaComponents3.BusyIndicator {
509 id: busyIndicator
510
511 visible: listItem.isBusy
512
513 // Otherwise it makes the list item taller when it appears
514 Layout.maximumHeight: defaultActionButton.implicitHeight
515 Layout.maximumWidth: Layout.maximumHeight
516 }
517
518 // Default action button
519 PlasmaComponents3.ToolButton {
520 id: defaultActionButton
521
522 visible: defaultActionButtonAction
523 && listItem.defaultActionButtonVisible
524 && (!busyIndicator.visible || listItem.showDefaultActionButtonWhenBusy)
525
526 KeyNavigation.tab: expandToggleButton
527 KeyNavigation.right: expandToggleButton
528 KeyNavigation.down: expandToggleButton.KeyNavigation.down
529 Keys.onUpPressed: event => listItem.Keys.upPressed(event)
530
531 Accessible.name: action !== null ? action.text : ""
532 }
533
534 // Expand/collapse button
535 PlasmaComponents3.ToolButton {
536 id: expandToggleButton
537 visible: listItem.hasExpandableContent
538
539 display: PlasmaComponents3.AbstractButton.IconOnly
540 text: expandedView.expanded ? i18ndc("libplasma6", "@action:button", "Collapse") : i18ndc("libplasma6", "@action:button", "Expand")
541 icon.name: expandedView.expanded ? "collapse" : "expand"
542
543 Keys.onUpPressed: event => listItem.Keys.upPressed(event)
544
545 onClicked: listItem.toggleExpanded()
546
547 PlasmaComponents3.ToolTip {
548 text: parent.text
549 }
550 }
551 }
552
553
554 // Expanded view with actions and/or custom content in it
555 Item {
556 id: expandedView
557 property bool expanded: false
558
559 Layout.preferredHeight: expanded ?
560 expandedViewLayout.implicitHeight + expandedViewLayout.anchors.topMargin + expandedViewLayout.anchors.bottomMargin : 0
561 Layout.fillWidth: true
562
563 opacity: expanded ? 1 : 0
564 Behavior on opacity {
565 enabled: listItem.ListView.view.highlightResizeDuration > 0
566 SmoothedAnimation { // to match the highlight
567 id: expandedItemOpacityFade
568 duration: listItem.ListView.view.highlightResizeDuration || -1
569 // velocity is divided by the default speed, as we're in the range 0-1
570 velocity: listItem.ListView.view.highlightResizeVelocity / 200
571 easing.type: Easing.InOutCubic
572 }
573 }
574 visible: opacity > 0
575
576 ColumnLayout {
577 id: expandedViewLayout
578 anchors.fill: parent
579 anchors.margins: Kirigami.Units.smallSpacing
580
581 spacing: Kirigami.Units.smallSpacing
582
583 // Actions list
584 Loader {
585 id: actionsListLoader
586
587 visible: status === Loader.Ready
588 active: expandedView.visible && listItem.__enabledContextualActions.length > 0
589
590 Layout.fillWidth: true
591
592 sourceComponent: Item {
593 height: childrenRect.height
594 width: actionsListLoader.width // basically, parent.width but null-proof
595
596 ColumnLayout {
597 anchors.top: parent.top
598 anchors.left: parent.left
599 anchors.right: parent.right
600 anchors.leftMargin: Kirigami.Units.gridUnit
601 anchors.rightMargin: Kirigami.Units.gridUnit
602
603 spacing: 0
604
605 Repeater {
606 id: actionRepeater
607
608 model: listItem.__enabledContextualActions
609
610 delegate: PlasmaComponents3.ToolButton {
611 required property int index
612 required property T.Action modelData
613
614 Layout.fillWidth: true
615
616 text: modelData.text
617 icon.name: modelData.icon.name
618
619 KeyNavigation.up: index > 0 ? actionRepeater.itemAt(index - 1) : expandToggleButton
620 Keys.onDownPressed: event => {
621 if (index === actionRepeater.count - 1) {
622 event.accepted = true;
623 listItem.ListView.view.incrementCurrentIndex();
624 listItem.ListView.view.currentItem.forceActiveFocus(Qt.TabFocusReason);
625 } else {
626 event.accepted = false; // Forward to KeyNavigation.down
627 }
628 }
629
630 onClicked: {
631 modelData.trigger()
632 collapse()
633 }
634 }
635 }
636 }
637 }
638 }
639
640 // Separator between the two items when both are shown
641 KSvg.SvgItem {
642 Layout.fillWidth: true
643 imagePath: "widgets/line"
644 elementId: "horizontal-line"
645 visible: actionsListLoader.visible && customContentLoader.visible
646 }
647
648 // Custom content item, if any
649 Loader {
650 id: customContentLoader
651 visible: status === Loader.Ready
652
653 Layout.fillWidth: true
654
655 active: expandedView.visible
656 asynchronous: true
657 sourceComponent: listItem.customExpandedViewContent
658 }
659 }
660 }
661 }
662 }
663}
alias expanded
expanded: bool Whether the expanded view is visible.
Q_SCRIPTABLE CaptureState status()
QString i18ndc(const char *domain, const char *context, const char *text, const TYPE &arg...)
AKONADI_CALENDAR_EXPORT KCalendarCore::Event::Ptr event(const Akonadi::Item &item)
Type type(const QSqlDatabase &db)
QString name(const QVariant &location)
QStringView level(QStringView ifopt)
const QList< QKeySequence > & up()
QStringList filter(QStringView str, Qt::CaseSensitivity cs) const const
qsizetype count(QChar ch, Qt::CaseSensitivity cs) const const
QTextStream & left(QTextStream &stream)
QTextStream & right(QTextStream &stream)
This file is part of the KDE documentation.
Documentation copyright © 1996-2025 The KDE developers.
Generated on Fri Apr 11 2025 11:56:56 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.