import QtQuick import QtQuick.Controls import qs.Commons // Themed single-select dropdown. Trigger row paints with the kit's focus // chrome; the popup anchors below and uses Color.popups.background + // Color.popups.border so it reads as a panel surface rather than the // platform-native ComboBox look. // // `options` accepts either a plain string[] or an array of // { value, label } objects (label is what we render; value is what we // emit). Mixing is fine — each row is interpreted independently. // // Keyboard: Tab to focus the trigger, Enter/Space opens, Esc closes, // j/k or Up/Down walks options inside the open popup, Enter selects. // A sibling SearchableDropdown reuses the same visuals but adds an // embedded filter input — keep the two separate so each stays simple. Item { id: root property string label: "" property string value: "" property var options: [] property color foreground: Color.foreground property color background: Color.popups.background property color popupBorder: Color.popups.border property color accent: Color.accent property string fontFamily: "JetBrainsMono Nerd Font" property int rowHeight: 28 property int popupRowHeight: 28 property bool showLabel: true signal changed(string value) function optionValue(o) { return (o && typeof o === "object") ? String(o.value) : String(o) } function optionLabel(o) { return (o && typeof o === "object") ? String(o.label) : String(o) } function currentLabel() { for (var i = 0; i < options.length; i++) { if (optionValue(options[i]) === value) return optionLabel(options[i]) } return value } implicitWidth: 240 implicitHeight: showLabel && label !== "" ? rowHeight + 18 : rowHeight Column { anchors.fill: parent spacing: 4 Text { visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) font.family: root.fontFamily font.pixelSize: 10 font.bold: true } Rectangle { id: trigger width: parent.width height: root.rowHeight radius: Style.cornerRadius color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, trigger.activeFocus ? 0.08 : 0.04) border.color: trigger.activeFocus ? Style.focusBorderColor : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4) border.width: trigger.activeFocus ? Style.focusBorderWidth : 1 activeFocusOnTab: true Keys.onPressed: function(event) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space || event.key === Qt.Key_Down) { popup.opened ? popup.close() : popup.open() event.accepted = true } else if (event.key === Qt.Key_Escape && popup.opened) { popup.close(); event.accepted = true } } Text { anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 10 anchors.rightMargin: 6 text: root.currentLabel() color: root.foreground font.family: root.fontFamily font.pixelSize: 12 elide: Text.ElideRight } Text { id: chevron anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.rightMargin: 8 text: "󰅀" color: Qt.darker(root.foreground, 1.2) font.family: root.fontFamily font.pixelSize: 12 } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { trigger.forceActiveFocus() popup.opened ? popup.close() : popup.open() } } Popup { id: popup x: 0 y: trigger.height + 2 width: trigger.width implicitHeight: Math.min(root.options.length * root.popupRowHeight + 2, root.popupRowHeight * 8 + 2) padding: 1 focus: true background: Rectangle { color: root.background border.color: root.popupBorder border.width: 1 radius: Style.cornerRadius } onOpened: optionList.currentIndex = Math.max(0, optionList.indexOfValue(root.value)) Keys.onPressed: function(event) { if (event.key === Qt.Key_Escape) { popup.close(); event.accepted = true } else if (event.key === Qt.Key_Down || event.text === "j") { optionList.currentIndex = Math.min(root.options.length - 1, optionList.currentIndex + 1) event.accepted = true } else if (event.key === Qt.Key_Up || event.text === "k") { optionList.currentIndex = Math.max(0, optionList.currentIndex - 1) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { optionList.selectCurrent(); event.accepted = true } } contentItem: ListView { id: optionList implicitHeight: contentHeight clip: true boundsBehavior: Flickable.StopAtBounds model: root.options currentIndex: -1 function indexOfValue(v) { for (var i = 0; i < root.options.length; i++) if (root.optionValue(root.options[i]) === v) return i return -1 } function selectCurrent() { if (currentIndex < 0 || currentIndex >= root.options.length) return var v = root.optionValue(root.options[currentIndex]) root.value = v root.changed(v) popup.close() } delegate: Rectangle { required property var modelData required property int index width: optionList.width height: root.popupRowHeight color: index === optionList.currentIndex ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.14) : "transparent" Text { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 10 anchors.rightMargin: 10 text: root.optionLabel(modelData) color: index === optionList.currentIndex ? root.accent : root.foreground font.family: root.fontFamily font.pixelSize: 12 elide: Text.ElideRight } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onPositionChanged: optionList.currentIndex = parent.index onClicked: optionList.selectCurrent() } } } } } } }