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 * subtitleColor: color
137 * The color of the subtitle text
138 *
139 * Optional; if not defined, the subtitle will use the default text color
140 */
141 property alias subtitleColor: listItemSubtitle.color
142
143 /*
144 * allowStyledText: bool
145 * Whether to allow the title, subtitle, and tooltip to contain styled text.
146 * For performance and security reasons, keep this off unless needed.
147 *
148 * Optional, defaults to false.
149 */
150 property bool allowStyledText: false
151
152 /*
153 * defaultActionButtonAction: T.Action
154 * The Action to execute when the default button is clicked.
155 *
156 * Optional; if not defined, no default action button will be displayed.
157 */
158 property alias defaultActionButtonAction: defaultActionButton.action
159
160 /*
161 * defaultActionButtonVisible: bool
162 * When/whether to show to default action button. Useful for making it
163 * conditionally appear or disappear.
164 *
165 * Optional; defaults to true
166 */
167 property bool defaultActionButtonVisible: true
168
169 /*
170 * showDefaultActionButtonWhenBusy : bool
171 * Whether to continue showing the default action button while the busy
172 * indicator is visible. Useful for cancelable actions that could take a few
173 * seconds and show a busy indicator while processing.
174 *
175 * Optional; defaults to false
176 */
177 property bool showDefaultActionButtonWhenBusy: false
178
179 /*
180 * contextualActions: list<T.Action>
181 * A list of standard QQC2.Action objects that describes additional actions
182 * that can be performed on this list item. For example:
183 *
184 * @code
185 * contextualActions: [
186 * Action {
187 * text: "Do something"
188 * icon.name: "document-edit"
189 * onTriggered: doSomething()
190 * },
191 * Action {
192 * text: "Do something else"
193 * icon.name: "draw-polygon"
194 * onTriggered: doSomethingElse()
195 * },
196 * Action {
197 * text: "Do something completely different"
198 * icon.name: "games-highscores"
199 * onTriggered: doSomethingCompletelyDifferent()
200 * }
201 * ]
202 * @endcode
203 *
204 * Optional; if not defined, no contextual actions will be displayed and
205 * you should instead assign a custom view to customExpandedViewContent,
206 * which will be shown when the user expands the list item.
207 */
208 property list<T.Action> contextualActions
209
210 readonly property list<T.Action> __enabledContextualActions: contextualActions.filter(action => action?.enabled ?? false)
211
212 /*
213 * A custom view to display when the user expands the list item.
214 *
215 * This component must define width and height properties. Width should be
216 * equal to the width of the list item itself, while height: will depend
217 * on the component itself.
218 *
219 * Optional; if not defined, no custom view actions will be displayed and
220 * you should instead define contextualActions, and then actions will
221 * be shown when the user expands the list item.
222 */
223 property Component customExpandedViewContent
224
225 /*
226 * The actual instance of the custom view content, if loaded.
227 * @since 5.72
228 */
229 property alias customExpandedViewContentItem: customContentLoader.item
230
231 /*
232 * isBusy: bool
233 * Whether or not to display a busy indicator on the list item. Set to true
234 * while the item should be non-interactive because things are processing.
235 *
236 * Optional; defaults to false.
237 */
238 property bool isBusy: false
239
240 /*
241 * isDefault: bool
242 * Whether or not this list item should be considered the "default" or
243 * "Current" item in the list. When set to true, and the list itself has
244 * more than one item in it, the list item's title and subtitle will be
245 * drawn in a bold style.
246 *
247 * Optional; defaults to false.
248 */
249 property bool isDefault: false
250
251 /**
252 * expanded: bool
253 * Whether the expanded view is visible.
254 *
255 * @since 5.98
256 */
257 readonly property alias expanded: expandedView.expanded
258
259 /*
260 * hasExpandableContent: bool (read-only)
261 * Whether or not this expandable list item is actually expandable. True if
262 * this item has either a custom view or else at least one enabled action.
263 * Otherwise false.
264 */
265 readonly property bool hasExpandableContent: customExpandedViewContent !== null || __enabledContextualActions.length > 0
266
267 /*
268 * expand()
269 * Show the expanded view, growing the list item to its taller size.
270 */
271 function expand() {
272 if (!listItem.hasExpandableContent) {
273 return;
274 }
275 expandedView.expanded = true
276 listItem.itemExpanded()
277 }
278
279 /*
280 * collapse()
281 * Hide the expanded view and collapse the list item to its shorter size.
282 */
283 function collapse() {
284 if (!listItem.hasExpandableContent) {
285 return;
286 }
287 expandedView.expanded = false
288 listItem.itemCollapsed()
289 }
290
291 /*
292 * toggleExpanded()
293 * Expand or collapse the list item depending on its current state.
294 */
295 function toggleExpanded() {
296 if (!listItem.hasExpandableContent) {
297 return;
298 }
299 expandedView.expanded ? listItem.collapse() : listItem.expand()
300 }
301
302 signal itemExpanded()
303 signal itemCollapsed()
304
305 width: parent ? parent.width : undefined // Assume that we will be used as a delegate, not placed in a layout
306 height: mainLayout.height
307
308 Behavior on height {
309 enabled: listItem.ListView.view.highlightResizeDuration > 0
310 SmoothedAnimation { // to match the highlight
311 id: heightAnimation
312 duration: listItem.ListView.view.highlightResizeDuration || -1
313 velocity: listItem.ListView.view.highlightResizeVelocity
314 easing.type: Easing.InOutCubic
315 }
316 }
317 clip: heightAnimation.running || expandedItemOpacityFade.running
318
319 onEnabledChanged: if (!listItem.enabled) { collapse() }
320
321 Keys.onPressed: event => {
322 if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
323 if (defaultActionButtonAction) {
324 defaultActionButtonAction.trigger()
325 } else {
326 toggleExpanded();
327 }
328 event.accepted = true;
329 } else if (event.key === Qt.Key_Escape) {
330 if (expandedView.expanded) {
331 collapse();
332 event.accepted = true;
333 }
334 // if not active, we'll let the Escape event pass through, so it can close the applet, etc.
335 } else if (event.key === Qt.Key_Space) {
336 toggleExpanded();
337 event.accepted = true;
338 }
339 }
340
341 KeyNavigation.tab: defaultActionButtonVisible ? defaultActionButton : expandToggleButton
342 KeyNavigation.right: defaultActionButtonVisible ? defaultActionButton : expandToggleButton
343 KeyNavigation.down: expandToggleButton.KeyNavigation.down
344 Keys.onDownPressed: event => {
345 if (!actionsListLoader.item || ListView.view.currentIndex < 0) {
346 ListView.view.incrementCurrentIndex();
347 const item = ListView.view.currentItem;
348 if (item) {
349 item.forceActiveFocus(Qt.TabFocusReason);
350 }
351 event.accepted = true;
352 return;
353 }
354 event.accepted = false; // Forward to KeyNavigation.down
355 }
356 Keys.onUpPressed: event => {
357 if (ListView.view.currentIndex === 0) {
358 event.accepted = false;
359 } else {
360 ListView.view.decrementCurrentIndex();
361 const item = ListView.view.currentItem;
362 if (item) {
363 item.forceActiveFocus(Qt.BacktabFocusReason);
364 }
365 event.accepted = true;
366 }
367 }
368
369 Accessible.role: Accessible.Button
370 Accessible.name: title
371 Accessible.description: subtitle
372
373 // Handle left clicks and taps; don't accept stylus input or else it steals
374 // events from the buttons on the list item
375 TapHandler {
376 enabled: listItem.hasExpandableContent
377
378 acceptedPointerTypes: PointerDevice.Generic | PointerDevice.Finger
379
380 onSingleTapped: {
381 listItem.ListView.view.currentIndex = index
382 listItem.toggleExpanded()
383 }
384 }
385
386 MouseArea {
387 anchors.fill: parent
388
389 // This MouseArea used to intercept RightButton to open a context
390 // menu, but that has been removed, and now it's only used for hover
391 acceptedButtons: Qt.NoButton
392 hoverEnabled: true
393
394 // using onPositionChanged instead of onContainsMouseChanged so this doesn't trigger when the list reflows
395 onPositionChanged: {
396 // don't change currentIndex if it would make listview scroll
397 // see https://bugs.kde.org/show_bug.cgi?id=387797
398 // this is a workaround till https://bugreports.qt.io/browse/QTBUG-114574 gets fixed
399 // which would allow a proper solution
400 if (parent.y - listItem.ListView.view.contentY >= 0 && parent.y - listItem.ListView.view.contentY + parent.height + 1 /* border */ < listItem.ListView.view.height) {
401 listItem.ListView.view.currentIndex = (containsMouse ? index : -1)
402 }
403 }
404 onExited: if (listItem.ListView.view.currentIndex === index) {
405 listItem.ListView.view.currentIndex = -1;
406 }
407
408 ColumnLayout {
409 id: mainLayout
410
411 anchors.top: parent.top
412 anchors.left: parent.left
413 anchors.right: parent.right
414
415 spacing: 0
416
417 RowLayout {
418 id: mainRowLayout
419
420 Layout.fillWidth: true
421 Layout.margins: Kirigami.Units.smallSpacing
422 // Otherwise it becomes taller when the button appears
423 Layout.minimumHeight: defaultActionButton.height
424
425 // Icon and optional emblem
426 Kirigami.Icon {
427 id: listItemIcon
428
429 implicitWidth: Kirigami.Units.iconSizes.medium
430 implicitHeight: Kirigami.Units.iconSizes.medium
431
432 Kirigami.Icon {
433 id: iconEmblem
434
435 visible: valid
436
437 anchors.right: parent.right
438 anchors.bottom: parent.bottom
439
440 implicitWidth: Kirigami.Units.iconSizes.small
441 implicitHeight: Kirigami.Units.iconSizes.small
442 }
443 }
444
445 // Title and subtitle
446 ColumnLayout {
447 Layout.fillWidth: true
448 Layout.alignment: Qt.AlignVCenter
449
450 spacing: 0
451
452 Kirigami.Heading {
453 id: listItemTitle
454
455 visible: text.length > 0
456
457 Layout.fillWidth: true
458
459 level: 5
460
461 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText
462 elide: Text.ElideRight
463 maximumLineCount: 1
464
465 // Even if it's the default item, only make it bold when
466 // there's more than one item in the list, or else there's
467 // only one item and it's bold, which is a little bit weird
468 font.weight: listItem.isDefault && listItem.ListView.view.count > 1
469 ? Font.Bold
470 : Font.Normal
471 }
472
473 PlasmaComponents3.Label {
474 id: listItemSubtitle
475
476 visible: text.length > 0
477 font: Kirigami.Theme.smallFont
478
479 // Otherwise colored text can be hard to see
480 opacity: color === Kirigami.Theme.textColor ? 0.7 : 1.0
481
482 Layout.fillWidth: true
483
484 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText
485 elide: Text.ElideRight
486 maximumLineCount: subtitleCanWrap ? 9999 : 1
487 wrapMode: subtitleCanWrap ? Text.WordWrap : Text.NoWrap
488 }
489 }
490
491 // Busy indicator
492 PlasmaComponents3.BusyIndicator {
493 id: busyIndicator
494
495 visible: listItem.isBusy
496
497 // Otherwise it makes the list item taller when it appears
498 Layout.maximumHeight: defaultActionButton.implicitHeight
499 Layout.maximumWidth: Layout.maximumHeight
500 }
501
502 // Default action button
503 PlasmaComponents3.ToolButton {
504 id: defaultActionButton
505
506 visible: defaultActionButtonAction
507 && listItem.defaultActionButtonVisible
508 && (!busyIndicator.visible || listItem.showDefaultActionButtonWhenBusy)
509
510 KeyNavigation.tab: expandToggleButton
511 KeyNavigation.right: expandToggleButton
512 KeyNavigation.down: expandToggleButton.KeyNavigation.down
513 Keys.onUpPressed: event => listItem.Keys.upPressed(event)
514
515 Accessible.name: action !== null ? action.text : ""
516 }
517
518 // Expand/collapse button
519 PlasmaComponents3.ToolButton {
520 id: expandToggleButton
521 visible: listItem.hasExpandableContent
522
523 display: PlasmaComponents3.AbstractButton.IconOnly
524 text: expandedView.expanded ? i18ndc("libplasma6", "@action:button", "Collapse") : i18ndc("libplasma6", "@action:button", "Expand")
525 icon.name: expandedView.expanded ? "collapse" : "expand"
526
527 Keys.onUpPressed: event => listItem.Keys.upPressed(event)
528
529 onClicked: listItem.toggleExpanded()
530
531 PlasmaComponents3.ToolTip {
532 text: parent.text
533 }
534 }
535 }
536
537
538 // Expanded view with actions and/or custom content in it
539 Item {
540 id: expandedView
541 property bool expanded: false
542
543 Layout.preferredHeight: expanded ?
544 expandedViewLayout.implicitHeight + expandedViewLayout.anchors.topMargin + expandedViewLayout.anchors.bottomMargin : 0
545 Layout.fillWidth: true
546
547 opacity: expanded ? 1 : 0
548 Behavior on opacity {
549 enabled: listItem.ListView.view.highlightResizeDuration > 0
550 SmoothedAnimation { // to match the highlight
551 id: expandedItemOpacityFade
552 duration: listItem.ListView.view.highlightResizeDuration || -1
553 // velocity is divided by the default speed, as we're in the range 0-1
554 velocity: listItem.ListView.view.highlightResizeVelocity / 200
555 easing.type: Easing.InOutCubic
556 }
557 }
558 visible: opacity > 0
559
560 ColumnLayout {
561 id: expandedViewLayout
562 anchors.fill: parent
563 anchors.margins: Kirigami.Units.smallSpacing
564
565 spacing: Kirigami.Units.smallSpacing
566
567 // Actions list
568 Loader {
569 id: actionsListLoader
570
571 visible: status === Loader.Ready
572 active: expandedView.visible && listItem.__enabledContextualActions.length > 0
573
574 Layout.fillWidth: true
575
576 sourceComponent: Item {
577 height: childrenRect.height
578 width: actionsListLoader.width // basically, parent.width but null-proof
579
580 ColumnLayout {
581 anchors.top: parent.top
582 anchors.left: parent.left
583 anchors.right: parent.right
584 anchors.leftMargin: Kirigami.Units.gridUnit
585 anchors.rightMargin: Kirigami.Units.gridUnit
586
587 spacing: 0
588
589 Repeater {
590 id: actionRepeater
591
592 model: listItem.__enabledContextualActions
593
594 delegate: PlasmaComponents3.ToolButton {
595 required property int index
596 required property T.Action modelData
597
598 Layout.fillWidth: true
599
600 text: modelData.text
601 icon.name: modelData.icon.name
602
603 KeyNavigation.up: index > 0 ? actionRepeater.itemAt(index - 1) : expandToggleButton
604 Keys.onDownPressed: event => {
605 if (index === actionRepeater.count - 1) {
606 event.accepted = true;
607 listItem.ListView.view.incrementCurrentIndex();
608 listItem.ListView.view.currentItem.forceActiveFocus(Qt.TabFocusReason);
609 } else {
610 event.accepted = false; // Forward to KeyNavigation.down
611 }
612 }
613
614 onClicked: {
615 modelData.trigger()
616 collapse()
617 }
618 }
619 }
620 }
621 }
622 }
623
624 // Separator between the two items when both are shown
625 KSvg.SvgItem {
626 Layout.fillWidth: true
627 imagePath: "widgets/line"
628 elementId: "horizontal-line"
629 visible: actionsListLoader.visible && customContentLoader.visible
630 }
631
632 // Custom content item, if any
633 Loader {
634 id: customContentLoader
635 visible: status === Loader.Ready
636
637 Layout.fillWidth: true
638
639 active: expandedView.visible
640 asynchronous: true
641 sourceComponent: listItem.customExpandedViewContent
642 }
643 }
644 }
645 }
646 }
647}
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)
QString name(GameStandardAction id)
QStringView level(QStringView ifopt)
VehicleSection::Type type(QStringView coachNumber, QStringView coachClassification)
QAction * up(const QObject *recvr, const char *slot, QObject *parent)
qsizetype length() const const
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-2024 The KDE developers.
Generated on Mon Nov 4 2024 16:34:35 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.