/* SPDX-FileCopyrightText: 2020 Nate Graham SPDX-License-Identifier: LGPL-2.0-or-later */ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import QtQuick.Templates as T import org.kde.plasma.core as PlasmaCore import org.kde.ksvg as KSvg import org.kde.plasma.components as PlasmaComponents3 import org.kde.plasma.extras as PlasmaExtras import org.kde.kirigami as Kirigami /*! \qmltype ExpandableListItem \inqmlmodule org.kde.plasma.extras \brief A list item that expands when clicked to show additional actions and/or a custom view. The list item has a standardized appearance, with an icon on the left badged with an optional emblem, a title and optional subtitle to the right, an optional default action button, and a button to expand and collapse the list item. When expanded, the list item shows a list of contextually-appropriate actions if contextualActions has been defined. If customExpandedViewContent has been defined, it will show a custom view. If both have been defined, it shows both, with the actions above the custom view. It is not valid to define neither; define one or both. \note this component should only be used for lists where the maximum number of items is very low, ideally less than 10. For longer lists, consider using a different paradigm. Example usage: \qml import QtQuick import QtQuick.Controls as QQC2 import org.kde.kirigami as Kirigami import org.kde.plasma.extras as PlasmaExtras import org.kde.plasma.components as PlasmaComponents PlasmaComponents.ScrollView { ListView { anchors.fill: parent focus: true currentIndex: -1 clip: true model: myModel highlight: PlasmaExtras.Highlight {} highlightMoveDuration: Kirigami.Units.shortDuration highlightResizeDuration: Kirigami.Units.shortDuration delegate: PlasmaExtras.ExpandableListItem { icon: model.iconName iconEmblem: model.isPaused ? "emblem-pause" : "" title: model.name subtitle: model.subtitle isDefault: model.isDefault defaultActionButtonAction: QQC2.Action { icon.name: model.isPaused ? "media-playback-start" : "media-playback-pause" text: model.isPaused ? "Resume" : "Pause" onTriggered: { if (model.isPaused) { model.resume(model.name); } else { model.pause(model.name); } } } contextualActions: [ QQC2.Action { icon.name: "configure" text: "Configure…" onTriggered: model.configure(model.name); } ] } } } \endqml */ Item { id: listItem /*! \qmlproperty icon ExpandableListItem::icon The name of the icon used in the list item. Required. \sa Kirigami.Icon::source */ property alias icon: listItemIcon.source /*! \qmlproperty icon ExpandableListItem::iconEmblem The name of the emblem to badge the icon with. Optional, defaults to nothing, in which case there is no emblem. \sa Kirigami.Icon::source */ property alias iconEmblem: iconEmblem.source /*! \qmlproperty string ExpandableListItem::title The name or title for this list item. Optional; if not defined, there will be no title and the subtitle will be vertically centered in the list item. */ property alias title: listItemTitle.text /*! \qmlproperty string ExpandableListItem::subtitle The subtitle for this list item, displayed under the title. Optional; if not defined, there will be no subtitle and the title will be vertically centered in the list item. */ property alias subtitle: listItemSubtitle.text /*! Whether to allow the subtitle to become a multi-line string instead of eliding when the text is very long. Optional, defaults to false. */ property bool subtitleCanWrap: false /*! The maximum number of lines the subtitle can have when subtitleCanWrap is true. Optional, defaults to -1, which means no limit. \since 6.9 */ property int subtitleMaximumLineCount: -1 /*! \qmlproperty color ExpandableListItem::subtitleColor subtitleColor: color The color of the subtitle text Optional; if not defined, the subtitle will use the default text color */ property alias subtitleColor: listItemSubtitle.color /*! Whether to allow the title, subtitle, and tooltip to contain styled text. For performance and security reasons, keep this off unless needed. Optional, defaults to false. */ property bool allowStyledText: false /*! \qmlproperty Action ExpandableListItem::defaultActionButtonAction The Action to execute when the default button is clicked. Optional; if not defined, no default action button will be displayed. */ property alias defaultActionButtonAction: defaultActionButton.action /*! When/whether to show to default action button. Useful for making it conditionally appear or disappear. Optional; defaults to true */ property bool defaultActionButtonVisible: true /*! Whether to continue showing the default action button while the busy indicator is visible. Useful for cancelable actions that could take a few seconds and show a busy indicator while processing. Optional; defaults to false */ property bool showDefaultActionButtonWhenBusy: false /*! A list of standard QQC2.Action objects that describes additional actions that can be performed on this list item. For example: \qml contextualActions: [ Action { text: "Do something" icon.name: "document-edit" onTriggered: doSomething() }, Action { text: "Do something else" icon.name: "draw-polygon" onTriggered: doSomethingElse() }, Action { text: "Do something completely different" icon.name: "games-highscores" onTriggered: doSomethingCompletelyDifferent() } ] \endqml Optional; if not defined, no contextual actions will be displayed and you should instead assign a custom view to customExpandedViewContent, which will be shown when the user expands the list item. */ property list contextualActions readonly property list __enabledContextualActions: contextualActions.filter(action => action?.enabled ?? false) /*! A custom view to display when the user expands the list item. This component must define width and height properties. Width should be equal to the width of the list item itself, while height: will depend on the component itself. Optional; if not defined, no custom view actions will be displayed and you should instead define contextualActions, and then actions will be shown when the user expands the list item. */ property Component customExpandedViewContent /*! \qmlproperty Item ExpandableListItem::customExpandedViewContentItem The actual instance of the custom view content, if loaded. \since 5.72 */ property alias customExpandedViewContentItem: customContentLoader.item /*! Whether or not to display a busy indicator on the list item. Set to true while the item should be non-interactive because things are processing. Optional; defaults to false. */ property bool isBusy: false /*! Whether or not this list item should be considered the "default" or "Current" item in the list. When set to true, and the list itself has more than one item in it, the list item's title and subtitle will be drawn in a bold style. Optional; defaults to false. */ property bool isDefault: false /*! \qmlproperty bool ExpandableListItem::expanded Whether the expanded view is visible. \since 5.98 */ readonly property alias expanded: expandedView.expanded /*! Whether or not this expandable list item is actually expandable. True if this item has either a custom view or else at least one enabled action. Otherwise false. */ readonly property bool hasExpandableContent: customExpandedViewContent !== null || __enabledContextualActions.length > 0 /*! The index of this item in the view \since 6.6 */ property int index /*! Show the expanded view, growing the list item to its taller size. */ function expand() { if (!listItem.hasExpandableContent) { return; } expandedView.expanded = true listItem.itemExpanded() } /*! Hide the expanded view and collapse the list item to its shorter size. */ function collapse() { if (!listItem.hasExpandableContent) { return; } expandedView.expanded = false listItem.itemCollapsed() } /*! Expand or collapse the list item depending on its current state. */ function toggleExpanded() { if (!listItem.hasExpandableContent) { return; } expandedView.expanded ? listItem.collapse() : listItem.expand() } /*! */ signal itemExpanded() /*! */ signal itemCollapsed() width: parent ? parent.width : undefined // Assume that we will be used as a delegate, not placed in a layout height: mainLayout.height Behavior on height { enabled: listItem.ListView.view.highlightResizeDuration > 0 SmoothedAnimation { // to match the highlight id: heightAnimation duration: listItem.ListView.view.highlightResizeDuration || -1 velocity: listItem.ListView.view.highlightResizeVelocity easing.type: Easing.InOutCubic } } clip: heightAnimation.running || expandedItemOpacityFade.running onEnabledChanged: if (!listItem.enabled) { listItem.collapse() } Keys.onPressed: event => { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (defaultActionButtonAction) { defaultActionButtonAction.trigger() } else { toggleExpanded(); } event.accepted = true; } else if (event.key === Qt.Key_Escape) { if (expandedView.expanded) { collapse(); event.accepted = true; } // if not active, we'll let the Escape event pass through, so it can close the applet, etc. } else if (event.key === Qt.Key_Space) { toggleExpanded(); event.accepted = true; } } KeyNavigation.tab: defaultActionButtonVisible ? defaultActionButton : expandToggleButton KeyNavigation.right: defaultActionButtonVisible ? defaultActionButton : expandToggleButton KeyNavigation.down: expandToggleButton.KeyNavigation.down Keys.onDownPressed: event => { if (!actionsListLoader.item || ListView.view.currentIndex < 0) { ListView.view.incrementCurrentIndex(); const item = ListView.view.currentItem; if (item) { item.forceActiveFocus(Qt.TabFocusReason); } event.accepted = true; return; } event.accepted = false; // Forward to KeyNavigation.down } Keys.onUpPressed: event => { if (ListView.view.currentIndex === 0) { event.accepted = false; } else { ListView.view.decrementCurrentIndex(); const item = ListView.view.currentItem; if (item) { item.forceActiveFocus(Qt.BacktabFocusReason); } event.accepted = true; } } Accessible.role: Accessible.Button Accessible.name: title Accessible.description: subtitle PlasmaComponents3.ToolTip { text: listItem.title + (listItem.subtitle.length > 0 ? "\n" + listItem.subtitle : "") visible: mouseArea.containsMouse && (listItemTitle.truncated || listItemSubtitle.truncated) textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText } // Handle left clicks and taps; don't accept stylus input or else it steals // events from the buttons on the list item TapHandler { enabled: listItem.hasExpandableContent acceptedPointerTypes: PointerDevice.Generic | PointerDevice.Finger onSingleTapped: { listItem.ListView.view.currentIndex = listItem.index listItem.toggleExpanded() } } MouseArea { id: mouseArea property PlasmaExtras.Menu contextMenu: null anchors.fill: parent acceptedButtons: !listItem.expanded && listItem.__enabledContextualActions.length > 0 ? Qt.RightButton : Qt.NoButton hoverEnabled: true // using onPositionChanged instead of onContainsMouseChanged so this doesn't trigger when the list reflows onPositionChanged: { // don't change currentIndex if it would make listview scroll // see https://bugs.kde.org/show_bug.cgi?id=387797 // this is a workaround till https://bugreports.qt.io/browse/QTBUG-114574 gets fixed // which would allow a proper solution if (parent.y - listItem.ListView.view.contentY >= 0 && parent.y - listItem.ListView.view.contentY + parent.height + 1 /* border */ < listItem.ListView.view.height) { listItem.ListView.view.currentIndex = (containsMouse ? listItem.index : -1) } } onPressed: (mouse) => { contextMenu = menuComponent.createObject(listItem); for (let action of listItem.__enabledContextualActions) { const menuItem = menuItemComponent.createObject(contextMenu, { // Unfortunately cannot just assign action to menuItem.action. text: action.text, icon: action.icon.name, }) as PlasmaExtras.MenuItem; menuItem.clicked.connect(action.trigger); } contextMenu.open(mouse.x, mouse.y); } onExited: if (listItem.ListView.view.currentIndex === listItem.index && contextMenu?.status !== PlasmaExtras.Menu.Open) { listItem.ListView.view.currentIndex = -1; } Component { id: menuComponent PlasmaExtras.Menu { onStatusChanged: { if (status === PlasmaExtras.Menu.Closed) { destroy(); mouseArea.contextMenu = null; } } } } Component { id: menuItemComponent PlasmaExtras.MenuItem { } } ColumnLayout { id: mainLayout anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right spacing: 0 RowLayout { id: mainRowLayout Layout.fillWidth: true Layout.margins: Kirigami.Units.smallSpacing // Otherwise it becomes taller when the button appears Layout.minimumHeight: defaultActionButton.height // Icon and optional emblem Kirigami.Icon { id: listItemIcon implicitWidth: Kirigami.Units.iconSizes.medium implicitHeight: Kirigami.Units.iconSizes.medium Kirigami.Icon { id: iconEmblem visible: valid anchors.right: parent.right anchors.bottom: parent.bottom implicitWidth: Kirigami.Units.iconSizes.small implicitHeight: Kirigami.Units.iconSizes.small } } // Title and subtitle ColumnLayout { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter spacing: 0 Kirigami.Heading { id: listItemTitle visible: text.length > 0 Layout.fillWidth: true level: 5 textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText elide: Text.ElideRight maximumLineCount: 1 // Even if it's the default item, only make it bold when // there's more than one item in the list, or else there's // only one item and it's bold, which is a little bit weird font.weight: listItem.isDefault && listItem.ListView.view.count > 1 ? Font.Bold : Font.Normal } PlasmaComponents3.Label { id: listItemSubtitle visible: text.length > 0 font: Kirigami.Theme.smallFont // Otherwise colored text can be hard to see opacity: color === Kirigami.Theme.textColor ? 0.75 : 1.0 Layout.fillWidth: true textFormat: listItem.allowStyledText ? Text.StyledText : Text.PlainText elide: Text.ElideRight maximumLineCount: listItem.subtitleCanWrap ? (listItem.subtitleMaximumLineCount === -1 ? undefined : listItem.subtitleMaximumLineCount) : 1 wrapMode: listItem.subtitleCanWrap ? Text.WordWrap : Text.NoWrap } } // Busy indicator PlasmaComponents3.BusyIndicator { id: busyIndicator visible: listItem.isBusy // Otherwise it makes the list item taller when it appears Layout.maximumHeight: defaultActionButton.implicitHeight Layout.maximumWidth: Layout.maximumHeight } // Default action button PlasmaComponents3.ToolButton { id: defaultActionButton visible: listItem.defaultActionButtonAction && listItem.defaultActionButtonVisible && (!busyIndicator.visible || listItem.showDefaultActionButtonWhenBusy) KeyNavigation.tab: expandToggleButton KeyNavigation.right: expandToggleButton KeyNavigation.down: expandToggleButton.KeyNavigation.down Keys.onUpPressed: event => listItem.Keys.upPressed(event) Accessible.name: action !== null ? action.text : "" } // Expand/collapse button PlasmaComponents3.ToolButton { id: expandToggleButton visible: listItem.hasExpandableContent display: PlasmaComponents3.AbstractButton.IconOnly text: expandedView.expanded ? i18ndc("libplasma6", "@action:button", "Collapse") : i18ndc("libplasma6", "@action:button", "Expand") icon.name: expandedView.expanded ? "collapse" : "expand" Keys.onUpPressed: event => listItem.Keys.upPressed(event) onClicked: listItem.toggleExpanded() PlasmaComponents3.ToolTip { text: expandToggleButton.text } } } // Expanded view with actions and/or custom content in it Item { id: expandedView property bool expanded: false Layout.preferredHeight: expanded ? expandedViewLayout.implicitHeight + expandedViewLayout.anchors.topMargin + expandedViewLayout.anchors.bottomMargin : 0 Layout.fillWidth: true opacity: expanded ? 1 : 0 Behavior on opacity { enabled: listItem.ListView.view.highlightResizeDuration > 0 SmoothedAnimation { // to match the highlight id: expandedItemOpacityFade duration: listItem.ListView.view.highlightResizeDuration || -1 // velocity is divided by the default speed, as we're in the range 0-1 velocity: listItem.ListView.view.highlightResizeVelocity / 200 easing.type: Easing.InOutCubic } } visible: opacity > 0 ColumnLayout { id: expandedViewLayout anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing // Actions list Loader { id: actionsListLoader visible: status === Loader.Ready active: expandedView.visible && listItem.__enabledContextualActions.length > 0 Layout.fillWidth: true sourceComponent: Item { height: childrenRect.height width: actionsListLoader.width // basically, parent.width but null-proof ColumnLayout { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.leftMargin: Kirigami.Units.gridUnit anchors.rightMargin: Kirigami.Units.gridUnit spacing: 0 Repeater { id: actionRepeater model: listItem.__enabledContextualActions delegate: PlasmaComponents3.ToolButton { required property int index required property T.Action modelData Layout.fillWidth: true text: modelData.text icon.name: modelData.icon.name KeyNavigation.up: index > 0 ? actionRepeater.itemAt(index - 1) : expandToggleButton Keys.onDownPressed: event => { if (index === actionRepeater.count - 1) { event.accepted = true; listItem.ListView.view.incrementCurrentIndex(); listItem.ListView.view.currentItem.forceActiveFocus(Qt.TabFocusReason); } else { event.accepted = false; // Forward to KeyNavigation.down } } onClicked: { modelData.trigger() listItem.collapse() } } } } } } // Separator between the two items when both are shown KSvg.SvgItem { Layout.fillWidth: true imagePath: "widgets/line" elementId: "horizontal-line" visible: actionsListLoader.visible && customContentLoader.visible } // Custom content item, if any Loader { id: customContentLoader visible: status === Loader.Ready Layout.fillWidth: true active: expandedView.visible asynchronous: true sourceComponent: listItem.customExpandedViewContent } } } } } }