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 // Handle left clicks and taps; don't accept stylus input or else it steals
383 // events from the buttons on the list item
384 TapHandler {
385 enabled: listItem.hasExpandableContent
386
387 acceptedPointerTypes: PointerDevice.Generic | PointerDevice.Finger
388
389 onSingleTapped: {
390 listItem.ListView.view.currentIndex = index
391 listItem.toggleExpanded()
392 }
393 }
394
395 MouseArea {
396 anchors.fill: parent
397
398 // This MouseArea used to intercept RightButton to open a context
399 // menu, but that has been removed, and now it's only used for hover
400 acceptedButtons: Qt.NoButton
401 hoverEnabled: true
402
403 // using onPositionChanged instead of onContainsMouseChanged so this doesn't trigger when the list reflows
404 onPositionChanged: {
405 // don't change currentIndex if it would make listview scroll
406 // see https://bugs.kde.org/show_bug.cgi?id=387797
407 // this is a workaround till https://bugreports.qt.io/browse/QTBUG-114574 gets fixed
408 // which would allow a proper solution
409 if (parent.y - listItem.ListView.view.contentY >= 0 && parent.y - listItem.ListView.view.contentY + parent.height + 1 /* border */ < listItem.ListView.view.height) {
410 listItem.ListView.view.currentIndex = (containsMouse ? index : -1)
411 }
412 }
413 onExited: if (listItem.ListView.view.currentIndex === index) {
414 listItem.ListView.view.currentIndex = -1;
415 }
416
417 ColumnLayout {
418 id: mainLayout
419
420 anchors.top: parent.top
421 anchors.left: parent.left
422 anchors.right: parent.right
423
424 spacing: 0
425
426 RowLayout {
427 id: mainRowLayout
428
429 Layout.fillWidth: true
430 Layout.margins: Kirigami.Units.smallSpacing
431 // Otherwise it becomes taller when the button appears
432 Layout.minimumHeight: defaultActionButton.height
433
434 // Icon and optional emblem
435 Kirigami.Icon {
436 id: listItemIcon
437
438 implicitWidth: Kirigami.Units.iconSizes.medium
439 implicitHeight: Kirigami.Units.iconSizes.medium
440
441 Kirigami.Icon {
442 id: iconEmblem
443
444 visible: valid
445
446 anchors.right: parent.right
447 anchors.bottom: parent.bottom
448
449 implicitWidth: Kirigami.Units.iconSizes.small
450 implicitHeight: Kirigami.Units.iconSizes.small
451 }
452 }
453
454 // Title and subtitle
455 ColumnLayout {
456 Layout.fillWidth: true
457 Layout.alignment: Qt.AlignVCenter
458
459 spacing: 0
460
461 Kirigami.Heading {
462 id: listItemTitle
463
464 visible: text.length > 0
465
466 Layout.fillWidth: true
467
468 level: 5
469
470 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText
471 elide: Text.ElideRight
472 maximumLineCount: 1
473
474 // Even if it's the default item, only make it bold when
475 // there's more than one item in the list, or else there's
476 // only one item and it's bold, which is a little bit weird
477 font.weight: listItem.isDefault && listItem.ListView.view.count > 1
478 ? Font.Bold
479 : Font.Normal
480 }
481
482 PlasmaComponents3.Label {
483 id: listItemSubtitle
484
485 visible: text.length > 0
486 font: Kirigami.Theme.smallFont
487
488 // Otherwise colored text can be hard to see
489 opacity: color === Kirigami.Theme.textColor ? 0.7 : 1.0
490
491 Layout.fillWidth: true
492
493 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText
494 elide: Text.ElideRight
495 maximumLineCount: subtitleCanWrap ? (subtitleMaximumLineCount === -1 ? undefined : subtitleMaximumLineCount) : 1
496 wrapMode: subtitleCanWrap ? Text.WordWrap : Text.NoWrap
497 }
498 }
499
500 // Busy indicator
501 PlasmaComponents3.BusyIndicator {
502 id: busyIndicator
503
504 visible: listItem.isBusy
505
506 // Otherwise it makes the list item taller when it appears
507 Layout.maximumHeight: defaultActionButton.implicitHeight
508 Layout.maximumWidth: Layout.maximumHeight
509 }
510
511 // Default action button
512 PlasmaComponents3.ToolButton {
513 id: defaultActionButton
514
515 visible: defaultActionButtonAction
516 && listItem.defaultActionButtonVisible
517 && (!busyIndicator.visible || listItem.showDefaultActionButtonWhenBusy)
518
519 KeyNavigation.tab: expandToggleButton
520 KeyNavigation.right: expandToggleButton
521 KeyNavigation.down: expandToggleButton.KeyNavigation.down
522 Keys.onUpPressed: event => listItem.Keys.upPressed(event)
523
524 Accessible.name: action !== null ? action.text : ""
525 }
526
527 // Expand/collapse button
528 PlasmaComponents3.ToolButton {
529 id: expandToggleButton
530 visible: listItem.hasExpandableContent
531
532 display: PlasmaComponents3.AbstractButton.IconOnly
533 text: expandedView.expanded ? i18ndc("libplasma6", "@action:button", "Collapse") : i18ndc("libplasma6", "@action:button", "Expand")
534 icon.name: expandedView.expanded ? "collapse" : "expand"
535
536 Keys.onUpPressed: event => listItem.Keys.upPressed(event)
537
538 onClicked: listItem.toggleExpanded()
539
540 PlasmaComponents3.ToolTip {
541 text: parent.text
542 }
543 }
544 }
545
546
547 // Expanded view with actions and/or custom content in it
548 Item {
549 id: expandedView
550 property bool expanded: false
551
552 Layout.preferredHeight: expanded ?
553 expandedViewLayout.implicitHeight + expandedViewLayout.anchors.topMargin + expandedViewLayout.anchors.bottomMargin : 0
554 Layout.fillWidth: true
555
556 opacity: expanded ? 1 : 0
557 Behavior on opacity {
558 enabled: listItem.ListView.view.highlightResizeDuration > 0
559 SmoothedAnimation { // to match the highlight
560 id: expandedItemOpacityFade
561 duration: listItem.ListView.view.highlightResizeDuration || -1
562 // velocity is divided by the default speed, as we're in the range 0-1
563 velocity: listItem.ListView.view.highlightResizeVelocity / 200
564 easing.type: Easing.InOutCubic
565 }
566 }
567 visible: opacity > 0
568
569 ColumnLayout {
570 id: expandedViewLayout
571 anchors.fill: parent
572 anchors.margins: Kirigami.Units.smallSpacing
573
574 spacing: Kirigami.Units.smallSpacing
575
576 // Actions list
577 Loader {
578 id: actionsListLoader
579
580 visible: status === Loader.Ready
581 active: expandedView.visible && listItem.__enabledContextualActions.length > 0
582
583 Layout.fillWidth: true
584
585 sourceComponent: Item {
586 height: childrenRect.height
587 width: actionsListLoader.width // basically, parent.width but null-proof
588
589 ColumnLayout {
590 anchors.top: parent.top
591 anchors.left: parent.left
592 anchors.right: parent.right
593 anchors.leftMargin: Kirigami.Units.gridUnit
594 anchors.rightMargin: Kirigami.Units.gridUnit
595
596 spacing: 0
597
598 Repeater {
599 id: actionRepeater
600
601 model: listItem.__enabledContextualActions
602
603 delegate: PlasmaComponents3.ToolButton {
604 required property int index
605 required property T.Action modelData
606
607 Layout.fillWidth: true
608
609 text: modelData.text
610 icon.name: modelData.icon.name
611
612 KeyNavigation.up: index > 0 ? actionRepeater.itemAt(index - 1) : expandToggleButton
613 Keys.onDownPressed: event => {
614 if (index === actionRepeater.count - 1) {
615 event.accepted = true;
616 listItem.ListView.view.incrementCurrentIndex();
617 listItem.ListView.view.currentItem.forceActiveFocus(Qt.TabFocusReason);
618 } else {
619 event.accepted = false; // Forward to KeyNavigation.down
620 }
621 }
622
623 onClicked: {
624 modelData.trigger()
625 collapse()
626 }
627 }
628 }
629 }
630 }
631 }
632
633 // Separator between the two items when both are shown
634 KSvg.SvgItem {
635 Layout.fillWidth: true
636 imagePath: "widgets/line"
637 elementId: "horizontal-line"
638 visible: actionsListLoader.visible && customContentLoader.visible
639 }
640
641 // Custom content item, if any
642 Loader {
643 id: customContentLoader
644 visible: status === Loader.Ready
645
646 Layout.fillWidth: true
647
648 active: expandedView.visible
649 asynchronous: true
650 sourceComponent: listItem.customExpandedViewContent
651 }
652 }
653 }
654 }
655 }
656}
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)
QStringView level(QStringView ifopt)
QString name(StandardAction id)
const QList< QKeySequence > & up()
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 18 2024 12:10:41 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.