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.popups.text property color background: Color.popups.background property color popupBorder: Color.popups.border property color accent: Color.accent readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth) property string fontFamily: Style.font.family property int rowHeight: Style.spacing.controlHeight property int popupRowHeight: Style.spacing.popupRowHeight property bool showLabel: true // Panel-cursor flag. When true, the trigger renders the shared // hover-cursor state. Active Qt focus defaults to the same visuals. // Emits `hovered(bool)` on pointer enter/leave so the panel can keep // its cursor state in sync with the mouse. property bool hasCursor: false // popupOpen + open()/close()/toggle() let a parent panel know when the // dropdown owns keys (its embedded ListView is active) and suspend its // own keyCatcher so j/k inside the popup don't double-drive the panel // cursor. readonly property bool popupOpen: popup.opened function open() { popup.open() } function close() { popup.close() } function toggle() { popup.opened ? popup.close() : popup.open() } signal changed(string value) signal hovered(bool isHovered) 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: Style.spacing.dropdownWidth implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight Column { anchors.fill: parent spacing: Style.spacing.labelGap Text { visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) font.family: root.fontFamily font.pixelSize: Style.font.caption font.bold: true } BorderSurface { id: trigger width: parent.width height: root.rowHeight radius: Style.cornerRadius readonly property bool _focused: trigger.activeFocus readonly property bool _hot: triggerHover.hovered || root.hasCursor readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent) color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent) borderSpec: _borderSpec activeFocusOnTab: true HoverHandler { id: triggerHover onHoveredChanged: root.hovered(hovered) } 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: trigger.borderLeft + Style.spacing.controlPaddingX anchors.rightMargin: trigger.borderRight + Style.spacing.md text: root.currentLabel() color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight } Text { id: chevron anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.rightMargin: trigger.borderRight + Style.spacing.controlGap text: "󰅀" color: Qt.darker(root.foreground, 1.2) font.family: root.fontFamily font.pixelSize: Style.font.body } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { trigger.forceActiveFocus() popup.opened ? popup.close() : popup.open() } } Popup { id: popup x: 0 y: trigger.height + Style.spacing.xxs width: trigger.width implicitHeight: Math.min(root.options.length * root.popupRowHeight + Math.max(0, root.options.length - 1) * Style.spacing.labelGap + Style.spacing.xxs, root.popupRowHeight * 8 + 7 * Style.spacing.labelGap + Style.spacing.xxs) padding: Style.spacing.hairline leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline bottomPadding: Border.bottom(root.popupBorderSpec) + Style.spacing.hairline focus: true background: BorderSurface { color: root.background borderSpec: root.popupBorderSpec radius: Style.cornerRadius } onOpened: { optionList.currentIndex = Math.max(0, optionList.indexOfValue(root.value)) optionList.forceActiveFocus() } contentItem: ListView { id: optionList spacing: Style.spacing.labelGap Keys.priority: Keys.BeforeItem 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 } } 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 ? Style.hoverFillFor(root.foreground, root.accent) : "transparent" Text { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.spacing.controlPaddingX anchors.rightMargin: Style.spacing.controlPaddingX text: root.optionLabel(modelData) color: index === optionList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onPositionChanged: optionList.currentIndex = parent.index onClicked: optionList.selectCurrent() } } } } } } }